Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdf3ffd7bf | ||
|
|
07ce253f5b | ||
|
|
f3db010c74 | ||
|
|
dcf2791fe5 | ||
|
|
8fcf3551d8 | ||
|
|
2d987849d8 | ||
|
|
3b99926f3b | ||
|
|
113d69a4df | ||
|
|
5d12ba7216 | ||
|
|
72b59020b4 | ||
|
|
4c323b9daa | ||
|
|
72da83d648 | ||
|
|
783a4538a4 | ||
|
|
15e62cad11 | ||
|
|
c52b20ca80 | ||
|
|
04706a6d7c | ||
|
|
0755cbeb6c | ||
|
|
8eb01c8bbf | ||
|
|
ed4f89ff66 | ||
|
|
d9fe647f8e | ||
|
|
843c2d52e7 | ||
|
|
017a3676a4 | ||
|
|
fcb70c0e9a | ||
|
|
0fec21b9ce | ||
|
|
968b1ada94 | ||
|
|
5c9b599b1e | ||
|
|
717c3e17df | ||
|
|
a4540a0802 | ||
|
|
31bacc2646 | ||
|
|
6e5d0f0e76 | ||
|
|
f0712e5961 | ||
|
|
3fbd66dece | ||
|
|
1283432632 | ||
|
|
59eaaec903 | ||
|
|
4f0f210076 | ||
|
|
e4a317f038 | ||
|
|
9779d020c7 | ||
|
|
f8280ec8ee | ||
|
|
6c26f8967b | ||
|
|
e1424b851c | ||
|
|
d14e609579 | ||
|
|
8c0627ff27 | ||
|
|
18c133d096 | ||
|
|
0061ecea78 | ||
|
|
d01cf8319d | ||
|
|
843895d876 | ||
|
|
7c99ed39e4 | ||
|
|
71be59b2e9 | ||
|
|
1c20512ecc | ||
|
|
90342c552f | ||
|
|
b396c8a695 | ||
|
|
6996e30889 | ||
|
|
7ba793fad8 | ||
|
|
f11f836518 | ||
|
|
04fe0fcec8 | ||
|
|
799835a629 | ||
|
|
4e7da4108b | ||
|
|
7c7b082b3a | ||
|
|
38d6c51921 | ||
|
|
1738cbdd97 | ||
|
|
2e885b76a1 | ||
|
|
f94680e487 | ||
|
|
c682a58842 | ||
|
|
921cf871ee | ||
|
|
d5b1593aca | ||
|
|
6676b4e2a4 | ||
|
|
5f30ddcfca | ||
|
|
41d0de539d | ||
|
|
e254ee3203 | ||
|
|
6d42360549 | ||
|
|
70c5143445 | ||
|
|
41b66b18f5 | ||
|
|
dda0720ed4 |
2
.github/workflows/main.yml
vendored
2
.github/workflows/main.yml
vendored
@@ -16,6 +16,8 @@ jobs:
|
||||
args: "--target aarch64-apple-darwin"
|
||||
- platform: "macos-latest" # for Intel based macs.
|
||||
args: "--target x86_64-apple-darwin"
|
||||
- platform: "macos-latest" # for Intel based macs.
|
||||
args: "--target universal-apple-darwin"
|
||||
#- platform: 'ubuntu-22.04'
|
||||
# args: ''
|
||||
#- platform: 'windows-latest'
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -35,4 +35,4 @@ dist/
|
||||
.DS_Store
|
||||
*.pem
|
||||
.vscode/
|
||||
ndb/
|
||||
.idea/
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Lume Desktop</title>
|
||||
</head>
|
||||
<body
|
||||
class="relative h-screen w-screen cursor-default select-none overflow-hidden font-sans text-black antialiased dark:text-white"
|
||||
>
|
||||
<div id="root" class="h-full w-full"></div>
|
||||
<script type="module" src="/src/app.tsx"></script>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Lume Desktop</title>
|
||||
</head>
|
||||
<body
|
||||
class="relative h-screen w-screen cursor-default select-none overflow-hidden font-sans text-black antialiased dark:text-white"
|
||||
>
|
||||
<div id="root" class="h-full w-full"></div>
|
||||
<script type="module" src="/src/app.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -9,56 +9,52 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lume/system": "workspace:^",
|
||||
"@getalby/bitcoin-connect-react": "^3.5.3",
|
||||
"@lume/icons": "workspace:^",
|
||||
"@lume/system": "workspace:^",
|
||||
"@lume/ui": "workspace:^",
|
||||
"@lume/utils": "workspace:^",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-hover-card": "^1.0.7",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@tanstack/query-sync-storage-persister": "^5.36.0",
|
||||
"@tanstack/react-query": "^5.36.0",
|
||||
"@tanstack/react-query-persist-client": "^5.36.0",
|
||||
"@tanstack/react-router": "1.32.5",
|
||||
"i18next": "^23.11.4",
|
||||
"@radix-ui/react-avatar": "^1.1.0",
|
||||
"@radix-ui/react-checkbox": "^1.1.1",
|
||||
"@radix-ui/react-popover": "^1.1.1",
|
||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.2",
|
||||
"@tanstack/query-persist-client-core": "^5.51.9",
|
||||
"@tanstack/react-query": "^5.51.9",
|
||||
"@tanstack/react-router": "^1.45.4",
|
||||
"embla-carousel-react": "^8.1.6",
|
||||
"i18next": "^23.12.1",
|
||||
"i18next-resources-to-backend": "^1.2.1",
|
||||
"minidenticons": "^4.2.1",
|
||||
"nanoid": "^5.0.7",
|
||||
"nostr-tools": "^2.5.2",
|
||||
"nostr-tools": "^2.7.1",
|
||||
"react": "^18.3.1",
|
||||
"react-currency-input-field": "^3.8.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.51.4",
|
||||
"react-hotkeys-hook": "^4.5.0",
|
||||
"react-i18next": "^14.1.1",
|
||||
"react-hook-form": "^7.52.1",
|
||||
"react-i18next": "^14.1.3",
|
||||
"react-string-replace": "^1.1.1",
|
||||
"slate": "^0.103.0",
|
||||
"slate-react": "^0.102.0",
|
||||
"sonner": "^1.4.41",
|
||||
"use-debounce": "^10.0.0",
|
||||
"slate-react": "^0.105.0",
|
||||
"use-debounce": "^10.0.1",
|
||||
"virtua": "^0.31.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lume/tailwindcss": "workspace:^",
|
||||
"@lume/tsconfig": "workspace:^",
|
||||
"@lume/types": "workspace:^",
|
||||
"@tanstack/router-devtools": "^1.32.5",
|
||||
"@tanstack/router-vite-plugin": "^1.32.2",
|
||||
"@types/react": "^18.3.2",
|
||||
"@tanstack/router-devtools": "^1.45.4",
|
||||
"@tanstack/router-vite-plugin": "^1.45.3",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.11",
|
||||
"vite-plugin-top-level-await": "^1.4.1",
|
||||
"postcss": "^8.4.39",
|
||||
"tailwindcss": "^3.4.6",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.3.4",
|
||||
"vite-tsconfig-paths": "^4.3.2"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
apps/desktop2/public/404.jpg
Normal file
BIN
apps/desktop2/public/404.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/desktop2/public/poster_1.jpeg
Normal file
BIN
apps/desktop2/public/poster_1.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 171 KiB |
BIN
apps/desktop2/public/poster_2.jpeg
Normal file
BIN
apps/desktop2/public/poster_2.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 201 KiB |
BIN
apps/desktop2/public/poster_3.jpeg
Normal file
BIN
apps/desktop2/public/poster_3.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
BIN
apps/desktop2/public/poster_4.jpeg
Normal file
BIN
apps/desktop2/public/poster_4.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
@@ -2,18 +2,6 @@
|
||||
@tailwind utilities;
|
||||
@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 {
|
||||
.content-break {
|
||||
word-break: break-word;
|
||||
|
||||
@@ -1,38 +1,19 @@
|
||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
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 { 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 persister = createSyncStoragePersister({
|
||||
storage: window.localStorage,
|
||||
});
|
||||
|
||||
// Set up a Router instance
|
||||
const platform = type();
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
context: {
|
||||
queryClient,
|
||||
platform: os,
|
||||
},
|
||||
context: { queryClient, platform },
|
||||
Wrap: ({ children }) => {
|
||||
return (
|
||||
<I18nextProvider i18n={i18n} defaultNS={"translation"}>
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{ persister }}
|
||||
>
|
||||
{children}
|
||||
</PersistQueryClientProvider>
|
||||
</I18nextProvider>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { cn } from "@lume/utils";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import {
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
type SetStateAction,
|
||||
useState,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function AvatarUploader({
|
||||
setPicture,
|
||||
@@ -27,7 +27,7 @@ export function AvatarUploader({
|
||||
setPicture(image);
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(String(e));
|
||||
await message(String(e), { title: "Lume", kind: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,97 +1,112 @@
|
||||
import { CancelIcon, CheckIcon } from "@lume/icons";
|
||||
import { CheckIcon, HorizontalDotsIcon } from "@lume/icons";
|
||||
import type { LumeColumn } from "@lume/types";
|
||||
import { cn } from "@lume/utils";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrent } from "@tauri-apps/api/webviewWindow";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
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,
|
||||
account,
|
||||
isScroll,
|
||||
isResize,
|
||||
}: {
|
||||
column: LumeColumn;
|
||||
account: string;
|
||||
isScroll: boolean;
|
||||
isResize: boolean;
|
||||
}) {
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const webviewLabel = `column-${account}_${column.label}`;
|
||||
|
||||
const [isCreated, setIsCreated] = useState(false);
|
||||
|
||||
const repositionWebview = async () => {
|
||||
const repositionWebview = useCallback(async () => {
|
||||
const newRect = container.current.getBoundingClientRect();
|
||||
await invoke("reposition_column", {
|
||||
label: webviewLabel,
|
||||
x: newRect.x,
|
||||
y: newRect.y,
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
const resizeWebview = async () => {
|
||||
const resizeWebview = useCallback(async () => {
|
||||
const newRect = container.current.getBoundingClientRect();
|
||||
await invoke("resize_column", {
|
||||
label: webviewLabel,
|
||||
width: newRect.width,
|
||||
height: newRect.height,
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCreated) resizeWebview();
|
||||
}, [isResize]);
|
||||
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(() => {
|
||||
if (isScroll && isCreated) repositionWebview();
|
||||
}, [isScroll]);
|
||||
if (!container?.current) return;
|
||||
|
||||
useEffect(() => {
|
||||
const rect = container.current.getBoundingClientRect();
|
||||
const url = `${column.content}?account=${account}&label=${column.label}&name=${column.name}`;
|
||||
|
||||
// create new webview
|
||||
invoke("create_column", {
|
||||
const prop = {
|
||||
label: webviewLabel,
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
url,
|
||||
}).then(() => setIsCreated(true));
|
||||
};
|
||||
|
||||
// create new webview
|
||||
invoke("create_column", { column: prop }).then(() => {
|
||||
console.log("created: ", webviewLabel);
|
||||
setIsCreated(true);
|
||||
});
|
||||
|
||||
// close webview when unmounted
|
||||
return () => {
|
||||
invoke("close_column", { label: webviewLabel });
|
||||
invoke("close_column", { label: webviewLabel }).then(() => {
|
||||
console.log("closed: ", webviewLabel);
|
||||
});
|
||||
};
|
||||
}, [account]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-[440px] shrink-0 p-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col w-full h-full rounded-xl",
|
||||
column.label !== "open"
|
||||
? "bg-black/5 dark:bg-white/5 backdrop-blur-sm"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{column.label !== "open" ? (
|
||||
<Header label={column.label} name={column.name} />
|
||||
) : null}
|
||||
<div className="h-full w-[480px] shrink-0 p-2">
|
||||
<div className="flex flex-col w-full h-full rounded-xl bg-black/5 dark:bg-white/10">
|
||||
<Header
|
||||
label={column.label}
|
||||
webview={webviewLabel}
|
||||
name={column.name}
|
||||
/>
|
||||
<div ref={container} className="flex-1 w-full h-full" />
|
||||
</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 [isChanged, setIsChanged] = useState(false);
|
||||
|
||||
const saveNewTitle = async () => {
|
||||
const mainWindow = getCurrent();
|
||||
const mainWindow = getCurrentWindow();
|
||||
await mainWindow.emit("columns", { type: "set_title", label, title });
|
||||
|
||||
// update search params
|
||||
@@ -102,20 +117,69 @@ function Header({ label, name }: { label: string; name: string }) {
|
||||
setIsChanged(false);
|
||||
};
|
||||
|
||||
const close = async () => {
|
||||
const mainWindow = getCurrent();
|
||||
await mainWindow.emit("columns", { type: "remove", label });
|
||||
};
|
||||
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
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(() => {
|
||||
if (title.length !== name.length) setIsChanged(true);
|
||||
}, [title]);
|
||||
|
||||
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="shrink-0 h-9 flex items-center justify-center">
|
||||
<div className="relative flex gap-2 items-center">
|
||||
<div className="flex items-center justify-center shrink-0 h-7">
|
||||
<div className="relative flex items-center gap-2">
|
||||
<div
|
||||
contentEditable
|
||||
suppressContentEditableWarning={true}
|
||||
@@ -137,10 +201,10 @@ function Header({ label, name }: { label: string; name: string }) {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => close()}
|
||||
className="size-7 inline-flex hover:bg-black/10 rounded-lg dark:hover:bg-white/10 items-center justify-center text-neutral-600 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200"
|
||||
onClick={(e) => showContextMenu(e)}
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,34 +1,28 @@
|
||||
import { ThreadIcon } from "@lume/icons";
|
||||
import type { NostrEvent } from "@lume/types";
|
||||
import { Note } from "@/components/note";
|
||||
import { ThreadIcon } from "@lume/icons";
|
||||
import type { LumeEvent } from "@lume/system";
|
||||
import { cn } from "@lume/utils";
|
||||
import { LumeEvent } from "@lume/system";
|
||||
import { useMemo } from "react";
|
||||
import { memo, useMemo } from "react";
|
||||
|
||||
export function Conversation({
|
||||
export const Conversation = memo(function Conversation({
|
||||
event,
|
||||
gossip,
|
||||
className,
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
gossip?: boolean;
|
||||
event: LumeEvent;
|
||||
className?: string;
|
||||
}) {
|
||||
const thread = useMemo(
|
||||
() => LumeEvent.getEventThread(event.tags, gossip),
|
||||
[event],
|
||||
);
|
||||
const thread = useMemo(() => event.thread, [event]);
|
||||
|
||||
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",
|
||||
"bg-white dark:bg-black/20 rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<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="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||
<ThreadIcon className="size-4" />
|
||||
@@ -36,18 +30,18 @@ export function Conversation({
|
||||
</div>
|
||||
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
|
||||
</div>
|
||||
{thread?.reply ? <Note.Child eventId={thread?.reply} /> : null}
|
||||
{thread?.reply?.id ? <Note.Child event={thread?.reply} /> : null}
|
||||
<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 />
|
||||
</div>
|
||||
<Note.Content className="px-3" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center h-14 px-3">
|
||||
<div className="flex items-center px-3 h-14">
|
||||
<Note.Open />
|
||||
</div>
|
||||
</Note.Root>
|
||||
</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(
|
||||
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
||||
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",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { QuoteIcon, RepostIcon } from "@lume/icons";
|
||||
import { cn } from "@lume/utils";
|
||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { useNoteContext } from "../provider";
|
||||
import { RepostIcon } from "@lume/icons";
|
||||
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 }) {
|
||||
const event = useNoteContext();
|
||||
const { settings } = useRouteContext({ strict: false });
|
||||
|
||||
const [t] = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isRepost, setIsRepost] = useState(false);
|
||||
|
||||
const repost = async () => {
|
||||
if (isRepost) return;
|
||||
|
||||
try {
|
||||
if (isRepost) return;
|
||||
setLoading(true);
|
||||
|
||||
// repost
|
||||
@@ -27,74 +27,55 @@ export function NoteRepost({ large = false }: { large?: boolean }) {
|
||||
// update state
|
||||
setLoading(false);
|
||||
setIsRepost(true);
|
||||
|
||||
// notify
|
||||
toast.success("You've reposted this post successfully");
|
||||
} catch (e) {
|
||||
} catch {
|
||||
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 (
|
||||
<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 text-neutral-800 dark:text-neutral-200 rounded-full",
|
||||
large
|
||||
? "bg-neutral-100 dark:bg-white/10 h-7 gap-1.5 w-24 text-sm font-medium hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
|
||||
: "size-7",
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<RepostIcon
|
||||
className={cn("size-4", isRepost ? "text-blue-500" : "")}
|
||||
/>
|
||||
)}
|
||||
{large ? "Repost" : null}
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
</DropdownMenu.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
||||
{t("note.buttons.repost")}
|
||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl bg-black p-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => repost()}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||
>
|
||||
<RepostIcon className="size-4" />
|
||||
{t("note.buttons.repost")}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => showContextMenu(e)}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
||||
large
|
||||
? "rounded-full h-7 gap-1.5 w-24 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
|
||||
: "size-7",
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<RepostIcon className={cn("size-4", isRepost ? "text-blue-500" : "")} />
|
||||
)}
|
||||
{large ? "Repost" : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { ZapIcon } from "@lume/icons";
|
||||
import { LumeWindow } from "@lume/system";
|
||||
import { cn } from "@lume/utils";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useNoteContext } from "../provider";
|
||||
import { cn } from "@lume/utils";
|
||||
import { LumeWindow } from "@lume/system";
|
||||
|
||||
export function NoteZap({ large = false }: { large?: boolean }) {
|
||||
const event = useNoteContext();
|
||||
const { settings } = useRouteContext({ strict: false });
|
||||
|
||||
if (!settings.zap) return null;
|
||||
if (!settings.display_zap_button) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openZap(event.id, event.pubkey)}
|
||||
onClick={() => LumeWindow.openZap(event.id)}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
||||
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",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -2,32 +2,33 @@ import { useEvent } from "@lume/system";
|
||||
import { cn } from "@lume/utils";
|
||||
import { Note } from ".";
|
||||
import { InfoIcon } from "@lume/icons";
|
||||
import type { EventTag } from "@lume/types";
|
||||
|
||||
export function NoteChild({
|
||||
eventId,
|
||||
event,
|
||||
isRoot,
|
||||
}: {
|
||||
eventId: string;
|
||||
event: EventTag;
|
||||
isRoot?: boolean;
|
||||
}) {
|
||||
const { isLoading, isError, data } = useEvent(eventId);
|
||||
const { isLoading, isError, data } = useEvent(event.id, event.relayHint);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="pt-3 px-3 flex items-center gap-2">
|
||||
<div className="size-8 shrink-0 rounded-full 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="flex items-center gap-2 px-3 pt-3">
|
||||
<div className="rounded-full size-8 shrink-0 bg-neutral-200 dark:bg-neutral-800 animate-pulse" />
|
||||
<div className="w-2/3 h-4 rounded-md animate-pulse bg-neutral-200 dark:bg-neutral-800" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="pt-3 px-3 flex items-center gap-2">
|
||||
<div className="size-8 shrink-0 rounded-full bg-red-500 text-white inline-flex items-center justify-center">
|
||||
<div className="flex items-center gap-2 px-3 pt-3">
|
||||
<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-red-500 text-sm">
|
||||
<p className="text-sm text-red-500">
|
||||
Event not found with your current relay set
|
||||
</p>
|
||||
</div>
|
||||
@@ -37,7 +38,7 @@ export function NoteChild({
|
||||
return (
|
||||
<Note.Provider event={data}>
|
||||
<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 />
|
||||
</div>
|
||||
<Note.Content className="px-3" />
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { NOSTR_EVENTS, NOSTR_MENTIONS, cn, parser } from "@lume/utils";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { cn } from "@lume/utils";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { nanoid } from "nanoid";
|
||||
import { type ReactNode, useMemo, useState } from "react";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
import { Hashtag } from "./mentions/hashtag";
|
||||
import { MentionNote } from "./mentions/note";
|
||||
@@ -7,7 +9,6 @@ import { MentionUser } from "./mentions/user";
|
||||
import { Images } from "./preview/images";
|
||||
import { Videos } from "./preview/videos";
|
||||
import { useNoteContext } from "./provider";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export function NoteContent({
|
||||
quote = true,
|
||||
@@ -20,55 +21,48 @@ export function NoteContent({
|
||||
clean?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { settings } = useRouteContext({ strict: false });
|
||||
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 {
|
||||
if (hashtags.length) {
|
||||
for (const hashtag of hashtags) {
|
||||
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
|
||||
richContent = reactStringReplace(richContent, regex, (_, index) => {
|
||||
return <Hashtag key={hashtag + index} tag={hashtag} />;
|
||||
});
|
||||
// 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) {
|
||||
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
|
||||
richContent = reactStringReplace(richContent, regex, (_, index) => {
|
||||
return <Hashtag key={hashtag + index} tag={hashtag} />;
|
||||
});
|
||||
}
|
||||
|
||||
for (const event of events) {
|
||||
if (quote) {
|
||||
richContent = reactStringReplace(richContent, event, (_, index) => (
|
||||
<MentionNote key={event + index} eventId={event} />
|
||||
));
|
||||
}
|
||||
|
||||
if (!quote && clean) {
|
||||
richContent = reactStringReplace(richContent, event, () => null);
|
||||
}
|
||||
}
|
||||
|
||||
if (events.length) {
|
||||
for (const event of events) {
|
||||
if (quote) {
|
||||
richContent = reactStringReplace(richContent, event, (_, index) => (
|
||||
<MentionNote key={event + index} eventId={event} />
|
||||
));
|
||||
}
|
||||
|
||||
if (!quote && clean) {
|
||||
richContent = reactStringReplace(richContent, event, () => null);
|
||||
}
|
||||
for (const user of mentions) {
|
||||
if (mention) {
|
||||
richContent = reactStringReplace(richContent, user, (_, index) => (
|
||||
<MentionUser key={user + index} pubkey={user} />
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (mentions.length) {
|
||||
for (const user of mentions) {
|
||||
if (mention) {
|
||||
richContent = reactStringReplace(richContent, user, (_, index) => (
|
||||
<MentionUser key={user + index} pubkey={user} />
|
||||
));
|
||||
}
|
||||
|
||||
if (!mention && clean) {
|
||||
richContent = reactStringReplace(richContent, user, () => null);
|
||||
}
|
||||
if (!mention && clean) {
|
||||
richContent = reactStringReplace(richContent, user, () => null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +75,7 @@ export function NoteContent({
|
||||
href={match}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="line-clamp-1 text-blue-500 hover:text-blue-600"
|
||||
className="inline text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
{match}
|
||||
</a>
|
||||
@@ -92,25 +86,56 @@ export function NoteContent({
|
||||
<div key={nanoid()} className="h-3" />
|
||||
));
|
||||
|
||||
return { content: richContent, images, videos };
|
||||
} catch (e) {
|
||||
return { content, images, videos };
|
||||
return richContent;
|
||||
} catch {
|
||||
return event.content;
|
||||
}
|
||||
}, []);
|
||||
}, [event.content]);
|
||||
|
||||
const [blurred, setBlurred] = useState(() => warning?.length > 0);
|
||||
|
||||
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
|
||||
className={cn(
|
||||
"select-text text-[15px] text-balance break-words overflow-hidden",
|
||||
event.content.length > 500 ? "max-h-[300px] gradient-mask-b-0" : "",
|
||||
"select-text text-pretty content-break overflow-hidden",
|
||||
event.meta?.content.length > 500
|
||||
? "max-h-[250px] gradient-mask-b-0"
|
||||
: "",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{data.content}
|
||||
{content}
|
||||
</div>
|
||||
{data.images.length ? <Images urls={data.images} /> : null}
|
||||
{data.videos.length ? <Videos urls={data.videos} /> : null}
|
||||
{settings.display_media ? (
|
||||
<>
|
||||
{event.meta?.images.length ? (
|
||||
<Images urls={event.meta.images} />
|
||||
) : null}
|
||||
{event.meta?.videos.length ? (
|
||||
<Videos urls={event.meta.videos} />
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
import type { Settings } from "@lume/types";
|
||||
import {
|
||||
AUDIOS,
|
||||
IMAGES,
|
||||
NOSTR_EVENTS,
|
||||
NOSTR_MENTIONS,
|
||||
VIDEOS,
|
||||
cn,
|
||||
} from "@lume/utils";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { cn } from "@lume/utils";
|
||||
import { nanoid } from "nanoid";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
@@ -19,135 +10,85 @@ import { VideoPreview } from "./preview/video";
|
||||
import { useNoteContext } from "./provider";
|
||||
|
||||
export function NoteContentLarge({
|
||||
compact = true,
|
||||
className,
|
||||
}: {
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { settings }: { settings: Settings } = useRouteContext({
|
||||
strict: false,
|
||||
});
|
||||
const event = useNoteContext();
|
||||
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 {
|
||||
if (hashtags.length) {
|
||||
for (const hashtag of hashtags) {
|
||||
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
|
||||
parsedContent = reactStringReplace(parsedContent, regex, () => {
|
||||
return <Hashtag key={nanoid()} tag={hashtag} />;
|
||||
});
|
||||
}
|
||||
}
|
||||
// Get parsed meta
|
||||
const { images, videos, hashtags, events, mentions } = event.meta;
|
||||
|
||||
if (events.length) {
|
||||
for (const event of events) {
|
||||
parsedContent = reactStringReplace(
|
||||
parsedContent,
|
||||
event,
|
||||
(match, i) => <MentionNote key={match + i} eventId={event} />,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Define rich content
|
||||
let richContent: ReactNode[] | string = event.content;
|
||||
|
||||
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" />
|
||||
for (const hashtag of hashtags) {
|
||||
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
|
||||
richContent = reactStringReplace(richContent, regex, () => (
|
||||
<Hashtag key={nanoid()} tag={hashtag} />
|
||||
));
|
||||
}
|
||||
|
||||
parsedContent = reactStringReplace(
|
||||
parsedContent,
|
||||
/[\r]?\n[\r]?\n/g,
|
||||
(_, index) => <br key={event.id + "_br_" + index} />,
|
||||
for (const event of events) {
|
||||
richContent = reactStringReplace(richContent, event, (match, i) => (
|
||||
<MentionNote key={match + i} eventId={event} />
|
||||
));
|
||||
}
|
||||
|
||||
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) {
|
||||
return text;
|
||||
console.log("[parser]: ", e);
|
||||
return event.content;
|
||||
}
|
||||
}, []);
|
||||
}, [event.content]);
|
||||
|
||||
return (
|
||||
<div className={cn("select-text", className)}>
|
||||
<div className="text-[15px] text-balance content-break leading-normal">
|
||||
{content}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"select-text leading-normal text-pretty content-break",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { NoteActivity } from "./activity";
|
||||
import { NoteOpenThread } from "./buttons/open";
|
||||
import { NoteReply } from "./buttons/reply";
|
||||
import { NoteRepost } from "./buttons/repost";
|
||||
@@ -23,5 +22,4 @@ export const Note = {
|
||||
Zap: NoteZap,
|
||||
Open: NoteOpenThread,
|
||||
Child: NoteChild,
|
||||
Activity: NoteActivity,
|
||||
};
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
export function Hashtag({ tag }: { tag: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="break-all cursor-default leading-normal group"
|
||||
>
|
||||
<span className="leading-normal break-all cursor-default group text-start">
|
||||
<span className="text-blue-500">#</span>
|
||||
<span className="underline-offset-1 underline decoration-2 decoration-blue-200 dark:decoration-blue-800 group-hover:decoration-blue-500">
|
||||
<span className="underline underline-offset-1 decoration-2 decoration-blue-200 dark:decoration-blue-800 group-hover:decoration-blue-500">
|
||||
{tag.replace("#", "")}
|
||||
</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 { LinkIcon } from "@lume/icons";
|
||||
import { LumeWindow, useEvent } from "@lume/system";
|
||||
import { Spinner } from "@lume/ui";
|
||||
|
||||
export function MentionNote({
|
||||
@@ -12,65 +10,67 @@ export function MentionNote({
|
||||
eventId: string;
|
||||
openable?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { isLoading, isError, data } = useEvent(eventId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mt-2 w-full flex h-20 items-center justify-center rounded-xl border border-black/10 dark:border-white/10">
|
||||
<Spinner className="size-5" />
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="mt-2 w-full rounded-xl border border-black/10 p-3 dark:border-white/10">
|
||||
{t("note.error")}
|
||||
<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">
|
||||
<p className="text-sm font-medium text-red-500">
|
||||
Event not found with your current relay set
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex w-full cursor-default flex-col rounded-xl border border-black/10 dark:border-white/10">
|
||||
<User.Provider pubkey={data.pubkey}>
|
||||
<User.Root className="flex h-12 items-center gap-2 px-3">
|
||||
<User.Avatar className="size-6 shrink-0 rounded-full object-cover" />
|
||||
<div className="inline-flex flex-1 items-center gap-2">
|
||||
<User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" />
|
||||
<span className="text-neutral-600 dark:text-neutral-400">·</span>
|
||||
<User.Time
|
||||
time={data.created_at}
|
||||
className="text-neutral-600 dark:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<div
|
||||
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}
|
||||
</div>
|
||||
{openable ? (
|
||||
<div className="flex h-14 items-center justify-end px-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
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"
|
||||
>
|
||||
View post
|
||||
<LinkIcon className="size-4" />
|
||||
</button>
|
||||
<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.Root className="flex items-center gap-2 h-8">
|
||||
<User.Avatar className="rounded-full size-6" />
|
||||
<div className="inline-flex items-center flex-1 gap-2">
|
||||
<User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" />
|
||||
<span className="text-neutral-600 dark:text-neutral-400">·</span>
|
||||
<User.Time
|
||||
time={data.created_at}
|
||||
className="text-neutral-600 dark:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<div className="select-text text-pretty line-clamp-3 content-break leading-normal">
|
||||
{data.content}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-3" />
|
||||
)}
|
||||
{openable ? (
|
||||
<div className="flex items-center justify-start mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
LumeWindow.openEvent(data);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-blue-500 text-sm"
|
||||
>
|
||||
View post
|
||||
<LinkIcon className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-3" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,99 +1,62 @@
|
||||
import { HorizontalDotsIcon } from "@lume/icons";
|
||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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() {
|
||||
const { t } = useTranslation();
|
||||
const event = useNoteContext();
|
||||
|
||||
const copyID = async () => {
|
||||
await writeText(await event.idAsBech32());
|
||||
};
|
||||
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const copyRaw = async () => {
|
||||
await writeText(JSON.stringify(event));
|
||||
};
|
||||
const menuItems = await Promise.all([
|
||||
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 () => {
|
||||
await writeText(await event.pubkeyAsBech32());
|
||||
};
|
||||
const menu = await Menu.new({
|
||||
items: menuItems,
|
||||
});
|
||||
|
||||
const copyLink = async () => {
|
||||
await writeText(`https://njump.me/${await event.idAsBech32()}`);
|
||||
};
|
||||
await menu.popup().catch((e) => console.error(e));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group inline-flex size-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
|
||||
>
|
||||
<HorizontalDotsIcon className="size-5" />
|
||||
</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>
|
||||
<button
|
||||
type="button"
|
||||
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" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,61 +1,48 @@
|
||||
import { CheckCircleIcon, DownloadIcon } from "@lume/icons";
|
||||
import { downloadDir } from "@tauri-apps/api/path";
|
||||
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { download } from "@tauri-apps/plugin-upload";
|
||||
import { type SyntheticEvent, useState } from "react";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { open } from "@tauri-apps/plugin-shell";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function ImagePreview({ url }: { url: string }) {
|
||||
const [downloaded, setDownloaded] = useState(false);
|
||||
const { settings } = useRouteContext({ strict: false });
|
||||
|
||||
const downloadImage = async (e: { stopPropagation: () => void }) => {
|
||||
try {
|
||||
e.stopPropagation();
|
||||
|
||||
const downloadDirPath = await downloadDir();
|
||||
const filename = url.substring(url.lastIndexOf("/") + 1);
|
||||
await download(url, `${downloadDirPath}/${filename}`);
|
||||
|
||||
setDownloaded(true);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
const imageUrl = useMemo(() => {
|
||||
if (settings.image_resize_service.length) {
|
||||
const newUrl = `${settings.image_resize_service}?url=${url}&ll&af&default=1&n=-1`;
|
||||
return newUrl;
|
||||
} else {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
}, [settings.image_resize_service]);
|
||||
|
||||
const open = async () => {
|
||||
const name = new URL(url).pathname.split("/").pop();
|
||||
return new WebviewWindow("image-viewer", {
|
||||
url,
|
||||
title: name,
|
||||
});
|
||||
};
|
||||
|
||||
const fallback = (event: SyntheticEvent<HTMLImageElement, Event>) => {
|
||||
event.currentTarget.src = "/fallback-image.jpg";
|
||||
};
|
||||
if (!settings.display_media) {
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
|
||||
<div onClick={() => open()} className="group relative my-1">
|
||||
<div className="my-1">
|
||||
<img
|
||||
src={url}
|
||||
src={imageUrl}
|
||||
alt={url}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: "auto" }}
|
||||
onError={fallback}
|
||||
className="max-h-[600px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||
className="max-h-[400px] max-w-[400px] h-auto 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,62 +1,176 @@
|
||||
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { Carousel, CarouselItem } from "@lume/ui";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
|
||||
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[] }) {
|
||||
const open = async (url: string) => {
|
||||
const name = new URL(url).pathname
|
||||
.split("/")
|
||||
.pop()
|
||||
.replace(/[^a-zA-Z ]/g, "");
|
||||
const label = `viewer-${name}`;
|
||||
const window = WebviewWindow.getByLabel(label);
|
||||
const { settings } = useRouteContext({ strict: false });
|
||||
|
||||
if (!window) {
|
||||
const newWindow = new WebviewWindow(label, {
|
||||
url,
|
||||
title: "Image Viewer",
|
||||
width: 800,
|
||||
height: 800,
|
||||
titleBarStyle: "overlay",
|
||||
});
|
||||
const [slidesInView, setSlidesInView] = useState([]);
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||
dragFree: true,
|
||||
align: "start",
|
||||
watchSlides: false,
|
||||
});
|
||||
|
||||
return newWindow;
|
||||
const imageUrls = useMemo(() => {
|
||||
if (settings.image_resize_service.length) {
|
||||
let newUrls: string[];
|
||||
|
||||
if (urls.length === 1) {
|
||||
newUrls = urls.map(
|
||||
(url) =>
|
||||
`${settings.image_resize_service}?url=${url}&ll&af&default=1&n=-1`,
|
||||
);
|
||||
} else {
|
||||
newUrls = urls.map(
|
||||
(url) =>
|
||||
`${settings.image_resize_service}?url=${url}&w=480&h=640&ll&af&default=1&n=-1`,
|
||||
);
|
||||
}
|
||||
|
||||
return newUrls;
|
||||
} else {
|
||||
return urls;
|
||||
}
|
||||
}, [settings.image_resize_service]);
|
||||
|
||||
const scrollPrev = useCallback(() => {
|
||||
if (emblaApi) emblaApi.scrollPrev();
|
||||
}, [emblaApi]);
|
||||
|
||||
const scrollNext = useCallback(() => {
|
||||
if (emblaApi) emblaApi.scrollNext();
|
||||
}, [emblaApi]);
|
||||
|
||||
const updateSlidesInView = useCallback((emblaApi) => {
|
||||
setSlidesInView((slidesInView) => {
|
||||
if (slidesInView.length === emblaApi.slideNodes().length) {
|
||||
emblaApi.off("slidesInView", updateSlidesInView);
|
||||
}
|
||||
|
||||
const inView = emblaApi
|
||||
.slidesInView()
|
||||
.filter((index) => !slidesInView.includes(index));
|
||||
|
||||
return slidesInView.concat(inView);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (emblaApi && urls.length > 1) {
|
||||
updateSlidesInView(emblaApi);
|
||||
|
||||
emblaApi.on("slidesInView", updateSlidesInView);
|
||||
emblaApi.on("reInit", updateSlidesInView);
|
||||
}
|
||||
|
||||
return await window.setFocus();
|
||||
};
|
||||
return () => {
|
||||
emblaApi?.off("slidesInView", updateSlidesInView);
|
||||
emblaApi?.off("reInit", updateSlidesInView);
|
||||
};
|
||||
}, [emblaApi, updateSlidesInView]);
|
||||
|
||||
if (urls.length === 1) {
|
||||
return (
|
||||
<div className="group px-3">
|
||||
<div className="px-3 group">
|
||||
<img
|
||||
src={urls[0]}
|
||||
src={imageUrls[0]}
|
||||
alt={urls[0]}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: "auto" }}
|
||||
className="max-h-[400px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||
onClick={() => open(urls[0])}
|
||||
onClick={() => urls[0]}
|
||||
onKeyDown={() => urls[0]}
|
||||
onError={({ currentTarget }) => {
|
||||
currentTarget.onerror = null;
|
||||
currentTarget.src = "/404.jpg";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Carousel
|
||||
items={urls}
|
||||
renderItem={({ item, isSnapPoint }) => (
|
||||
<CarouselItem key={item} isSnapPoint={isSnapPoint}>
|
||||
<img
|
||||
src={item}
|
||||
alt={item}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: "auto" }}
|
||||
className="w-full h-full object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||
onClick={() => open(item)}
|
||||
/>
|
||||
</CarouselItem>
|
||||
)}
|
||||
/>
|
||||
<div className="relative px-3 overflow-hidden group">
|
||||
<div ref={emblaRef} className="w-full h-[320px]">
|
||||
<div className="flex w-full gap-2 scrollbar-none">
|
||||
{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
|
||||
src={
|
||||
inView
|
||||
? url
|
||||
: "data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs="
|
||||
}
|
||||
data-src={url}
|
||||
alt={url}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: "auto" }}
|
||||
className="object-cover w-full h-full rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||
onClick={() => open(url)}
|
||||
onKeyDown={() => open(url)}
|
||||
onLoad={setLoaded}
|
||||
onError={({ currentTarget }) => {
|
||||
currentTarget.onerror = null;
|
||||
currentTarget.src = "/404.jpg";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useOpenGraph } from "@lume/utils";
|
||||
|
||||
function isImage(url: string) {
|
||||
return /^https?:\/\/.+\.(jpg|jpeg|png|webp|avif)$/.test(url);
|
||||
}
|
||||
|
||||
export function LinkPreview({ url }: { url: string }) {
|
||||
const domain = new URL(url);
|
||||
const { isLoading, isError, data } = useOpenGraph(url);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="my-1.5 flex w-full flex-col overflow-hidden rounded-2xl border border-black/10 p-3 dark:border-white/10">
|
||||
<div className="h-48 w-full shrink-0 animate-pulse bg-neutral-300 dark:bg-neutral-700" />
|
||||
<div className="flex flex-col gap-2 px-3 py-3">
|
||||
<div className="h-3 w-2/3 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
|
||||
<div className="h-3 w-3/4 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
|
||||
<span className="mt-2.5 text-sm leading-none text-neutral-600 dark:text-neutral-400">
|
||||
{domain.hostname}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data.title && !data.image && !data.description) {
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-block text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-block text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="my-1 flex w-full flex-col overflow-hidden rounded-2xl border border-black/10 dark:border-white/10"
|
||||
>
|
||||
{isImage(data.image) ? (
|
||||
<img
|
||||
src={data.image}
|
||||
alt={url}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-48 w-full shrink-0 rounded-t-lg bg-white object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex flex-col items-start p-3">
|
||||
<div className="flex flex-col items-start text-left">
|
||||
{data.title ? (
|
||||
<div className="content-break line-clamp-1 text-base font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{data.title}
|
||||
</div>
|
||||
) : null}
|
||||
{data.description ? (
|
||||
<div className="content-break mb-2 line-clamp-3 text-balance text-sm text-neutral-700 dark:text-neutral-400">
|
||||
{data.description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="break-all text-sm font-semibold text-blue-500">
|
||||
{domain.hostname}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,30 @@
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
|
||||
export function VideoPreview({ url }: { url: string }) {
|
||||
const { settings } = useRouteContext({ strict: false });
|
||||
|
||||
if (settings.display_media) {
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-1 overflow-hidden rounded-xl">
|
||||
<div className="my-1">
|
||||
<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
|
||||
muted
|
||||
>
|
||||
<source src={url} type="video/mp4" />
|
||||
<source src={`${url}#t=0.1`} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
|
||||
@@ -1,36 +1,18 @@
|
||||
import { Carousel, CarouselItem } from "@lume/ui";
|
||||
|
||||
export function Videos({ urls }: { urls: string[] }) {
|
||||
if (urls.length === 1) {
|
||||
return (
|
||||
<div className="group px-3">
|
||||
return (
|
||||
<div className="group px-3">
|
||||
{urls.map((url) => (
|
||||
<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
|
||||
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.
|
||||
</video>
|
||||
</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>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { LumeEvent } from "@lume/system";
|
||||
import type { NostrEvent } from "@lume/types";
|
||||
import type { LumeEvent } from "@lume/system";
|
||||
import { type ReactNode, createContext, useContext } from "react";
|
||||
|
||||
const NoteContext = createContext<LumeEvent>(null);
|
||||
@@ -8,14 +7,10 @@ export function NoteProvider({
|
||||
event,
|
||||
children,
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
event: LumeEvent;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const lumeEvent = new LumeEvent(event);
|
||||
|
||||
return (
|
||||
<NoteContext.Provider value={lumeEvent}>{children}</NoteContext.Provider>
|
||||
);
|
||||
return <NoteContext.Provider value={event}>{children}</NoteContext.Provider>;
|
||||
}
|
||||
|
||||
export function useNoteContext() {
|
||||
|
||||
@@ -1,61 +1,62 @@
|
||||
import { LumeWindow } from "@lume/system";
|
||||
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 { useNoteContext } from "./provider";
|
||||
import { LumeWindow } from "@lume/system";
|
||||
|
||||
export function NoteUser({ className }: { className?: string }) {
|
||||
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 (
|
||||
<User.Provider pubkey={event.pubkey}>
|
||||
<HoverCard.Root>
|
||||
<User.Root
|
||||
className={cn("flex items-start justify-between", className)}
|
||||
>
|
||||
<div className="flex w-full gap-2">
|
||||
<HoverCard.Trigger className="shrink-0">
|
||||
<User.Avatar className="size-8 rounded-full object-cover outline outline-1 -outline-offset-1 outline-black/15" />
|
||||
</HoverCard.Trigger>
|
||||
<div className="flex w-full items-center gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<User.Name className="font-semibold text-neutral-950 dark:text-neutral-50" />
|
||||
<User.NIP05 />
|
||||
</div>
|
||||
<div className="text-neutral-600 dark:text-neutral-400">·</div>
|
||||
<User.Time
|
||||
time={event.created_at}
|
||||
className="text-neutral-600 dark:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</User.Root>
|
||||
<HoverCard.Portal>
|
||||
<HoverCard.Content
|
||||
className="w-[300px] rounded-xl bg-black p-3 data-[side=bottom]:animate-slideUpAndFade data-[state=open]:transition-all dark:bg-white dark:shadow-none"
|
||||
sideOffset={5}
|
||||
side="right"
|
||||
<User.Root className={cn("flex items-start justify-between", className)}>
|
||||
<div className="flex w-full gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => showContextMenu(e)}
|
||||
className="shrink-0"
|
||||
>
|
||||
<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>
|
||||
<User.Avatar className="rounded-full size-8" />
|
||||
</button>
|
||||
<div className="flex items-center w-full gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<User.Name className="font-semibold text-neutral-950 dark:text-neutral-50" />
|
||||
<User.NIP05 />
|
||||
</div>
|
||||
<HoverCard.Arrow className="fill-black dark:fill-white" />
|
||||
</HoverCard.Content>
|
||||
</HoverCard.Portal>
|
||||
</HoverCard.Root>
|
||||
<div className="text-neutral-600 dark:text-neutral-400">·</div>
|
||||
<User.Time
|
||||
time={event.created_at}
|
||||
className="text-neutral-600 dark:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</User.Root>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +1,26 @@
|
||||
import { QuoteIcon } from "@lume/icons";
|
||||
import type { NostrEvent } from "@lume/types";
|
||||
import { Note } from "@/components/note";
|
||||
import { QuoteIcon } from "@lume/icons";
|
||||
import type { LumeEvent } from "@lume/system";
|
||||
import { cn } from "@lume/utils";
|
||||
import { memo } from "react";
|
||||
|
||||
export function Quote({
|
||||
export const Quote = memo(function Quote({
|
||||
event,
|
||||
className,
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
event: LumeEvent;
|
||||
className?: string;
|
||||
}) {
|
||||
const quoteEventId = event.tags.find(
|
||||
(tag) => tag[0] === "q" || tag[3] === "mention",
|
||||
)?.[1];
|
||||
|
||||
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",
|
||||
"bg-white dark:bg-black/20 rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<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="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||
<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>
|
||||
<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 />
|
||||
</div>
|
||||
<Note.Content className="px-3" quote={false} clean />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center h-14 px-3">
|
||||
<div className="flex items-center px-3 h-14">
|
||||
<Note.Open />
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,84 +1,82 @@
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { Note } from "@/components/note";
|
||||
import { User } from "@/components/user";
|
||||
import { type LumeEvent, NostrQuery } from "@lume/system";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { cn } from "@lume/utils";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { NostrEvent } from "@lume/types";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import { memo } from "react";
|
||||
|
||||
export function RepostNote({
|
||||
export const RepostNote = memo(function RepostNote({
|
||||
event,
|
||||
className,
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
event: LumeEvent;
|
||||
className?: string;
|
||||
}) {
|
||||
const {
|
||||
isLoading,
|
||||
isError,
|
||||
data: repostEvent,
|
||||
} = useQuery({
|
||||
queryKey: ["repost", event.id],
|
||||
const { isLoading, isError, data } = useQuery({
|
||||
queryKey: ["event", event.repostId],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
if (event.content.length > 50) {
|
||||
const embed: NostrEvent = JSON.parse(event.content);
|
||||
return embed;
|
||||
}
|
||||
|
||||
const id = event.tags.find((el) => el[0] === "e")?.[1];
|
||||
const repostEvent = await NostrQuery.getEvent(id);
|
||||
|
||||
return repostEvent;
|
||||
const data = await NostrQuery.getRepostEvent(event);
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(e);
|
||||
}
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
return (
|
||||
<Note.Root
|
||||
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,
|
||||
)}
|
||||
>
|
||||
<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 ? (
|
||||
<div className="flex h-20 items-center justify-center gap-2">
|
||||
<div className="flex items-center justify-center h-20 gap-2">
|
||||
<Spinner />
|
||||
Loading event...
|
||||
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
Loading event...
|
||||
</span>
|
||||
</div>
|
||||
) : isError || !repostEvent ? (
|
||||
<div className="flex h-20 items-center justify-center">
|
||||
) : isError || !data ? (
|
||||
<div className="flex items-center justify-center h-20">
|
||||
Event not found within your current relay set
|
||||
</div>
|
||||
) : (
|
||||
<Note.Provider event={repostEvent}>
|
||||
<Note.Provider event={data}>
|
||||
<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.Menu />
|
||||
</div>
|
||||
<Note.Content className="px-3" />
|
||||
<div className="mt-3 flex items-center gap-4 h-14 px-3">
|
||||
<Note.Open />
|
||||
<Note.Reply />
|
||||
<Note.Repost />
|
||||
<Note.Zap />
|
||||
<div className="flex items-center justify-between px-3 mt-3 h-14">
|
||||
<div className="inline-flex items-center gap-3">
|
||||
<Note.Open />
|
||||
<Note.Reply />
|
||||
<Note.Repost />
|
||||
<Note.Zap />
|
||||
</div>
|
||||
<div>
|
||||
<User.Provider pubkey={event.pubkey}>
|
||||
<User.Root className="flex items-center gap-2">
|
||||
<div className="text-sm font-medium text-neutral-800 dark:text-neutral-200">
|
||||
Reposted by
|
||||
</div>
|
||||
<User.Avatar className="rounded-full size-6" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</div>
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
)}
|
||||
</Note.Root>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
import type { NostrEvent } from "@lume/types";
|
||||
import { cn } from "@lume/utils";
|
||||
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,
|
||||
className,
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
event: LumeEvent;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Note.Provider event={event}>
|
||||
<Note.Root
|
||||
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,
|
||||
)}
|
||||
>
|
||||
<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.Menu />
|
||||
</div>
|
||||
<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.Reply />
|
||||
<Note.Repost />
|
||||
@@ -31,4 +32,4 @@ export function TextNote({
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,35 +1,74 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import * as Avatar from "@radix-ui/react-avatar";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { minidenticon } from "minidenticons";
|
||||
import { nanoid } from "nanoid";
|
||||
import { useMemo } from "react";
|
||||
import { useUserContext } from "./provider";
|
||||
|
||||
export function UserAvatar({ className }: { className?: string }) {
|
||||
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(
|
||||
minidenticon(user.pubkey || nanoid(), 90, 50),
|
||||
minidenticon(user.pubkey, 60, 50),
|
||||
)}`,
|
||||
[user],
|
||||
[user.pubkey],
|
||||
);
|
||||
|
||||
if (settings && !settings.display_avatar) {
|
||||
return (
|
||||
<Avatar.Root
|
||||
className={cn(
|
||||
"shrink-0 block overflow-hidden bg-neutral-200 dark:bg-neutral-800",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Avatar.Fallback delayMs={120}>
|
||||
<img
|
||||
src={fallback}
|
||||
alt={user.pubkey}
|
||||
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="shrink-0">
|
||||
<Avatar.Root
|
||||
className={cn(
|
||||
"shrink-0 block overflow-hidden bg-neutral-200 dark:bg-neutral-800",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Avatar.Image
|
||||
src={user.profile?.picture}
|
||||
src={picture}
|
||||
alt={user.pubkey}
|
||||
loading="eager"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className={cn("outline-[.5px] outline-black/5", className)}
|
||||
className="w-full aspect-square object-cover outline-[.5px] outline-black/5 content-visibility-auto contain-intrinsic-size-[auto]"
|
||||
/>
|
||||
<Avatar.Fallback delayMs={120}>
|
||||
<Avatar.Fallback>
|
||||
<img
|
||||
src={fallbackAvatar}
|
||||
src={fallback}
|
||||
alt={user.pubkey}
|
||||
className={cn("bg-black dark:bg-white", className)}
|
||||
className="size-full bg-black dark:bg-white outline-[.5px] outline-black/5 content-visibility-auto contain-intrinsic-size-[auto]"
|
||||
/>
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
|
||||
@@ -15,7 +15,7 @@ export function UserCover({ className }: { className?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (user && !user.profile.banner) {
|
||||
if (user && !user.profile?.banner) {
|
||||
return (
|
||||
<div
|
||||
className={cn("bg-gradient-to-b from-blue-400 to-teal-200", className)}
|
||||
@@ -25,7 +25,7 @@ export function UserCover({ className }: { className?: string }) {
|
||||
|
||||
return (
|
||||
<img
|
||||
src={user.profile.banner}
|
||||
src={user?.profile?.banner}
|
||||
alt="banner"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { useUserContext } from "./provider";
|
||||
import { NostrAccount } from "@lume/system";
|
||||
@@ -14,34 +13,30 @@ export function UserFollowButton({
|
||||
}) {
|
||||
const user = useUserContext();
|
||||
|
||||
const [t] = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [followed, setFollowed] = useState(false);
|
||||
|
||||
const toggleFollow = async () => {
|
||||
setLoading(true);
|
||||
if (!followed) {
|
||||
const add = await NostrAccount.follow(user.pubkey, user.profile?.name);
|
||||
if (add) setFollowed(true);
|
||||
} else {
|
||||
const remove = await NostrAccount.unfollow(user.pubkey);
|
||||
if (remove) setFollowed(false);
|
||||
|
||||
const toggle = await NostrAccount.toggleContact(user.pubkey);
|
||||
|
||||
if (toggle) {
|
||||
setFollowed((prev) => !prev);
|
||||
setLoading(false);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function status() {
|
||||
setLoading(true);
|
||||
let mounted = true;
|
||||
|
||||
const contacts = await NostrAccount.getContactList();
|
||||
if (contacts?.includes(user.pubkey)) {
|
||||
setFollowed(true);
|
||||
}
|
||||
NostrAccount.checkContact(user.pubkey).then((status) => {
|
||||
if (mounted) setFollowed(status);
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
status();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -55,10 +50,10 @@ export function UserFollowButton({
|
||||
<Spinner className="size-4" />
|
||||
) : followed ? (
|
||||
!simple ? (
|
||||
t("user.unfollow")
|
||||
"Unfollow"
|
||||
) : null
|
||||
) : (
|
||||
t("user.follow")
|
||||
"Follow"
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -4,19 +4,32 @@ import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useUserContext } from "./provider";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import { experimental_createPersister } from "@tanstack/query-persist-client-core";
|
||||
|
||||
export function UserNip05() {
|
||||
const user = useUserContext();
|
||||
const { isLoading, data: verified } = useQuery({
|
||||
queryKey: ["nip05", user?.pubkey],
|
||||
queryFn: async () => {
|
||||
if (!user.profile?.nip05?.length) return false;
|
||||
|
||||
const verify = await NostrQuery.verifyNip05(
|
||||
user.pubkey,
|
||||
user.profile?.nip05,
|
||||
);
|
||||
|
||||
return verify;
|
||||
},
|
||||
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;
|
||||
@@ -26,7 +39,7 @@ export function UserNip05() {
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger>
|
||||
{!isLoading && verified ? (
|
||||
<VerifiedIcon className="size-4 text-teal-500" />
|
||||
<VerifiedIcon className="text-teal-500 size-4" />
|
||||
) : null}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { Column } from "@/components/column";
|
||||
import { Toolbar } from "@/components/toolbar";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
|
||||
import { ArrowLeftIcon, ArrowRightIcon, PlusIcon } from "@lume/icons";
|
||||
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 { 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 { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { VList, type VListHandle } from "virtua";
|
||||
|
||||
export const Route = createFileRoute("/$account/home")({
|
||||
loader: async () => {
|
||||
const columns = NostrQuery.getColumns();
|
||||
const columns = await NostrQuery.getColumns();
|
||||
return columns;
|
||||
},
|
||||
component: Screen,
|
||||
@@ -22,65 +22,66 @@ export const Route = createFileRoute("/$account/home")({
|
||||
function Screen() {
|
||||
const { account } = Route.useParams();
|
||||
const initialColumnList = Route.useLoaderData();
|
||||
const vlistRef = useRef<VListHandle>(null);
|
||||
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [columns, setColumns] = useState([]);
|
||||
const [isScroll, setIsScroll] = useState(false);
|
||||
const [isResize, setIsResize] = useState(false);
|
||||
const [columns, setColumns] = useState<LumeColumn[]>([]);
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||
watchDrag: false,
|
||||
loop: false,
|
||||
});
|
||||
|
||||
const goLeft = () => {
|
||||
const prevIndex = Math.max(selectedIndex - 1, 0);
|
||||
setSelectedIndex(prevIndex);
|
||||
vlistRef.current.scrollToIndex(prevIndex, {
|
||||
align: "center",
|
||||
const scrollPrev = useCallback(() => {
|
||||
if (emblaApi) emblaApi.scrollPrev();
|
||||
}, [emblaApi]);
|
||||
|
||||
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 goRight = () => {
|
||||
const nextIndex = Math.min(selectedIndex + 1, columns.length - 1);
|
||||
setSelectedIndex(nextIndex);
|
||||
vlistRef.current.scrollToIndex(nextIndex, {
|
||||
align: "center",
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
const add = useDebouncedCallback((column: LumeColumn) => {
|
||||
// update col label
|
||||
column.label = `${column.label}-${nanoid()}`;
|
||||
|
||||
// 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",
|
||||
});
|
||||
column.label = `${column.label}-${nanoid()}`; // update col label
|
||||
setColumns((prev) => [column, ...prev]);
|
||||
}, 150);
|
||||
|
||||
const remove = useDebouncedCallback((label: string) => {
|
||||
const newCols = columns.filter((t) => t.label !== label);
|
||||
|
||||
setColumns(newCols);
|
||||
setSelectedIndex(newCols.length);
|
||||
setIsScroll(true);
|
||||
|
||||
// scroll to the first column
|
||||
vlistRef.current.scrollToIndex(newCols.length - 1, {
|
||||
align: "start",
|
||||
});
|
||||
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);
|
||||
},
|
||||
150,
|
||||
);
|
||||
|
||||
const updateName = useDebouncedCallback((label: string, title: string) => {
|
||||
const currentColIndex = columns.findIndex((col) => col.label === label);
|
||||
|
||||
@@ -93,77 +94,115 @@ function Screen() {
|
||||
setColumns(newCols);
|
||||
}, 150);
|
||||
|
||||
const startResize = useDebouncedCallback(
|
||||
() => setIsResize((prev) => !prev),
|
||||
150,
|
||||
);
|
||||
const reset = useDebouncedCallback(() => setColumns([]), 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(() => {
|
||||
setColumns(initialColumnList);
|
||||
}, [initialColumnList]);
|
||||
|
||||
// Listen for keyboard event
|
||||
useEffect(() => {
|
||||
// save state
|
||||
NostrQuery.setColumns(columns);
|
||||
}, [columns]);
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [handleKeyDown]);
|
||||
|
||||
// Listen for columns event
|
||||
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 === "remove") remove(data.payload.label);
|
||||
if (data.payload.type === "move")
|
||||
move(data.payload.label, data.payload.direction);
|
||||
if (data.payload.type === "set_title")
|
||||
updateName(data.payload.label, data.payload.title);
|
||||
});
|
||||
|
||||
const unlistenWindowResize = getCurrent().listen("tauri://resize", () => {
|
||||
startResize();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlistenColEvent.then((f) => f());
|
||||
unlistenWindowResize.then((f) => f());
|
||||
unlisten.then((f) => f());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<VList
|
||||
ref={vlistRef}
|
||||
horizontal
|
||||
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
|
||||
key={column.label}
|
||||
column={column}
|
||||
account={account}
|
||||
isScroll={isScroll}
|
||||
isResize={isResize}
|
||||
/>
|
||||
))}
|
||||
</VList>
|
||||
<Toolbar>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goLeft()}
|
||||
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"
|
||||
>
|
||||
<ArrowLeftIcon className="size-5" />
|
||||
</button>
|
||||
<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>
|
||||
<div className="size-full">
|
||||
<div ref={emblaRef} className="overflow-hidden size-full">
|
||||
<div className="flex size-full">
|
||||
{columns?.map((column) => (
|
||||
<Column
|
||||
key={account + column.label}
|
||||
column={column}
|
||||
account={account}
|
||||
/>
|
||||
))}
|
||||
<div className="shrink-0 p-2 h-full w-[480px]">
|
||||
<div className="size-full bg-black/5 dark:bg-white/5 rounded-xl flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openLumeStore()}
|
||||
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"
|
||||
>
|
||||
<PlusIcon className="size-5" />
|
||||
Add Column
|
||||
</button>
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,254 +1,233 @@
|
||||
import { User } from "@/components/user";
|
||||
import {
|
||||
BellIcon,
|
||||
ChevronDownIcon,
|
||||
ComposeFilledIcon,
|
||||
HorizontalDotsIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
} 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 { Link } from "@tanstack/react-router";
|
||||
|
||||
type AccountSearch = {
|
||||
accounts?: string[];
|
||||
};
|
||||
import { cn } from "@lume/utils";
|
||||
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
||||
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
|
||||
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")({
|
||||
validateSearch: (search: Record<string, unknown>): AccountSearch => {
|
||||
return {
|
||||
accounts: (search?.accounts as string[]) || [],
|
||||
};
|
||||
beforeLoad: async ({ params }) => {
|
||||
const settings = await NostrQuery.getUserSettings();
|
||||
const accounts = await NostrAccount.getAccounts();
|
||||
const otherAccounts = accounts.filter(
|
||||
(account) => account !== params.account,
|
||||
);
|
||||
|
||||
return { otherAccounts, settings };
|
||||
},
|
||||
component: 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 (
|
||||
<div className="flex h-screen w-screen flex-col">
|
||||
<div className="flex flex-col w-screen h-screen">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className={cn(
|
||||
"flex h-11 shrink-0 items-center justify-between pr-2",
|
||||
platform === "macos" ? "ml-2 pl-20" : "pl-4",
|
||||
)}
|
||||
className="flex h-11 shrink-0 items-center justify-between px-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Accounts />
|
||||
<Link
|
||||
to="/landing/"
|
||||
className="inline-flex size-8 shrink-0 items-center justify-center rounded-full bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20"
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className={cn(
|
||||
"flex-1 flex items-center gap-2",
|
||||
platform === "macos" ? "pl-[64px]" : "",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openLumeStore()}
|
||||
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"
|
||||
>
|
||||
<PlusIcon className="size-5" />
|
||||
</Link>
|
||||
Column
|
||||
</button>
|
||||
<div id="toolbar" />
|
||||
</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
|
||||
type="button"
|
||||
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" />
|
||||
New Post
|
||||
</button>
|
||||
<Bell />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openSearch()}
|
||||
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
||||
>
|
||||
<SearchIcon className="size-5" />
|
||||
</button>
|
||||
<div id="toolbar" />
|
||||
<Accounts />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1",
|
||||
settings.vibrancy
|
||||
? ""
|
||||
: "bg-white dark:bg-black border-t border-black/20 dark:border-white/20",
|
||||
)}
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Accounts() {
|
||||
const { accounts } = Route.useSearch();
|
||||
const Accounts = memo(function Accounts() {
|
||||
const { otherAccounts } = Route.useRouteContext();
|
||||
const { account } = Route.useParams();
|
||||
|
||||
const [windowWidth, setWindowWidth] = useState<number>(null);
|
||||
|
||||
const navigate = Route.useNavigate();
|
||||
const sortedList = useMemo(() => {
|
||||
const list = accounts;
|
||||
|
||||
for (const [i, item] of list.entries()) {
|
||||
if (item === account) {
|
||||
list.splice(i, 1);
|
||||
list.unshift(item);
|
||||
const showContextMenu = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const menuItems = await Promise.all([
|
||||
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 menu = await Menu.new({
|
||||
items: menuItems,
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
if (select) {
|
||||
// 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 {
|
||||
await message("Something wrong.", { title: "Accounts", kind: "error" });
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [accounts]);
|
||||
|
||||
const changeAccount = async (npub: string) => {
|
||||
if (npub === account) {
|
||||
return await LumeWindow.openProfile(account);
|
||||
}
|
||||
|
||||
// change current account and update signer
|
||||
const select = await NostrAccount.loadAccount(npub);
|
||||
|
||||
if (select) {
|
||||
return navigate({ to: "/$account/home", params: { account: npub } });
|
||||
} else {
|
||||
toast.warning("Something wrong.");
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}, []);
|
||||
},
|
||||
[otherAccounts],
|
||||
);
|
||||
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function Bell() {
|
||||
const { account } = Route.useParams();
|
||||
const [count, setCount] = useState(0);
|
||||
const Search = memo(function Search() {
|
||||
const [searchType, setSearchType] = useState<"notes" | "users">("notes");
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = getCurrent().listen<string>(
|
||||
"activity",
|
||||
async (payload) => {
|
||||
setCount((prevCount) => prevCount + 1);
|
||||
await invoke("set_badge", { count });
|
||||
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
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);
|
||||
const menuItems = await Promise.all([
|
||||
MenuItem.new({
|
||||
text: "Notes",
|
||||
action: () => setSearchType("notes"),
|
||||
}),
|
||||
MenuItem.new({
|
||||
text: "Users",
|
||||
action: () => setSearchType("users"),
|
||||
}),
|
||||
]);
|
||||
|
||||
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;
|
||||
}
|
||||
},
|
||||
);
|
||||
const menu = await Menu.new({
|
||||
items: menuItems,
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten.then((f) => f());
|
||||
};
|
||||
await menu.popup().catch((e) => console.error(e));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCount(0);
|
||||
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" />
|
||||
{count > 0 ? (
|
||||
<span className="absolute right-0 top-0 block size-2 rounded-full bg-teal-500 ring-1 ring-black/5" />
|
||||
) : null}
|
||||
</button>
|
||||
<div className="h-8 w-full px-3 text-sm rounded-full inline-flex items-center bg-black/5 dark:bg-white/5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => showContextMenu(e)}
|
||||
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"
|
||||
>
|
||||
{searchType}
|
||||
<ChevronDownIcon className="size-3" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,46 +1,21 @@
|
||||
import { CheckCircleIcon, InfoCircleIcon, CancelCircleIcon } from "@lume/icons";
|
||||
import type { Settings } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
|
||||
import type { Platform } from "@tauri-apps/plugin-os";
|
||||
import { Toaster } from "sonner";
|
||||
import type { OsType } from "@tauri-apps/plugin-os";
|
||||
|
||||
interface RouterContext {
|
||||
// System
|
||||
queryClient: QueryClient;
|
||||
// App info
|
||||
platform?: Platform;
|
||||
locale?: string;
|
||||
// Settings
|
||||
settings?: Settings;
|
||||
// Accounts
|
||||
accounts?: string[];
|
||||
platform: OsType;
|
||||
}
|
||||
|
||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
component: () => (
|
||||
<>
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
icons={{
|
||||
success: <CheckCircleIcon className="size-5" />,
|
||||
info: <InfoCircleIcon className="size-5" />,
|
||||
error: <CancelCircleIcon className="size-5" />,
|
||||
}}
|
||||
closeButton
|
||||
theme="system"
|
||||
/>
|
||||
<Outlet />
|
||||
</>
|
||||
),
|
||||
component: () => <Outlet />,
|
||||
pendingComponent: Pending,
|
||||
wrapInSuspense: true,
|
||||
});
|
||||
|
||||
function Pending() {
|
||||
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" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Box, Container } from "@lume/ui";
|
||||
import { Container } from "@lume/ui";
|
||||
import { Outlet, createLazyFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createLazyFileRoute("/auth")({
|
||||
@@ -8,9 +8,9 @@ export const Route = createLazyFileRoute("/auth")({
|
||||
function Screen() {
|
||||
return (
|
||||
<Container withDrag>
|
||||
<Box className="px-3 pt-3">
|
||||
<div className="max-w-sm mx-auto size-full">
|
||||
<Outlet />
|
||||
</Box>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,37 +5,34 @@ import * as Checkbox from "@radix-ui/react-checkbox";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
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,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { account } = Route.useParams();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [key, setKey] = useState(null);
|
||||
const [passphase, setPassphase] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [confirm, setConfirm] = useState({ c1: false, c2: false, c3: false });
|
||||
|
||||
const navigate = useNavigate();
|
||||
const [confirm, setConfirm] = useState({ c1: false, c2: false });
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
if (key) {
|
||||
if (!confirm.c1 || !confirm.c2 || !confirm.c3) {
|
||||
return toast.warning("You need to confirm before continue");
|
||||
if (!confirm.c1 || !confirm.c2) {
|
||||
return await message("You need to confirm before continue", {
|
||||
title: "Backup",
|
||||
kind: "info",
|
||||
});
|
||||
}
|
||||
|
||||
return navigate({
|
||||
to: "/auth/$account/settings",
|
||||
params: { account },
|
||||
});
|
||||
navigate({ to: "/", replace: true });
|
||||
}
|
||||
|
||||
// start loading
|
||||
@@ -51,7 +48,10 @@ function Screen() {
|
||||
});
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(String(e));
|
||||
await message(String(e), {
|
||||
title: "Backup",
|
||||
kind: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -60,12 +60,15 @@ function Screen() {
|
||||
await writeText(key);
|
||||
setCopied(true);
|
||||
} catch (e) {
|
||||
toast.error(e);
|
||||
await message(String(e), {
|
||||
title: "Backup",
|
||||
kind: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
<h3 className="text-xl font-semibold">Backup your sign in keys</h3>
|
||||
<p className="text-neutral-700 dark:text-neutral-300">
|
||||
@@ -73,7 +76,7 @@ function Screen() {
|
||||
access to your account if you lose this key.
|
||||
</p>
|
||||
</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">
|
||||
<label htmlFor="passphase" className="font-medium">
|
||||
Set a passphase to secure your key
|
||||
@@ -84,7 +87,7 @@ function Screen() {
|
||||
type="password"
|
||||
value={passphase}
|
||||
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>
|
||||
@@ -100,12 +103,12 @@ function Screen() {
|
||||
type="text"
|
||||
value={displayNsec(key, 36)}
|
||||
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
|
||||
type="button"
|
||||
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"}
|
||||
</button>
|
||||
@@ -120,7 +123,7 @@ function Screen() {
|
||||
onCheckedChange={() =>
|
||||
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"
|
||||
>
|
||||
<Checkbox.Indicator className="text-blue-500">
|
||||
@@ -131,7 +134,7 @@ function Screen() {
|
||||
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
|
||||
htmlFor="confirm1"
|
||||
>
|
||||
{t("backup.confirm1")}
|
||||
I will make sure keep it safe and not sharing with anyone.
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -140,7 +143,7 @@ function Screen() {
|
||||
onCheckedChange={() =>
|
||||
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"
|
||||
>
|
||||
<Checkbox.Indicator className="text-blue-500">
|
||||
@@ -151,27 +154,7 @@ function Screen() {
|
||||
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
|
||||
htmlFor="confirm2"
|
||||
>
|
||||
{t("backup.confirm2")}
|
||||
</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")}
|
||||
I understand I cannot recover private key.
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -183,9 +166,9 @@ function Screen() {
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
139
apps/desktop2/src/routes/auth/create-profile.tsx
Normal file
139
apps/desktop2/src/routes/auth/create-profile.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
90
apps/desktop2/src/routes/auth/import.lazy.tsx
Normal file
90
apps/desktop2/src/routes/auth/import.lazy.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
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";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createLazyFileRoute("/auth/remote")({
|
||||
component: Screen,
|
||||
@@ -15,36 +15,34 @@ function Screen() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
if (!uri.startsWith("bunker://"))
|
||||
return toast.warning(
|
||||
if (!uri.startsWith("bunker://")) {
|
||||
return await message(
|
||||
"You need to enter a valid Connect URI starts with bunker://",
|
||||
{ title: "Nostr Connect", kind: "info" },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const npub = await NostrAccount.connectRemoteAccount(uri);
|
||||
const remoteAccount = await NostrAccount.connectRemoteAccount(uri);
|
||||
|
||||
if (npub) {
|
||||
navigate({
|
||||
to: "/auth/$account/settings",
|
||||
params: { account: npub },
|
||||
replace: true,
|
||||
});
|
||||
if (remoteAccount?.length) {
|
||||
navigate({ to: "/", replace: true });
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(e);
|
||||
await message(String(e), { title: "Nostr Connect", kind: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
<h3 className="text-xl font-semibold">Continue with Nostr Connect</h3>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-col w-full">
|
||||
<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
|
||||
htmlFor="uri"
|
||||
className="font-medium text-neutral-900 dark:text-neutral-100"
|
||||
@@ -57,20 +55,20 @@ function Screen() {
|
||||
placeholder="bunker://..."
|
||||
value={uri}
|
||||
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 className="flex flex-col gap-1 items-center">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<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"
|
||||
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>
|
||||
{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...
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
150
apps/desktop2/src/routes/bootstrap-relays.tsx
Normal file
150
apps/desktop2/src/routes/bootstrap-relays.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { User } from "@/components/user";
|
||||
import { CancelIcon, PlusIcon } from "@lume/icons";
|
||||
import { NostrAccount, NostrQuery } from "@lume/system";
|
||||
import type { ColumnRouteSearch } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { User } from "@/components/user";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { NostrAccount, NostrQuery } from "@lume/system";
|
||||
|
||||
export const Route = createFileRoute("/create-group")({
|
||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||
@@ -65,25 +65,25 @@ function Screen() {
|
||||
}
|
||||
} catch (e) {
|
||||
setIsLoading(false);
|
||||
toast.error(e);
|
||||
await message(String(e), { title: "Create Group", kind: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
|
||||
<div className="text-center flex flex-col items-center justify-center">
|
||||
<h1 className="text-2xl font-serif font-medium">
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-4">
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<h1 className="font-serif text-2xl font-medium">
|
||||
Focus feeds for people you like
|
||||
</h1>
|
||||
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
||||
Add some people for custom feeds.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-4/5 max-w-full flex flex-col 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 flex-col w-4/5 max-w-full gap-3">
|
||||
<div className="flex items-center w-full rounded-lg h-9 shrink-0 bg-black/5 dark:bg-white/5">
|
||||
<label
|
||||
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
|
||||
</label>
|
||||
@@ -92,23 +92,23 @@ function Screen() {
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
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 className="w-full flex flex-col items-center 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="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 rounded-xl">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
name="npub"
|
||||
value={npub}
|
||||
onChange={(e) => setNpub(e.target.value)}
|
||||
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
|
||||
type="button"
|
||||
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" />
|
||||
</button>
|
||||
@@ -122,11 +122,11 @@ function Screen() {
|
||||
key={item}
|
||||
type="button"
|
||||
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.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">
|
||||
<User.Name className="text-sm font-medium" />
|
||||
</div>
|
||||
@@ -138,7 +138,7 @@ function Screen() {
|
||||
</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.
|
||||
</div>
|
||||
)}
|
||||
@@ -153,11 +153,11 @@ function Screen() {
|
||||
key={item}
|
||||
type="button"
|
||||
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.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">
|
||||
<User.Name className="text-sm font-medium" />
|
||||
</div>
|
||||
@@ -166,7 +166,7 @@ function Screen() {
|
||||
</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>
|
||||
Find more user at{" "}
|
||||
<a
|
||||
@@ -187,7 +187,7 @@ function Screen() {
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
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"}
|
||||
</button>
|
||||
|
||||
@@ -2,8 +2,8 @@ import { NostrAccount } from "@lume/system";
|
||||
import type { ColumnRouteSearch } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/create-newsfeed/f2f")({
|
||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||
@@ -24,8 +24,12 @@ function Screen() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
if (!npub.startsWith("npub1"))
|
||||
return toast.warning("You must enter a valid npub.");
|
||||
if (!npub.startsWith("npub1")) {
|
||||
return await message("You must enter a valid npub.", {
|
||||
title: "Create Newsfeed",
|
||||
kind: "info",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
@@ -37,13 +41,16 @@ function Screen() {
|
||||
}
|
||||
} catch (e) {
|
||||
setIsLoading(false);
|
||||
toast.error(String(e));
|
||||
await message(String(e), {
|
||||
title: "Create Newsfeed",
|
||||
kind: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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="h-full flex flex-col justify-between">
|
||||
<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="flex flex-col justify-between h-full">
|
||||
<div className="flex-1 flex flex-col gap-1.5 justify-center px-5">
|
||||
<p className="font-semibold text-neutral-500">
|
||||
You already have a friend on Nostr?
|
||||
@@ -60,7 +67,7 @@ function Screen() {
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="npub" className="font-medium text-sm">
|
||||
<label htmlFor="npub" className="text-sm font-medium">
|
||||
NPUB
|
||||
</label>
|
||||
<input
|
||||
@@ -69,13 +76,13 @@ function Screen() {
|
||||
value={npub}
|
||||
onChange={(e) => setNpub(e.target.value)}
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
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"}
|
||||
</button>
|
||||
|
||||
@@ -1,25 +1,38 @@
|
||||
import type { ColumnRouteSearch } from "@lume/types";
|
||||
import { cn } from "@lume/utils";
|
||||
import { Link, Outlet } from "@tanstack/react-router";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/create-newsfeed")({
|
||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||
return {
|
||||
account: search.account,
|
||||
label: search.label,
|
||||
name: search.name,
|
||||
};
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const search = Route.useSearch();
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
|
||||
<div className="text-center flex flex-col items-center justify-center">
|
||||
<h1 className="text-2xl font-serif font-medium">
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-4">
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<h1 className="font-serif text-2xl font-medium">
|
||||
Build up your timeline.
|
||||
</h1>
|
||||
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
||||
Follow some people to keep up to date with them.
|
||||
</p>
|
||||
</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">
|
||||
<Link to="/create-newsfeed/users" className="flex-1 h-8">
|
||||
<Link
|
||||
to="/create-newsfeed/users"
|
||||
search={search}
|
||||
className="flex-1 h-8"
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -33,7 +46,11 @@ function Screen() {
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
<Link to="/create-newsfeed/f2f" className="flex-1 h-8">
|
||||
<Link
|
||||
to="/create-newsfeed/f2f"
|
||||
search={search}
|
||||
className="flex-1 h-8"
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
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 { Spinner } from "@lume/ui";
|
||||
import { toast } from "sonner";
|
||||
import type { ColumnRouteSearch } from "@lume/types";
|
||||
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")({
|
||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||
@@ -59,16 +59,19 @@ function Screen() {
|
||||
}
|
||||
} catch (e) {
|
||||
setIsLoading(false);
|
||||
toast.error(String(e));
|
||||
await message(String(e), {
|
||||
title: "Create Group",
|
||||
kind: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col items-center 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="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 rounded-xl">
|
||||
<Suspense
|
||||
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
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium"
|
||||
@@ -85,27 +88,27 @@ function Screen() {
|
||||
users.profiles.map((item: { pubkey: string }) => (
|
||||
<div
|
||||
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.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 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" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
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)
|
||||
? "Unfollow"
|
||||
: "Follow"}
|
||||
</button>
|
||||
</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>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
@@ -119,7 +122,7 @@ function Screen() {
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
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"}
|
||||
</button>
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { CheckCircleIcon } from "@lume/icons";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import type { ColumnRouteSearch, Topic } from "@lume/types";
|
||||
import type { ColumnRouteSearch } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { TOPICS } from "@lume/utils";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type Topic = {
|
||||
title: string;
|
||||
content: string[];
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/create-topic")({
|
||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||
@@ -48,38 +53,42 @@ function Screen() {
|
||||
}
|
||||
} catch (e) {
|
||||
setIsLoading(false);
|
||||
toast.error(String(e));
|
||||
await message(String(e), {
|
||||
title: "Create Topic",
|
||||
kind: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
|
||||
<div className="text-center flex flex-col items-center justify-center">
|
||||
<h1 className="text-2xl font-serif font-medium">
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-4">
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<h1 className="font-serif text-2xl font-medium">
|
||||
What are your interests?
|
||||
</h1>
|
||||
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
||||
Add some topics you want to focus on.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-4/5 max-w-full flex flex-col 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 flex-col w-4/5 max-w-full gap-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>
|
||||
</div>
|
||||
<div className="w-full flex flex-col items-center 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="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 rounded-xl">
|
||||
<div className="flex flex-col gap-3">
|
||||
{TOPICS.map((topic) => (
|
||||
<button
|
||||
key={topic.title}
|
||||
type="button"
|
||||
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>{topic.icon}</div>
|
||||
<div className="text-sm font-medium">
|
||||
<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
|
||||
</span>
|
||||
</div>
|
||||
@@ -95,7 +104,7 @@ function Screen() {
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
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"}
|
||||
</button>
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { AddMediaIcon } from "@lume/icons";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { cn, insertImage, isImagePath } from "@lume/utils";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { insertImage, isImagePath } from "@lume/utils";
|
||||
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 { useSlateStatic } from "slate-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function MediaButton({ className }: { className?: string }) {
|
||||
export function MediaButton() {
|
||||
const editor = useSlateStatic();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -25,7 +24,7 @@ export function MediaButton({ className }: { className?: string }) {
|
||||
setLoading(false);
|
||||
} catch (e) {
|
||||
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;
|
||||
|
||||
async function listenFileDrop() {
|
||||
const window = getCurrent();
|
||||
const window = getCurrentWindow();
|
||||
if (!unlisten) {
|
||||
unlisten = await window.listen("tauri://file-drop", async (event) => {
|
||||
// @ts-ignore, lfg !!!
|
||||
@@ -61,29 +60,18 @@ export function MediaButton({ className }: { className?: string }) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => upload()}
|
||||
disabled={loading}
|
||||
className={cn("inline-flex items-center justify-center", className)}
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<AddMediaIcon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
||||
Upload media
|
||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => upload()}
|
||||
disabled={loading}
|
||||
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 ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<AddMediaIcon className="size-4" />
|
||||
)}
|
||||
Add media
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { cn } from "@lume/utils";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { PowIcon } from "@lume/icons";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
export function PowToggle({
|
||||
pow,
|
||||
setPow,
|
||||
className,
|
||||
export function PowButton({
|
||||
setDifficulty,
|
||||
}: {
|
||||
pow: boolean;
|
||||
setPow: Dispatch<SetStateAction<boolean>>;
|
||||
className?: string;
|
||||
setDifficulty: Dispatch<SetStateAction<{ enable: boolean; num: number }>>;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPow((prev) => !prev)}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center",
|
||||
className,
|
||||
pow ? "bg-blue-500 text-white" : "",
|
||||
)}
|
||||
>
|
||||
<NsfwIcon className="size-4" />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
||||
Proof of Work
|
||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setDifficulty((prev) => ({ ...prev, enable: !prev.enable }))
|
||||
}
|
||||
className="inline-flex items-center h-8 gap-2 px-2.5 text-sm rounded-lg text-black/70 dark:text-white/70 w-max hover:bg-black/10 dark:hover:bg-white/10"
|
||||
>
|
||||
<PowIcon className="size-4" />
|
||||
PoW
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,40 +1,19 @@
|
||||
import { NsfwIcon } from "@lume/icons";
|
||||
import { cn } from "@lume/utils";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
export function WarningToggle({
|
||||
warning,
|
||||
export function WarningButton({
|
||||
setWarning,
|
||||
className,
|
||||
}: {
|
||||
warning: boolean;
|
||||
setWarning: Dispatch<SetStateAction<boolean>>;
|
||||
className?: string;
|
||||
setWarning: Dispatch<SetStateAction<{ enable: boolean; reason: string }>>;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWarning((prev) => !prev)}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center",
|
||||
className,
|
||||
warning ? "bg-blue-500 text-white" : "",
|
||||
)}
|
||||
>
|
||||
<NsfwIcon className="size-4" />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
||||
Mark as sensitive content
|
||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWarning((prev) => ({ ...prev, enable: !prev.enable }))}
|
||||
className="inline-flex items-center h-8 gap-2 px-2.5 text-sm rounded-lg text-black/70 dark:text-white/70 w-max hover:bg-black/10 dark:hover:bg-white/10"
|
||||
>
|
||||
<NsfwIcon className="size-4" />
|
||||
Mark as sensitive
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { LumeEvent, useEvent } from "@lume/system";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import {
|
||||
cn,
|
||||
insertImage,
|
||||
insertNostrEvent,
|
||||
isImageUrl,
|
||||
sendNativeNotification,
|
||||
} from "@lume/utils";
|
||||
import { cn, insertImage, insertNostrEvent, isImageUrl } from "@lume/utils";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { useEffect, useState } from "react";
|
||||
import { type Descendant, Node, Transforms, createEditor } from "slate";
|
||||
import {
|
||||
Editable,
|
||||
@@ -21,14 +19,12 @@ import {
|
||||
withReact,
|
||||
} from "slate-react";
|
||||
import { MediaButton } from "./-components/media";
|
||||
import { MentionButton } from "./-components/mention";
|
||||
import { LumeEvent } from "@lume/system";
|
||||
import { WarningToggle } from "./-components/warning";
|
||||
import { MentionNote } from "@/components/note/mentions/note";
|
||||
import { PowButton } from "./-components/pow";
|
||||
import { WarningButton } from "./-components/warning";
|
||||
|
||||
type EditorSearch = {
|
||||
reply_to: string;
|
||||
quote: boolean;
|
||||
quote: string;
|
||||
};
|
||||
|
||||
type EditorElement = {
|
||||
@@ -41,26 +37,47 @@ export const Route = createFileRoute("/editor/")({
|
||||
validateSearch: (search: Record<string, string>): EditorSearch => {
|
||||
return {
|
||||
reply_to: search.reply_to,
|
||||
quote: search.quote === "true" || false,
|
||||
quote: search.quote,
|
||||
};
|
||||
},
|
||||
beforeLoad: ({ search }) => {
|
||||
let 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",
|
||||
children: [{ text: "" }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return { initialValue };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
const initialValue: EditorElement[] = [
|
||||
{
|
||||
type: "paragraph",
|
||||
children: [{ text: "" }],
|
||||
},
|
||||
];
|
||||
|
||||
function Screen() {
|
||||
const search = Route.useSearch();
|
||||
const { reply_to } = Route.useSearch();
|
||||
const { initialValue } = Route.useRouteContext();
|
||||
|
||||
const [t] = useTranslation();
|
||||
const [editorValue, setEditorValue] = useState(initialValue);
|
||||
const [editorValue, setEditorValue] = useState<EditorElement[]>(null);
|
||||
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(() =>
|
||||
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
|
||||
);
|
||||
@@ -103,63 +120,40 @@ function Screen() {
|
||||
const content = serialize(editor.children);
|
||||
const eventId = await LumeEvent.publish(
|
||||
content,
|
||||
search.reply_to,
|
||||
search.quote,
|
||||
warning,
|
||||
warning.enable && warning.reason.length ? warning.reason : null,
|
||||
difficulty.enable && difficulty.num > 0 ? difficulty.num : null,
|
||||
reply_to,
|
||||
);
|
||||
|
||||
if (eventId) {
|
||||
await sendNativeNotification(
|
||||
"Your note has been published successfully.",
|
||||
"Lume",
|
||||
);
|
||||
// stop loading
|
||||
setLoading(false);
|
||||
// reset form
|
||||
reset();
|
||||
}
|
||||
|
||||
// stop loading
|
||||
setLoading(false);
|
||||
|
||||
// reset form
|
||||
reset();
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
await sendNativeNotification(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setEditorValue(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
if (!editorValue) return null;
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col">
|
||||
<div className="flex flex-col w-full h-full">
|
||||
<Slate editor={editor} initialValue={editorValue}>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
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"
|
||||
>
|
||||
<WarningToggle
|
||||
warning={warning}
|
||||
setWarning={setWarning}
|
||||
className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
||||
/>
|
||||
<MentionButton className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" />
|
||||
<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 data-tauri-drag-region className="h-9 shrink-0" />
|
||||
<div className="flex flex-col flex-1 overflow-y-auto">
|
||||
{reply_to?.length ? (
|
||||
<div className="flex items-center gap-3 px-2.5 pb-3 border-b border-black/5 dark:border-white/5">
|
||||
<div className="text-sm font-semibold shrink-0">Reply to:</div>
|
||||
<ChildNote id={reply_to} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="overflow-y-auto scrollbar-none p-4">
|
||||
<div className="px-4 py-4 overflow-y-auto">
|
||||
<Editable
|
||||
key={JSON.stringify(editorValue)}
|
||||
autoFocus={true}
|
||||
@@ -168,17 +162,103 @@ function Screen() {
|
||||
spellCheck={false}
|
||||
renderElement={(props) => <Element {...props} />}
|
||||
placeholder={
|
||||
search.reply_to ? "Type your reply..." : t("editor.placeholder")
|
||||
reply_to ? "Type your reply..." : "What're you up to?"
|
||||
}
|
||||
className="focus:outline-none"
|
||||
/>
|
||||
</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>
|
||||
</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 { insertData, isVoid } = editor;
|
||||
|
||||
@@ -190,7 +270,7 @@ const withNostrEvent = (editor: ReactEditor) => {
|
||||
editor.insertData = (data) => {
|
||||
const text = data.getData("text/plain");
|
||||
|
||||
if (text.startsWith("nevent1") || text.startsWith("note1")) {
|
||||
if (text.startsWith("nevent") || text.startsWith("note")) {
|
||||
insertNostrEvent(editor, text);
|
||||
} else {
|
||||
insertData(data);
|
||||
@@ -259,6 +339,7 @@ const Image = ({ attributes, element, children }) => {
|
||||
selected && focused ? "ring-blue-500" : "ring-transparent",
|
||||
)}
|
||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||
onKeyDown={() => Transforms.removeNodes(editor, { at: path })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -274,7 +355,7 @@ const Mention = ({ attributes, element }) => {
|
||||
type="button"
|
||||
contentEditable={false}
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -286,16 +367,13 @@ const Event = ({ attributes, element, children }) => {
|
||||
return (
|
||||
<div {...attributes}>
|
||||
{children}
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> */}
|
||||
<div
|
||||
contentEditable={false}
|
||||
className="relative my-2 user-select-none"
|
||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||
className="user-select-none relative my-2"
|
||||
onKeyDown={() => Transforms.removeNodes(editor, { at: path })}
|
||||
>
|
||||
<MentionNote
|
||||
eventId={element.eventId.replace("nostr:", "")}
|
||||
openable={false}
|
||||
/>
|
||||
<MentionNote eventId={element.eventId} openable={false} />
|
||||
</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
apps/desktop2/src/routes/events/$id.tsx
Normal file
146
apps/desktop2/src/routes/events/$id.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
apps/desktop2/src/routes/events/-components/child.tsx
Normal file
41
apps/desktop2/src/routes/events/-components/child.tsx
Normal file
@@ -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;
|
||||
41
apps/desktop2/src/routes/events/-components/parent.tsx
Normal file
41
apps/desktop2/src/routes/events/-components/parent.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,12 +2,14 @@ import { Conversation } from "@/components/conversation";
|
||||
import { Quote } from "@/components/quote";
|
||||
import { RepostNote } from "@/components/repost";
|
||||
import { TextNote } from "@/components/text";
|
||||
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import { type ColumnRouteSearch, type NostrEvent, Kind } from "@lume/types";
|
||||
import { ArrowRightCircleIcon } from "@lume/icons";
|
||||
import { type LumeEvent, NostrQuery } from "@lume/system";
|
||||
import { type ColumnRouteSearch, Kind } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
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";
|
||||
|
||||
export const Route = createFileRoute("/global")({
|
||||
@@ -19,7 +21,7 @@ export const Route = createFileRoute("/global")({
|
||||
};
|
||||
},
|
||||
beforeLoad: async () => {
|
||||
const settings = await NostrQuery.getSettings();
|
||||
const settings = await NostrQuery.getUserSettings();
|
||||
return { settings };
|
||||
},
|
||||
component: Screen,
|
||||
@@ -42,107 +44,92 @@ export function Screen() {
|
||||
return events;
|
||||
},
|
||||
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) => {
|
||||
if (!event) return;
|
||||
switch (event.kind) {
|
||||
case Kind.Repost:
|
||||
return <RepostNote key={event.id} event={event} />;
|
||||
default: {
|
||||
const isConversation =
|
||||
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
|
||||
.length > 0;
|
||||
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
if (isConversation) {
|
||||
return <Conversation key={event.id} event={event} className="mb-3" />;
|
||||
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" />;
|
||||
}
|
||||
|
||||
if (isQuote) {
|
||||
return <Quote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
|
||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
|
||||
{isFetching && !isLoading && !isFetchingNextPage ? (
|
||||
<div className="w-full h-11 flex items-center justify-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Spinner className="size-5" />
|
||||
<span className="text-sm font-medium">Fetching new notes...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<div className="flex h-16 w-full items-center justify-center gap-2">
|
||||
<Spinner className="size-5" />
|
||||
<span className="text-sm font-medium">Loading...</span>
|
||||
</div>
|
||||
) : !data.length ? (
|
||||
<Empty />
|
||||
) : (
|
||||
<Virtualizer overscan={3}>
|
||||
{data.map((item) => renderItem(item))}
|
||||
</Virtualizer>
|
||||
)}
|
||||
{data?.length && hasNextPage ? (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage || isLoading}
|
||||
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-black/5 px-3 font-medium hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<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 ? (
|
||||
<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">
|
||||
<Spinner className="size-5" />
|
||||
<span className="text-sm font-medium">
|
||||
Getting new notes...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center w-full h-16 gap-2">
|
||||
<Spinner className="size-5" />
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="size-5" />
|
||||
Load more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
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" />
|
||||
Show trending notes
|
||||
</Link>
|
||||
<Link
|
||||
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>
|
||||
<span className="text-sm font-medium">Loading...</span>
|
||||
</div>
|
||||
) : !data.length ? (
|
||||
<div className="flex items-center justify-center">
|
||||
Yo. You're catching up on all the things happening around you.
|
||||
</div>
|
||||
) : (
|
||||
data.map((item) => renderItem(item))
|
||||
)}
|
||||
{data?.length && hasNextPage ? (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage || isLoading}
|
||||
className="inline-flex items-center justify-center w-full gap-2 px-3 font-medium h-9 rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<Spinner className="size-5" />
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="size-5" />
|
||||
Load more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</Virtualizer>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
||||
</ScrollArea.Scrollbar>
|
||||
<ScrollArea.Corner className="bg-transparent" />
|
||||
</ScrollArea.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ import { Conversation } from "@/components/conversation";
|
||||
import { Quote } from "@/components/quote";
|
||||
import { RepostNote } from "@/components/repost";
|
||||
import { TextNote } from "@/components/text";
|
||||
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
||||
import { NostrAccount, NostrQuery } from "@lume/system";
|
||||
import { type ColumnRouteSearch, type NostrEvent, Kind } from "@lume/types";
|
||||
import { ArrowRightCircleIcon } from "@lume/icons";
|
||||
import { type LumeEvent, NostrQuery } from "@lume/system";
|
||||
import { type ColumnRouteSearch, Kind } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
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";
|
||||
|
||||
export const Route = createFileRoute("/group")({
|
||||
@@ -19,9 +21,9 @@ export const Route = createFileRoute("/group")({
|
||||
};
|
||||
},
|
||||
beforeLoad: async ({ search }) => {
|
||||
const key = `lume_group_${search.label}`;
|
||||
const groups = (await NostrQuery.getNstore(key)) as string[];
|
||||
const settings = await NostrQuery.getSettings();
|
||||
const key = `lume:group:${search.label}`;
|
||||
const groups: string[] = await NostrQuery.getNstore(key);
|
||||
const settings = await NostrQuery.getUserSettings();
|
||||
|
||||
if (!groups?.length) {
|
||||
throw redirect({
|
||||
@@ -33,10 +35,7 @@ export const Route = createFileRoute("/group")({
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
groups,
|
||||
settings,
|
||||
};
|
||||
return { groups, settings };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
@@ -55,112 +54,96 @@ export function Screen() {
|
||||
queryKey: [label, account],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||
const events = await NostrQuery.getLocalEvents(groups, pageParam);
|
||||
const events = await NostrQuery.getGroupEvents(groups, pageParam);
|
||||
return events;
|
||||
},
|
||||
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
|
||||
select: (data) =>
|
||||
data?.pages.flatMap((page) => page.filter((ev) => ev.kind === Kind.Text)),
|
||||
select: (data) => data?.pages.flat(),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const renderItem = (event: NostrEvent) => {
|
||||
if (!event) return;
|
||||
switch (event.kind) {
|
||||
case Kind.Repost:
|
||||
return <RepostNote key={event.id} event={event} />;
|
||||
default: {
|
||||
const isConversation =
|
||||
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
|
||||
.length > 0;
|
||||
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
if (isConversation) {
|
||||
return <Conversation key={event.id} event={event} className="mb-3" />;
|
||||
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" />;
|
||||
}
|
||||
|
||||
if (isQuote) {
|
||||
return <Quote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
|
||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
|
||||
{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 gap-2">
|
||||
<Spinner className="size-5" />
|
||||
<span className="text-sm font-medium">Fetching new notes...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<div className="flex h-16 w-full items-center justify-center gap-2">
|
||||
<Spinner className="size-5" />
|
||||
<span className="text-sm font-medium">Loading...</span>
|
||||
</div>
|
||||
) : !data.length ? (
|
||||
<Empty />
|
||||
) : (
|
||||
<Virtualizer overscan={3}>
|
||||
{data.map((item) => renderItem(item))}
|
||||
</Virtualizer>
|
||||
)}
|
||||
{data?.length && hasNextPage ? (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage || isLoading}
|
||||
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-neutral-100 px-3 font-medium hover:bg-neutral-50 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<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 ? (
|
||||
<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">
|
||||
<Spinner className="size-5" />
|
||||
<span className="text-sm font-medium">
|
||||
Getting new notes...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center w-full h-16 gap-2">
|
||||
<Spinner className="size-5" />
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="size-5" />
|
||||
Load more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
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" />
|
||||
Show trending notes
|
||||
</Link>
|
||||
<Link
|
||||
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>
|
||||
<span className="text-sm font-medium">Loading...</span>
|
||||
</div>
|
||||
) : !data.length ? (
|
||||
<div className="flex items-center justify-center">
|
||||
Yo. You're catching up on all the things happening around you.
|
||||
</div>
|
||||
) : (
|
||||
data.map((item) => renderItem(item))
|
||||
)}
|
||||
{data?.length && hasNextPage ? (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage || isLoading}
|
||||
className="inline-flex items-center justify-center w-full gap-2 px-3 font-medium h-9 rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<Spinner className="size-5" />
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="size-5" />
|
||||
Load more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</Virtualizer>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
||||
</ScrollArea.Scrollbar>
|
||||
<ScrollArea.Corner className="bg-transparent" />
|
||||
</ScrollArea.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { PlusIcon } from "@lume/icons";
|
||||
import { Spinner } from "@lume/ui";
|
||||
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 { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { NostrAccount } from "@lume/system";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
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();
|
||||
|
||||
if (accounts.length < 1) {
|
||||
@@ -29,11 +34,11 @@ function Screen() {
|
||||
const navigate = Route.useNavigate();
|
||||
const context = Route.useRouteContext();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loading, setLoading] = useState({ npub: "", status: false });
|
||||
|
||||
const select = async (npub: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setLoading({ npub, status: true });
|
||||
|
||||
const status = await NostrAccount.loadAccount(npub);
|
||||
|
||||
@@ -41,15 +46,15 @@ function Screen() {
|
||||
return navigate({
|
||||
to: "/$account/home",
|
||||
params: { account: npub },
|
||||
search: {
|
||||
accounts: context.accounts,
|
||||
},
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(String(e));
|
||||
setLoading({ npub: "", status: false });
|
||||
await message(String(e), {
|
||||
title: "Account",
|
||||
kind: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -60,47 +65,76 @@ function Screen() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full items-center justify-center">
|
||||
<div className="relative z-20 flex flex-col items-center gap-16">
|
||||
<div
|
||||
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">
|
||||
<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}
|
||||
</h2>
|
||||
<h2 className="text-2xl font-semibold">Welcome back!</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap px-3 items-center justify-center gap-6">
|
||||
{loading ? (
|
||||
<div className="inline-flex size-6 items-center justify-center">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{context.accounts.map((account) => (
|
||||
<button
|
||||
key={account}
|
||||
type="button"
|
||||
onClick={() => select(account)}
|
||||
>
|
||||
<User.Provider pubkey={account}>
|
||||
<User.Root className="flex h-36 w-32 flex-col items-center justify-center gap-3 rounded-2xl p-2 hover:bg-black/10 dark:hover:bg-white/10">
|
||||
<User.Avatar className="size-20 rounded-full object-cover" />
|
||||
<User.Name className="max-w-[6rem] truncate font-medium leading-tight" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</button>
|
||||
))}
|
||||
<Link to="/landing">
|
||||
<div className="flex h-36 w-32 flex-col items-center justify-center gap-3 rounded-2xl p-2 hover:bg-black/10 dark:hover:bg-white/10">
|
||||
<div className="flex size-20 items-center justify-center rounded-full bg-black/5 dark:bg-white/5">
|
||||
<PlusIcon className="size-8" />
|
||||
</div>
|
||||
<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) => (
|
||||
<div
|
||||
key={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.Root className="flex items-center gap-2.5 p-3">
|
||||
<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" />
|
||||
<span className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
{displayNpub(account, 16)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-medium leading-tight">Add</p>
|
||||
</div>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<div className="inline-flex items-center justify-center size-10">
|
||||
{loading.npub === account ? (
|
||||
loading.status ? (
|
||||
<Spinner />
|
||||
) : null
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Link
|
||||
to="/landing"
|
||||
className="flex items-center justify-between hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 p-3">
|
||||
<div className="inline-flex items-center justify-center rounded-full size-10 bg-neutral-200 dark:bg-white/10">
|
||||
<PlusIcon className="size-5" />
|
||||
</div>
|
||||
<span className="max-w-[6rem] truncate text-sm font-medium leading-tight">
|
||||
Add account
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="w-full max-w-sm mx-auto">
|
||||
<Link
|
||||
to="/bootstrap-relays"
|
||||
className="inline-flex items-center justify-center w-full h-8 gap-2 px-2 text-xs font-medium rounded-lg bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 text-neutral-700 dark:text-white/40"
|
||||
>
|
||||
<RelayIcon className="size-4" />
|
||||
Custom Bootstrap Relays
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,27 +9,27 @@ function Screen() {
|
||||
return (
|
||||
<div
|
||||
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="flex w-full flex-col gap-2 bg-white rounded-xl shadow-primary backdrop-blur-lg dark:bg-white/20 dark:ring-1 ring-neutral-800/50 px-2">
|
||||
<div className="h-20 flex items-center border-b border-neutral-100 dark:border-white/5">
|
||||
<div className="w-full max-w-xs mx-auto lg:max-w-md">
|
||||
<div className="flex flex-col w-full gap-2 px-2 bg-white rounded-xl shadow-primary dark:bg-white/20 dark:ring-1 ring-neutral-800/50">
|
||||
<div className="flex items-center h-20 border-b border-neutral-100 dark:border-white/5">
|
||||
<Link
|
||||
to="/auth/new/profile"
|
||||
className="h-14 w-full flex items-center justify-center gap-2 hover:bg-neutral-100 dark:hover:bg-white/10 rounded-lg px-2"
|
||||
to="/auth/create-profile"
|
||||
className="flex items-center justify-center w-full gap-2 px-2 rounded-lg h-14 hover:bg-neutral-100 dark:hover:bg-white/10"
|
||||
>
|
||||
<div className="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
|
||||
src="/icon.jpeg"
|
||||
alt="App Icon"
|
||||
className="size-9 object-cover rounded-full"
|
||||
className="object-cover rounded-full size-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 inline-flex flex-col">
|
||||
<span className="leading-tight font-semibold">
|
||||
<div className="inline-flex flex-col flex-1">
|
||||
<span className="font-semibold leading-tight">
|
||||
Create new account
|
||||
</span>
|
||||
<span className="leading-tight text-sm text-neutral-500">
|
||||
<span className="text-sm leading-tight text-neutral-500">
|
||||
Use everywhere
|
||||
</span>
|
||||
</div>
|
||||
@@ -37,19 +37,19 @@ function Screen() {
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 pb-2.5">
|
||||
<Link
|
||||
to="/auth/privkey"
|
||||
className="inline-flex h-11 w-full items-center gap-2 rounded-lg px-2 hover:bg-neutral-100 dark:hover:bg-white/10"
|
||||
to="/auth/import"
|
||||
className="inline-flex items-center w-full gap-2 px-2 rounded-lg h-11 hover:bg-neutral-100 dark:hover:bg-white/10"
|
||||
>
|
||||
<div className="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" />
|
||||
</div>
|
||||
Login with Private Key
|
||||
</Link>
|
||||
<Link
|
||||
to="/auth/remote"
|
||||
className="inline-flex h-11 w-full items-center gap-2 rounded-lg px-2 hover:bg-neutral-100 dark:hover:bg-white/10"
|
||||
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" />
|
||||
</div>
|
||||
Nostr Connect
|
||||
|
||||
@@ -2,15 +2,23 @@ import { Conversation } from "@/components/conversation";
|
||||
import { Quote } from "@/components/quote";
|
||||
import { RepostNote } from "@/components/repost";
|
||||
import { TextNote } from "@/components/text";
|
||||
import { ArrowRightCircleIcon } from "@lume/icons";
|
||||
import { NostrAccount, NostrQuery } from "@lume/system";
|
||||
import { type ColumnRouteSearch, type NostrEvent, Kind } from "@lume/types";
|
||||
import { ArrowRightCircleIcon, ArrowUpIcon } from "@lume/icons";
|
||||
import { LumeEvent, NostrAccount, NostrQuery } from "@lume/system";
|
||||
import { type ColumnRouteSearch, Kind, type Meta } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { redirect } from "@tanstack/react-router";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { type InfiniteData, useInfiniteQuery } from "@tanstack/react-query";
|
||||
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";
|
||||
|
||||
type Payload = {
|
||||
raw: string;
|
||||
parsed: Meta;
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/newsfeed")({
|
||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||
return {
|
||||
@@ -20,10 +28,10 @@ export const Route = createFileRoute("/newsfeed")({
|
||||
};
|
||||
},
|
||||
beforeLoad: async ({ search }) => {
|
||||
const settings = await NostrQuery.getSettings();
|
||||
const contacts = await NostrAccount.getContactList();
|
||||
const isContactListEmpty = await NostrAccount.isContactListEmpty();
|
||||
const settings = await NostrQuery.getUserSettings();
|
||||
|
||||
if (!contacts.length) {
|
||||
if (isContactListEmpty) {
|
||||
throw redirect({
|
||||
to: "/create-newsfeed/users",
|
||||
search: {
|
||||
@@ -33,14 +41,14 @@ export const Route = createFileRoute("/newsfeed")({
|
||||
});
|
||||
}
|
||||
|
||||
return { settings, contacts };
|
||||
return { settings };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
export function Screen() {
|
||||
const { queryClient } = Route.useRouteContext();
|
||||
const { label, account } = Route.useSearch();
|
||||
const { contacts, settings } = Route.useRouteContext();
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
@@ -52,88 +60,166 @@ export function Screen() {
|
||||
queryKey: [label, account],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||
const events = await NostrQuery.getLocalEvents(contacts, pageParam);
|
||||
const events = await NostrQuery.getLocalEvents(pageParam);
|
||||
return events;
|
||||
},
|
||||
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
|
||||
select: (data) => data?.pages.flatMap((page) => page),
|
||||
refetchOnWindowFocus: false,
|
||||
select: (data) => data?.pages.flat(),
|
||||
});
|
||||
|
||||
const renderItem = (event: NostrEvent) => {
|
||||
if (!event) return;
|
||||
switch (event.kind) {
|
||||
case Kind.Repost:
|
||||
return <RepostNote key={event.id} event={event} />;
|
||||
default: {
|
||||
const isConversation =
|
||||
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
|
||||
.length > 0;
|
||||
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
if (isConversation) {
|
||||
return (
|
||||
<Conversation
|
||||
key={event.id}
|
||||
className="mb-3"
|
||||
event={event}
|
||||
gossip={settings?.gossip}
|
||||
/>
|
||||
);
|
||||
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" />;
|
||||
}
|
||||
|
||||
if (isQuote) {
|
||||
return <Quote 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 (
|
||||
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
|
||||
{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 gap-2">
|
||||
<Spinner className="size-5" />
|
||||
<span className="text-sm font-medium">Fetching new notes...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<div className="flex h-16 w-full items-center justify-center gap-2">
|
||||
<Spinner className="size-5" />
|
||||
<span className="text-sm font-medium">Loading...</span>
|
||||
</div>
|
||||
) : !data.length ? (
|
||||
<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))}
|
||||
</Virtualizer>
|
||||
)}
|
||||
{data?.length && hasNextPage ? (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage || isLoading}
|
||||
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-black/5 px-3 font-medium hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<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 ? (
|
||||
<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">
|
||||
<Spinner className="size-5" />
|
||||
<span className="text-sm font-medium">
|
||||
Getting new notes...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center w-full h-16 gap-2">
|
||||
<Spinner className="size-5" />
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="size-5" />
|
||||
Load more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<span className="text-sm font-medium">Loading...</span>
|
||||
</div>
|
||||
) : !data.length ? (
|
||||
<div className="flex items-center justify-center">
|
||||
Yo. You're catching up on all the things happening around you.
|
||||
</div>
|
||||
) : (
|
||||
data.map((item) => renderItem(item))
|
||||
)}
|
||||
{data?.length && hasNextPage ? (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage || isLoading}
|
||||
className="inline-flex items-center justify-center w-full gap-2 px-3 font-medium h-9 rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<Spinner className="size-5" />
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="size-5" />
|
||||
Load more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</Virtualizer>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
||||
</ScrollArea.Scrollbar>
|
||||
<ScrollArea.Corner className="bg-transparent" />
|
||||
</ScrollArea.Root>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,7 @@ function Screen() {
|
||||
</p>
|
||||
</div>
|
||||
<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">
|
||||
01.
|
||||
</div>
|
||||
@@ -34,6 +34,8 @@ function Screen() {
|
||||
className="h-auto w-full aspect-square rounded-lg shadow-md transform"
|
||||
controls
|
||||
muted
|
||||
preload="none"
|
||||
poster="/poster_1.jpeg"
|
||||
>
|
||||
<source
|
||||
src="https://video.nostr.build/692f71e2be47ecfc29edcbdaa198cc5979bfb9c900f05d78682895dd546d8d4f.mp4"
|
||||
@@ -43,7 +45,7 @@ function Screen() {
|
||||
</video>
|
||||
</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">
|
||||
02.
|
||||
</div>
|
||||
@@ -55,6 +57,8 @@ function Screen() {
|
||||
className="h-auto w-full aspect-square rounded-lg shadow-md transform"
|
||||
controls
|
||||
muted
|
||||
preload="none"
|
||||
poster="/poster_2.jpeg"
|
||||
>
|
||||
<source
|
||||
src="https://video.nostr.build/d33962520506d86acfb4b55a7b265821e10ae637f5ec830a173b7e6092b16ec8.mp4"
|
||||
@@ -64,7 +68,7 @@ function Screen() {
|
||||
</video>
|
||||
</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">
|
||||
03.
|
||||
</div>
|
||||
@@ -76,6 +80,8 @@ function Screen() {
|
||||
className="h-auto w-full aspect-square rounded-lg shadow-md transform"
|
||||
controls
|
||||
muted
|
||||
preload="none"
|
||||
poster="/poster_3.jpeg"
|
||||
>
|
||||
<source
|
||||
src="https://video.nostr.build/927abbfde2097e470ac751181b1db456b7e4b9149550408efff1a966a7ffb9a8.mp4"
|
||||
@@ -85,7 +91,7 @@ function Screen() {
|
||||
</video>
|
||||
</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">
|
||||
04.
|
||||
</div>
|
||||
@@ -97,6 +103,8 @@ function Screen() {
|
||||
className="h-auto w-full rounded-lg shadow-md transform"
|
||||
controls
|
||||
muted
|
||||
preload="none"
|
||||
poster="/poster_4.jpeg"
|
||||
>
|
||||
<source
|
||||
src="https://video.nostr.build/513de4824b6abaf7e9698c1dad2f68096574356848c0c200bc8cb8074df29410.mp4"
|
||||
|
||||
@@ -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
apps/desktop2/src/routes/panel.$account.tsx
Normal file
361
apps/desktop2/src/routes/panel.$account.tsx
Normal 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
apps/desktop2/src/routes/search.notes.tsx
Normal file
96
apps/desktop2/src/routes/search.notes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,144 +1,44 @@
|
||||
import { SearchIcon } from "@lume/icons";
|
||||
import { type NostrEvent, Kind } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { Note } from "@/components/note";
|
||||
import { User } from "@/components/user";
|
||||
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";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
type Search = {
|
||||
query: string;
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/search")({
|
||||
validateSearch: (search: Record<string, string>): Search => {
|
||||
return {
|
||||
query: search.query,
|
||||
};
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
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]);
|
||||
const { query } = Route.useSearch();
|
||||
|
||||
return (
|
||||
<div data-tauri-drag-region className="flex flex-col w-full h-full">
|
||||
<div className="relative h-24 shrink-0 flex flex-col border-b border-black/5 dark:border-white/5">
|
||||
<div data-tauri-drag-region className="w-full h-4 shrink-0" />
|
||||
<input
|
||||
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 className="flex flex-col h-full">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
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"
|
||||
>
|
||||
Search result for: <span className="font-semibold">{query}</span>
|
||||
</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}>
|
||||
<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>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchNote({ event }: { event: NostrEvent }) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
|
||||
<Note.Provider event={event}>
|
||||
<Note.Root>
|
||||
<div className="px-3 h-14 flex items-center justify-between">
|
||||
<Note.User />
|
||||
<Note.Menu />
|
||||
</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>
|
||||
<ScrollArea.Root
|
||||
type={"scroll"}
|
||||
scrollHideDelay={300}
|
||||
className="overflow-hidden size-full flex-1"
|
||||
>
|
||||
<Outlet />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
101
apps/desktop2/src/routes/search.users.tsx
Normal file
101
apps/desktop2/src/routes/search.users.tsx
Normal 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,20 +8,17 @@ import {
|
||||
import { cn } from "@lume/utils";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export const Route = createFileRoute("/settings")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="flex flex-col w-full h-full">
|
||||
<div
|
||||
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">
|
||||
<Link to="/settings/general">
|
||||
@@ -36,9 +33,7 @@ function Screen() {
|
||||
)}
|
||||
>
|
||||
<SettingsIcon className="size-5 shrink-0" />
|
||||
<p className="text-sm font-medium">
|
||||
{t("settings.general.title")}
|
||||
</p>
|
||||
<p className="text-sm font-medium">General</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
@@ -55,9 +50,7 @@ function Screen() {
|
||||
)}
|
||||
>
|
||||
<UserIcon className="size-5 shrink-0" />
|
||||
<p className="text-sm font-medium">
|
||||
{t("settings.user.title")}
|
||||
</p>
|
||||
<p className="text-sm font-medium">User</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
@@ -79,7 +72,7 @@ function Screen() {
|
||||
);
|
||||
}}
|
||||
</Link>
|
||||
<Link to="/settings/zap">
|
||||
<Link to="/settings/wallet">
|
||||
{({ isActive }) => {
|
||||
return (
|
||||
<div
|
||||
@@ -91,9 +84,7 @@ function Screen() {
|
||||
)}
|
||||
>
|
||||
<ZapIcon className="size-5 shrink-0" />
|
||||
<p className="text-sm font-medium">
|
||||
{t("settings.zap.title")}
|
||||
</p>
|
||||
<p className="text-sm font-medium">Wallet</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
@@ -110,16 +101,14 @@ function Screen() {
|
||||
)}
|
||||
>
|
||||
<SecureIcon className="size-5 shrink-0" />
|
||||
<p className="text-sm font-medium">
|
||||
{t("settings.backup.title")}
|
||||
</p>
|
||||
<p className="text-sm font-medium">Backup</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Link>
|
||||
</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 />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { User } from "@/components/user";
|
||||
import { NostrAccount } from "@lume/system";
|
||||
import { displayNsec } from "@lume/utils";
|
||||
import { displayNpub } from "@lume/utils";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Account {
|
||||
npub: string;
|
||||
@@ -13,116 +13,61 @@ interface Account {
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/settings/backup")({
|
||||
component: Screen,
|
||||
loader: async () => {
|
||||
const npubs = await NostrAccount.getAccounts();
|
||||
const accounts: Account[] = [];
|
||||
|
||||
for (const npub of npubs) {
|
||||
const nsec: string = await invoke("get_stored_nsec", { npub });
|
||||
accounts.push({ npub, nsec });
|
||||
}
|
||||
|
||||
return accounts;
|
||||
beforeLoad: async () => {
|
||||
const accounts = await NostrAccount.getAccounts();
|
||||
return { accounts };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const accounts = Route.useLoaderData();
|
||||
const { accounts } = Route.useRouteContext();
|
||||
|
||||
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">
|
||||
{accounts.map((account) => (
|
||||
<List key={account.npub} account={account} />
|
||||
<Account key={account} account={account} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function List({ account }: { account: Account }) {
|
||||
const [key, setKey] = useState(account.nsec);
|
||||
function Account({ account }: { account: string }) {
|
||||
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 () => {
|
||||
try {
|
||||
await writeText(key);
|
||||
const data: string = await invoke("get_private_key", { npub: account });
|
||||
await writeText(data);
|
||||
setCopied(true);
|
||||
} catch (e) {
|
||||
toast.error(e);
|
||||
await message(String(e), { title: "Backup", kind: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-2 py-3">
|
||||
<User.Provider pubkey={account.npub}>
|
||||
<div className="flex items-center justify-between gap-2 py-3">
|
||||
<User.Provider pubkey={account}>
|
||||
<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">
|
||||
<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>
|
||||
</User.Root>
|
||||
</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">
|
||||
<input
|
||||
readOnly
|
||||
name="nsec"
|
||||
type="text"
|
||||
value={displayNsec(key, 36)}
|
||||
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyKey()}
|
||||
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<label
|
||||
htmlFor="passphase"
|
||||
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
Set a passphase to secure your key
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
name="passphase"
|
||||
type="password"
|
||||
value={passphase}
|
||||
onChange={(e) => setPassphase(e.target.value)}
|
||||
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => encrypt()}
|
||||
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyKey()}
|
||||
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 Private Key"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
39
apps/desktop2/src/routes/settings/bitcoin-connect.tsx
Normal file
39
apps/desktop2/src/routes/settings/bitcoin-connect.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,179 +1,102 @@
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import type { Settings } from "@lume/types";
|
||||
import { NostrQuery, type Settings } from "@lume/system";
|
||||
import * as Switch from "@radix-ui/react-switch";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { requestPermission } from "@tauri-apps/plugin-notification";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
type Theme = "auto" | "light" | "dark";
|
||||
|
||||
export const Route = createFileRoute("/settings/general")({
|
||||
beforeLoad: async () => {
|
||||
const settings = await NostrQuery.getSettings();
|
||||
return { settings };
|
||||
const initialSettings = await NostrQuery.getUserSettings();
|
||||
return { initialSettings };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { settings } = Route.useRouteContext();
|
||||
const [newSettings, setNewSettings] = useState<Settings>(settings);
|
||||
const { initialSettings } = Route.useRouteContext();
|
||||
|
||||
const toggleNofitication = async () => {
|
||||
await requestPermission();
|
||||
setNewSettings((prev) => ({
|
||||
...prev,
|
||||
notification: !newSettings.notification,
|
||||
}));
|
||||
};
|
||||
const [theme, setTheme] = useState<Theme>(null);
|
||||
const [settings, setSettings] = useState<Settings>(null);
|
||||
|
||||
const toggleGossip = async () => {
|
||||
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) => {
|
||||
const changeTheme = async (theme: string) => {
|
||||
if (theme === "auto" || theme === "light" || theme === "dark") {
|
||||
invoke("plugin:theme|set_theme", {
|
||||
theme: theme,
|
||||
}).then(() =>
|
||||
setNewSettings((prev) => ({
|
||||
...prev,
|
||||
theme,
|
||||
})),
|
||||
);
|
||||
}).then(() => setTheme(theme));
|
||||
}
|
||||
};
|
||||
|
||||
const updateSettings = useDebouncedCallback(() => {
|
||||
NostrQuery.setSettings(newSettings);
|
||||
const updateSettings = useDebouncedCallback(async () => {
|
||||
const newSettings = JSON.stringify(settings);
|
||||
await NostrQuery.setUserSettings(newSettings);
|
||||
}, 200);
|
||||
|
||||
useEffect(() => {
|
||||
updateSettings();
|
||||
}, [newSettings]);
|
||||
}, [settings]);
|
||||
|
||||
useEffect(() => {
|
||||
invoke("plugin:theme|get_theme").then((data: Theme) => setTheme(data));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSettings(initialSettings);
|
||||
}, [initialSettings]);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
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 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">
|
||||
<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
|
||||
</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 w-full items-start justify-between 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 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">Relay Hint</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Automatically connect to the necessary relay suggested by
|
||||
Relay Hint when fetching a new event.
|
||||
Use the relay hint if necessary.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-36 flex justify-end shrink-0">
|
||||
<div className="flex justify-end w-36 shrink-0">
|
||||
<Switch.Root
|
||||
checked={newSettings.gossip}
|
||||
onClick={() => toggleGossip()}
|
||||
checked={settings.use_relay_hint}
|
||||
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"
|
||||
>
|
||||
<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 items-start justify-between w-full gap-4 py-3">
|
||||
<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">
|
||||
Lume presents external resources like images, videos, or link
|
||||
previews in plain text.
|
||||
Shows a warning for notes that have a content warning.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-36 flex justify-end shrink-0">
|
||||
<div className="flex justify-end w-36 shrink-0">
|
||||
<Switch.Root
|
||||
checked={newSettings.enhancedPrivacy}
|
||||
onClick={() => toggleEnhancedPrivacy()}
|
||||
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
|
||||
>
|
||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||
</Switch.Root>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-start justify-between gap-4 py-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">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()}
|
||||
checked={settings.content_warning}
|
||||
onClick={() =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
content_warning: !prev.content_warning,
|
||||
}))
|
||||
}
|
||||
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]" />
|
||||
@@ -183,40 +106,22 @@ function Screen() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="font-semibold text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Interface
|
||||
<h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
||||
Appearance
|
||||
</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 w-full items-start justify-between gap-4 py-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 items-start justify-between w-full gap-4 py-3">
|
||||
<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">
|
||||
Show the Zap button in each note and user's profile screen,
|
||||
use for send bitcoin tip to other users.
|
||||
Require restarting the app to take effect.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-36 flex justify-end shrink-0">
|
||||
<Switch.Root
|
||||
checked={newSettings.zap}
|
||||
onClick={() => toggleZap()}
|
||||
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
|
||||
>
|
||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||
</Switch.Root>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-start justify-between gap-4 py-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">Appearance</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
* Require restarting the app to take effect.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-36 flex justify-end shrink-0">
|
||||
<div className="flex justify-end w-36 shrink-0">
|
||||
<select
|
||||
name="theme"
|
||||
className="bg-transparent shadow-none outline-none rounded-lg border-1 border-black/10 dark:border-white/10 py-1 w-24"
|
||||
defaultValue={settings.theme}
|
||||
className="w-24 py-1 bg-transparent rounded-lg shadow-none outline-none border-1 border-black/10 dark:border-white/10"
|
||||
defaultValue={theme}
|
||||
onChange={(e) => changeTheme(e.target.value)}
|
||||
>
|
||||
<option value="auto">Auto</option>
|
||||
@@ -225,6 +130,143 @@ function Screen() {
|
||||
</select>
|
||||
</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>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { CancelIcon, PlusIcon } from "@lume/icons";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
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 { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/settings/relay")({
|
||||
loader: async () => {
|
||||
@@ -15,39 +15,49 @@ export const Route = createFileRoute("/settings/relay")({
|
||||
|
||||
function Screen() {
|
||||
const relayList = Route.useLoaderData();
|
||||
const [relays, setRelays] = useState(relayList.connected);
|
||||
|
||||
const { register, reset, handleSubmit } = useForm();
|
||||
|
||||
const [relays, setRelays] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const onSubmit = async (data: { url: string }) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const add = await NostrQuery.connectRelay(data.url);
|
||||
|
||||
if (add) {
|
||||
setRelays((prev) => [...prev, data.url]);
|
||||
setIsLoading(false);
|
||||
reset();
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(String(e));
|
||||
setIsLoading(false);
|
||||
await message(String(e), { title: "Relay", kind: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setRelays(relayList.connected);
|
||||
}, [relayList]);
|
||||
|
||||
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-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
|
||||
</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) => (
|
||||
<div
|
||||
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">
|
||||
<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="relative inline-flex rounded-full size-2 bg-teal-500"></span>
|
||||
<span className="absolute inline-flex w-full h-full bg-teal-400 rounded-full opacity-75 animate-ping" />
|
||||
<span className="relative inline-flex bg-teal-500 rounded-full size-2" />
|
||||
</span>
|
||||
{relay}
|
||||
</div>
|
||||
@@ -55,7 +65,7 @@ function Screen() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => NostrQuery.removeRelay(relay)}
|
||||
className="inline-flex items-center justify-center size-7 rounded-md hover:bg-black/10 dark:hover:bg-white/10"
|
||||
className="inline-flex items-center justify-center rounded-md size-7 hover:bg-black/10 dark:hover:bg-white/10"
|
||||
>
|
||||
<CancelIcon className="size-4" />
|
||||
</button>
|
||||
@@ -65,7 +75,7 @@ function Screen() {
|
||||
<div className="flex items-center h-14">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="w-full flex items-center gap-2 mb-0"
|
||||
className="flex items-center w-full gap-2 mb-0"
|
||||
>
|
||||
<input
|
||||
{...register("url", {
|
||||
@@ -75,11 +85,12 @@ function Screen() {
|
||||
name="url"
|
||||
placeholder="wss://..."
|
||||
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
|
||||
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" />
|
||||
</button>
|
||||
@@ -88,21 +99,21 @@ function Screen() {
|
||||
</div>
|
||||
</div>
|
||||
<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)
|
||||
</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">
|
||||
Lume will automatically connect to the user's relay list, but the
|
||||
manager function (like adding, removing, changing relay purpose)
|
||||
is not yet available.
|
||||
</p>
|
||||
</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) => (
|
||||
<div
|
||||
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-xs font-semibold">READ</div>
|
||||
@@ -111,7 +122,7 @@ function Screen() {
|
||||
{relayList.write?.map((relay) => (
|
||||
<div
|
||||
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-xs font-semibold">WRITE</div>
|
||||
@@ -120,7 +131,7 @@ function Screen() {
|
||||
{relayList.both?.map((relay) => (
|
||||
<div
|
||||
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-xs font-semibold">READ + WRITE</div>
|
||||
|
||||
@@ -5,9 +5,9 @@ import type { Metadata } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/settings/user")({
|
||||
beforeLoad: async () => {
|
||||
@@ -34,31 +34,31 @@ function Screen() {
|
||||
setLoading(false);
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(String(e));
|
||||
await message(String(e), { title: "Profile", kind: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full h-full">
|
||||
<div className="flex-1 h-full flex items-center flex-col justify-center gap-3">
|
||||
<div className="relative size-24 rounded-full bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200">
|
||||
<div className="flex flex-col items-center justify-center flex-1 h-full gap-3">
|
||||
<div className="relative rounded-full size-24 bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200">
|
||||
{profile.picture ? (
|
||||
<img
|
||||
src={picture || profile.picture}
|
||||
alt="avatar"
|
||||
loading="lazy"
|
||||
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}
|
||||
<AvatarUploader
|
||||
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" />
|
||||
</AvatarUploader>
|
||||
</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-neutral-800 dark:text-neutral-200">
|
||||
{profile.nip05}
|
||||
@@ -66,7 +66,7 @@ function Screen() {
|
||||
<div className="mt-4">
|
||||
<Link
|
||||
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
|
||||
</Link>
|
||||
@@ -78,7 +78,7 @@ function Screen() {
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
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
|
||||
htmlFor="display_name"
|
||||
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||
@@ -89,10 +89,10 @@ function Screen() {
|
||||
name="display_name"
|
||||
{...register("display_name")}
|
||||
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 className="flex w-full flex-col gap-1">
|
||||
<div className="flex flex-col w-full gap-1">
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||
@@ -103,10 +103,10 @@ function Screen() {
|
||||
name="name"
|
||||
{...register("name")}
|
||||
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 className="flex w-full flex-col gap-1">
|
||||
<div className="flex flex-col w-full gap-1">
|
||||
<label
|
||||
htmlFor="website"
|
||||
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||
@@ -118,10 +118,10 @@ function Screen() {
|
||||
type="url"
|
||||
{...register("website")}
|
||||
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 className="flex w-full flex-col gap-1">
|
||||
<div className="flex flex-col w-full gap-1">
|
||||
<label
|
||||
htmlFor="banner"
|
||||
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||
@@ -133,10 +133,10 @@ function Screen() {
|
||||
type="url"
|
||||
{...register("banner")}
|
||||
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 className="flex w-full flex-col gap-1">
|
||||
<div className="flex flex-col w-full gap-1">
|
||||
<label
|
||||
htmlFor="nip05"
|
||||
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||
@@ -148,10 +148,10 @@ function Screen() {
|
||||
type="email"
|
||||
{...register("nip05")}
|
||||
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 className="flex w-full flex-col gap-1">
|
||||
<div className="flex flex-col w-full gap-1">
|
||||
<label
|
||||
htmlFor="lnaddress"
|
||||
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||
@@ -162,13 +162,13 @@ function Screen() {
|
||||
name="lnaddress"
|
||||
type="email"
|
||||
{...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 className="flex items-center justify-end">
|
||||
<button
|
||||
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"}
|
||||
</button>
|
||||
|
||||
59
apps/desktop2/src/routes/settings/wallet.tsx
Normal file
59
apps/desktop2/src/routes/settings/wallet.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +1,104 @@
|
||||
import { GlobalIcon, LaurelIcon } from "@lume/icons";
|
||||
import type { ColumnRouteSearch } from "@lume/types";
|
||||
import { cn } from "@lume/utils";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
||||
import { CommunityIcon, LaurelIcon } from "@lume/icons";
|
||||
import type { LumeColumn } from "@lume/types";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { 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")({
|
||||
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 {
|
||||
account: search.account,
|
||||
label: search.label,
|
||||
name: search.name,
|
||||
officialColumns,
|
||||
};
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { officialColumns } = Route.useRouteContext();
|
||||
|
||||
const install = async (column: LumeColumn) => {
|
||||
const mainWindow = getCurrentWindow();
|
||||
await mainWindow.emit("columns", { type: "add", column });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-3 mt-2 mb-1">
|
||||
<div className="p-1 shrink-0 inline-flex w-full rounded-lg items-center gap-1 bg-black/5 dark:bg-white/5">
|
||||
<Link to="/store/official" className="flex-1">
|
||||
{({ isActive }) => (
|
||||
<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",
|
||||
)}
|
||||
>
|
||||
<div className="size-full">
|
||||
<ScrollArea.Root
|
||||
type={"scroll"}
|
||||
scrollHideDelay={300}
|
||||
className="flex-1 overflow-hidden size-full"
|
||||
>
|
||||
<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" />
|
||||
Official
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
<Link to="/store/community" className="flex-1">
|
||||
{({ isActive }) => (
|
||||
<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",
|
||||
)}
|
||||
>
|
||||
<GlobalIcon className="size-4" />
|
||||
Community
|
||||
Official
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{officialColumns.map((column) => (
|
||||
<div
|
||||
key={column.label}
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto scrollbar-none">
|
||||
<Outlet />
|
||||
</div>
|
||||
Community
|
||||
</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">
|
||||
Coming Soon.
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,19 +2,20 @@ import { Conversation } from "@/components/conversation";
|
||||
import { Quote } from "@/components/quote";
|
||||
import { RepostNote } from "@/components/repost";
|
||||
import { TextNote } from "@/components/text";
|
||||
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import {
|
||||
type ColumnRouteSearch,
|
||||
type NostrEvent,
|
||||
Kind,
|
||||
Topic,
|
||||
} from "@lume/types";
|
||||
import { ArrowRightCircleIcon } from "@lume/icons";
|
||||
import { type LumeEvent, NostrQuery } from "@lume/system";
|
||||
import { type ColumnRouteSearch, Kind } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
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";
|
||||
|
||||
type Topic = {
|
||||
content: string[];
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/topic")({
|
||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||
return {
|
||||
@@ -24,9 +25,9 @@ export const Route = createFileRoute("/topic")({
|
||||
};
|
||||
},
|
||||
beforeLoad: async ({ search }) => {
|
||||
const key = `lume_topic_${search.label}`;
|
||||
const topics = (await NostrQuery.getNstore(key)) as unknown as Topic[];
|
||||
const settings = await NostrQuery.getSettings();
|
||||
const key = `lume:topic:${search.label}`;
|
||||
const topics: Topic[] = await NostrQuery.getNstore(key);
|
||||
const settings = await NostrQuery.getUserSettings();
|
||||
|
||||
if (!topics?.length) {
|
||||
throw redirect({
|
||||
@@ -38,16 +39,13 @@ export const Route = createFileRoute("/topic")({
|
||||
});
|
||||
}
|
||||
|
||||
let hashtags: string[] = [];
|
||||
const hashtags: string[] = [];
|
||||
|
||||
for (const topic of topics) {
|
||||
hashtags.push(...topic.content);
|
||||
}
|
||||
|
||||
return {
|
||||
hashtags,
|
||||
settings,
|
||||
};
|
||||
return { settings, hashtags };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
@@ -70,107 +68,92 @@ export function Screen() {
|
||||
return events;
|
||||
},
|
||||
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) => {
|
||||
if (!event) return;
|
||||
switch (event.kind) {
|
||||
case Kind.Repost:
|
||||
return <RepostNote key={event.id} event={event} />;
|
||||
default: {
|
||||
const isConversation =
|
||||
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
|
||||
.length > 0;
|
||||
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
if (isConversation) {
|
||||
return <Conversation key={event.id} event={event} className="mb-3" />;
|
||||
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" />;
|
||||
}
|
||||
|
||||
if (isQuote) {
|
||||
return <Quote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
|
||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
|
||||
{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 gap-2">
|
||||
<Spinner className="size-5" />
|
||||
<span className="text-sm font-medium">Fetching new notes...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<div className="flex h-16 w-full items-center justify-center gap-2">
|
||||
<Spinner className="size-5" />
|
||||
<span className="text-sm font-medium">Loading...</span>
|
||||
</div>
|
||||
) : !data.length ? (
|
||||
<Empty />
|
||||
) : (
|
||||
<Virtualizer overscan={3}>
|
||||
{data.map((item) => renderItem(item))}
|
||||
</Virtualizer>
|
||||
)}
|
||||
{data?.length && hasNextPage ? (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage || isLoading}
|
||||
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-neutral-100 px-3 font-medium hover:bg-neutral-50 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<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 ? (
|
||||
<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">
|
||||
<Spinner className="size-5" />
|
||||
<span className="text-sm font-medium">
|
||||
Getting new notes...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center w-full h-16 gap-2">
|
||||
<Spinner className="size-5" />
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="size-5" />
|
||||
Load more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
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" />
|
||||
Show trending notes
|
||||
</Link>
|
||||
<Link
|
||||
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>
|
||||
<span className="text-sm font-medium">Loading...</span>
|
||||
</div>
|
||||
) : !data.length ? (
|
||||
<div className="flex items-center justify-center">
|
||||
Yo. You're catching up on all the things happening around you.
|
||||
</div>
|
||||
) : (
|
||||
data.map((item) => renderItem(item))
|
||||
)}
|
||||
{data?.length && hasNextPage ? (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage || isLoading}
|
||||
className="inline-flex items-center justify-center w-full gap-2 px-3 font-medium h-9 rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<Spinner className="size-5" />
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="size-5" />
|
||||
Load more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</Virtualizer>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
||||
</ScrollArea.Scrollbar>
|
||||
<ScrollArea.Corner className="bg-transparent" />
|
||||
</ScrollArea.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 type { NostrEvent } from "@lume/types";
|
||||
import { LumeEvent } 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 { Await, createFileRoute } from "@tanstack/react-router";
|
||||
import { defer } from "@tanstack/react-router";
|
||||
import { Suspense } from "react";
|
||||
import { Suspense, useCallback, useRef } from "react";
|
||||
import { Virtualizer } from "virtua";
|
||||
|
||||
export const Route = createFileRoute("/trending/notes")({
|
||||
@@ -15,7 +20,15 @@ export const Route = createFileRoute("/trending/notes")({
|
||||
signal: abortController.signal,
|
||||
})
|
||||
.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) {
|
||||
@@ -28,32 +41,59 @@ export const Route = createFileRoute("/trending/notes")({
|
||||
export function Screen() {
|
||||
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 (
|
||||
<div className="w-full h-full">
|
||||
<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}>
|
||||
{(notes) =>
|
||||
notes.map((event) => (
|
||||
<TextNote key={event.id} event={event} className="mb-3" />
|
||||
))
|
||||
<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}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex flex-col items-center justify-center w-full h-20 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>
|
||||
</Suspense>
|
||||
</Virtualizer>
|
||||
</div>
|
||||
>
|
||||
<Await promise={data}>
|
||||
{(notes) => notes.map((event) => renderItem(event))}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,23 +14,25 @@ export const Route = createFileRoute("/trending")({
|
||||
};
|
||||
},
|
||||
beforeLoad: async () => {
|
||||
const settings = await NostrQuery.getSettings();
|
||||
const settings = await NostrQuery.getUserSettings();
|
||||
return { settings };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const search = Route.useSearch();
|
||||
|
||||
return (
|
||||
<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="inline-flex h-full w-full items-center gap-1">
|
||||
<Link to="/trending/notes">
|
||||
<div className="shrink-0 h-11 flex items-center w-full gap-1 px-3">
|
||||
<div className="flex w-full h-full gap-1">
|
||||
<Link to="/trending/notes" search={search}>
|
||||
{({ 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",
|
||||
"inline-flex h-8 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
|
||||
isActive ? "bg-black/10 dark:bg-white/10" : "opacity-50",
|
||||
)}
|
||||
>
|
||||
<ArticleIcon className="size-4" />
|
||||
@@ -38,12 +40,12 @@ function Screen() {
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
<Link to="/trending/users">
|
||||
<Link to="/trending/users" search={search}>
|
||||
{({ 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",
|
||||
"inline-flex h-8 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
|
||||
isActive ? "bg-black/10 dark:bg-white/10" : "opacity-50",
|
||||
)}
|
||||
>
|
||||
<GroupFeedsIcon className="size-4" />
|
||||
@@ -53,7 +55,7 @@ function Screen() {
|
||||
</Link>
|
||||
</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 />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { User } from "@/components/user";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { Await, defer } from "@tanstack/react-router";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Suspense } from "react";
|
||||
@@ -45,14 +45,14 @@ export function Screen() {
|
||||
users.profiles.map((item: { pubkey: string }) => (
|
||||
<div
|
||||
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.Root>
|
||||
<div className="flex h-full w-full flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<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" />
|
||||
</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" />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user