Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
4b60b39119 | ||
|
|
d2e5122d5a | ||
|
|
32f3315344 | ||
|
|
5ca9444358 | ||
|
|
4dc13385a5 | ||
|
|
b90ad1421f | ||
|
|
bba324ea53 | ||
|
|
7449000f5f | ||
|
|
dc7762ca11 | ||
|
|
3a3f960dde | ||
|
|
12e066ff2e | ||
|
|
fe4f965ed5 | ||
|
|
5d3f2264e9 | ||
|
|
407fe40b67 | ||
|
|
1f38eba2cc | ||
|
|
9b5867f80c | ||
|
|
cac774a0c1 | ||
|
|
82689bf3c3 | ||
|
|
f60e438a64 | ||
|
|
ca06f2b6ed | ||
|
|
99d9c70826 | ||
|
|
60afbf090b | ||
|
|
10ca4e6ff4 | ||
|
|
b0f387d029 | ||
|
|
1a8f750640 | ||
|
|
25523229a2 | ||
|
|
47835ed857 | ||
|
|
d84647bc6b | ||
|
|
7724eccd72 | ||
|
|
8ea2335225 | ||
|
|
b60d4db0df | ||
|
|
f1e17ff3c4 | ||
|
|
32954f17b6 | ||
|
|
cf70b0f882 |
@@ -16,6 +16,8 @@ jobs:
|
|||||||
args: "--target aarch64-apple-darwin"
|
args: "--target aarch64-apple-darwin"
|
||||||
- platform: "macos-latest" # for Intel based macs.
|
- platform: "macos-latest" # for Intel based macs.
|
||||||
args: "--target x86_64-apple-darwin"
|
args: "--target x86_64-apple-darwin"
|
||||||
|
- platform: "macos-latest" # for Intel based macs.
|
||||||
|
args: "--target universal-apple-darwin"
|
||||||
#- platform: 'ubuntu-22.04'
|
#- platform: 'ubuntu-22.04'
|
||||||
# args: ''
|
# args: ''
|
||||||
#- platform: 'windows-latest'
|
#- platform: 'windows-latest'
|
||||||
|
|||||||
@@ -35,4 +35,4 @@ dist/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
.vscode/
|
.vscode/
|
||||||
ndb/
|
.idea/
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
_Note_: Lume is under rewrite to using Rust Nostr as back-end and more lightweight front-end. If you need stable version, you can download v3 and below.
|
|
||||||
|
|
||||||
Source code for v3 is stored here: https://github.com/lumehq/lume/tree/old
|
|
||||||
|
|
||||||
--
|
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
Lume is a Nostr client for desktop include Linux, Windows and macOS. It is free and open source, you can look at source code on Github. Lume is actively improving the app and adding new features, you can expect new update every month.
|
Lume is a Nostr client for desktop include Linux, Windows and macOS. It is free and open source, you can look at source code on Github. Lume is actively improving the app and adding new features, you can expect new update every month.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Download Lume v3 (v3.0.1-stable) for your platform here: [https://github.com/lumehq/lume/releases](https://github.com/lumehq/lume/releases)
|
Download Lume v4 for your platform here: [https://github.com/lumehq/lume/releases](https://github.com/lumehq/lume/releases)
|
||||||
|
|
||||||
Supported platform: macOS, Windows and Linux
|
Supported platform: macOS. Windows and Linux are coming soon.
|
||||||
|
|
||||||
|
Windows and Linux are availabel on v3 and below.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
|
|||||||
@@ -9,52 +9,52 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lume/ark": "workspace:^",
|
"@getalby/bitcoin-connect-react": "^3.5.3",
|
||||||
"@lume/icons": "workspace:^",
|
"@lume/icons": "workspace:^",
|
||||||
|
"@lume/system": "workspace:^",
|
||||||
"@lume/ui": "workspace:^",
|
"@lume/ui": "workspace:^",
|
||||||
"@lume/utils": "workspace:^",
|
"@lume/utils": "workspace:^",
|
||||||
|
"@radix-ui/react-avatar": "^1.0.4",
|
||||||
"@radix-ui/react-checkbox": "^1.0.4",
|
"@radix-ui/react-checkbox": "^1.0.4",
|
||||||
"@radix-ui/react-collapsible": "^1.0.3",
|
|
||||||
"@radix-ui/react-dialog": "^1.0.5",
|
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
|
||||||
"@radix-ui/react-popover": "^1.0.7",
|
"@radix-ui/react-popover": "^1.0.7",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||||
"@radix-ui/react-switch": "^1.0.3",
|
"@radix-ui/react-switch": "^1.0.3",
|
||||||
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
"@radix-ui/react-tooltip": "^1.0.7",
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
"@tanstack/query-sync-storage-persister": "^5.32.0",
|
"@tanstack/query-persist-client-core": "^5.45.0",
|
||||||
"@tanstack/react-query": "^5.32.0",
|
"@tanstack/react-query": "^5.45.0",
|
||||||
"@tanstack/react-query-persist-client": "^5.32.0",
|
"@tanstack/react-router": "^1.38.1",
|
||||||
"@tanstack/react-router": "1.29.2",
|
"embla-carousel-react": "^8.1.5",
|
||||||
"i18next": "^23.11.3",
|
"i18next": "^23.11.5",
|
||||||
"i18next-resources-to-backend": "^1.2.1",
|
"i18next-resources-to-backend": "^1.2.1",
|
||||||
"minidenticons": "^4.2.1",
|
"minidenticons": "^4.2.1",
|
||||||
"nanoid": "^5.0.7",
|
"nanoid": "^5.0.7",
|
||||||
"nostr-tools": "^2.5.1",
|
"nostr-tools": "^2.7.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-currency-input-field": "^3.8.0",
|
"react-currency-input-field": "^3.8.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.51.3",
|
"react-hook-form": "^7.52.0",
|
||||||
"react-hotkeys-hook": "^4.5.0",
|
"react-i18next": "^14.1.2",
|
||||||
"react-i18next": "^14.1.1",
|
"react-string-replace": "^1.1.1",
|
||||||
"slate": "^0.102.0",
|
"slate": "^0.103.0",
|
||||||
"slate-react": "^0.102.0",
|
"slate-react": "^0.105.0",
|
||||||
"sonner": "^1.4.41",
|
"use-debounce": "^10.0.1",
|
||||||
"use-debounce": "^10.0.0",
|
"virtua": "^0.31.0"
|
||||||
"virtua": "^0.30.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lume/tailwindcss": "workspace:^",
|
"@lume/tailwindcss": "workspace:^",
|
||||||
"@lume/tsconfig": "workspace:^",
|
"@lume/tsconfig": "workspace:^",
|
||||||
"@lume/types": "workspace:^",
|
"@lume/types": "workspace:^",
|
||||||
"@tanstack/router-devtools": "^1.31.3",
|
"@tanstack/router-devtools": "^1.38.1",
|
||||||
"@tanstack/router-vite-plugin": "^1.30.0",
|
"@tanstack/router-vite-plugin": "^1.38.0",
|
||||||
"@types/react": "^18.3.1",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"tailwindcss": "^3.4.3",
|
"tailwindcss": "^3.4.4",
|
||||||
"typescript": "^5.4.5",
|
"typescript": "^5.4.5",
|
||||||
"vite": "^5.2.10",
|
"vite": "^5.3.1",
|
||||||
"vite-plugin-top-level-await": "^1.4.1",
|
"vite-plugin-top-level-await": "^1.4.1",
|
||||||
"vite-tsconfig-paths": "^4.3.2"
|
"vite-tsconfig-paths": "^4.3.2"
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 310 KiB |
|
Before Width: | Height: | Size: 951 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 257 KiB |
@@ -2,18 +2,6 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
|
|
||||||
*::-webkit-scrollbar {
|
|
||||||
@apply w-[5px];
|
|
||||||
}
|
|
||||||
|
|
||||||
*::-webkit-scrollbar-track {
|
|
||||||
@apply bg-transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
*::-webkit-scrollbar-thumb {
|
|
||||||
@apply rounded bg-black dark:bg-white;
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.content-break {
|
.content-break {
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
|||||||
@@ -1,33 +1,31 @@
|
|||||||
import { Ark } from "@lume/ark";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { CancelCircleIcon, CheckCircleIcon, InfoCircleIcon } from "@lume/icons";
|
|
||||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
|
||||||
import { QueryClient } from "@tanstack/react-query";
|
|
||||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
|
||||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||||
import { platform } from "@tauri-apps/plugin-os";
|
|
||||||
import React, { StrictMode } from "react";
|
import React, { StrictMode } from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import { I18nextProvider } from "react-i18next";
|
import { I18nextProvider } from "react-i18next";
|
||||||
import { Toaster } from "sonner";
|
|
||||||
import "./app.css";
|
import "./app.css";
|
||||||
|
import { type } from "@tauri-apps/plugin-os";
|
||||||
import i18n from "./locale";
|
import i18n from "./locale";
|
||||||
import { routeTree } from "./router.gen"; // auto generated file
|
import { routeTree } from "./router.gen"; // auto generated file
|
||||||
|
|
||||||
const ark = new Ark();
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
const platformName = await platform();
|
const os = await type();
|
||||||
|
|
||||||
const persister = createSyncStoragePersister({
|
|
||||||
storage: window.localStorage,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set up a Router instance
|
// Set up a Router instance
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
routeTree,
|
routeTree,
|
||||||
context: {
|
context: {
|
||||||
ark,
|
|
||||||
queryClient,
|
queryClient,
|
||||||
platform: platformName,
|
platform: os,
|
||||||
|
},
|
||||||
|
Wrap: ({ children }) => {
|
||||||
|
return (
|
||||||
|
<I18nextProvider i18n={i18n} defaultNS={"translation"}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
</I18nextProvider>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,25 +46,8 @@ const rootElement = document.getElementById("root")!;
|
|||||||
if (!rootElement.innerHTML) {
|
if (!rootElement.innerHTML) {
|
||||||
const root = ReactDOM.createRoot(rootElement);
|
const root = ReactDOM.createRoot(rootElement);
|
||||||
root.render(
|
root.render(
|
||||||
<I18nextProvider i18n={i18n} defaultNS={"translation"}>
|
<StrictMode>
|
||||||
<PersistQueryClientProvider
|
<App />
|
||||||
client={queryClient}
|
</StrictMode>,
|
||||||
persistOptions={{ persister }}
|
|
||||||
>
|
|
||||||
<StrictMode>
|
|
||||||
<Toaster
|
|
||||||
position="bottom-right"
|
|
||||||
icons={{
|
|
||||||
success: <CheckCircleIcon className="size-5" />,
|
|
||||||
info: <InfoCircleIcon className="size-5" />,
|
|
||||||
error: <CancelCircleIcon className="size-5" />,
|
|
||||||
}}
|
|
||||||
closeButton
|
|
||||||
theme="system"
|
|
||||||
/>
|
|
||||||
<App />
|
|
||||||
</StrictMode>
|
|
||||||
</PersistQueryClientProvider>
|
|
||||||
</I18nextProvider>,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
|
import { NostrQuery } from "@lume/system";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
import { message } from "@tauri-apps/plugin-dialog";
|
||||||
import {
|
import {
|
||||||
type Dispatch,
|
type Dispatch,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
type SetStateAction,
|
type SetStateAction,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
export function AvatarUploader({
|
export function AvatarUploader({
|
||||||
setPicture,
|
setPicture,
|
||||||
@@ -18,21 +18,17 @@ export function AvatarUploader({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const uploadAvatar = async () => {
|
const uploadAvatar = async () => {
|
||||||
// start loading
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const image = await ark.upload();
|
setLoading(true);
|
||||||
|
const image = await NostrQuery.upload();
|
||||||
setPicture(image);
|
setPicture(image);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(String(e));
|
setLoading(false);
|
||||||
|
await message(String(e), { title: "Lume", kind: "error" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// stop loading
|
|
||||||
setLoading(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import { User } from "@lume/ui";
|
import { User } from "@/components/user";
|
||||||
|
import { NostrAccount } from "@lume/system";
|
||||||
import { getBitcoinDisplayValues } from "@lume/utils";
|
import { getBitcoinDisplayValues } from "@lume/utils";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
export function Balance({ account }: { account: string }) {
|
export function Balance({ account }: { account: string }) {
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const [balance, setBalance] = useState(0);
|
const [balance, setBalance] = useState(0);
|
||||||
const value = useMemo(() => getBitcoinDisplayValues(balance), [balance]);
|
const value = useMemo(() => getBitcoinDisplayValues(balance), [balance]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function getBalance() {
|
async function getBalance() {
|
||||||
const val = await ark.get_balance();
|
const val = await NostrAccount.getBalance();
|
||||||
setBalance(val);
|
setBalance(val);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,86 +2,87 @@ import { CancelIcon, CheckIcon } from "@lume/icons";
|
|||||||
import type { LumeColumn } from "@lume/types";
|
import type { LumeColumn } from "@lume/types";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { getCurrent } from "@tauri-apps/api/webviewWindow";
|
import { getCurrent } from "@tauri-apps/api/webviewWindow";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
export function Col({
|
type WindowEvent = {
|
||||||
|
scroll: boolean;
|
||||||
|
resize: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Column({
|
||||||
column,
|
column,
|
||||||
account,
|
account,
|
||||||
isScroll,
|
|
||||||
isResize,
|
|
||||||
}: {
|
}: {
|
||||||
column: LumeColumn;
|
column: LumeColumn;
|
||||||
account: string;
|
account: string;
|
||||||
isScroll: boolean;
|
|
||||||
isResize: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const container = useRef<HTMLDivElement>(null);
|
const container = useRef<HTMLDivElement>(null);
|
||||||
const [webview, setWebview] = useState<string | undefined>(undefined);
|
const webviewLabel = `column-${account}_${column.label}`;
|
||||||
|
|
||||||
const repositionWebview = async () => {
|
const [isCreated, setIsCreated] = useState(false);
|
||||||
if (webview && webview.length > 1) {
|
|
||||||
const newRect = container.current.getBoundingClientRect();
|
|
||||||
await invoke("reposition_column", {
|
|
||||||
label: webview,
|
|
||||||
x: newRect.x,
|
|
||||||
y: newRect.y,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resizeWebview = async () => {
|
const repositionWebview = useCallback(async () => {
|
||||||
if (webview && webview.length > 1) {
|
const newRect = container.current.getBoundingClientRect();
|
||||||
const newRect = container.current.getBoundingClientRect();
|
await invoke("reposition_column", {
|
||||||
await invoke("resize_column", {
|
label: webviewLabel,
|
||||||
label: webview,
|
x: newRect.x,
|
||||||
width: newRect.width,
|
y: newRect.y,
|
||||||
height: newRect.height,
|
});
|
||||||
});
|
}, []);
|
||||||
}
|
|
||||||
};
|
const resizeWebview = useCallback(async () => {
|
||||||
|
const newRect = container.current.getBoundingClientRect();
|
||||||
|
await invoke("resize_column", {
|
||||||
|
label: webviewLabel,
|
||||||
|
width: newRect.width,
|
||||||
|
height: newRect.height,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
resizeWebview();
|
if (!isCreated) return;
|
||||||
}, [isResize]);
|
|
||||||
|
const unlisten = listen<WindowEvent>("child-webview", (data) => {
|
||||||
|
if (data.payload.scroll) repositionWebview();
|
||||||
|
if (data.payload.resize) repositionWebview().then(() => resizeWebview());
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unlisten.then((f) => f());
|
||||||
|
};
|
||||||
|
}, [isCreated]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isScroll) repositionWebview();
|
if (!container?.current) return;
|
||||||
}, [isScroll]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const rect = container.current.getBoundingClientRect();
|
||||||
(async () => {
|
const url = `${column.content}?account=${account}&label=${column.label}&name=${column.name}`;
|
||||||
if (webview && webview.length > 1) return;
|
|
||||||
|
|
||||||
const rect = container.current.getBoundingClientRect();
|
// create new webview
|
||||||
const windowLabel = `column-${column.label}`;
|
invoke("create_column", {
|
||||||
const url = `${column.content}?account=${account}&label=${column.label}&name=${column.name}`;
|
label: webviewLabel,
|
||||||
|
x: rect.x,
|
||||||
// create new webview
|
y: rect.y,
|
||||||
const label: string = await invoke("create_column", {
|
width: rect.width,
|
||||||
label: windowLabel,
|
height: rect.height,
|
||||||
x: rect.x,
|
url,
|
||||||
y: rect.y,
|
}).then(() => {
|
||||||
width: rect.width,
|
console.log("created: ", webviewLabel);
|
||||||
height: rect.height,
|
setIsCreated(true);
|
||||||
url,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
setWebview(label);
|
|
||||||
})();
|
|
||||||
|
|
||||||
// close webview when unmounted
|
// close webview when unmounted
|
||||||
return () => {
|
return () => {
|
||||||
if (webview && webview.length > 1) {
|
invoke("close_column", { label: webviewLabel }).then(() => {
|
||||||
invoke("close_column", {
|
console.log("closed: ", webviewLabel);
|
||||||
label: webview,
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [webview]);
|
}, [account]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-[440px] shrink-0 p-2">
|
<div className="h-full w-[500px] shrink-0 p-2">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col w-full h-full rounded-xl",
|
"flex flex-col w-full h-full rounded-xl",
|
||||||
@@ -90,9 +91,7 @@ export function Col({
|
|||||||
: "",
|
: "",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{column.label !== "open" ? (
|
<Header label={column.label} name={column.name} />
|
||||||
<Header label={column.label} name={column.name} />
|
|
||||||
) : null}
|
|
||||||
<div ref={container} className="flex-1 w-full h-full" />
|
<div ref={container} className="flex-1 w-full h-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,10 +124,10 @@ function Header({ label, name }: { label: string; name: string }) {
|
|||||||
}, [title]);
|
}, [title]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-9 w-full flex items-center justify-between shrink-0 px-1">
|
<div className="flex items-center justify-between w-full px-1 h-9 shrink-0">
|
||||||
<div className="size-7" />
|
<div className="size-7" />
|
||||||
<div className="shrink-0 h-9 flex items-center justify-center">
|
<div className="flex items-center justify-center shrink-0 h-9">
|
||||||
<div className="relative flex gap-2 items-center">
|
<div className="relative flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
contentEditable
|
contentEditable
|
||||||
suppressContentEditableWarning={true}
|
suppressContentEditableWarning={true}
|
||||||
@@ -151,7 +150,7 @@ function Header({ label, name }: { label: string; name: string }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => close()}
|
onClick={() => close()}
|
||||||
className="size-7 inline-flex hover:bg-black/10 rounded-lg dark:hover:bg-white/10 items-center justify-center text-neutral-600 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200"
|
className="inline-flex items-center justify-center rounded-lg size-7 hover:bg-black/10 dark:hover:bg-white/10 text-neutral-600 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200"
|
||||||
>
|
>
|
||||||
<CancelIcon className="size-4" />
|
<CancelIcon className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1,18 +1,17 @@
|
|||||||
import { ThreadIcon } from "@lume/icons";
|
import { ThreadIcon } from "@lume/icons";
|
||||||
import type { Event } from "@lume/types";
|
import { Note } from "@/components/note";
|
||||||
import { Note } from "@lume/ui";
|
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
import type { LumeEvent } from "@lume/system";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
export function Conversation({
|
export function Conversation({
|
||||||
event,
|
event,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
event: Event;
|
event: LumeEvent;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { ark } = useRouteContext({ strict: false });
|
const thread = useMemo(() => event.thread, [event]);
|
||||||
const thread = ark.parse_event_thread(event.tags);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Note.Provider event={event}>
|
<Note.Provider event={event}>
|
||||||
@@ -23,7 +22,7 @@ export function Conversation({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{thread?.root ? <Note.Child eventId={thread?.root} isRoot /> : null}
|
{thread?.root?.id ? <Note.Child event={thread?.root} isRoot /> : null}
|
||||||
<div className="flex items-center gap-2 px-3">
|
<div className="flex items-center gap-2 px-3">
|
||||||
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||||
<ThreadIcon className="size-4" />
|
<ThreadIcon className="size-4" />
|
||||||
@@ -31,15 +30,15 @@ export function Conversation({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
|
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
|
||||||
</div>
|
</div>
|
||||||
{thread?.reply ? <Note.Child eventId={thread?.reply} /> : null}
|
{thread?.reply?.id ? <Note.Child event={thread?.reply} /> : null}
|
||||||
<div>
|
<div>
|
||||||
<div className="px-3 h-14 flex items-center justify-between">
|
<div className="flex items-center justify-between px-3 h-14">
|
||||||
<Note.User />
|
<Note.User />
|
||||||
</div>
|
</div>
|
||||||
<Note.Content className="px-3" />
|
<Note.Content className="px-3" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center h-14 px-3">
|
<div className="flex items-center px-3 h-14">
|
||||||
<Note.Open />
|
<Note.Open />
|
||||||
</div>
|
</div>
|
||||||
</Note.Root>
|
</Note.Root>
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { VisitIcon } from "@lume/icons";
|
import { VisitIcon } from "@lume/icons";
|
||||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import { useNoteContext } from "../provider";
|
import { useNoteContext } from "../provider";
|
||||||
|
import { LumeWindow } from "@lume/system";
|
||||||
|
|
||||||
export function NoteOpenThread() {
|
export function NoteOpenThread() {
|
||||||
const event = useNoteContext();
|
const event = useNoteContext();
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip.Provider>
|
<Tooltip.Provider>
|
||||||
@@ -13,7 +12,7 @@ export function NoteOpenThread() {
|
|||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Trigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => ark.open_event(event)}
|
onClick={() => LumeWindow.openEvent(event)}
|
||||||
className="group inline-flex h-7 w-14 bg-neutral-100 dark:bg-white/10 rounded-full items-center justify-center text-sm font-medium text-neutral-800 dark:text-neutral-200 hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
|
className="group inline-flex h-7 w-14 bg-neutral-100 dark:bg-white/10 rounded-full items-center justify-center text-sm font-medium text-neutral-800 dark:text-neutral-200 hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
|
||||||
>
|
>
|
||||||
<VisitIcon className="shrink-0 size-4" />
|
<VisitIcon className="shrink-0 size-4" />
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import { ReplyIcon } from "@lume/icons";
|
import { ReplyIcon } from "@lume/icons";
|
||||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import { useNoteContext } from "../provider";
|
import { useNoteContext } from "../provider";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
|
import { LumeWindow } from "@lume/system";
|
||||||
|
|
||||||
export function NoteReply({ large = false }: { large?: boolean }) {
|
export function NoteReply({ large = false }: { large?: boolean }) {
|
||||||
const event = useNoteContext();
|
const event = useNoteContext();
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip.Provider>
|
<Tooltip.Provider>
|
||||||
@@ -14,7 +13,7 @@ export function NoteReply({ large = false }: { large?: boolean }) {
|
|||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Trigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => ark.open_editor(event.id)}
|
onClick={() => LumeWindow.openEditor(event.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
||||||
large
|
large
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
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 [loading, setLoading] = useState(false);
|
||||||
|
const [isRepost, setIsRepost] = useState(false);
|
||||||
|
|
||||||
|
const repost = async () => {
|
||||||
|
if (isRepost) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// repost
|
||||||
|
await event.repost();
|
||||||
|
|
||||||
|
// update state
|
||||||
|
setLoading(false);
|
||||||
|
setIsRepost(true);
|
||||||
|
} catch {
|
||||||
|
setLoading(false);
|
||||||
|
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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => showContextMenu(e)}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,33 +1,19 @@
|
|||||||
import { ZapIcon } from "@lume/icons";
|
import { ZapIcon } from "@lume/icons";
|
||||||
import { useRouteContext, useSearch } from "@tanstack/react-router";
|
import { LumeWindow } from "@lume/system";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { useNoteContext } from "../provider";
|
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
|
import { useNoteContext } from "../provider";
|
||||||
|
|
||||||
export function NoteZap({ large = false }: { large?: boolean }) {
|
export function NoteZap({ large = false }: { large?: boolean }) {
|
||||||
const event = useNoteContext();
|
const event = useNoteContext();
|
||||||
const { ark, settings } = useRouteContext({ strict: false });
|
const { settings } = useRouteContext({ strict: false });
|
||||||
const { account } = useSearch({ strict: false });
|
|
||||||
|
|
||||||
const zap = async () => {
|
if (!settings.display_zap_button) return null;
|
||||||
try {
|
|
||||||
const nwc = await ark.load_nwc();
|
|
||||||
if (!nwc) {
|
|
||||||
ark.open_nwc();
|
|
||||||
} else {
|
|
||||||
ark.open_zap(event.id, event.pubkey, account);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(String(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!settings.zap) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => zap()}
|
onClick={() => LumeWindow.openZap(event.id, event.pubkey)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
||||||
large
|
large
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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({
|
||||||
|
event,
|
||||||
|
isRoot,
|
||||||
|
}: {
|
||||||
|
event: EventTag;
|
||||||
|
isRoot?: boolean;
|
||||||
|
}) {
|
||||||
|
const { isLoading, isError, data } = useEvent(event.id, event.relayHint);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<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="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-sm text-red-500">
|
||||||
|
Event not found with your current relay set
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Note.Provider event={data}>
|
||||||
|
<Note.Root className={cn(isRoot ? "mb-3" : "")}>
|
||||||
|
<div className="flex items-center justify-between px-3 h-14">
|
||||||
|
<Note.User />
|
||||||
|
</div>
|
||||||
|
<Note.Content className="px-3" />
|
||||||
|
</Note.Root>
|
||||||
|
</Note.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
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";
|
||||||
|
import { MentionUser } from "./mentions/user";
|
||||||
|
import { Images } from "./preview/images";
|
||||||
|
import { Videos } from "./preview/videos";
|
||||||
|
import { useNoteContext } from "./provider";
|
||||||
|
|
||||||
|
export function NoteContent({
|
||||||
|
quote = true,
|
||||||
|
mention = true,
|
||||||
|
clean,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
quote?: boolean;
|
||||||
|
mention?: boolean;
|
||||||
|
clean?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const { settings } = useRouteContext({ strict: false });
|
||||||
|
const event = useNoteContext();
|
||||||
|
|
||||||
|
const warning = useMemo(() => event.warning, [event]);
|
||||||
|
const content = useMemo(() => {
|
||||||
|
try {
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
richContent = reactStringReplace(
|
||||||
|
richContent,
|
||||||
|
/(https?:\/\/\S+)/gi,
|
||||||
|
(match, index) => (
|
||||||
|
<a
|
||||||
|
key={match + index}
|
||||||
|
href={match}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline text-blue-500 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
{match}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
richContent = reactStringReplace(richContent, /(\r\n|\r|\n)+/g, () => (
|
||||||
|
<div key={nanoid()} className="h-3" />
|
||||||
|
));
|
||||||
|
|
||||||
|
return richContent;
|
||||||
|
} catch {
|
||||||
|
return event.content;
|
||||||
|
}
|
||||||
|
}, [event.content]);
|
||||||
|
|
||||||
|
const [blurred, setBlurred] = useState(() => warning?.length > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-col gap-2">
|
||||||
|
{blurred ? (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center w-full h-full bg-black/80 backdrop-blur-xl">
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 text-center">
|
||||||
|
<p className="text-sm text-white/60">
|
||||||
|
The content is hidden because the author
|
||||||
|
<br />
|
||||||
|
marked it with a warning for a reason:
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-medium text-white">{warning}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBlurred(false)}
|
||||||
|
className="inline-flex items-center justify-center px-2 mt-4 text-sm font-medium border rounded-lg text-white/70 h-9 w-max bg-white/20 hover:bg-white/30 border-white/5"
|
||||||
|
>
|
||||||
|
View anyway
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"select-text text-pretty content-break overflow-hidden",
|
||||||
|
event.meta?.content.length > 400
|
||||||
|
? "max-h-[250px] gradient-mask-b-0"
|
||||||
|
: "",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { type ReactNode, useMemo } from "react";
|
||||||
|
import reactStringReplace from "react-string-replace";
|
||||||
|
import { Hashtag } from "./mentions/hashtag";
|
||||||
|
import { MentionNote } from "./mentions/note";
|
||||||
|
import { MentionUser } from "./mentions/user";
|
||||||
|
import { ImagePreview } from "./preview/image";
|
||||||
|
import { VideoPreview } from "./preview/video";
|
||||||
|
import { useNoteContext } from "./provider";
|
||||||
|
|
||||||
|
export function NoteContentLarge({
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const event = useNoteContext();
|
||||||
|
const content = useMemo(() => {
|
||||||
|
try {
|
||||||
|
// Get parsed meta
|
||||||
|
const { images, videos, hashtags, events, mentions } = event.meta;
|
||||||
|
|
||||||
|
// Define rich content
|
||||||
|
let richContent: ReactNode[] | string = event.content;
|
||||||
|
|
||||||
|
for (const hashtag of hashtags) {
|
||||||
|
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
|
||||||
|
richContent = reactStringReplace(richContent, regex, () => (
|
||||||
|
<Hashtag key={nanoid()} tag={hashtag} />
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
richContent = reactStringReplace(richContent, /(\r\n|\r|\n)+/g, () => (
|
||||||
|
<div key={nanoid()} className="h-3" />
|
||||||
|
));
|
||||||
|
|
||||||
|
return richContent;
|
||||||
|
} catch (e) {
|
||||||
|
console.log("[parser]: ", e);
|
||||||
|
return event.content;
|
||||||
|
}
|
||||||
|
}, [event.content]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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 { NoteOpenThread } from "./buttons/open";
|
||||||
import { NoteReply } from "./buttons/reply";
|
import { NoteReply } from "./buttons/reply";
|
||||||
import { NoteRepost } from "./buttons/repost";
|
import { NoteRepost } from "./buttons/repost";
|
||||||
@@ -9,7 +8,6 @@ import { NoteContentLarge } from "./contentLarge";
|
|||||||
import { NoteMenu } from "./menu";
|
import { NoteMenu } from "./menu";
|
||||||
import { NoteProvider } from "./provider";
|
import { NoteProvider } from "./provider";
|
||||||
import { NoteRoot } from "./root";
|
import { NoteRoot } from "./root";
|
||||||
import { NoteThread } from "./thread";
|
|
||||||
import { NoteUser } from "./user";
|
import { NoteUser } from "./user";
|
||||||
|
|
||||||
export const Note = {
|
export const Note = {
|
||||||
@@ -24,6 +22,4 @@ export const Note = {
|
|||||||
Zap: NoteZap,
|
Zap: NoteZap,
|
||||||
Open: NoteOpenThread,
|
Open: NoteOpenThread,
|
||||||
Child: NoteChild,
|
Child: NoteChild,
|
||||||
Thread: NoteThread,
|
|
||||||
Activity: NoteActivity,
|
|
||||||
};
|
};
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
export function Hashtag({ tag }: { tag: string }) {
|
export function Hashtag({ tag }: { tag: string }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<span className="leading-normal break-all cursor-default group text-start">
|
||||||
type="button"
|
|
||||||
className="break-all cursor-default leading-normal group"
|
|
||||||
>
|
|
||||||
<span className="text-blue-500">#</span>
|
<span className="text-blue-500">#</span>
|
||||||
<span className="underline-offset-1 underline decoration-2 decoration-blue-200 dark:decoration-blue-800 group-hover:decoration-blue-500">
|
<span className="underline underline-offset-1 decoration-2 decoration-blue-200 dark:decoration-blue-800 group-hover:decoration-blue-500">
|
||||||
{tag.replace("#", "")}
|
{tag.replace("#", "")}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
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 { Spinner } from "@lume/ui";
|
||||||
|
|
||||||
|
export function MentionNote({
|
||||||
|
eventId,
|
||||||
|
openable = true,
|
||||||
|
}: {
|
||||||
|
eventId: string;
|
||||||
|
openable?: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isLoading, isError, data } = useEvent(eventId);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center w-full h-20 mt-2 border rounded-xl border-black/10 dark:border-white/10">
|
||||||
|
<Spinner className="size-5" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return (
|
||||||
|
<div className="w-full p-3 mt-2 border rounded-xl border-black/10 dark:border-white/10">
|
||||||
|
{t("note.error")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col w-full border rounded-lg cursor-default border-black/10 dark:border-white/10">
|
||||||
|
<User.Provider pubkey={data.pubkey}>
|
||||||
|
<User.Root className="flex items-center gap-2 px-3 h-11">
|
||||||
|
<User.Avatar className="object-cover rounded-full size-6 shrink-0" />
|
||||||
|
<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={cn(
|
||||||
|
"px-3 select-text whitespace-normal text-pretty content-break leading-normal",
|
||||||
|
data.content.length > 400 ? "max-h-[150px] gradient-mask-b-0" : "",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{data.content}
|
||||||
|
</div>
|
||||||
|
{openable ? (
|
||||||
|
<div className="flex items-center justify-end px-2 h-11">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
LumeWindow.openEvent(data);
|
||||||
|
}}
|
||||||
|
className="z-10 inline-flex items-center justify-center gap-1 text-sm rounded-full h-7 w-28 bg-black/10 dark:bg-white/10 text-neutral-600 hover:text-blue-500 dark:text-neutral-400"
|
||||||
|
>
|
||||||
|
View post
|
||||||
|
<LinkIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-3" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,24 +1,20 @@
|
|||||||
import { useProfile } from "@lume/ark";
|
import { LumeWindow, useProfile } from "@lume/system";
|
||||||
import { displayNpub } from "@lume/utils";
|
import { displayNpub } from "@lume/utils";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export function MentionUser({ pubkey }: { pubkey: string }) {
|
export function MentionUser({ pubkey }: { pubkey: string }) {
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const { isLoading, isError, profile } = useProfile(pubkey);
|
const { isLoading, isError, profile } = useProfile(pubkey);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => ark.open_profile(pubkey)}
|
onClick={() => LumeWindow.openProfile(pubkey)}
|
||||||
className="break-words text-start text-blue-500 hover:text-blue-600"
|
className="break-words text-start text-blue-500 hover:text-blue-600"
|
||||||
>
|
>
|
||||||
{isLoading
|
{isLoading
|
||||||
? "@anon"
|
? "@anon"
|
||||||
: isError
|
: isError
|
||||||
? displayNpub(pubkey, 16)
|
? displayNpub(pubkey, 16)
|
||||||
: `@${
|
: `@${profile?.name || profile?.display_name || "anon"}`}
|
||||||
profile?.name || profile?.display_name || profile?.name || "anon"
|
|
||||||
}`}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { HorizontalDotsIcon } from "@lume/icons";
|
||||||
|
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||||
|
import { useNoteContext } from "./provider";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
|
||||||
|
|
||||||
|
export function NoteMenu() {
|
||||||
|
const event = useNoteContext();
|
||||||
|
|
||||||
|
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
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 menu = await Menu.new({
|
||||||
|
items: menuItems,
|
||||||
|
});
|
||||||
|
|
||||||
|
await menu.popup().catch((e) => console.error(e));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
|
import { open } from "@tauri-apps/plugin-shell";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
export function ImagePreview({ url }: { url: string }) {
|
||||||
|
const { settings } = useRouteContext({ strict: false });
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
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">
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={url}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
style={{ contentVisibility: "auto" }}
|
||||||
|
className="max-h-[600px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||||
|
onClick={() => open(url)}
|
||||||
|
onKeyDown={() => open(url)}
|
||||||
|
onError={({ currentTarget }) => {
|
||||||
|
currentTarget.onerror = null;
|
||||||
|
currentTarget.src = "/404.jpg";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
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 { settings } = useRouteContext({ strict: false });
|
||||||
|
|
||||||
|
const [slidesInView, setSlidesInView] = useState([]);
|
||||||
|
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||||
|
dragFree: true,
|
||||||
|
align: "start",
|
||||||
|
watchSlides: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
emblaApi?.off("slidesInView", updateSlidesInView);
|
||||||
|
emblaApi?.off("reInit", updateSlidesInView);
|
||||||
|
};
|
||||||
|
}, [emblaApi, updateSlidesInView]);
|
||||||
|
|
||||||
|
if (urls.length === 1) {
|
||||||
|
return (
|
||||||
|
<div className="px-3 group">
|
||||||
|
<img
|
||||||
|
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={() => urls[0]}
|
||||||
|
onKeyDown={() => urls[0]}
|
||||||
|
onError={({ currentTarget }) => {
|
||||||
|
currentTarget.onerror = null;
|
||||||
|
currentTarget.src = "/404.jpg";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative pl-2 overflow-hidden group">
|
||||||
|
<div ref={emblaRef} className="w-full">
|
||||||
|
<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
|
||||||
|
aria-hidden
|
||||||
|
className="absolute z-10 items-center justify-between hidden w-full px-5 transform -translate-x-1/2 -translate-y-1/2 group-hover:flex left-1/2 top-1/2"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!emblaApi?.canScrollPrev}
|
||||||
|
className={cn(
|
||||||
|
"size-11 rounded-full bg-black/30 backdrop-blur-sm flex items-center justify-center text-white",
|
||||||
|
!emblaApi?.canScrollPrev ? "opacity-50" : "",
|
||||||
|
)}
|
||||||
|
onClick={() => scrollPrev()}
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="size-6" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!emblaApi?.canScrollNext}
|
||||||
|
className={cn(
|
||||||
|
"size-11 rounded-full bg-black/30 backdrop-blur-sm flex items-center justify-center text-white",
|
||||||
|
!emblaApi?.canScrollNext ? "opacity-50" : "",
|
||||||
|
)}
|
||||||
|
onClick={() => scrollNext()}
|
||||||
|
>
|
||||||
|
<ArrowRightIcon className="size-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LazyImage({ url, inView }: { url: string; inView: boolean }) {
|
||||||
|
const [hasLoaded, setHasLoaded] = useState(false);
|
||||||
|
|
||||||
|
const setLoaded = useCallback(() => {
|
||||||
|
if (inView) setHasLoaded(true);
|
||||||
|
}, [inView, setHasLoaded]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-[240px] h-[320px] shrink-0 relative rounded-lg overflow-hidden">
|
||||||
|
{!hasLoaded ? (
|
||||||
|
<div className="flex items-center justify-center size-full bg-black/5 dark:bg-white/5">
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<img
|
||||||
|
src={
|
||||||
|
inView
|
||||||
|
? url
|
||||||
|
: "data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs="
|
||||||
|
}
|
||||||
|
data-src={url}
|
||||||
|
alt={url}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
style={{ contentVisibility: "auto" }}
|
||||||
|
className="object-cover w-full h-full rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||||
|
onClick={() => open(url)}
|
||||||
|
onKeyDown={() => open(url)}
|
||||||
|
onLoad={setLoaded}
|
||||||
|
onError={({ currentTarget }) => {
|
||||||
|
currentTarget.onerror = null;
|
||||||
|
currentTarget.src = "/404.jpg";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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">
|
||||||
|
<video
|
||||||
|
className="max-h-[600px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||||
|
controls
|
||||||
|
muted
|
||||||
|
>
|
||||||
|
<source src={url} type="video/mp4" />
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Carousel, CarouselItem } from "../../carousel";
|
import { Carousel, CarouselItem } from "@lume/ui";
|
||||||
|
|
||||||
export function Videos({ urls }: { urls: string[] }) {
|
export function Videos({ urls }: { urls: string[] }) {
|
||||||
if (urls.length === 1) {
|
if (urls.length === 1) {
|
||||||
@@ -1,19 +1,20 @@
|
|||||||
import type { Event } from "@lume/types";
|
import type { LumeEvent } from "@lume/system";
|
||||||
import { type ReactNode, createContext, useContext } from "react";
|
import { type ReactNode, createContext, useContext } from "react";
|
||||||
|
|
||||||
const EventContext = createContext<Event>(null);
|
const NoteContext = createContext<LumeEvent>(null);
|
||||||
|
|
||||||
export function NoteProvider({
|
export function NoteProvider({
|
||||||
event,
|
event,
|
||||||
children,
|
children,
|
||||||
}: { event: Event; children: ReactNode }) {
|
}: {
|
||||||
return (
|
event: LumeEvent;
|
||||||
<EventContext.Provider value={event}>{children}</EventContext.Provider>
|
children: ReactNode;
|
||||||
);
|
}) {
|
||||||
|
return <NoteContext.Provider value={event}>{children}</NoteContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNoteContext() {
|
export function useNoteContext() {
|
||||||
const context = useContext(EventContext);
|
const context = useContext(NoteContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error("Please import Note Provider to use useNoteContext() hook");
|
throw new Error("Please import Note Provider to use useNoteContext() hook");
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { LumeWindow } from "@lume/system";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
|
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";
|
||||||
|
|
||||||
|
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}>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<User.Avatar className="object-cover rounded-full size-8 outline outline-1 -outline-offset-1 outline-black/15" />
|
||||||
|
</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>
|
||||||
|
<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 { Event } from "@lume/types";
|
|
||||||
import { Note } from "@lume/ui";
|
|
||||||
import { cn } from "@lume/utils";
|
|
||||||
|
|
||||||
export function Notification({
|
|
||||||
event,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
event: Event;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Note.Provider event={event}>
|
|
||||||
<Note.Root
|
|
||||||
className={cn(
|
|
||||||
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div className="px-3 h-14 flex items-center justify-between">
|
|
||||||
<Note.User />
|
|
||||||
</div>
|
|
||||||
<Note.Content className="px-3" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center h-14 px-3">
|
|
||||||
<Note.Open />
|
|
||||||
</div>
|
|
||||||
</Note.Root>
|
|
||||||
</Note.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,15 @@
|
|||||||
import { QuoteIcon } from "@lume/icons";
|
import { QuoteIcon } from "@lume/icons";
|
||||||
import type { Event } from "@lume/types";
|
import { Note } from "@/components/note";
|
||||||
import { Note } from "@lume/ui";
|
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
|
import type { LumeEvent } from "@lume/system";
|
||||||
|
|
||||||
export function Quote({
|
export function Quote({
|
||||||
event,
|
event,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
event: Event;
|
event: LumeEvent;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const quoteEventId = event.tags.find(
|
|
||||||
(tag) => tag[0] === "q" || tag[3] === "mention",
|
|
||||||
)?.[1];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Note.Provider event={event}>
|
<Note.Provider event={event}>
|
||||||
<Note.Root
|
<Note.Root
|
||||||
@@ -23,7 +19,7 @@ export function Quote({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<Note.Child eventId={quoteEventId} isRoot />
|
<Note.Child event={event.quote} isRoot />
|
||||||
<div className="flex items-center gap-2 px-3">
|
<div className="flex items-center gap-2 px-3">
|
||||||
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||||
<QuoteIcon className="size-4" />
|
<QuoteIcon className="size-4" />
|
||||||
@@ -32,13 +28,13 @@ export function Quote({
|
|||||||
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
|
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="px-3 h-14 flex items-center justify-between">
|
<div className="flex items-center justify-between px-3 h-14">
|
||||||
<Note.User />
|
<Note.User />
|
||||||
</div>
|
</div>
|
||||||
<Note.Content className="px-3" quote={false} clean />
|
<Note.Content className="px-3" quote={false} clean />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center h-14 px-3">
|
<div className="flex items-center px-3 h-14">
|
||||||
<Note.Open />
|
<Note.Open />
|
||||||
</div>
|
</div>
|
||||||
</Note.Root>
|
</Note.Root>
|
||||||
|
|||||||
@@ -1,79 +1,77 @@
|
|||||||
import type { Event } from "@lume/types";
|
import { Spinner } from "@lume/ui";
|
||||||
import { Note, Spinner, User } from "@lume/ui";
|
import { Note } from "@/components/note";
|
||||||
|
import { User } from "@/components/user";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
import { type LumeEvent, NostrQuery } from "@lume/system";
|
||||||
|
|
||||||
export function RepostNote({
|
export function RepostNote({
|
||||||
event,
|
event,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
event: Event;
|
event: LumeEvent;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { ark } = useRouteContext({ strict: false });
|
const { isLoading, isError, data } = useQuery({
|
||||||
const {
|
queryKey: ["event", event.repostId],
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
data: repostEvent,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["repost", event.id],
|
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
if (event.content.length > 50) {
|
const data = await NostrQuery.getRepostEvent(event);
|
||||||
const embed: Event = JSON.parse(event.content);
|
return data;
|
||||||
return embed;
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = event.tags.find((el) => el[0] === "e")?.[1];
|
|
||||||
const repostEvent = await ark.get_event(id);
|
|
||||||
|
|
||||||
return repostEvent;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(e);
|
throw new Error(e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
staleTime: Number.POSITIVE_INFINITY,
|
||||||
|
retry: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Note.Root
|
<Note.Root
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl mb-3 shadow-primary dark:ring-1 ring-neutral-800/50",
|
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<User.Provider pubkey={event.pubkey}>
|
|
||||||
<User.Root className="flex items-center gap-2 px-3 py-3 border-b border-neutral-100 dark:border-neutral-800/50 rounded-t-xl">
|
|
||||||
<div className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
|
||||||
Reposted by
|
|
||||||
</div>
|
|
||||||
<User.Avatar className="size-6 shrink-0 rounded-full object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex h-20 items-center justify-center gap-2">
|
<div className="flex items-center justify-center h-20 gap-2">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
Loading event...
|
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||||
|
Loading event...
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : isError || !repostEvent ? (
|
) : isError || !data ? (
|
||||||
<div className="flex h-20 items-center justify-center">
|
<div className="flex items-center justify-center h-20">
|
||||||
Event not found within your current relay set
|
Event not found within your current relay set
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Note.Provider event={repostEvent}>
|
<Note.Provider event={data}>
|
||||||
<Note.Root>
|
<Note.Root>
|
||||||
<div className="px-3 h-14 flex items-center justify-between">
|
<div className="flex items-center justify-between px-3 h-14">
|
||||||
<Note.User />
|
<Note.User />
|
||||||
<Note.Menu />
|
<Note.Menu />
|
||||||
</div>
|
</div>
|
||||||
<Note.Content className="px-3" />
|
<Note.Content className="px-3" />
|
||||||
<div className="mt-3 flex items-center gap-4 h-14 px-3">
|
<div className="flex items-center justify-between px-3 mt-3 h-14">
|
||||||
<Note.Open />
|
<div className="inline-flex items-center gap-3">
|
||||||
<Note.Reply />
|
<Note.Open />
|
||||||
<Note.Repost />
|
<Note.Reply />
|
||||||
<Note.Zap />
|
<Note.Repost />
|
||||||
|
<Note.Zap />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<User.Provider pubkey={event.pubkey}>
|
||||||
|
<User.Root className="flex items-center gap-2">
|
||||||
|
<div className="text-sm font-medium text-neutral-800 dark:text-neutral-200">
|
||||||
|
Reposted by
|
||||||
|
</div>
|
||||||
|
<User.Avatar className="object-cover rounded-full size-6 shrink-0 ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Note.Root>
|
</Note.Root>
|
||||||
</Note.Provider>
|
</Note.Provider>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import type { Event } from "@lume/types";
|
|
||||||
import { Note } from "@lume/ui";
|
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
|
import { Note } from "@/components/note";
|
||||||
|
import type { LumeEvent } from "@lume/system";
|
||||||
|
|
||||||
export function TextNote({
|
export function TextNote({
|
||||||
event,
|
event,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
event: Event;
|
event: LumeEvent;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -17,12 +17,12 @@ export function TextNote({
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="px-3 h-14 flex items-center justify-between">
|
<div className="flex items-center justify-between px-3 h-14">
|
||||||
<Note.User />
|
<Note.User />
|
||||||
<Note.Menu />
|
<Note.Menu />
|
||||||
</div>
|
</div>
|
||||||
<Note.Content className="px-3" />
|
<Note.Content className="px-3" />
|
||||||
<div className="mt-3 flex items-center gap-4 h-14 px-3">
|
<div className="flex items-center gap-4 px-3 mt-3 h-14">
|
||||||
<Note.Open />
|
<Note.Open />
|
||||||
<Note.Reply />
|
<Note.Reply />
|
||||||
<Note.Repost />
|
<Note.Repost />
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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 picture = useMemo(() => {
|
||||||
|
if (
|
||||||
|
settings?.image_resize_service?.length &&
|
||||||
|
user.profile?.picture?.length
|
||||||
|
) {
|
||||||
|
const url = `${settings.image_resize_service}?url=${user.profile?.picture}&w=100&h=100&default=1&n=-1`;
|
||||||
|
return url;
|
||||||
|
} else {
|
||||||
|
return user.profile?.picture;
|
||||||
|
}
|
||||||
|
}, [user.profile?.picture]);
|
||||||
|
|
||||||
|
const fallbackAvatar = useMemo(
|
||||||
|
() =>
|
||||||
|
`data:image/svg+xml;utf8,${encodeURIComponent(
|
||||||
|
minidenticon(user.pubkey || nanoid(), 90, 50),
|
||||||
|
)}`,
|
||||||
|
[user.pubkey],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (settings && !settings.display_avatar) {
|
||||||
|
return (
|
||||||
|
<Avatar.Root className="shrink-0">
|
||||||
|
<Avatar.Fallback delayMs={120}>
|
||||||
|
<img
|
||||||
|
src={fallbackAvatar}
|
||||||
|
alt={user.pubkey}
|
||||||
|
className={cn("bg-black dark:bg-white", className)}
|
||||||
|
/>
|
||||||
|
</Avatar.Fallback>
|
||||||
|
</Avatar.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Avatar.Root className="shrink-0">
|
||||||
|
<Avatar.Image
|
||||||
|
src={picture}
|
||||||
|
alt={user.pubkey}
|
||||||
|
loading="eager"
|
||||||
|
decoding="async"
|
||||||
|
className={cn("outline-[.5px] outline-black/5 object-cover", className)}
|
||||||
|
/>
|
||||||
|
<Avatar.Fallback delayMs={120}>
|
||||||
|
<img
|
||||||
|
src={fallbackAvatar}
|
||||||
|
alt={user.pubkey}
|
||||||
|
className={cn("bg-black dark:bg-white", className)}
|
||||||
|
/>
|
||||||
|
</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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn("bg-gradient-to-b from-blue-400 to-teal-200", className)}
|
className={cn("bg-gradient-to-b from-blue-400 to-teal-200", className)}
|
||||||
@@ -25,7 +25,7 @@ export function UserCover({ className }: { className?: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={user.profile.banner}
|
src={user?.profile?.banner}
|
||||||
alt="banner"
|
alt="banner"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { useUserContext } from "./provider";
|
||||||
|
import { NostrAccount } from "@lume/system";
|
||||||
|
|
||||||
|
export function UserFollowButton({
|
||||||
|
simple = false,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
simple?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const user = useUserContext();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [followed, setFollowed] = useState(false);
|
||||||
|
|
||||||
|
const toggleFollow = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const toggle = await NostrAccount.toggleContact(user.pubkey);
|
||||||
|
|
||||||
|
if (toggle) {
|
||||||
|
setFollowed((prev) => !prev);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
NostrAccount.checkContact(user.pubkey).then((status) => {
|
||||||
|
if (mounted) setFollowed(status);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => toggleFollow()}
|
||||||
|
className={cn("w-max", className)}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
) : followed ? (
|
||||||
|
!simple ? (
|
||||||
|
"Unfollow"
|
||||||
|
) : null
|
||||||
|
) : (
|
||||||
|
"Follow"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,21 +2,34 @@ import { VerifiedIcon } from "@lume/icons";
|
|||||||
import { displayLongHandle, displayNpub } from "@lume/utils";
|
import { displayLongHandle, displayNpub } from "@lume/utils";
|
||||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import { useUserContext } from "./provider";
|
import { useUserContext } from "./provider";
|
||||||
|
import { NostrQuery } from "@lume/system";
|
||||||
|
import { experimental_createPersister } from "@tanstack/query-persist-client-core";
|
||||||
|
|
||||||
export function UserNip05() {
|
export function UserNip05() {
|
||||||
const user = useUserContext();
|
const user = useUserContext();
|
||||||
|
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const { isLoading, data: verified } = useQuery({
|
const { isLoading, data: verified } = useQuery({
|
||||||
queryKey: ["nip05", user?.pubkey],
|
queryKey: ["nip05", user?.pubkey],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!user.profile?.nip05) return false;
|
if (!user.profile?.nip05?.length) return false;
|
||||||
const verify = await ark.verify_nip05(user.pubkey, user.profile?.nip05);
|
|
||||||
|
const verify = await NostrQuery.verifyNip05(
|
||||||
|
user.pubkey,
|
||||||
|
user.profile?.nip05,
|
||||||
|
);
|
||||||
|
|
||||||
return verify;
|
return verify;
|
||||||
},
|
},
|
||||||
enabled: !!user.profile,
|
enabled: !!user.profile?.nip05,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
staleTime: Number.POSITIVE_INFINITY,
|
||||||
|
retry: false,
|
||||||
|
persister: experimental_createPersister({
|
||||||
|
storage: localStorage,
|
||||||
|
maxAge: 1000 * 60 * 60 * 72, // 72 hours
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user.profile?.nip05?.length) return;
|
if (!user.profile?.nip05?.length) return;
|
||||||
@@ -26,7 +39,7 @@ export function UserNip05() {
|
|||||||
<Tooltip.Root delayDuration={150}>
|
<Tooltip.Root delayDuration={150}>
|
||||||
<Tooltip.Trigger>
|
<Tooltip.Trigger>
|
||||||
{!isLoading && verified ? (
|
{!isLoading && verified ? (
|
||||||
<VerifiedIcon className="size-4 text-teal-500" />
|
<VerifiedIcon className="text-teal-500 size-4" />
|
||||||
) : null}
|
) : null}
|
||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
<Tooltip.Portal>
|
<Tooltip.Portal>
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useProfile } from "@lume/ark";
|
import { useProfile } from "@lume/system";
|
||||||
import type { Metadata } from "@lume/types";
|
import type { Metadata } from "@lume/types";
|
||||||
import { type ReactNode, createContext, useContext } from "react";
|
import { type ReactNode, createContext, useContext } from "react";
|
||||||
|
|
||||||
const UserContext = createContext<{
|
const UserContext = createContext<{
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
|
profile: Metadata;
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
profile: Metadata;
|
|
||||||
}>(null);
|
}>(null);
|
||||||
|
|
||||||
export function UserProvider({
|
export function UserProvider({
|
||||||
@@ -21,7 +21,7 @@ export function UserProvider({
|
|||||||
const { isLoading, isError, profile } = useProfile(pubkey, embedProfile);
|
const { isLoading, isError, profile } = useProfile(pubkey, embedProfile);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserContext.Provider value={{ pubkey, isError, isLoading, profile }}>
|
<UserContext.Provider value={{ pubkey, profile, isError, isLoading }}>
|
||||||
{children}
|
{children}
|
||||||
</UserContext.Provider>
|
</UserContext.Provider>
|
||||||
);
|
);
|
||||||
@@ -1,100 +1,68 @@
|
|||||||
import { Col } from "@/components/col";
|
import { Column } from "@/components/column";
|
||||||
import { Toolbar } from "@/components/toolbar";
|
import { Toolbar } from "@/components/toolbar";
|
||||||
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
|
import { ArrowLeftIcon, ArrowRightIcon, PlusSquareIcon } from "@lume/icons";
|
||||||
import type { EventColumns, LumeColumn } from "@lume/types";
|
import { NostrQuery } from "@lume/system";
|
||||||
|
import type { ColumnEvent, LumeColumn } from "@lume/types";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { resolveResource } from "@tauri-apps/api/path";
|
|
||||||
import { getCurrent } from "@tauri-apps/api/window";
|
import { getCurrent } from "@tauri-apps/api/window";
|
||||||
import { readTextFile } from "@tauri-apps/plugin-fs";
|
import useEmblaCarousel from "embla-carousel-react";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
import { VList, type VListHandle } from "virtua";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/$account/home")({
|
export const Route = createFileRoute("/$account/home")({
|
||||||
beforeLoad: async ({ context }) => {
|
loader: async () => {
|
||||||
try {
|
const columns = await NostrQuery.getColumns();
|
||||||
const ark = context.ark;
|
return columns;
|
||||||
const resourcePath = await resolveResource(
|
|
||||||
"resources/system_columns.json",
|
|
||||||
);
|
|
||||||
const systemColumns: LumeColumn[] = JSON.parse(
|
|
||||||
await readTextFile(resourcePath),
|
|
||||||
);
|
|
||||||
const userColumns = await ark.get_columns();
|
|
||||||
|
|
||||||
return {
|
|
||||||
storedColumns: !userColumns.length ? systemColumns : userColumns,
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
console.error(String(e));
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const vlistRef = useRef<VListHandle>(null);
|
|
||||||
|
|
||||||
const { account } = Route.useParams();
|
const { account } = Route.useParams();
|
||||||
const { ark, storedColumns } = Route.useRouteContext();
|
const initialColumnList = Route.useLoaderData();
|
||||||
|
|
||||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
const [columns, setColumns] = useState<LumeColumn[]>([]);
|
||||||
const [columns, setColumns] = useState(storedColumns);
|
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||||
const [isScroll, setIsScroll] = useState(false);
|
watchDrag: false,
|
||||||
const [isResize, setIsResize] = useState(false);
|
loop: false,
|
||||||
|
});
|
||||||
|
|
||||||
const goLeft = () => {
|
const scrollPrev = useCallback(() => {
|
||||||
const prevIndex = Math.max(selectedIndex - 1, 0);
|
if (emblaApi) emblaApi.scrollPrev(true);
|
||||||
setSelectedIndex(prevIndex);
|
}, [emblaApi]);
|
||||||
vlistRef.current.scrollToIndex(prevIndex, {
|
|
||||||
align: "center",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const goRight = () => {
|
const scrollNext = useCallback(() => {
|
||||||
const nextIndex = Math.min(selectedIndex + 1, columns.length - 1);
|
if (emblaApi) emblaApi.scrollNext(true);
|
||||||
setSelectedIndex(nextIndex);
|
}, [emblaApi]);
|
||||||
vlistRef.current.scrollToIndex(nextIndex, {
|
|
||||||
align: "center",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const add = useDebouncedCallback((column: LumeColumn) => {
|
const emitScrollEvent = useCallback(() => {
|
||||||
// update col label
|
getCurrent().emit("child-webview", { scroll: true });
|
||||||
column.label = `${column.label}-${nanoid()}`;
|
}, []);
|
||||||
|
|
||||||
// create new cols
|
const emitResizeEvent = useCallback(() => {
|
||||||
const cols = [...columns];
|
getCurrent().emit("child-webview", { resize: true, direction: "x" });
|
||||||
const openColIndex = cols.findIndex((col) => col.label === "open");
|
}, []);
|
||||||
const newCols = [
|
|
||||||
...cols.slice(0, openColIndex),
|
|
||||||
column,
|
|
||||||
...cols.slice(openColIndex),
|
|
||||||
];
|
|
||||||
|
|
||||||
setColumns(newCols);
|
const openLumeStore = useDebouncedCallback(async () => {
|
||||||
setSelectedIndex(newCols.length);
|
await getCurrent().emit("columns", {
|
||||||
setIsScroll(true);
|
type: "add",
|
||||||
|
column: {
|
||||||
// scroll to the newest column
|
label: "store",
|
||||||
vlistRef.current.scrollToIndex(newCols.length - 1, {
|
name: "Store",
|
||||||
align: "end",
|
content: "/store/official",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}, 150);
|
}, 150);
|
||||||
|
|
||||||
|
const add = useDebouncedCallback((column: LumeColumn) => {
|
||||||
|
column.label = `${column.label}-${nanoid()}`; // update col label
|
||||||
|
setColumns((prev) => [column, ...prev]);
|
||||||
|
}, 150);
|
||||||
|
|
||||||
const remove = useDebouncedCallback((label: string) => {
|
const remove = useDebouncedCallback((label: string) => {
|
||||||
const newCols = columns.filter((t) => t.label !== label);
|
setColumns((prev) => prev.filter((t) => t.label !== label));
|
||||||
|
|
||||||
setColumns(newCols);
|
|
||||||
setSelectedIndex(newCols.length);
|
|
||||||
setIsScroll(true);
|
|
||||||
|
|
||||||
// scroll to the first column
|
|
||||||
vlistRef.current.scrollToIndex(newCols.length - 1, {
|
|
||||||
align: "start",
|
|
||||||
});
|
|
||||||
}, 150);
|
}, 150);
|
||||||
|
|
||||||
const updateName = useDebouncedCallback((label: string, title: string) => {
|
const updateName = useDebouncedCallback((label: string, title: string) => {
|
||||||
@@ -109,71 +77,108 @@ function Screen() {
|
|||||||
setColumns(newCols);
|
setColumns(newCols);
|
||||||
}, 150);
|
}, 150);
|
||||||
|
|
||||||
const startResize = useDebouncedCallback(
|
const reset = useDebouncedCallback(() => setColumns([]), 150);
|
||||||
() => setIsResize((prev) => !prev),
|
|
||||||
150,
|
const handleKeyDown = useDebouncedCallback((event) => {
|
||||||
);
|
if (event.defaultPrevented) return;
|
||||||
|
|
||||||
|
switch (event.code) {
|
||||||
|
case "ArrowLeft":
|
||||||
|
if (emblaApi) emblaApi.scrollPrev(true);
|
||||||
|
break;
|
||||||
|
case "ArrowRight":
|
||||||
|
if (emblaApi) emblaApi.scrollNext(true);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
}, 150);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// save state
|
if (emblaApi) {
|
||||||
ark.set_columns(columns);
|
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]);
|
}, [columns]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unlistenColEvent = listen<EventColumns>("columns", (data) => {
|
setColumns(initialColumnList);
|
||||||
|
}, [initialColumnList]);
|
||||||
|
|
||||||
|
// Listen for keyboard event
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [handleKeyDown]);
|
||||||
|
|
||||||
|
// Listen for columns event
|
||||||
|
useEffect(() => {
|
||||||
|
const unlisten = listen<ColumnEvent>("columns", (data) => {
|
||||||
|
if (data.payload.type === "reset") reset();
|
||||||
if (data.payload.type === "add") add(data.payload.column);
|
if (data.payload.type === "add") add(data.payload.column);
|
||||||
if (data.payload.type === "remove") remove(data.payload.label);
|
if (data.payload.type === "remove") remove(data.payload.label);
|
||||||
if (data.payload.type === "set_title")
|
if (data.payload.type === "set_title")
|
||||||
updateName(data.payload.label, data.payload.title);
|
updateName(data.payload.label, data.payload.title);
|
||||||
});
|
});
|
||||||
|
|
||||||
const unlistenWindowResize = getCurrent().listen("tauri://resize", () => {
|
|
||||||
startResize();
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unlistenColEvent.then((f) => f());
|
unlisten.then((f) => f());
|
||||||
unlistenWindowResize.then((f) => f());
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full">
|
<div className="size-full">
|
||||||
<VList
|
<div ref={emblaRef} className="overflow-hidden size-full">
|
||||||
ref={vlistRef}
|
<div className="flex size-full">
|
||||||
horizontal
|
{columns?.map((column) => (
|
||||||
tabIndex={-1}
|
<Column
|
||||||
itemSize={440}
|
key={account + column.label}
|
||||||
overscan={3}
|
column={column}
|
||||||
onScroll={() => setIsScroll(true)}
|
account={account}
|
||||||
onScrollEnd={() => setIsScroll(false)}
|
/>
|
||||||
className="scrollbar-none h-full w-full overflow-x-auto focus:outline-none"
|
))}
|
||||||
>
|
</div>
|
||||||
{columns.map((column) => (
|
</div>
|
||||||
<Col
|
|
||||||
key={column.label}
|
|
||||||
column={column}
|
|
||||||
account={account}
|
|
||||||
isScroll={isScroll}
|
|
||||||
isResize={isResize}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</VList>
|
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center h-8 gap-1 p-[2px] rounded-full bg-black/5 dark:bg-white/5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => goLeft()}
|
onClick={() => scrollPrev()}
|
||||||
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
className="inline-flex items-center justify-center rounded-full size-7 text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
||||||
>
|
>
|
||||||
<ArrowLeftIcon className="size-5" />
|
<ArrowLeftIcon className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => goRight()}
|
onClick={() => openLumeStore()}
|
||||||
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
className="inline-flex items-center justify-center rounded-full size-7 text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
||||||
>
|
>
|
||||||
<ArrowRightIcon className="size-5" />
|
<PlusSquareIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => scrollNext()}
|
||||||
|
className="inline-flex items-center justify-center rounded-full size-7 text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<ArrowRightIcon className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
|
|||||||
@@ -1,33 +1,28 @@
|
|||||||
import { BellIcon, ComposeFilledIcon, PlusIcon, SearchIcon } from "@lume/icons";
|
import { User } from "@/components/user";
|
||||||
import { Event, Kind } from "@lume/types";
|
import { ComposeFilledIcon, HorizontalDotsIcon, PlusIcon } from "@lume/icons";
|
||||||
import { User } from "@lume/ui";
|
import { LumeWindow, NostrAccount } from "@lume/system";
|
||||||
import {
|
import { cn } from "@lume/utils";
|
||||||
cn,
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
decodeZapInvoice,
|
|
||||||
displayNpub,
|
|
||||||
sendNativeNotification,
|
|
||||||
} from "@lume/utils";
|
|
||||||
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { Menu, MenuItem } from "@tauri-apps/api/menu";
|
||||||
import { getCurrent } from "@tauri-apps/api/window";
|
import { getCurrent } from "@tauri-apps/api/window";
|
||||||
import { useEffect, useState } from "react";
|
import { message } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
export const Route = createFileRoute("/$account")({
|
export const Route = createFileRoute("/$account")({
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: async () => {
|
||||||
const ark = context.ark;
|
const accounts = await NostrAccount.getAccounts();
|
||||||
const accounts = await ark.get_all_accounts();
|
|
||||||
|
|
||||||
return { accounts };
|
return { accounts };
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const { ark, platform } = Route.useRouteContext();
|
const { platform } = Route.useRouteContext();
|
||||||
const navigate = Route.useNavigate();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-screen flex-col">
|
<div className="flex flex-col w-screen h-screen">
|
||||||
<div
|
<div
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -37,31 +32,22 @@ function Screen() {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Accounts />
|
<Accounts />
|
||||||
<button
|
<Link
|
||||||
type="button"
|
to="/landing"
|
||||||
onClick={() => navigate({ to: "/landing" })}
|
className="inline-flex items-center justify-center rounded-full size-8 shrink-0 bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20"
|
||||||
className="inline-flex size-8 items-center justify-center rounded-full bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20"
|
|
||||||
>
|
>
|
||||||
<PlusIcon className="size-5" />
|
<PlusIcon className="size-5" />
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => ark.open_editor()}
|
onClick={() => LumeWindow.openEditor()}
|
||||||
className="inline-flex h-8 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600"
|
className="inline-flex items-center justify-center h-8 gap-1 px-3 text-sm font-medium text-white bg-blue-500 rounded-full w-max hover:bg-blue-600"
|
||||||
>
|
>
|
||||||
<ComposeFilledIcon className="size-4" />
|
<ComposeFilledIcon className="size-4" />
|
||||||
New Post
|
New Post
|
||||||
</button>
|
</button>
|
||||||
<Bell />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => ark.open_search()}
|
|
||||||
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
|
||||||
>
|
|
||||||
<SearchIcon className="size-5" />
|
|
||||||
</button>
|
|
||||||
<div id="toolbar" />
|
<div id="toolbar" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,105 +60,151 @@ function Screen() {
|
|||||||
|
|
||||||
function Accounts() {
|
function Accounts() {
|
||||||
const navigate = Route.useNavigate();
|
const navigate = Route.useNavigate();
|
||||||
const { ark, accounts } = Route.useRouteContext();
|
const { accounts } = Route.useRouteContext();
|
||||||
const { account } = Route.useParams();
|
const { account } = Route.useParams();
|
||||||
|
|
||||||
const changeAccount = async (npub: string) => {
|
const [windowWidth, setWindowWidth] = useState<number>(null);
|
||||||
if (npub === account) return;
|
|
||||||
|
|
||||||
const select = await ark.load_selected_account(npub);
|
const sortedList = useMemo(() => {
|
||||||
|
const list = accounts;
|
||||||
|
|
||||||
|
for (const [i, item] of list.entries()) {
|
||||||
|
if (item === account) {
|
||||||
|
list.splice(i, 1);
|
||||||
|
list.unshift(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}, [accounts]);
|
||||||
|
|
||||||
|
const showContextMenu = useCallback(
|
||||||
|
async (e: React.MouseEvent, npub: string) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const menuItems = await Promise.all([
|
||||||
|
MenuItem.new({
|
||||||
|
text: "View Profile",
|
||||||
|
action: () => LumeWindow.openProfile(npub),
|
||||||
|
}),
|
||||||
|
MenuItem.new({
|
||||||
|
text: "Open Settings",
|
||||||
|
action: () => LumeWindow.openSettings(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const menu = await Menu.new({
|
||||||
|
items: menuItems,
|
||||||
|
});
|
||||||
|
|
||||||
|
await menu.popup().catch((e) => console.error(e));
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const changeAccount = async (e: React.MouseEvent, npub: string) => {
|
||||||
|
if (npub === account) {
|
||||||
|
return showContextMenu(e, npub);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change current account and update signer
|
||||||
|
const select = await NostrAccount.loadAccount(npub);
|
||||||
|
|
||||||
if (select) {
|
if (select) {
|
||||||
return navigate({ to: "/$account/home", params: { account: npub } });
|
// Reset current columns
|
||||||
|
await getCurrent().emit("columns", { type: "reset" });
|
||||||
|
|
||||||
|
// Redirect to new account
|
||||||
|
return navigate({
|
||||||
|
to: "/$account/home",
|
||||||
|
params: { account: npub },
|
||||||
|
resetScroll: true,
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await message("Something wrong.", { title: "Accounts", kind: "error" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const getWindowDimensions = () => {
|
||||||
<div data-tauri-drag-region className="flex items-center gap-3">
|
const { innerWidth: width, innerHeight: height } = window;
|
||||||
{accounts.map((user) => (
|
return {
|
||||||
<button key={user} type="button" onClick={() => changeAccount(user)}>
|
width,
|
||||||
<User.Provider pubkey={user}>
|
height,
|
||||||
<User.Root
|
};
|
||||||
className={cn(
|
};
|
||||||
"rounded-full",
|
|
||||||
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",
|
|
||||||
user === account ? "w-7" : "w-8",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Bell() {
|
|
||||||
const { ark } = Route.useRouteContext();
|
|
||||||
const { account } = Route.useParams();
|
|
||||||
|
|
||||||
const [count, setCount] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unlisten = getCurrent().listen<string>(
|
function handleResize() {
|
||||||
"activity",
|
setWindowWidth(getWindowDimensions().width);
|
||||||
async (payload) => {
|
}
|
||||||
setCount((prevCount) => prevCount + 1);
|
|
||||||
await invoke("set_badge", { count });
|
|
||||||
|
|
||||||
const event: Event = JSON.parse(payload.payload);
|
if (!windowWidth) {
|
||||||
const user = await ark.get_profile(event.pubkey);
|
setWindowWidth(getWindowDimensions().width);
|
||||||
const userName =
|
}
|
||||||
user.display_name || user.name || displayNpub(event.pubkey, 16);
|
|
||||||
|
|
||||||
switch (event.kind) {
|
window.addEventListener("resize", handleResize);
|
||||||
case Kind.Text: {
|
|
||||||
sendNativeNotification("Mentioned you in a note", userName);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case Kind.Repost: {
|
|
||||||
sendNativeNotification("Reposted your note", userName);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case Kind.ZapReceipt: {
|
|
||||||
const amount = decodeZapInvoice(event.tags);
|
|
||||||
sendNativeNotification(
|
|
||||||
`Zapped ₿ ${amount.bitcoinFormatted}`,
|
|
||||||
userName,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unlisten.then((f) => f());
|
window.removeEventListener("resize", handleResize);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div data-tauri-drag-region className="flex items-center gap-3">
|
||||||
type="button"
|
{sortedList
|
||||||
onClick={() => {
|
.slice(0, windowWidth > 500 ? account.length : 2)
|
||||||
setCount(0);
|
.map((user) => (
|
||||||
ark.open_activity(account);
|
<button
|
||||||
}}
|
key={user}
|
||||||
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"
|
type="button"
|
||||||
>
|
onClick={(e) => changeAccount(e, user)}
|
||||||
<BellIcon className="size-5" />
|
>
|
||||||
{count > 0 ? (
|
<User.Provider pubkey={user}>
|
||||||
<span className="absolute right-0 top-0 block size-2 rounded-full bg-teal-500 ring-1 ring-black/5" />
|
<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 items-center justify-center rounded-full size-8 shrink-0 bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20">
|
||||||
|
<HorizontalDotsIcon className="size-5" />
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Portal>
|
||||||
|
<Popover.Content className="flex h-11 select-none items-center justify-center rounded-md bg-black/20 p-1 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade">
|
||||||
|
{sortedList.slice(2).map((user) => (
|
||||||
|
<button
|
||||||
|
key={user}
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => changeAccount(e, user)}
|
||||||
|
className="inline-flex items-center justify-center rounded-md size-9 hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<User.Provider pubkey={user}>
|
||||||
|
<User.Root className="rounded-full ring-1 ring-white/10">
|
||||||
|
<User.Avatar className="object-cover h-auto rounded-full size-7 aspect-square" />
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<Popover.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Portal>
|
||||||
|
</Popover.Root>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,19 @@
|
|||||||
import type { Ark } from "@lume/ark";
|
import type { Settings } from "@lume/system";
|
||||||
import type { Interests, Metadata, Settings } from "@lume/types";
|
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import type { QueryClient } from "@tanstack/react-query";
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
|
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
|
||||||
import type { Platform } from "@tauri-apps/plugin-os";
|
import type { Platform } from "@tauri-apps/plugin-os";
|
||||||
import type { Descendant } from "slate";
|
|
||||||
|
|
||||||
type EditorElement = {
|
|
||||||
type: string;
|
|
||||||
children: Descendant[];
|
|
||||||
eventId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface RouterContext {
|
interface RouterContext {
|
||||||
// System
|
// System
|
||||||
ark: Ark;
|
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
// App info
|
// App info
|
||||||
platform?: Platform;
|
platform?: Platform;
|
||||||
locale?: string;
|
locale?: string;
|
||||||
// Settings
|
// Settings
|
||||||
settings?: Settings;
|
settings?: Settings;
|
||||||
interests?: Interests;
|
// Accounts
|
||||||
// Profile
|
|
||||||
accounts?: string[];
|
accounts?: string[];
|
||||||
profile?: Metadata;
|
|
||||||
isNewUser?: boolean;
|
|
||||||
// Editor
|
|
||||||
initialValue?: EditorElement[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||||
@@ -38,7 +24,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
|||||||
|
|
||||||
function Pending() {
|
function Pending() {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-screen flex-col items-center justify-center">
|
<div className="flex flex-col items-center justify-center w-screen h-screen">
|
||||||
<Spinner className="size-5" />
|
<Spinner className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/activity/$account/messages")({
|
|
||||||
component: () => <div>Hello /activity/$account/messages!</div>,
|
|
||||||
});
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { Note, Spinner } from "@lume/ui";
|
|
||||||
import { Await, createFileRoute, defer } from "@tanstack/react-router";
|
|
||||||
import { Suspense } from "react";
|
|
||||||
import { Virtualizer } from "virtua";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/activity/$account/texts")({
|
|
||||||
loader: async ({ context, params }) => {
|
|
||||||
const ark = context.ark;
|
|
||||||
return { data: defer(ark.get_activities(params.account, "1")) };
|
|
||||||
},
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const { data } = Route.useLoaderData();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Virtualizer overscan={3}>
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center gap-2 text-sm font-medium"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<Spinner className="size-5" />
|
|
||||||
Loading...
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Await promise={data}>
|
|
||||||
{(events) =>
|
|
||||||
events.map((event) => (
|
|
||||||
<div
|
|
||||||
key={event.id}
|
|
||||||
className="flex flex-col gap-2 mb-3 bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"
|
|
||||||
>
|
|
||||||
<Note.Provider event={event}>
|
|
||||||
<Note.Root>
|
|
||||||
<div className="px-3 h-14 flex items-center justify-between">
|
|
||||||
<Note.User />
|
|
||||||
<Note.Menu />
|
|
||||||
</div>
|
|
||||||
<Note.Activity className="px-3" />
|
|
||||||
<Note.Content className="px-3" quote={false} clean />
|
|
||||||
<div className="mt-3 flex items-center gap-4 h-14 px-3">
|
|
||||||
<Note.Open />
|
|
||||||
</div>
|
|
||||||
</Note.Root>
|
|
||||||
</Note.Provider>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</Await>
|
|
||||||
</Suspense>
|
|
||||||
</Virtualizer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { Box, Container } from "@lume/ui";
|
|
||||||
import { cn } from "@lume/utils";
|
|
||||||
import { Link, Outlet } from "@tanstack/react-router";
|
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/activity/$account")({
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const { account } = Route.useParams();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container withDrag withNavigate={false}>
|
|
||||||
<Box className="scrollbar-none shadow-none bg-black/5 dark:bg-white/5 backdrop-blur-sm flex flex-col overflow-y-auto">
|
|
||||||
<div className="h-14 shrink-0 flex w-full items-center gap-1 px-3">
|
|
||||||
<div className="inline-flex h-full w-full items-center gap-1">
|
|
||||||
<Link to="/activity/$account/texts" params={{ account }}>
|
|
||||||
{({ isActive }) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
|
|
||||||
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Notes
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
<Link to="/activity/$account/zaps" params={{ account }}>
|
|
||||||
{({ isActive }) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
|
|
||||||
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Zaps
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="px-2 flex-1 overflow-y-auto w-full h-full scrollbar-none">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</Box>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { Note, Spinner, User } from "@lume/ui";
|
|
||||||
import { decodeZapInvoice } from "@lume/utils";
|
|
||||||
import { Await, createFileRoute, defer } from "@tanstack/react-router";
|
|
||||||
import { Suspense } from "react";
|
|
||||||
import { Virtualizer } from "virtua";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/activity/$account/zaps")({
|
|
||||||
loader: async ({ context, params }) => {
|
|
||||||
const ark = context.ark;
|
|
||||||
return { data: defer(ark.get_activities(params.account, "9735")) };
|
|
||||||
},
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const { data } = Route.useLoaderData();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Virtualizer overscan={3}>
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center gap-2 text-sm font-medium"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<Spinner className="size-5" />
|
|
||||||
Loading...
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Await promise={data}>
|
|
||||||
{(events) =>
|
|
||||||
events.map((event) => (
|
|
||||||
<div
|
|
||||||
key={event.id}
|
|
||||||
className="flex flex-col gap-2 mb-3 bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"
|
|
||||||
>
|
|
||||||
<User.Provider pubkey={event.pubkey}>
|
|
||||||
<User.Root className="flex flex-col">
|
|
||||||
<div className="text-lg h-20 font-medium leading-tight flex w-full items-center justify-center">
|
|
||||||
₿ {decodeZapInvoice(event.tags).bitcoinFormatted}
|
|
||||||
</div>
|
|
||||||
<div className="h-11 border-t border-neutral-100 dark:border-neutral-900 flex items-center gap-1 px-2">
|
|
||||||
<div className="inline-flex items-center gap-2">
|
|
||||||
<User.Avatar className="size-7 rounded-full shrink-0" />
|
|
||||||
<User.Name className="text-sm font-medium" />
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
||||||
zapped you
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</Await>
|
|
||||||
</Suspense>
|
|
||||||
</Virtualizer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,26 +1,20 @@
|
|||||||
import { CheckIcon } from "@lume/icons";
|
import { CheckIcon } from "@lume/icons";
|
||||||
import type { AppRouteSearch } from "@lume/types";
|
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { displayNsec } from "@lume/utils";
|
import { displayNsec } from "@lume/utils";
|
||||||
import * as Checkbox from "@radix-ui/react-checkbox";
|
import * as Checkbox from "@radix-ui/react-checkbox";
|
||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||||
|
import { message } from "@tauri-apps/plugin-dialog";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/auth/new/backup")({
|
export const Route = createFileRoute("/auth/$account/backup")({
|
||||||
validateSearch: (search: Record<string, string>): AppRouteSearch => {
|
|
||||||
return {
|
|
||||||
account: search.account,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const { account } = Route.useSearch();
|
const { account } = Route.useParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [key, setKey] = useState(null);
|
const [key, setKey] = useState(null);
|
||||||
@@ -35,13 +29,13 @@ function Screen() {
|
|||||||
try {
|
try {
|
||||||
if (key) {
|
if (key) {
|
||||||
if (!confirm.c1 || !confirm.c2 || !confirm.c3) {
|
if (!confirm.c1 || !confirm.c2 || !confirm.c3) {
|
||||||
return toast.warning("You need to confirm before continue");
|
return await message("You need to confirm before continue", {
|
||||||
|
title: "Backup",
|
||||||
|
kind: "info",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return navigate({
|
navigate({ to: "/", replace: true });
|
||||||
to: "/auth/settings",
|
|
||||||
search: { account },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// start loading
|
// start loading
|
||||||
@@ -57,7 +51,10 @@ function Screen() {
|
|||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
toast.error(String(e));
|
await message(String(e), {
|
||||||
|
title: "Backup",
|
||||||
|
kind: "error",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -66,12 +63,15 @@ function Screen() {
|
|||||||
await writeText(key);
|
await writeText(key);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e);
|
await message(String(e), {
|
||||||
|
title: "Backup",
|
||||||
|
kind: "error",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
|
<div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
|
||||||
<div className="flex flex-col text-center">
|
<div className="flex flex-col text-center">
|
||||||
<h3 className="text-xl font-semibold">Backup your sign in keys</h3>
|
<h3 className="text-xl font-semibold">Backup your sign in keys</h3>
|
||||||
<p className="text-neutral-700 dark:text-neutral-300">
|
<p className="text-neutral-700 dark:text-neutral-300">
|
||||||
@@ -79,7 +79,7 @@ function Screen() {
|
|||||||
access to your account if you lose this key.
|
access to your account if you lose this key.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-col gap-5">
|
<div className="flex flex-col w-full gap-5">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label htmlFor="passphase" className="font-medium">
|
<label htmlFor="passphase" className="font-medium">
|
||||||
Set a passphase to secure your key
|
Set a passphase to secure your key
|
||||||
@@ -90,7 +90,7 @@ function Screen() {
|
|||||||
type="password"
|
type="password"
|
||||||
value={passphase}
|
value={passphase}
|
||||||
onChange={(e) => setPassphase(e.target.value)}
|
onChange={(e) => setPassphase(e.target.value)}
|
||||||
className="w-full h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
className="w-full px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,12 +106,12 @@ function Screen() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={displayNsec(key, 36)}
|
value={displayNsec(key, 36)}
|
||||||
readOnly
|
readOnly
|
||||||
className="w-full h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
className="w-full px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => copyKey()}
|
onClick={() => copyKey()}
|
||||||
className="inline-flex h-11 w-24 items-center justify-center rounded-lg bg-neutral-200 hover:bg-neutral-300 dark:bg-white/20 dark:hover:bg-white/30"
|
className="inline-flex items-center justify-center w-24 rounded-lg h-11 bg-neutral-200 hover:bg-neutral-300 dark:bg-white/20 dark:hover:bg-white/30"
|
||||||
>
|
>
|
||||||
{copied ? "Copied" : "Copy"}
|
{copied ? "Copied" : "Copy"}
|
||||||
</button>
|
</button>
|
||||||
@@ -126,7 +126,7 @@ function Screen() {
|
|||||||
onCheckedChange={() =>
|
onCheckedChange={() =>
|
||||||
setConfirm((state) => ({ ...state, c1: !state.c1 }))
|
setConfirm((state) => ({ ...state, c1: !state.c1 }))
|
||||||
}
|
}
|
||||||
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
className="flex items-center justify-center rounded-md outline-none appearance-none size-6 bg-neutral-100 dark:bg-white/10 dark:hover:bg-white/20"
|
||||||
id="confirm1"
|
id="confirm1"
|
||||||
>
|
>
|
||||||
<Checkbox.Indicator className="text-blue-500">
|
<Checkbox.Indicator className="text-blue-500">
|
||||||
@@ -146,7 +146,7 @@ function Screen() {
|
|||||||
onCheckedChange={() =>
|
onCheckedChange={() =>
|
||||||
setConfirm((state) => ({ ...state, c2: !state.c2 }))
|
setConfirm((state) => ({ ...state, c2: !state.c2 }))
|
||||||
}
|
}
|
||||||
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
className="flex items-center justify-center rounded-md outline-none appearance-none size-6 bg-neutral-100 dark:bg-white/10 dark:hover:bg-white/20"
|
||||||
id="confirm2"
|
id="confirm2"
|
||||||
>
|
>
|
||||||
<Checkbox.Indicator className="text-blue-500">
|
<Checkbox.Indicator className="text-blue-500">
|
||||||
@@ -166,7 +166,7 @@ function Screen() {
|
|||||||
onCheckedChange={() =>
|
onCheckedChange={() =>
|
||||||
setConfirm((state) => ({ ...state, c3: !state.c3 }))
|
setConfirm((state) => ({ ...state, c3: !state.c3 }))
|
||||||
}
|
}
|
||||||
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
className="flex items-center justify-center rounded-md outline-none appearance-none size-6 bg-neutral-100 dark:bg-white/10 dark:hover:bg-white/20"
|
||||||
id="confirm3"
|
id="confirm3"
|
||||||
>
|
>
|
||||||
<Checkbox.Indicator className="text-blue-500">
|
<Checkbox.Indicator className="text-blue-500">
|
||||||
@@ -189,7 +189,7 @@ function Screen() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => submit()}
|
onClick={() => submit()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
className="inline-flex items-center justify-center w-full font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{loading ? <Spinner /> : t("global.continue")}
|
{loading ? <Spinner /> : t("global.continue")}
|
||||||
</button>
|
</button>
|
||||||
@@ -1,26 +1,27 @@
|
|||||||
import { AvatarUploader } from "@/components/avatarUploader";
|
import { AvatarUploader } from "@/components/avatarUploader";
|
||||||
import { PlusIcon } from "@lume/icons";
|
import { PlusIcon } from "@lume/icons";
|
||||||
|
import { NostrAccount } from "@lume/system";
|
||||||
import type { Metadata } from "@lume/types";
|
import type { Metadata } from "@lume/types";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { message } from "@tauri-apps/plugin-dialog";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/auth/new/profile")({
|
export const Route = createFileRoute("/auth/create-profile")({
|
||||||
component: Screen,
|
component: Screen,
|
||||||
loader: ({ context }) => {
|
loader: async () => {
|
||||||
return context.ark.create_keys();
|
const account = await NostrAccount.createAccount();
|
||||||
|
return account;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const keys = Route.useLoaderData();
|
const account = Route.useLoaderData();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { ark } = Route.useRouteContext();
|
|
||||||
const { register, handleSubmit } = useForm();
|
const { register, handleSubmit } = useForm();
|
||||||
|
|
||||||
const [picture, setPicture] = useState<string>("");
|
const [picture, setPicture] = useState<string>("");
|
||||||
@@ -35,46 +36,46 @@ function Screen() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Save account keys
|
// Save account keys
|
||||||
const save = await ark.save_account(keys.nsec);
|
const save = await NostrAccount.saveAccount(account.nsec);
|
||||||
|
|
||||||
// Then create profile
|
// Then create profile
|
||||||
if (save) {
|
if (save) {
|
||||||
const profile: Metadata = { ...data, picture };
|
const profile: Metadata = { ...data, picture };
|
||||||
const eventId = await ark.create_profile(profile);
|
const eventId = await NostrAccount.createProfile(profile);
|
||||||
|
|
||||||
if (eventId) {
|
if (eventId) {
|
||||||
navigate({
|
navigate({
|
||||||
to: "/auth/new/backup",
|
to: "/auth/$account/backup",
|
||||||
search: { account: keys.npub },
|
params: { account: account.npub },
|
||||||
replace: true,
|
replace: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
toast.error(String(e));
|
await message(String(e), { title: "Create Profile", kind: "error" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
|
<div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-xl font-semibold">Let's set up your profile.</h3>
|
<h3 className="text-xl font-semibold">Let's set up your profile.</h3>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="relative size-24 rounded-full bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200">
|
<div className="relative rounded-full size-24 bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200">
|
||||||
{picture ? (
|
{picture ? (
|
||||||
<img
|
<img
|
||||||
src={picture}
|
src={picture}
|
||||||
alt="avatar"
|
alt="avatar"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
className="absolute inset-0 z-10 h-full w-full rounded-full object-cover"
|
className="absolute inset-0 z-10 object-cover w-full h-full rounded-full"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<AvatarUploader
|
<AvatarUploader
|
||||||
setPicture={setPicture}
|
setPicture={setPicture}
|
||||||
className="absolute inset-0 z-20 flex h-full w-full items-center justify-center rounded-full dark:text-black bg-black/10 text-white hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
className="absolute inset-0 z-20 flex items-center justify-center w-full h-full text-white rounded-full dark:text-black bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
||||||
>
|
>
|
||||||
<PlusIcon className="size-8" />
|
<PlusIcon className="size-8" />
|
||||||
</AvatarUploader>
|
</AvatarUploader>
|
||||||
@@ -82,7 +83,7 @@ function Screen() {
|
|||||||
</div>
|
</div>
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
className="flex w-full flex-col gap-3"
|
className="flex flex-col w-full gap-3"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label htmlFor="display_name" className="font-medium">
|
<label htmlFor="display_name" className="font-medium">
|
||||||
@@ -93,7 +94,7 @@ function Screen() {
|
|||||||
{...register("display_name", { required: true, minLength: 1 })}
|
{...register("display_name", { required: true, minLength: 1 })}
|
||||||
placeholder="e.g. Alice in Nostrland"
|
placeholder="e.g. Alice in Nostrland"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -105,7 +106,7 @@ function Screen() {
|
|||||||
{...register("name")}
|
{...register("name")}
|
||||||
placeholder="e.g. alice"
|
placeholder="e.g. alice"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -128,12 +129,12 @@ function Screen() {
|
|||||||
{...register("website")}
|
{...register("website")}
|
||||||
placeholder="e.g. https://alice.me"
|
placeholder="e.g. https://alice.me"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-500 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
className="inline-flex items-center justify-center w-full mt-3 font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{loading ? <Spinner /> : t("global.continue")}
|
{loading ? <Spinner /> : t("global.continue")}
|
||||||
</button>
|
</button>
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
|
import { NostrAccount } from "@lume/system";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||||
|
import { message } from "@tauri-apps/plugin-dialog";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/auth/privkey")({
|
export const Route = createLazyFileRoute("/auth/import")({
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const { ark } = Route.useRouteContext();
|
|
||||||
const navigate = Route.useNavigate();
|
const navigate = Route.useNavigate();
|
||||||
|
|
||||||
const [key, setKey] = useState("");
|
const [key, setKey] = useState("");
|
||||||
@@ -16,35 +16,33 @@ function Screen() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!key.startsWith("nsec1"))
|
if (!key.startsWith("nsec1")) {
|
||||||
return toast.warning(
|
return await message(
|
||||||
"You need to enter a valid private key starts with nsec or ncryptsec",
|
"You need to enter a valid private key starts with nsec or ncryptsec",
|
||||||
|
{ title: "Import Key", kind: "info" },
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const npub = await ark.save_account(key, password);
|
const npub = await NostrAccount.saveAccount(key, password);
|
||||||
|
|
||||||
if (npub) {
|
if (npub) {
|
||||||
navigate({
|
navigate({ to: "/", replace: true });
|
||||||
to: "/auth/settings",
|
|
||||||
search: { account: npub },
|
|
||||||
replace: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
toast.error(e);
|
await message(String(e), { title: "Import Key", kind: "error" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
|
<div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-xl font-semibold">Continue with Private Key</h3>
|
<h3 className="text-xl font-semibold">Continue with Private Key</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-col gap-3">
|
<div className="flex flex-col w-full gap-3">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label
|
<label
|
||||||
htmlFor="key"
|
htmlFor="key"
|
||||||
@@ -58,7 +56,7 @@ function Screen() {
|
|||||||
placeholder="nsec or ncryptsec..."
|
placeholder="nsec or ncryptsec..."
|
||||||
value={key}
|
value={key}
|
||||||
onChange={(e) => setKey(e.target.value)}
|
onChange={(e) => setKey(e.target.value)}
|
||||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -73,14 +71,14 @@ function Screen() {
|
|||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => submit()}
|
onClick={() => submit()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
className="inline-flex items-center justify-center w-full mt-3 font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{loading ? <Spinner /> : "Login"}
|
{loading ? <Spinner /> : "Login"}
|
||||||
</button>
|
</button>
|
||||||
@@ -1,49 +1,47 @@
|
|||||||
|
import { NostrAccount } from "@lume/system";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||||
|
import { message } from "@tauri-apps/plugin-dialog";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/auth/remote")({
|
export const Route = createLazyFileRoute("/auth/remote")({
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const { ark } = Route.useRouteContext();
|
|
||||||
const navigate = Route.useNavigate();
|
const navigate = Route.useNavigate();
|
||||||
|
|
||||||
const [uri, setUri] = useState("");
|
const [uri, setUri] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!uri.startsWith("bunker://"))
|
if (!uri.startsWith("bunker://")) {
|
||||||
return toast.warning(
|
return await message(
|
||||||
"You need to enter a valid Connect URI starts with bunker://",
|
"You need to enter a valid Connect URI starts with bunker://",
|
||||||
|
{ title: "Nostr Connect", kind: "info" },
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const npub = await ark.nostr_connect(uri);
|
const remoteAccount = await NostrAccount.connectRemoteAccount(uri);
|
||||||
|
|
||||||
if (npub) {
|
if (remoteAccount?.length) {
|
||||||
navigate({
|
navigate({ to: "/", replace: true });
|
||||||
to: "/auth/settings",
|
|
||||||
search: { account: npub },
|
|
||||||
replace: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
toast.error(e);
|
await message(String(e), { title: "Nostr Connect", kind: "error" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
|
<div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-xl font-semibold">Continue with Nostr Connect</h3>
|
<h3 className="text-xl font-semibold">Continue with Nostr Connect</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-col gap-3">
|
<div className="flex flex-col w-full gap-3">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label
|
<label
|
||||||
htmlFor="uri"
|
htmlFor="uri"
|
||||||
@@ -57,17 +55,24 @@ function Screen() {
|
|||||||
placeholder="bunker://..."
|
placeholder="bunker://..."
|
||||||
value={uri}
|
value={uri}
|
||||||
onChange={(e) => setUri(e.target.value)}
|
onChange={(e) => setUri(e.target.value)}
|
||||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex flex-col items-center gap-1">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => submit()}
|
type="button"
|
||||||
disabled={loading}
|
onClick={() => 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"
|
disabled={loading}
|
||||||
>
|
className="inline-flex items-center justify-center w-full mt-3 font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
|
||||||
{loading ? <Spinner /> : "Login"}
|
>
|
||||||
</button>
|
{loading ? <Spinner /> : "Login"}
|
||||||
|
</button>
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-center text-neutral-600 dark:text-neutral-400">
|
||||||
|
Waiting confirmation...
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,218 +0,0 @@
|
|||||||
import { LaurelIcon } from "@lume/icons";
|
|
||||||
import type { AppRouteSearch, Settings } from "@lume/types";
|
|
||||||
import { Spinner } from "@lume/ui";
|
|
||||||
import * as Switch from "@radix-ui/react-switch";
|
|
||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
|
||||||
import {
|
|
||||||
isPermissionGranted,
|
|
||||||
requestPermission,
|
|
||||||
} from "@tauri-apps/plugin-notification";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/auth/settings")({
|
|
||||||
validateSearch: (search: Record<string, string>): AppRouteSearch => {
|
|
||||||
return {
|
|
||||||
account: search.account,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
beforeLoad: async ({ context }) => {
|
|
||||||
const permissionGranted = await isPermissionGranted(); // get notification permission
|
|
||||||
const ark = context.ark;
|
|
||||||
const settings = await ark.get_settings();
|
|
||||||
|
|
||||||
return {
|
|
||||||
settings: { ...settings, notification: permissionGranted },
|
|
||||||
};
|
|
||||||
},
|
|
||||||
component: Screen,
|
|
||||||
pendingComponent: Pending,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const { account } = Route.useSearch();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { ark, settings } = Route.useRouteContext();
|
|
||||||
|
|
||||||
const [newSettings, setNewSettings] = useState<Settings>(settings);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const toggleNofitication = async () => {
|
|
||||||
await requestPermission();
|
|
||||||
setNewSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
notification: !newSettings.notification,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleAutoUpdate = () => {
|
|
||||||
setNewSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
autoUpdate: !newSettings.autoUpdate,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleEnhancedPrivacy = () => {
|
|
||||||
setNewSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
enhancedPrivacy: !newSettings.enhancedPrivacy,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleZap = () => {
|
|
||||||
setNewSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
zap: !newSettings.zap,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleNsfw = () => {
|
|
||||||
setNewSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
nsfw: !newSettings.nsfw,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
try {
|
|
||||||
// start loading
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
// publish settings
|
|
||||||
const eventId = await ark.set_settings(newSettings);
|
|
||||||
|
|
||||||
if (eventId) {
|
|
||||||
return navigate({
|
|
||||||
to: "/$account/home",
|
|
||||||
params: { account },
|
|
||||||
replace: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setLoading(false);
|
|
||||||
toast.error(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
|
|
||||||
<div className="flex flex-col items-center gap-5 text-center">
|
|
||||||
<div className="flex size-20 items-center justify-center rounded-full bg-teal-100 dark:bg-teal-950 text-teal-500">
|
|
||||||
<LaurelIcon className="size-8" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-semibold">
|
|
||||||
{t("onboardingSettings.title")}
|
|
||||||
</h1>
|
|
||||||
<p className="leading-snug text-neutral-600 dark:text-neutral-400">
|
|
||||||
{t("onboardingSettings.subtitle")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-5">
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-semibold">Push Notification</h3>
|
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
||||||
Enabling push notifications will allow you to receive
|
|
||||||
notifications from Lume.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch.Root
|
|
||||||
checked={newSettings.notification}
|
|
||||||
onClick={() => toggleNofitication()}
|
|
||||||
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
|
|
||||||
>
|
|
||||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
|
||||||
</Switch.Root>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-semibold">Enhanced Privacy</h3>
|
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
||||||
Lume will display external resources like image, video or link
|
|
||||||
preview as plain text.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch.Root
|
|
||||||
checked={newSettings.enhancedPrivacy}
|
|
||||||
onClick={() => toggleEnhancedPrivacy()}
|
|
||||||
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
|
|
||||||
>
|
|
||||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
|
||||||
</Switch.Root>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-semibold">Auto Update</h3>
|
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
||||||
Automatically download and install new version.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch.Root
|
|
||||||
checked={newSettings.autoUpdate}
|
|
||||||
onClick={() => toggleAutoUpdate()}
|
|
||||||
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
|
|
||||||
>
|
|
||||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
|
||||||
</Switch.Root>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-semibold">Zap</h3>
|
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
||||||
Show the Zap button in each note and user's profile screen, use
|
|
||||||
for send Bitcoin tip to other users.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch.Root
|
|
||||||
checked={newSettings.zap}
|
|
||||||
onClick={() => toggleZap()}
|
|
||||||
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
|
|
||||||
>
|
|
||||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
|
||||||
</Switch.Root>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-semibold">Filter sensitive content</h3>
|
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
||||||
By default, Lume will display all content which have Content
|
|
||||||
Warning tag, it's may include NSFW content.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch.Root
|
|
||||||
checked={newSettings.nsfw}
|
|
||||||
onClick={() => toggleNsfw()}
|
|
||||||
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
|
|
||||||
>
|
|
||||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
|
||||||
</Switch.Root>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => submit()}
|
|
||||||
disabled={loading}
|
|
||||||
className="mb-1 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{t("global.continue")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Pending() {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<button type="button" className="size-5" disabled>
|
|
||||||
<Spinner className="size-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { CancelIcon, PlusIcon } from "@lume/icons";
|
||||||
|
import { NostrQuery } from "@lume/system";
|
||||||
|
import type { Relay } from "@lume/types";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { message } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/bootstrap-relays")({
|
||||||
|
loader: async () => {
|
||||||
|
const bootstrapRelays = await NostrQuery.getBootstrapRelays();
|
||||||
|
return bootstrapRelays;
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const bootstrapRelays = Route.useLoaderData();
|
||||||
|
const { register, reset, handleSubmit } = useForm();
|
||||||
|
|
||||||
|
const [relays, setRelays] = useState<Relay[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const removeRelay = (url: string) => {
|
||||||
|
setRelays((prev) => prev.filter((relay) => relay.url !== url));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (data: { url: string; purpose: string }) => {
|
||||||
|
try {
|
||||||
|
const relay: Relay = { url: data.url, purpose: data.purpose };
|
||||||
|
setRelays((prev) => [...prev, relay]);
|
||||||
|
reset();
|
||||||
|
} catch (e) {
|
||||||
|
await message(String(e), { title: "Bootstrap Relays", kind: "error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
await NostrQuery.saveBootstrapRelays(relays);
|
||||||
|
} catch (e) {
|
||||||
|
await message(String(e), { title: "Bootstrap Relays", kind: "error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRelays(bootstrapRelays);
|
||||||
|
}, [bootstrapRelays]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center w-screen h-screen">
|
||||||
|
<div className="w-full max-w-sm mx-auto lg:max-w-lg">
|
||||||
|
<div className="text-center h-11">
|
||||||
|
<h1 className="font-semibold">Customize Bootstrap Relays</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col w-full px-2 bg-white rounded-xl shadow-primary backdrop-blur-lg dark:bg-white/20 dark:ring-1 ring-neutral-800/50">
|
||||||
|
{relays.map((relay) => (
|
||||||
|
<div
|
||||||
|
key={relay.url}
|
||||||
|
className="flex items-center justify-between h-11"
|
||||||
|
>
|
||||||
|
<div className="inline-flex items-center gap-2 text-sm font-medium">
|
||||||
|
{relay.url}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{relay.purpose?.length ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center justify-center px-2 text-xs font-medium uppercase rounded-md h-7 w-max hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
{relay.purpose}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeRelay(relay.url)}
|
||||||
|
className="inline-flex items-center justify-center rounded-md size-7 text-neutral-700 dark:text-white/20 hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<CancelIcon className="size-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center border-t h-14 border-neutral-100 dark:border-white/5">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
className="flex items-center w-full gap-2 mb-0"
|
||||||
|
>
|
||||||
|
<div className="flex items-center flex-1 gap-2 border rounded-lg border-neutral-300 dark:border-white/20">
|
||||||
|
<input
|
||||||
|
{...register("url", {
|
||||||
|
required: true,
|
||||||
|
minLength: 1,
|
||||||
|
})}
|
||||||
|
name="url"
|
||||||
|
placeholder="wss://..."
|
||||||
|
spellCheck={false}
|
||||||
|
className="flex-1 px-3 bg-transparent border-none rounded-l-lg h-9 placeholder:text-neutral-500 dark:placeholder:text-neutral-400"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
{...register("purpose")}
|
||||||
|
className="flex-1 p-0 m-0 text-sm bg-transparent border-none outline-none h-9 ring-0 focus:outline-none focus:ring-0"
|
||||||
|
>
|
||||||
|
<option value="read">Read</option>
|
||||||
|
<option value="write">Write</option>
|
||||||
|
<option value="">Both</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="inline-flex items-center justify-center px-2 text-sm font-medium text-white rounded-lg shrink-0 h-9 w-14 bg-black/20 dark:bg-white/20 hover:bg-blue-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-7" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => save()}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="inline-flex items-center justify-center w-full h-10 mt-4 text-sm font-semibold text-white bg-blue-500 rounded-lg shrink-0 hover:bg-blue-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? <Spinner /> : "Save & Relaunch"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { CheckCircleIcon } from "@lume/icons";
|
import { User } from "@/components/user";
|
||||||
|
import { CancelIcon, PlusIcon } from "@lume/icons";
|
||||||
|
import { NostrAccount, NostrQuery } from "@lume/system";
|
||||||
import type { ColumnRouteSearch } from "@lume/types";
|
import type { ColumnRouteSearch } from "@lume/types";
|
||||||
import { Spinner, User } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { message } from "@tauri-apps/plugin-dialog";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/create-group")({
|
export const Route = createFileRoute("/create-group")({
|
||||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||||
@@ -13,113 +15,183 @@ export const Route = createFileRoute("/create-group")({
|
|||||||
name: search.name,
|
name: search.name,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
loader: async ({ context }) => {
|
loader: async () => {
|
||||||
const ark = context.ark;
|
const contacts = await NostrAccount.getContactList();
|
||||||
const contacts = await ark.get_contact_list();
|
|
||||||
return contacts;
|
return contacts;
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [npub, setNpub] = useState("");
|
||||||
|
const [users, setUsers] = useState<string[]>([
|
||||||
|
"npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445", // reya
|
||||||
|
]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const contacts = Route.useLoaderData();
|
const contacts = Route.useLoaderData();
|
||||||
const router = useRouter();
|
const search = Route.useSearch();
|
||||||
|
const navigate = Route.useNavigate();
|
||||||
const { ark } = Route.useRouteContext();
|
|
||||||
const { label, redirect } = Route.useSearch();
|
|
||||||
|
|
||||||
const [title, setTitle] = useState<string>("Just a new group");
|
|
||||||
const [users, setUsers] = useState<Array<string>>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [isDone, setIsDone] = useState(false);
|
|
||||||
|
|
||||||
const toggleUser = (pubkey: string) => {
|
const toggleUser = (pubkey: string) => {
|
||||||
const arr = users.includes(pubkey)
|
setUsers((prev) =>
|
||||||
? users.filter((i) => i !== pubkey)
|
prev.includes(pubkey)
|
||||||
: [...users, pubkey];
|
? prev.filter((i) => i !== pubkey)
|
||||||
setUsers(arr);
|
: [...prev, pubkey],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addUser = () => {
|
||||||
|
if (!npub.startsWith("npub1")) return;
|
||||||
|
if (users.includes(npub)) return;
|
||||||
|
|
||||||
|
setUsers((prev) => [...prev, npub]);
|
||||||
|
setNpub("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
try {
|
try {
|
||||||
if (isDone) return router.history.push(redirect);
|
setIsLoading(true);
|
||||||
|
|
||||||
// start loading
|
const key = `lume_group_${search.label}`;
|
||||||
setLoading(true);
|
const createGroup = await NostrQuery.setNstore(
|
||||||
|
key,
|
||||||
const groups = await ark.set_nstore(
|
|
||||||
`lume_group_${label}`,
|
|
||||||
JSON.stringify(users),
|
JSON.stringify(users),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (groups) {
|
if (createGroup) {
|
||||||
toast.success("Group has been created successfully.");
|
return navigate({ to: search.redirect, search: { ...search } });
|
||||||
// start loading
|
|
||||||
setIsDone(true);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLoading(false);
|
setIsLoading(false);
|
||||||
toast.error(e);
|
await message(String(e), { title: "Create Group", kind: "error" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-y-auto scrollbar-none">
|
<div className="flex flex-col items-center justify-center w-full h-full gap-4">
|
||||||
<div className="flex flex-col gap-5 p-3">
|
<div className="flex flex-col items-center justify-center text-center">
|
||||||
<div className="flex flex-col gap-1">
|
<h1 className="font-serif text-2xl font-medium">
|
||||||
<label htmlFor="name" className="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="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 text-sm font-semibold text-center border-r border-black/10 dark:border-white/10 shrink-0"
|
||||||
|
>
|
||||||
Name
|
Name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
name="name"
|
name="name"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="Nostrichs..."
|
placeholder="Enter a name for this group"
|
||||||
className="h-10 rounded-lg bg-transparent border border-neutral-300 dark:border-neutral-700 px-3 placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
|
className="h-full px-3 text-sm bg-transparent border-none placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col items-center w-full gap-3">
|
||||||
<div className="inline-flex items-center justify-between">
|
<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">
|
||||||
<span className="font-medium">Pick user</span>
|
<div className="flex gap-2">
|
||||||
<span className="text-neutral-600 dark:text-neutral-400">{`${users.length} / ∞`}</span>
|
<input
|
||||||
</div>
|
name="npub"
|
||||||
<div className="flex flex-col gap-2">
|
value={npub}
|
||||||
{contacts.map((item: string) => (
|
onChange={(e) => setNpub(e.target.value)}
|
||||||
|
placeholder="npub1..."
|
||||||
|
className="w-full px-3 text-sm border-none rounded-lg h-9 bg-black/10 dark:bg-white/10 placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
key={item}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleUser(item)}
|
onClick={() => addUser()}
|
||||||
className="inline-flex items-center justify-between px-3 py-2 rounded-lg bg-black/10 dark:bg-white/10 hover:bg-black/20 dark:hover:bg-white/20"
|
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"
|
||||||
>
|
>
|
||||||
<User.Provider pubkey={item}>
|
<PlusIcon className="size-6" />
|
||||||
<User.Root className="flex items-center gap-2.5">
|
|
||||||
<User.Avatar className="size-10 rounded-full object-cover" />
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<User.Name className="font-medium" />
|
|
||||||
<User.NIP05 />
|
|
||||||
</div>
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
{users.includes(item) ? (
|
|
||||||
<CheckCircleIcon className="size-5 text-teal-500" />
|
|
||||||
) : null}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-sm font-semibold">Added</span>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{users.length ? (
|
||||||
|
users.map((item: string) => (
|
||||||
|
<button
|
||||||
|
key={item}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleUser(item)}
|
||||||
|
className="inline-flex items-center justify-between px-3 py-2 bg-white rounded-lg dark:bg-black/20 backdrop-blur-lg shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||||
|
>
|
||||||
|
<User.Provider pubkey={item}>
|
||||||
|
<User.Root className="flex items-center gap-2.5">
|
||||||
|
<User.Avatar className="object-cover rounded-full size-8" />
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<User.Name className="text-sm font-medium" />
|
||||||
|
</div>
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
<div>
|
||||||
|
<CancelIcon className="size-4" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center text-sm rounded-lg bg-black/5 dark:bg-white/5 h-14">
|
||||||
|
Empty.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-sm font-semibold">Contacts</span>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{contacts.length ? (
|
||||||
|
contacts.map((item: string) => (
|
||||||
|
<button
|
||||||
|
key={item}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleUser(item)}
|
||||||
|
className="inline-flex items-center justify-between px-3 py-2 bg-white rounded-lg dark:bg-black/20 backdrop-blur-lg shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||||
|
>
|
||||||
|
<User.Provider pubkey={item}>
|
||||||
|
<User.Root className="flex items-center gap-2.5">
|
||||||
|
<User.Avatar className="object-cover rounded-full size-8" />
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<User.Name className="text-sm font-medium" />
|
||||||
|
</div>
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<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
|
||||||
|
href="https://www.nostr.directory/"
|
||||||
|
target="_blank"
|
||||||
|
className="text-blue-600 after:content-['_↗']"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Nostr Directory
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="fixed z-10 flex items-center justify-center w-full bottom-6">
|
|
||||||
{users.length >= 1 ? (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => submit()}
|
onClick={() => submit()}
|
||||||
disabled={users.length < 1}
|
disabled={isLoading || users.length < 1}
|
||||||
className="inline-flex items-center justify-center px-4 font-medium text-white transform bg-blue-500 rounded-full active:translate-y-1 w-32 h-10 hover:bg-blue-600 focus:outline-none"
|
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"
|
||||||
>
|
>
|
||||||
{isDone ? "Back" : loading ? <Spinner /> : "Update"}
|
{isLoading ? <Spinner /> : "Confirm"}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/create-newsfeed/f2f")({
|
||||||
|
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||||
|
return {
|
||||||
|
account: search.account,
|
||||||
|
label: search.label,
|
||||||
|
name: search.name,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const navigate = Route.useNavigate();
|
||||||
|
const { redirect } = Route.useSearch();
|
||||||
|
|
||||||
|
const [npub, setNpub] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!npub.startsWith("npub1")) {
|
||||||
|
return await message("You must enter a valid npub.", {
|
||||||
|
title: "Create Newsfeed",
|
||||||
|
kind: "info",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const sync = await NostrAccount.f2f(npub);
|
||||||
|
|
||||||
|
if (sync) {
|
||||||
|
return navigate({ to: redirect });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setIsLoading(false);
|
||||||
|
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="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?
|
||||||
|
</p>
|
||||||
|
<p>Instead of building the timeline by yourself.</p>
|
||||||
|
<p className="font-semibold text-neutral-500">
|
||||||
|
Just enter your friend's{" "}
|
||||||
|
<span className="text-blue-500">npub.</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
You will have the same experience as your friend. Of course, you
|
||||||
|
always can edit your network later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="npub" className="text-sm font-medium">
|
||||||
|
NPUB
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
name="npub"
|
||||||
|
placeholder="npub1..."
|
||||||
|
value={npub}
|
||||||
|
onChange={(e) => setNpub(e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
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 text-sm font-medium text-white bg-blue-500 rounded-lg h-9 hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
{isLoading ? <Spinner /> : "Confirm"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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="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="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"
|
||||||
|
search={search}
|
||||||
|
className="flex-1 h-8"
|
||||||
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"text-sm font-medium rounded-md h-full flex items-center justify-center",
|
||||||
|
isActive
|
||||||
|
? "bg-white dark:bg-white/20 shadow"
|
||||||
|
: "bg-transparent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Users
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/create-newsfeed/f2f"
|
||||||
|
search={search}
|
||||||
|
className="flex-1 h-8"
|
||||||
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-md h-full flex items-center justify-center",
|
||||||
|
isActive ? "bg-white dark:bg-white/20" : "bg-transparent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Friend to Friend
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { User } from "@/components/user";
|
||||||
|
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 => {
|
||||||
|
return {
|
||||||
|
account: search.account,
|
||||||
|
label: search.label,
|
||||||
|
name: search.name,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
loader: async ({ abortController }) => {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
data: defer(
|
||||||
|
fetch("https://api.nostr.band/v0/trending/profiles", {
|
||||||
|
signal: abortController.signal,
|
||||||
|
}).then((res) => res.json()),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(String(e));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const { data } = Route.useLoaderData();
|
||||||
|
const { redirect } = Route.useSearch();
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [follows, setFollows] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const navigate = Route.useNavigate();
|
||||||
|
|
||||||
|
const toggleFollow = (pubkey: string) => {
|
||||||
|
setFollows((prev) =>
|
||||||
|
prev.includes(pubkey)
|
||||||
|
? prev.filter((i) => i !== pubkey)
|
||||||
|
: [...prev, pubkey],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const newContactList = await NostrAccount.setContactList(follows);
|
||||||
|
|
||||||
|
if (newContactList) {
|
||||||
|
return navigate({ to: redirect });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setIsLoading(false);
|
||||||
|
await message(String(e), {
|
||||||
|
title: "Create Group",
|
||||||
|
kind: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center w-full gap-3">
|
||||||
|
<div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl">
|
||||||
|
<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 promise={data}>
|
||||||
|
{(users) =>
|
||||||
|
users.profiles.map((item: { pubkey: string }) => (
|
||||||
|
<div
|
||||||
|
key={item.pubkey}
|
||||||
|
className="w-full p-2 mb-2 overflow-hidden bg-white rounded-lg h-max dark:bg-black/20 backdrop-blur-lg shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||||
|
>
|
||||||
|
<User.Provider pubkey={item.pubkey}>
|
||||||
|
<User.Root>
|
||||||
|
<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="object-cover rounded-full size-7 shrink-0" />
|
||||||
|
<User.Name className="text-sm leadning-tight max-w-[15rem] truncate font-semibold" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleFollow(item.pubkey)}
|
||||||
|
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="select-text line-clamp-3 max-w-none text-neutral-800 dark:text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</Await>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => submit()}
|
||||||
|
disabled={isLoading || follows.length < 1}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { CheckCircleIcon } from "@lume/icons";
|
||||||
|
import { NostrQuery } from "@lume/system";
|
||||||
|
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";
|
||||||
|
|
||||||
|
type Topic = {
|
||||||
|
title: string;
|
||||||
|
content: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/create-topic")({
|
||||||
|
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||||
|
return {
|
||||||
|
account: search.account,
|
||||||
|
label: search.label,
|
||||||
|
name: search.name,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const [topics, setTopics] = useState<Topic[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const search = Route.useSearch();
|
||||||
|
const navigate = Route.useNavigate();
|
||||||
|
|
||||||
|
const toggleTopic = (topic: Topic) => {
|
||||||
|
setTopics((prev) =>
|
||||||
|
prev.find((item) => item.title === topic.title)
|
||||||
|
? prev.filter((i) => i.title !== topic.title)
|
||||||
|
: [...prev, topic],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const key = `lume_topic_${search.label}`;
|
||||||
|
const createTopic = await NostrQuery.setNstore(
|
||||||
|
key,
|
||||||
|
JSON.stringify(topics),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (createTopic) {
|
||||||
|
return navigate({ to: search.redirect, search: { ...search } });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setIsLoading(false);
|
||||||
|
await message(String(e), {
|
||||||
|
title: "Create Topic",
|
||||||
|
kind: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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="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="flex flex-col items-center w-full gap-3">
|
||||||
|
<div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{TOPICS.map((topic) => (
|
||||||
|
<button
|
||||||
|
key={topic.title}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleTopic(topic)}
|
||||||
|
className="flex items-center justify-between px-3 bg-white border border-transparent rounded-lg h-11 dark:bg-black/20 backdrop-blur-lg hover:border-blue-500 shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||||
|
>
|
||||||
|
<div className="inline-flex items-center gap-1">
|
||||||
|
<div>{topic.icon}</div>
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
<span>{topic.title}</span>
|
||||||
|
<span className="ml-1 italic font-normal text-neutral-400 dark:text-neutral-600">
|
||||||
|
{topic.content.length} hashtags
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{topics.find((item) => item.title === topic.title) ? (
|
||||||
|
<CheckCircleIcon className="text-teal-500 size-4" />
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => submit()}
|
||||||
|
disabled={isLoading || topics.length < 1}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,32 +1,30 @@
|
|||||||
import { AddMediaIcon } from "@lume/icons";
|
import { AddMediaIcon } from "@lume/icons";
|
||||||
|
import { NostrQuery } from "@lume/system";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { cn, insertImage, isImagePath } from "@lume/utils";
|
import { insertImage, isImagePath } from "@lume/utils";
|
||||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||||
import { getCurrent } from "@tauri-apps/api/window";
|
import { getCurrent } from "@tauri-apps/api/window";
|
||||||
|
import { message } from "@tauri-apps/plugin-dialog";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useSlateStatic } from "slate-react";
|
import { useSlateStatic } from "slate-react";
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
export function MediaButton({ className }: { className?: string }) {
|
export function MediaButton() {
|
||||||
const editor = useSlateStatic();
|
const editor = useSlateStatic();
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const uploadToNostrBuild = async () => {
|
const upload = async () => {
|
||||||
try {
|
try {
|
||||||
// start loading
|
// start loading
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const image = await ark.upload();
|
const image = await NostrQuery.upload();
|
||||||
insertImage(editor, image);
|
insertImage(editor, image);
|
||||||
|
|
||||||
// reset loading
|
// reset loading
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
toast.error(`Upload failed, error: ${e}`);
|
await message(String(e), { title: "Upload", kind: "error" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -44,7 +42,7 @@ export function MediaButton({ className }: { className?: string }) {
|
|||||||
// upload all images
|
// upload all images
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (isImagePath(item)) {
|
if (isImagePath(item)) {
|
||||||
const image = await ark.upload(item);
|
const image = await NostrQuery.upload(item);
|
||||||
insertImage(editor, image);
|
insertImage(editor, image);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,29 +60,18 @@ export function MediaButton({ className }: { className?: string }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip.Provider>
|
<button
|
||||||
<Tooltip.Root delayDuration={150}>
|
type="button"
|
||||||
<Tooltip.Trigger asChild>
|
onClick={() => upload()}
|
||||||
<button
|
disabled={loading}
|
||||||
type="button"
|
className="inline-flex items-center h-8 gap-2 px-2.5 text-sm rounded-lg text-black/70 dark:text-white/70 w-max hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
onClick={() => uploadToNostrBuild()}
|
>
|
||||||
disabled={loading}
|
{loading ? (
|
||||||
className={cn("inline-flex items-center justify-center", className)}
|
<Spinner className="size-4" />
|
||||||
>
|
) : (
|
||||||
{loading ? (
|
<AddMediaIcon className="size-4" />
|
||||||
<Spinner className="size-4" />
|
)}
|
||||||
) : (
|
Add media
|
||||||
<AddMediaIcon className="size-4" />
|
</button>
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</Tooltip.Trigger>
|
|
||||||
<Tooltip.Portal>
|
|
||||||
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
|
||||||
Upload media
|
|
||||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
|
||||||
</Tooltip.Content>
|
|
||||||
</Tooltip.Portal>
|
|
||||||
</Tooltip.Root>
|
|
||||||
</Tooltip.Provider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
import { MentionIcon } from "@lume/icons";
|
|
||||||
import { cn, insertMention } from "@lume/utils";
|
|
||||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
|
||||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import { User } from "@lume/ui";
|
|
||||||
import { useSlateStatic } from "slate-react";
|
|
||||||
import type { Contact } from "@lume/types";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
export function MentionButton({ className }: { className?: string }) {
|
|
||||||
const editor = useSlateStatic();
|
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const [contacts, setContacts] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const select = async (user: string) => {
|
|
||||||
try {
|
|
||||||
const metadata = await ark.get_profile(user);
|
|
||||||
const contact: Contact = { pubkey: user, profile: metadata };
|
|
||||||
|
|
||||||
insertMention(editor, contact);
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(String(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function getContacts() {
|
|
||||||
const data = await ark.get_contact_list();
|
|
||||||
setContacts(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
getContacts();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<Tooltip.Provider>
|
|
||||||
<Tooltip.Root delayDuration={150}>
|
|
||||||
<DropdownMenu.Trigger asChild>
|
|
||||||
<Tooltip.Trigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center justify-center",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<MentionIcon className="size-4" />
|
|
||||||
</button>
|
|
||||||
</Tooltip.Trigger>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<Tooltip.Portal>
|
|
||||||
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
|
||||||
Mention
|
|
||||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
|
||||||
</Tooltip.Content>
|
|
||||||
</Tooltip.Portal>
|
|
||||||
</Tooltip.Root>
|
|
||||||
</Tooltip.Provider>
|
|
||||||
<DropdownMenu.Portal>
|
|
||||||
<DropdownMenu.Content className="flex w-[220px] h-[220px] scrollbar-none flex-col overflow-y-auto rounded-xl bg-black py-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
|
|
||||||
{contacts.map((contact) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={contact}
|
|
||||||
onClick={() => select(contact)}
|
|
||||||
className="shrink-0 h-11 flex items-center hover:bg-white/10 px-2"
|
|
||||||
>
|
|
||||||
<User.Provider pubkey={contact}>
|
|
||||||
<User.Root className="flex items-center gap-2">
|
|
||||||
<User.Avatar className="shrink-0 size-8 rounded-full" />
|
|
||||||
<User.Name className="text-sm font-medium text-white dark:text-black" />
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
<DropdownMenu.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Portal>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { NsfwIcon } from "@lume/icons";
|
|
||||||
import { cn } from "@lume/utils";
|
|
||||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
|
||||||
import type { Dispatch, SetStateAction } from "react";
|
|
||||||
|
|
||||||
export function NsfwToggle({
|
|
||||||
nsfw,
|
|
||||||
setNsfw,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
nsfw: boolean;
|
|
||||||
setNsfw: Dispatch<SetStateAction<boolean>>;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Tooltip.Provider>
|
|
||||||
<Tooltip.Root delayDuration={150}>
|
|
||||||
<Tooltip.Trigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setNsfw((prev) => !prev)}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center justify-center",
|
|
||||||
className,
|
|
||||||
nsfw ? "bg-blue-500 text-white" : "",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<NsfwIcon className="size-4" />
|
|
||||||
</button>
|
|
||||||
</Tooltip.Trigger>
|
|
||||||
<Tooltip.Portal>
|
|
||||||
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
|
||||||
Mark as sensitive content
|
|
||||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
|
||||||
</Tooltip.Content>
|
|
||||||
</Tooltip.Portal>
|
|
||||||
</Tooltip.Root>
|
|
||||||
</Tooltip.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,40 +1,21 @@
|
|||||||
import { NsfwIcon } from "@lume/icons";
|
import { PowIcon } from "@lume/icons";
|
||||||
import { cn } from "@lume/utils";
|
|
||||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
|
||||||
import type { Dispatch, SetStateAction } from "react";
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
|
||||||
export function PowToggle({
|
export function PowButton({
|
||||||
pow,
|
setDifficulty,
|
||||||
setPow,
|
|
||||||
className,
|
|
||||||
}: {
|
}: {
|
||||||
pow: boolean;
|
setDifficulty: Dispatch<SetStateAction<{ enable: boolean; num: number }>>;
|
||||||
setPow: Dispatch<SetStateAction<boolean>>;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Tooltip.Provider>
|
<button
|
||||||
<Tooltip.Root delayDuration={150}>
|
type="button"
|
||||||
<Tooltip.Trigger asChild>
|
onClick={() =>
|
||||||
<button
|
setDifficulty((prev) => ({ ...prev, enable: !prev.enable }))
|
||||||
type="button"
|
}
|
||||||
onClick={() => setPow((prev) => !prev)}
|
className="inline-flex items-center h-8 gap-2 px-2.5 text-sm rounded-lg text-black/70 dark:text-white/70 w-max hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
className={cn(
|
>
|
||||||
"inline-flex items-center justify-center",
|
<PowIcon className="size-4" />
|
||||||
className,
|
PoW
|
||||||
pow ? "bg-blue-500 text-white" : "",
|
</button>
|
||||||
)}
|
|
||||||
>
|
|
||||||
<NsfwIcon className="size-4" />
|
|
||||||
</button>
|
|
||||||
</Tooltip.Trigger>
|
|
||||||
<Tooltip.Portal>
|
|
||||||
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
|
||||||
Proof of Work
|
|
||||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
|
||||||
</Tooltip.Content>
|
|
||||||
</Tooltip.Portal>
|
|
||||||
</Tooltip.Root>
|
|
||||||
</Tooltip.Provider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { NsfwIcon } from "@lume/icons";
|
||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
|
||||||
|
export function WarningButton({
|
||||||
|
setWarning,
|
||||||
|
}: {
|
||||||
|
setWarning: Dispatch<SetStateAction<{ enable: boolean; reason: string }>>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setWarning((prev) => ({ ...prev, enable: !prev.enable }))}
|
||||||
|
className="inline-flex items-center h-8 gap-2 px-2.5 text-sm rounded-lg text-black/70 dark:text-white/70 w-max hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<NsfwIcon className="size-4" />
|
||||||
|
Mark as sensitive
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,8 @@
|
|||||||
import { ComposeFilledIcon, TrashIcon } from "@lume/icons";
|
import { ComposeFilledIcon } from "@lume/icons";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { MentionNote } from "@lume/ui/src/note/mentions/note";
|
import { cn, insertImage, insertNostrEvent, isImageUrl } from "@lume/utils";
|
||||||
import {
|
|
||||||
cn,
|
|
||||||
insertImage,
|
|
||||||
insertNostrEvent,
|
|
||||||
isImageUrl,
|
|
||||||
sendNativeNotification,
|
|
||||||
} from "@lume/utils";
|
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { nip19 } from "nostr-tools";
|
import { useEffect, useState } from "react";
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { type Descendant, Node, Transforms, createEditor } from "slate";
|
import { type Descendant, Node, Transforms, createEditor } from "slate";
|
||||||
import {
|
import {
|
||||||
Editable,
|
Editable,
|
||||||
@@ -23,58 +14,70 @@ import {
|
|||||||
withReact,
|
withReact,
|
||||||
} from "slate-react";
|
} from "slate-react";
|
||||||
import { MediaButton } from "./-components/media";
|
import { MediaButton } from "./-components/media";
|
||||||
import { NsfwToggle } from "./-components/nsfw";
|
import { LumeEvent, useEvent } from "@lume/system";
|
||||||
import { MentionButton } from "./-components/mention";
|
import { WarningButton } from "./-components/warning";
|
||||||
|
import { MentionNote } from "@/components/note/mentions/note";
|
||||||
|
import { PowButton } from "./-components/pow";
|
||||||
|
import { User } from "@/components/user";
|
||||||
|
import { Note } from "@/components/note";
|
||||||
|
import { nip19 } from "nostr-tools";
|
||||||
|
|
||||||
type EditorSearch = {
|
type EditorSearch = {
|
||||||
reply_to: string;
|
reply_to: string;
|
||||||
quote: boolean;
|
quote: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EditorElement = {
|
||||||
|
type: string;
|
||||||
|
children: Descendant[];
|
||||||
|
eventId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Route = createFileRoute("/editor/")({
|
export const Route = createFileRoute("/editor/")({
|
||||||
validateSearch: (search: Record<string, string>): EditorSearch => {
|
validateSearch: (search: Record<string, string>): EditorSearch => {
|
||||||
return {
|
return {
|
||||||
reply_to: search.reply_to,
|
reply_to: search.reply_to,
|
||||||
quote: search.quote === "true" ?? false,
|
quote: search.quote,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
beforeLoad: async ({ search }) => {
|
beforeLoad: ({ search }) => {
|
||||||
return {
|
let initialValue: EditorElement[];
|
||||||
initialValue: search.quote
|
|
||||||
? [
|
if (search?.quote?.length) {
|
||||||
{
|
const eventId = nip19.noteEncode(search.quote);
|
||||||
type: "paragraph",
|
initialValue = [
|
||||||
children: [{ text: "" }],
|
{
|
||||||
},
|
type: "paragraph",
|
||||||
{
|
children: [{ text: "" }],
|
||||||
type: "event",
|
},
|
||||||
eventId: `nostr:${nip19.noteEncode(search.reply_to)}`,
|
{
|
||||||
children: [{ text: "" }],
|
type: "event",
|
||||||
},
|
eventId: `nostr:${eventId}`,
|
||||||
{
|
children: [{ text: "" }],
|
||||||
type: "paragraph",
|
},
|
||||||
children: [{ text: "" }],
|
];
|
||||||
},
|
} else {
|
||||||
]
|
initialValue = [
|
||||||
: [
|
{
|
||||||
{
|
type: "paragraph",
|
||||||
type: "paragraph",
|
children: [{ text: "" }],
|
||||||
children: [{ text: "" }],
|
},
|
||||||
},
|
];
|
||||||
],
|
}
|
||||||
};
|
|
||||||
|
return { initialValue };
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const { reply_to, quote } = Route.useSearch();
|
const { reply_to } = Route.useSearch();
|
||||||
const { ark, initialValue } = Route.useRouteContext();
|
const { initialValue } = Route.useRouteContext();
|
||||||
|
|
||||||
const [t] = useTranslation();
|
const [editorValue, setEditorValue] = useState<EditorElement[]>(null);
|
||||||
const [editorValue, setEditorValue] = useState(initialValue);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [nsfw, setNsfw] = useState(false);
|
const [warning, setWarning] = useState({ enable: false, reason: "" });
|
||||||
|
const [difficulty, setDifficulty] = useState({ enable: false, num: 21 });
|
||||||
const [editor] = useState(() =>
|
const [editor] = useState(() =>
|
||||||
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
|
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
|
||||||
);
|
);
|
||||||
@@ -115,60 +118,42 @@ function Screen() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const content = serialize(editor.children);
|
const content = serialize(editor.children);
|
||||||
const eventId = await ark.publish(content, reply_to, quote);
|
const eventId = await LumeEvent.publish(
|
||||||
|
content,
|
||||||
|
warning.enable && warning.reason.length ? warning.reason : null,
|
||||||
|
difficulty.enable && difficulty.num > 0 ? difficulty.num : null,
|
||||||
|
reply_to,
|
||||||
|
);
|
||||||
|
|
||||||
if (eventId) {
|
if (eventId) {
|
||||||
await sendNativeNotification(
|
// stop loading
|
||||||
"Your note has been published successfully.",
|
setLoading(false);
|
||||||
"Lume",
|
// reset form
|
||||||
);
|
reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
// stop loading
|
|
||||||
setLoading(false);
|
|
||||||
|
|
||||||
// reset form
|
|
||||||
reset();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
await sendNativeNotification(String(e));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEditorValue(initialValue);
|
||||||
|
}, [initialValue]);
|
||||||
|
|
||||||
|
if (!editorValue) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full">
|
<div className="flex flex-col w-full h-full">
|
||||||
<Slate editor={editor} initialValue={editorValue}>
|
<Slate editor={editor} initialValue={editorValue}>
|
||||||
<div
|
<div data-tauri-drag-region className="h-9 shrink-0" />
|
||||||
data-tauri-drag-region
|
<div className="flex flex-col flex-1 overflow-y-auto">
|
||||||
className="flex h-14 w-full shrink-0 items-center justify-end gap-2 px-2 border-b border-black/10 dark:border-white/10"
|
{reply_to?.length ? (
|
||||||
>
|
<div className="flex items-center gap-3 px-2.5 pb-3 border-b border-black/5 dark:border-white/5">
|
||||||
<NsfwToggle
|
<div className="text-sm font-semibold shrink-0">Reply to:</div>
|
||||||
nsfw={nsfw}
|
<ChildNote id={reply_to} />
|
||||||
setNsfw={setNsfw}
|
|
||||||
className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
|
||||||
/>
|
|
||||||
<MentionButton className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" />
|
|
||||||
<MediaButton className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => publish()}
|
|
||||||
className="inline-flex h-8 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<Spinner className="size-4" />
|
|
||||||
) : (
|
|
||||||
<ComposeFilledIcon className="size-4" />
|
|
||||||
)}
|
|
||||||
{t("global.post")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-full w-full flex-1 flex-col">
|
|
||||||
{reply_to && !quote ? (
|
|
||||||
<div className="px-4 py-2">
|
|
||||||
<MentionNote eventId={reply_to} />
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="overflow-y-auto p-4">
|
<div className="px-4 py-4 overflow-y-auto">
|
||||||
<Editable
|
<Editable
|
||||||
key={JSON.stringify(editorValue)}
|
key={JSON.stringify(editorValue)}
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
@@ -177,17 +162,103 @@ function Screen() {
|
|||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
renderElement={(props) => <Element {...props} />}
|
renderElement={(props) => <Element {...props} />}
|
||||||
placeholder={
|
placeholder={
|
||||||
reply_to ? "Type your reply..." : t("editor.placeholder")
|
reply_to ? "Type your reply..." : "What're you up to?"
|
||||||
}
|
}
|
||||||
className="focus:outline-none"
|
className="focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{warning.enable ? (
|
||||||
|
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5">
|
||||||
|
<span className="text-sm shrink-0 text-black/50 dark:text-white/50">
|
||||||
|
Reason:
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="NSFW..."
|
||||||
|
value={warning.reason}
|
||||||
|
onChange={(e) =>
|
||||||
|
setWarning((prev) => ({ ...prev, reason: e.target.value }))
|
||||||
|
}
|
||||||
|
className="flex-1 text-sm bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-black/50 dark:placeholder:text-white/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{difficulty.enable ? (
|
||||||
|
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5">
|
||||||
|
<span className="text-sm shrink-0 text-black/50 dark:text-white/50">
|
||||||
|
Difficulty:
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]"
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (!/[0-9]/.test(event.key)) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="21"
|
||||||
|
defaultValue={difficulty.num}
|
||||||
|
onChange={(e) =>
|
||||||
|
setWarning((prev) => ({ ...prev, num: Number(e.target.value) }))
|
||||||
|
}
|
||||||
|
className="flex-1 text-sm bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-black/50 dark:placeholder:text-white/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
data-tauri-drag-region
|
||||||
|
className="flex items-center w-full h-16 gap-4 px-4 border-t divide-x divide-black/5 dark:divide-white/5 shrink-0 border-black/5 dark:border-white/5"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => publish()}
|
||||||
|
className="inline-flex items-center justify-center h-8 gap-1 px-2.5 text-sm font-medium rounded-lg bg-black/10 w-max hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
) : (
|
||||||
|
<ComposeFilledIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
Publish
|
||||||
|
</button>
|
||||||
|
<div className="inline-flex items-center flex-1 gap-2 pl-4">
|
||||||
|
<MediaButton />
|
||||||
|
<WarningButton setWarning={setWarning} />
|
||||||
|
<PowButton setDifficulty={setDifficulty} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Slate>
|
</Slate>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ChildNote({ id }: { id: string }) {
|
||||||
|
const { isLoading, isError, data } = useEvent(id);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Spinner className="size-5" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return <div>Event not found with your current relay set.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Note.Provider event={data}>
|
||||||
|
<Note.Root className="flex items-center gap-2">
|
||||||
|
<User.Provider pubkey={data.pubkey}>
|
||||||
|
<User.Root className="shrink-0">
|
||||||
|
<User.Avatar className="rounded-full size-8 shrink-0" />
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
<div className="content-break line-clamp-1">{data.content}</div>
|
||||||
|
</Note.Root>
|
||||||
|
</Note.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const withNostrEvent = (editor: ReactEditor) => {
|
const withNostrEvent = (editor: ReactEditor) => {
|
||||||
const { insertData, isVoid } = editor;
|
const { insertData, isVoid } = editor;
|
||||||
|
|
||||||
@@ -199,7 +270,7 @@ const withNostrEvent = (editor: ReactEditor) => {
|
|||||||
editor.insertData = (data) => {
|
editor.insertData = (data) => {
|
||||||
const text = data.getData("text/plain");
|
const text = data.getData("text/plain");
|
||||||
|
|
||||||
if (text.startsWith("nevent1") || text.startsWith("note1")) {
|
if (text.startsWith("nevent") || text.startsWith("note")) {
|
||||||
insertNostrEvent(editor, text);
|
insertNostrEvent(editor, text);
|
||||||
} else {
|
} else {
|
||||||
insertData(data);
|
insertData(data);
|
||||||
@@ -251,35 +322,25 @@ const withImages = (editor: ReactEditor) => {
|
|||||||
return editor;
|
return editor;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Image = ({ attributes, children, element }) => {
|
const Image = ({ attributes, element, children }) => {
|
||||||
const editor = useSlateStatic();
|
const editor = useSlateStatic();
|
||||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
|
||||||
|
|
||||||
const selected = useSelected();
|
const selected = useSelected();
|
||||||
const focused = useFocused();
|
const focused = useFocused();
|
||||||
|
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div {...attributes}>
|
<div {...attributes}>
|
||||||
{children}
|
{children}
|
||||||
<div contentEditable={false} className="relative my-2">
|
<img
|
||||||
<img
|
src={element.url}
|
||||||
src={element.url}
|
alt={element.url}
|
||||||
alt={element.url}
|
className={cn(
|
||||||
className={cn(
|
"my-2 h-auto w-1/2 rounded-lg object-cover ring-2 outline outline-1 -outline-offset-1 outline-black/15",
|
||||||
"h-auto w-full rounded-lg border border-neutral-100 object-cover ring-2 dark:border-neutral-900",
|
selected && focused ? "ring-blue-500" : "ring-transparent",
|
||||||
selected && focused ? "ring-blue-500" : "ring-transparent",
|
)}
|
||||||
)}
|
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||||
contentEditable={false}
|
onKeyDown={() => Transforms.removeNodes(editor, { at: path })}
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
contentEditable={false}
|
|
||||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
|
||||||
className="absolute right-2 top-2 inline-flex size-8 items-center justify-center rounded-lg bg-red-500 text-white hover:bg-red-600"
|
|
||||||
>
|
|
||||||
<TrashIcon className="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -294,7 +355,7 @@ const Mention = ({ attributes, element }) => {
|
|||||||
type="button"
|
type="button"
|
||||||
contentEditable={false}
|
contentEditable={false}
|
||||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||||
className="inline-block align-baseline text-blue-500 hover:text-blue-600"
|
className="inline-block text-blue-500 align-baseline hover:text-blue-600"
|
||||||
>{`@${element.name}`}</span>
|
>{`@${element.name}`}</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -306,16 +367,13 @@ const Event = ({ attributes, element, children }) => {
|
|||||||
return (
|
return (
|
||||||
<div {...attributes}>
|
<div {...attributes}>
|
||||||
{children}
|
{children}
|
||||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> */}
|
|
||||||
<div
|
<div
|
||||||
contentEditable={false}
|
contentEditable={false}
|
||||||
|
className="relative my-2 user-select-none"
|
||||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||||
className="user-select-none relative my-2"
|
onKeyDown={() => Transforms.removeNodes(editor, { at: path })}
|
||||||
>
|
>
|
||||||
<MentionNote
|
<MentionNote eventId={element.eventId} openable={false} />
|
||||||
eventId={element.eventId.replace("nostr:", "")}
|
|
||||||
openable={false}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,71 +1,82 @@
|
|||||||
import { useEvent } from "@lume/ark";
|
import { Note } from "@/components/note";
|
||||||
import type { Event } from "@lume/types";
|
import { type LumeEvent, NostrQuery } from "@lume/system";
|
||||||
import { Box, Container, Note, Spinner } from "@lume/ui";
|
import { Box, Container, Spinner } from "@lume/ui";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { WindowVirtualizer } from "virtua";
|
import { WindowVirtualizer } from "virtua";
|
||||||
import { ReplyList } from "./-components/replyList";
|
import { Reply } from "./-components/reply";
|
||||||
|
|
||||||
export const Route = createFileRoute("/events/$eventId")({
|
export const Route = createFileRoute("/events/$eventId")({
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: async () => {
|
||||||
const ark = context.ark;
|
const settings = await NostrQuery.getUserSettings();
|
||||||
const settings = await ark.get_settings();
|
|
||||||
|
|
||||||
return { settings };
|
return { settings };
|
||||||
},
|
},
|
||||||
|
loader: async ({ params }) => {
|
||||||
|
const event = await NostrQuery.getEvent(params.eventId);
|
||||||
|
return event;
|
||||||
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const { eventId } = Route.useParams();
|
const event = Route.useLoaderData();
|
||||||
const { isLoading, isError, data } = useEvent(eventId);
|
|
||||||
|
|
||||||
if (isLoading) {
|
const [reload, setReload] = useState(false);
|
||||||
return (
|
const [replies, setReplies] = useState<LumeEvent[]>(null);
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<Spinner className="size-5" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError) {
|
useEffect(() => {
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
let mounted = true;
|
||||||
<p>Not found.</p>
|
|
||||||
</div>;
|
if (event) {
|
||||||
}
|
event.getAllReplies().then((data) => {
|
||||||
|
if (mounted) setReplies(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, [event]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container withDrag>
|
<Container withDrag>
|
||||||
<Box className="scrollbar-none">
|
<Box className="scrollbar-none">
|
||||||
<WindowVirtualizer>
|
<WindowVirtualizer>
|
||||||
<MainNote data={data} />
|
<Note.Provider event={event}>
|
||||||
{data ? (
|
<Note.Root>
|
||||||
<ReplyList eventId={eventId} />
|
<div className="flex items-center justify-between px-3 h-14">
|
||||||
) : (
|
<Note.User />
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
<Note.Menu />
|
||||||
<Spinner className="size-5" />
|
</div>
|
||||||
|
<Note.ContentLarge className="px-3" />
|
||||||
|
<div className="flex items-center justify-end gap-2 px-3 mt-4 h-11">
|
||||||
|
<Note.Reply large />
|
||||||
|
<Note.Repost large />
|
||||||
|
<Note.Zap large />
|
||||||
|
</div>
|
||||||
|
</Note.Root>
|
||||||
|
</Note.Provider>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center px-3 text-sm font-semibold border-t h-11 text-neutral-700 dark:text-neutral-300 border-neutral-100 dark:border-neutral-900">
|
||||||
|
Replies ({replies?.length ?? 0})
|
||||||
</div>
|
</div>
|
||||||
)}
|
{!replies ? (
|
||||||
|
<Spinner />
|
||||||
|
) : !replies.length ? (
|
||||||
|
<div className="flex items-center justify-center w-full">
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 py-6">
|
||||||
|
<h3 className="text-3xl">👋</h3>
|
||||||
|
<p className="leading-none text-neutral-600 dark:text-neutral-400">
|
||||||
|
Be the first to Reply!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
replies.map((event) => <Reply key={event.id} event={event} />)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</WindowVirtualizer>
|
</WindowVirtualizer>
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MainNote({ data }: { data: Event }) {
|
|
||||||
return (
|
|
||||||
<Note.Provider event={data}>
|
|
||||||
<Note.Root>
|
|
||||||
<div className="px-3 h-14 flex items-center justify-between">
|
|
||||||
<Note.User />
|
|
||||||
<Note.Menu />
|
|
||||||
</div>
|
|
||||||
<Note.ContentLarge className="px-3" />
|
|
||||||
<div className="mt-4 h-11 gap-2 flex items-center justify-end px-3">
|
|
||||||
<Note.Reply large />
|
|
||||||
<Note.Repost large />
|
|
||||||
<Note.Zap large />
|
|
||||||
</div>
|
|
||||||
</Note.Root>
|
|
||||||
</Note.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import type { EventWithReplies } from "@lume/types";
|
import { Note } from "@/components/note";
|
||||||
import { Note } from "@lume/ui";
|
import type { LumeEvent } from "@lume/system";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { SubReply } from "./subReply";
|
import { SubReply } from "./subReply";
|
||||||
|
|
||||||
export function Reply({ event }: { event: EventWithReplies }) {
|
export function Reply({ event }: { event: LumeEvent }) {
|
||||||
return (
|
return (
|
||||||
<Note.Provider event={event}>
|
<Note.Provider event={event}>
|
||||||
<Note.Root className="border-t border-neutral-100 dark:border-neutral-900">
|
<Note.Root className="border-t border-neutral-100 dark:border-neutral-900">
|
||||||
<div className="px-3 h-14 flex items-center justify-between">
|
<div className="flex items-center justify-between px-3 h-14">
|
||||||
<Note.User />
|
<Note.User />
|
||||||
<Note.Menu />
|
<Note.Menu />
|
||||||
</div>
|
</div>
|
||||||
<Note.ContentLarge className="px-3" />
|
<Note.ContentLarge className="px-3" />
|
||||||
<div className="mt-3 flex items-center gap-4 px-3 h-14">
|
<div className="flex items-center gap-4 px-3 mt-3 h-14">
|
||||||
<Note.Reply />
|
<Note.Reply />
|
||||||
<Note.Repost />
|
<Note.Repost />
|
||||||
<Note.Zap />
|
<Note.Zap />
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { EventWithReplies } from "@lume/types";
|
import type { EventWithReplies } from "@lume/types";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Reply } from "./reply";
|
import { Reply } from "./reply";
|
||||||
|
import { LumeEvent } from "@lume/system";
|
||||||
|
|
||||||
export function ReplyList({
|
export function ReplyList({
|
||||||
eventId,
|
eventId,
|
||||||
@@ -13,13 +13,12 @@ export function ReplyList({
|
|||||||
eventId: string;
|
eventId: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const [t] = useTranslation();
|
const [t] = useTranslation();
|
||||||
const [data, setData] = useState<null | EventWithReplies[]>(null);
|
const [data, setData] = useState<null | EventWithReplies[]>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function getReplies() {
|
async function getReplies() {
|
||||||
const events = await ark.get_event_thread(eventId);
|
const events = await LumeEvent.getReplies(eventId);
|
||||||
setData(events);
|
setData(events);
|
||||||
}
|
}
|
||||||
getReplies();
|
getReplies();
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import type { Event } from "@lume/types";
|
import type { NostrEvent } from "@lume/types";
|
||||||
import { Note } from "@lume/ui";
|
import { Note } from "@/components/note";
|
||||||
|
|
||||||
export function SubReply({ event }: { event: Event; rootEventId?: string }) {
|
export function SubReply({
|
||||||
|
event,
|
||||||
|
}: {
|
||||||
|
event: NostrEvent;
|
||||||
|
rootEventId?: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Note.Provider event={event}>
|
<Note.Provider event={event}>
|
||||||
<Note.Root>
|
<Note.Root>
|
||||||
|
|||||||
@@ -1,155 +0,0 @@
|
|||||||
import { RepostNote } from "@/components/repost";
|
|
||||||
import { TextNote } from "@/components/text";
|
|
||||||
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
|
||||||
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types";
|
|
||||||
import { Spinner } from "@lume/ui";
|
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
||||||
import { Link, createFileRoute, redirect } from "@tanstack/react-router";
|
|
||||||
import { Virtualizer } from "virtua";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/foryou")({
|
|
||||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
|
||||||
return {
|
|
||||||
account: search.account,
|
|
||||||
label: search.label,
|
|
||||||
name: search.name,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
beforeLoad: async ({ search, context }) => {
|
|
||||||
const ark = context.ark;
|
|
||||||
const interests = await ark.get_interest();
|
|
||||||
const settings = await ark.get_settings();
|
|
||||||
|
|
||||||
if (!interests) {
|
|
||||||
throw redirect({
|
|
||||||
to: "/interests",
|
|
||||||
search: {
|
|
||||||
...search,
|
|
||||||
redirect: "/foryou",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
interests,
|
|
||||||
settings,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function Screen() {
|
|
||||||
const { name, account } = Route.useSearch();
|
|
||||||
const { ark, interests } = Route.useRouteContext();
|
|
||||||
const {
|
|
||||||
data,
|
|
||||||
isLoading,
|
|
||||||
isFetching,
|
|
||||||
isFetchingNextPage,
|
|
||||||
hasNextPage,
|
|
||||||
fetchNextPage,
|
|
||||||
} = useInfiniteQuery({
|
|
||||||
queryKey: [name, account],
|
|
||||||
initialPageParam: 0,
|
|
||||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
|
||||||
const events = await ark.get_events_from_interests(
|
|
||||||
interests.hashtags,
|
|
||||||
20,
|
|
||||||
pageParam,
|
|
||||||
);
|
|
||||||
return events;
|
|
||||||
},
|
|
||||||
getNextPageParam: (lastPage) => {
|
|
||||||
const lastEvent = lastPage?.at(-1);
|
|
||||||
return lastEvent ? lastEvent.created_at - 1 : null;
|
|
||||||
},
|
|
||||||
select: (data) => data?.pages.flatMap((page) => page),
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderItem = (event: Event) => {
|
|
||||||
if (!event) return;
|
|
||||||
switch (event.kind) {
|
|
||||||
case Kind.Repost:
|
|
||||||
return <RepostNote key={event.id} event={event} />;
|
|
||||||
default:
|
|
||||||
return <TextNote key={event.id} event={event} />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div 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-neutral-100 px-3 font-medium hover:bg-neutral-50 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
|
||||||
>
|
|
||||||
{isFetchingNextPage ? (
|
|
||||||
<Spinner className="size-5" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,11 +2,14 @@ import { Conversation } from "@/components/conversation";
|
|||||||
import { Quote } from "@/components/quote";
|
import { Quote } from "@/components/quote";
|
||||||
import { RepostNote } from "@/components/repost";
|
import { RepostNote } from "@/components/repost";
|
||||||
import { TextNote } from "@/components/text";
|
import { TextNote } from "@/components/text";
|
||||||
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
import { ArrowRightCircleIcon } from "@lume/icons";
|
||||||
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types";
|
import { type LumeEvent, NostrQuery } from "@lume/system";
|
||||||
|
import { type ColumnRouteSearch, Kind } from "@lume/types";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
|
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useCallback, useRef } from "react";
|
||||||
import { Virtualizer } from "virtua";
|
import { Virtualizer } from "virtua";
|
||||||
|
|
||||||
export const Route = createFileRoute("/global")({
|
export const Route = createFileRoute("/global")({
|
||||||
@@ -17,18 +20,15 @@ export const Route = createFileRoute("/global")({
|
|||||||
name: search.name,
|
name: search.name,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: async () => {
|
||||||
const ark = context.ark;
|
const settings = await NostrQuery.getUserSettings();
|
||||||
const settings = await ark.get_settings();
|
|
||||||
|
|
||||||
return { settings };
|
return { settings };
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function Screen() {
|
export function Screen() {
|
||||||
const { account } = Route.useSearch();
|
const { label, account } = Route.useSearch();
|
||||||
const { ark } = Route.useRouteContext();
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -37,117 +37,99 @@ export function Screen() {
|
|||||||
hasNextPage,
|
hasNextPage,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
} = useInfiniteQuery({
|
} = useInfiniteQuery({
|
||||||
queryKey: ["global", account],
|
queryKey: [label, account],
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||||
const events = await ark.get_events(20, pageParam, undefined, true);
|
const events = await NostrQuery.getGlobalEvents(pageParam);
|
||||||
return events;
|
return events;
|
||||||
},
|
},
|
||||||
getNextPageParam: (lastPage) => {
|
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
|
||||||
const lastEvent = lastPage?.at(-1);
|
select: (data) => data?.pages.flat(),
|
||||||
return lastEvent ? lastEvent.created_at - 1 : null;
|
|
||||||
},
|
|
||||||
select: (data) => data?.pages.flatMap((page) => page),
|
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderItem = (event: Event) => {
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
if (!event) return;
|
|
||||||
switch (event.kind) {
|
|
||||||
case Kind.Repost:
|
|
||||||
return <RepostNote key={event.id} event={event} />;
|
|
||||||
default: {
|
|
||||||
const isConversation =
|
|
||||||
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
|
|
||||||
.length > 0;
|
|
||||||
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
|
|
||||||
|
|
||||||
if (isConversation) {
|
const renderItem = useCallback(
|
||||||
return <Conversation key={event.id} event={event} className="mb-3" />;
|
(event: LumeEvent) => {
|
||||||
|
if (!event) return;
|
||||||
|
switch (event.kind) {
|
||||||
|
case Kind.Repost:
|
||||||
|
return <RepostNote key={event.id} event={event} className="mb-3" />;
|
||||||
|
default: {
|
||||||
|
if (event.isConversation) {
|
||||||
|
return (
|
||||||
|
<Conversation key={event.id} className="mb-3" event={event} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (event.isQuote) {
|
||||||
|
return <Quote key={event.id} event={event} className="mb-3" />;
|
||||||
|
}
|
||||||
|
return <TextNote key={event.id} event={event} className="mb-3" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isQuote) {
|
|
||||||
return <Quote key={event.id} event={event} className="mb-3" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
};
|
[data],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
|
<ScrollArea.Root
|
||||||
{isFetching && !isLoading && !isFetchingNextPage ? (
|
type={"scroll"}
|
||||||
<div className="w-full h-11 flex items-center justify-center">
|
scrollHideDelay={300}
|
||||||
<div className="flex items-center justify-center gap-2">
|
className="overflow-hidden size-full"
|
||||||
<Spinner className="size-5" />
|
>
|
||||||
<span className="text-sm font-medium">Fetching new notes...</span>
|
<ScrollArea.Viewport ref={ref} className="h-full px-3 pb-3">
|
||||||
</div>
|
<Virtualizer scrollRef={ref}>
|
||||||
</div>
|
{isFetching && !isLoading && !isFetchingNextPage ? (
|
||||||
) : null}
|
<div className="flex items-center justify-center w-full mb-3 h-11 bg-black/10 dark:bg-white/10 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
|
||||||
{isLoading ? (
|
<div className="flex items-center justify-center gap-2">
|
||||||
<div className="flex h-16 w-full items-center justify-center gap-2">
|
<Spinner className="size-5" />
|
||||||
<Spinner className="size-5" />
|
<span className="text-sm font-medium">
|
||||||
<span className="text-sm font-medium">Loading...</span>
|
Fetching new notes...
|
||||||
</div>
|
</span>
|
||||||
) : !data.length ? (
|
</div>
|
||||||
<Empty />
|
</div>
|
||||||
) : (
|
) : null}
|
||||||
<Virtualizer overscan={3}>
|
{isLoading ? (
|
||||||
{data.map((item) => renderItem(item))}
|
<div className="flex items-center justify-center w-full h-16 gap-2">
|
||||||
</Virtualizer>
|
|
||||||
)}
|
|
||||||
{data?.length && hasNextPage ? (
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => fetchNextPage()}
|
|
||||||
disabled={isFetchingNextPage || isLoading}
|
|
||||||
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-black/5 px-3 font-medium hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
|
||||||
>
|
|
||||||
{isFetchingNextPage ? (
|
|
||||||
<Spinner className="size-5" />
|
<Spinner className="size-5" />
|
||||||
) : (
|
<span className="text-sm font-medium">Loading...</span>
|
||||||
<>
|
</div>
|
||||||
<ArrowRightCircleIcon className="size-5" />
|
) : !data.length ? (
|
||||||
Load more
|
<div className="flex items-center justify-center">
|
||||||
</>
|
Yo. You're catching up on all the things happening around you.
|
||||||
)}
|
</div>
|
||||||
</button>
|
) : (
|
||||||
</div>
|
data.map((item) => renderItem(item))
|
||||||
) : null}
|
)}
|
||||||
</div>
|
{data?.length && hasNextPage ? (
|
||||||
);
|
<div>
|
||||||
}
|
<button
|
||||||
|
type="button"
|
||||||
function Empty() {
|
onClick={() => fetchNextPage()}
|
||||||
return (
|
disabled={isFetchingNextPage || isLoading}
|
||||||
<div className="flex flex-col py-10 gap-10">
|
className="inline-flex items-center justify-center w-full gap-2 px-3 font-medium h-9 rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||||
<div className="text-center flex flex-col items-center justify-center">
|
>
|
||||||
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8">
|
{isFetchingNextPage ? (
|
||||||
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" />
|
<Spinner className="size-5" />
|
||||||
</div>
|
) : (
|
||||||
<p className="text-lg font-medium">Your newsfeed is empty</p>
|
<>
|
||||||
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
<ArrowRightCircleIcon className="size-5" />
|
||||||
Here are few suggestions to get started.
|
Load more
|
||||||
</p>
|
</>
|
||||||
</div>
|
)}
|
||||||
<div className="flex flex-col px-3 gap-2">
|
</button>
|
||||||
<Link
|
</div>
|
||||||
to="/trending/notes"
|
) : null}
|
||||||
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
|
</Virtualizer>
|
||||||
>
|
</ScrollArea.Viewport>
|
||||||
<ArrowRightIcon className="size-5" />
|
<ScrollArea.Scrollbar
|
||||||
Show trending notes
|
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
||||||
</Link>
|
orientation="vertical"
|
||||||
<Link
|
>
|
||||||
to="/trending/users"
|
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
||||||
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
|
</ScrollArea.Scrollbar>
|
||||||
>
|
<ScrollArea.Corner className="bg-transparent" />
|
||||||
<ArrowRightIcon className="size-5" />
|
</ScrollArea.Root>
|
||||||
Discover trending users
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
|
import { Conversation } from "@/components/conversation";
|
||||||
|
import { Quote } from "@/components/quote";
|
||||||
import { RepostNote } from "@/components/repost";
|
import { RepostNote } from "@/components/repost";
|
||||||
import { TextNote } from "@/components/text";
|
import { TextNote } from "@/components/text";
|
||||||
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
import { ArrowRightCircleIcon } from "@lume/icons";
|
||||||
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types";
|
import { type LumeEvent, NostrQuery } from "@lume/system";
|
||||||
|
import { type ColumnRouteSearch, Kind } from "@lume/types";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
|
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { Link, createFileRoute, redirect } from "@tanstack/react-router";
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
|
import { useCallback, useRef } from "react";
|
||||||
import { Virtualizer } from "virtua";
|
import { Virtualizer } from "virtua";
|
||||||
|
|
||||||
export const Route = createFileRoute("/group")({
|
export const Route = createFileRoute("/group")({
|
||||||
@@ -15,14 +20,12 @@ export const Route = createFileRoute("/group")({
|
|||||||
name: search.name,
|
name: search.name,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
beforeLoad: async ({ search, context }) => {
|
beforeLoad: async ({ search }) => {
|
||||||
const ark = context.ark;
|
const key = `lume:group:${search.label}`;
|
||||||
const groups = (await ark.get_nstore(
|
const groups: string[] = await NostrQuery.getNstore(key);
|
||||||
`lume_group_${search.label}`,
|
const settings = await NostrQuery.getUserSettings();
|
||||||
)) as string[];
|
|
||||||
const settings = await ark.get_settings();
|
|
||||||
|
|
||||||
if (!groups) {
|
if (!groups?.length) {
|
||||||
throw redirect({
|
throw redirect({
|
||||||
to: "/create-group",
|
to: "/create-group",
|
||||||
search: {
|
search: {
|
||||||
@@ -32,17 +35,14 @@ export const Route = createFileRoute("/group")({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { groups, settings };
|
||||||
groups,
|
|
||||||
settings,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function Screen() {
|
export function Screen() {
|
||||||
const { name, account } = Route.useSearch();
|
const { label, account } = Route.useSearch();
|
||||||
const { ark, groups } = Route.useRouteContext();
|
const { groups } = Route.useRouteContext();
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -51,104 +51,99 @@ export function Screen() {
|
|||||||
hasNextPage,
|
hasNextPage,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
} = useInfiniteQuery({
|
} = useInfiniteQuery({
|
||||||
queryKey: [name, account],
|
queryKey: [label, account],
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||||
const events = await ark.get_events(20, pageParam, groups);
|
const events = await NostrQuery.getGroupEvents(groups, pageParam);
|
||||||
return events;
|
return events;
|
||||||
},
|
},
|
||||||
getNextPageParam: (lastPage) => {
|
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
|
||||||
const lastEvent = lastPage?.at(-1);
|
select: (data) => data?.pages.flat(),
|
||||||
return lastEvent ? lastEvent.created_at - 1 : null;
|
|
||||||
},
|
|
||||||
select: (data) =>
|
|
||||||
data?.pages.flatMap((page) => page.filter((ev) => ev.kind === Kind.Text)),
|
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderItem = (event: Event) => {
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
if (!event) return;
|
|
||||||
switch (event.kind) {
|
const renderItem = useCallback(
|
||||||
case Kind.Repost:
|
(event: LumeEvent) => {
|
||||||
return <RepostNote key={event.id} event={event} />;
|
if (!event) return;
|
||||||
default:
|
switch (event.kind) {
|
||||||
return <TextNote key={event.id} event={event} />;
|
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 (
|
return (
|
||||||
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
|
<ScrollArea.Root
|
||||||
{isFetching && !isLoading && !isFetchingNextPage ? (
|
type={"scroll"}
|
||||||
<div className="w-full h-11 flex items-center justify-center">
|
scrollHideDelay={300}
|
||||||
<div className="flex items-center justify-center gap-2">
|
className="overflow-hidden size-full"
|
||||||
<Spinner className="size-5" />
|
>
|
||||||
<span className="text-sm font-medium">Fetching new notes...</span>
|
<ScrollArea.Viewport ref={ref} className="h-full px-3 pb-3">
|
||||||
</div>
|
<Virtualizer scrollRef={ref}>
|
||||||
</div>
|
{isFetching && !isLoading && !isFetchingNextPage ? (
|
||||||
) : null}
|
<div className="flex items-center justify-center w-full mb-3 h-11 bg-black/10 dark:bg-white/10 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
|
||||||
{isLoading ? (
|
<div className="flex items-center justify-center gap-2">
|
||||||
<div className="flex h-16 w-full items-center justify-center gap-2">
|
<Spinner className="size-5" />
|
||||||
<Spinner className="size-5" />
|
<span className="text-sm font-medium">
|
||||||
<span className="text-sm font-medium">Loading...</span>
|
Fetching new notes...
|
||||||
</div>
|
</span>
|
||||||
) : !data.length ? (
|
</div>
|
||||||
<Empty />
|
</div>
|
||||||
) : (
|
) : null}
|
||||||
<Virtualizer overscan={3}>
|
{isLoading ? (
|
||||||
{data.map((item) => renderItem(item))}
|
<div className="flex items-center justify-center w-full h-16 gap-2">
|
||||||
</Virtualizer>
|
|
||||||
)}
|
|
||||||
{data?.length && hasNextPage ? (
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => fetchNextPage()}
|
|
||||||
disabled={isFetchingNextPage || isLoading}
|
|
||||||
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-neutral-100 px-3 font-medium hover:bg-neutral-50 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
|
||||||
>
|
|
||||||
{isFetchingNextPage ? (
|
|
||||||
<Spinner className="size-5" />
|
<Spinner className="size-5" />
|
||||||
) : (
|
<span className="text-sm font-medium">Loading...</span>
|
||||||
<>
|
</div>
|
||||||
<ArrowRightCircleIcon className="size-5" />
|
) : !data.length ? (
|
||||||
Load more
|
<div className="flex items-center justify-center">
|
||||||
</>
|
Yo. You're catching up on all the things happening around you.
|
||||||
)}
|
</div>
|
||||||
</button>
|
) : (
|
||||||
</div>
|
data.map((item) => renderItem(item))
|
||||||
) : null}
|
)}
|
||||||
</div>
|
{data?.length && hasNextPage ? (
|
||||||
);
|
<div>
|
||||||
}
|
<button
|
||||||
|
type="button"
|
||||||
function Empty() {
|
onClick={() => fetchNextPage()}
|
||||||
return (
|
disabled={isFetchingNextPage || isLoading}
|
||||||
<div className="flex flex-col py-10 gap-10">
|
className="inline-flex items-center justify-center w-full gap-2 px-3 font-medium h-9 rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||||
<div className="text-center flex flex-col items-center justify-center">
|
>
|
||||||
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8">
|
{isFetchingNextPage ? (
|
||||||
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" />
|
<Spinner className="size-5" />
|
||||||
</div>
|
) : (
|
||||||
<p className="text-lg font-medium">Your newsfeed is empty</p>
|
<>
|
||||||
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
<ArrowRightCircleIcon className="size-5" />
|
||||||
Here are few suggestions to get started.
|
Load more
|
||||||
</p>
|
</>
|
||||||
</div>
|
)}
|
||||||
<div className="flex flex-col px-3 gap-2">
|
</button>
|
||||||
<Link
|
</div>
|
||||||
to="/trending/notes"
|
) : null}
|
||||||
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
|
</Virtualizer>
|
||||||
>
|
</ScrollArea.Viewport>
|
||||||
<ArrowRightIcon className="size-5" />
|
<ScrollArea.Scrollbar
|
||||||
Show trending notes
|
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
||||||
</Link>
|
orientation="vertical"
|
||||||
<Link
|
>
|
||||||
to="/trending/users"
|
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
||||||
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
|
</ScrollArea.Scrollbar>
|
||||||
>
|
<ScrollArea.Corner className="bg-transparent" />
|
||||||
<ArrowRightIcon className="size-5" />
|
</ScrollArea.Root>
|
||||||
Discover trending users
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
import { PlusIcon } from "@lume/icons";
|
import { User } from "@/components/user";
|
||||||
import { Spinner, User } from "@lume/ui";
|
import { PlusIcon, RelayIcon } from "@lume/icons";
|
||||||
import { checkForAppUpdates } from "@lume/utils";
|
import { NostrAccount } from "@lume/system";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { checkForAppUpdates, displayNpub } from "@lume/utils";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { message } from "@tauri-apps/plugin-dialog";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({
|
export const Route = createFileRoute("/")({
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: async () => {
|
||||||
// check for app updates
|
// Check for app updates
|
||||||
|
// TODO: move this function to rust
|
||||||
await checkForAppUpdates(true);
|
await checkForAppUpdates(true);
|
||||||
|
|
||||||
const ark = context.ark;
|
// Get all accounts
|
||||||
const accounts = await ark.get_all_accounts();
|
// TODO: use emit & listen
|
||||||
|
const accounts = await NostrAccount.getAccounts();
|
||||||
|
|
||||||
if (!accounts.length) {
|
if (accounts.length < 1) {
|
||||||
throw redirect({
|
throw redirect({
|
||||||
to: "/landing",
|
to: "/landing",
|
||||||
replace: true,
|
replace: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run notification service
|
|
||||||
await invoke("run_notification", { accounts });
|
|
||||||
|
|
||||||
return { accounts };
|
return { accounts };
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
@@ -32,16 +32,17 @@ export const Route = createFileRoute("/")({
|
|||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const navigate = Route.useNavigate();
|
const navigate = Route.useNavigate();
|
||||||
const { ark, accounts } = Route.useRouteContext();
|
const context = Route.useRouteContext();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState({ npub: "", status: false });
|
||||||
|
|
||||||
const select = async (npub: string) => {
|
const select = async (npub: string) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading({ npub, status: true });
|
||||||
|
|
||||||
const loadAccount = await ark.load_selected_account(npub);
|
const status = await NostrAccount.loadAccount(npub);
|
||||||
if (loadAccount) {
|
|
||||||
|
if (status) {
|
||||||
return navigate({
|
return navigate({
|
||||||
to: "/$account/home",
|
to: "/$account/home",
|
||||||
params: { account: npub },
|
params: { account: npub },
|
||||||
@@ -49,8 +50,11 @@ function Screen() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLoading(false);
|
setLoading({ npub: "", status: false });
|
||||||
toast.error(String(e));
|
await message(String(e), {
|
||||||
|
title: "Account",
|
||||||
|
kind: "error",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -61,62 +65,72 @@ function Screen() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-full w-full items-center justify-center">
|
<div
|
||||||
<div className="relative z-20 flex flex-col items-center gap-16">
|
data-tauri-drag-region
|
||||||
<div className="text-center text-white">
|
className="flex flex-col items-center justify-between w-full h-full"
|
||||||
<h2 className="mb-1 text-2xl">{currentDate}</h2>
|
>
|
||||||
|
<div className="flex items-end justify-center flex-1 w-full px-4 pb-10">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="mb-1 text-lg text-neutral-700 dark:text-neutral-300">
|
||||||
|
{currentDate}
|
||||||
|
</h2>
|
||||||
<h2 className="text-2xl font-semibold">Welcome back!</h2>
|
<h2 className="text-2xl font-semibold">Welcome back!</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-center gap-6">
|
</div>
|
||||||
{loading ? (
|
<div className="flex flex-col items-center flex-1 w-full gap-3">
|
||||||
<div className="inline-flex size-6 items-center justify-center">
|
<div className="flex flex-col w-full max-w-sm mx-auto overflow-hidden bg-white divide-y divide-neutral-100 dark:divide-white/5 rounded-xl shadow-primary backdrop-blur-lg dark:bg-white/10 dark:ring-1 ring-white/15">
|
||||||
<Spinner className="size-6 text-white" />
|
{context.accounts.map((account) => (
|
||||||
</div>
|
<div
|
||||||
) : (
|
key={account}
|
||||||
<>
|
onClick={() => select(account)}
|
||||||
{accounts.map((account) => (
|
onKeyDown={() => select(account)}
|
||||||
<button
|
className="flex items-center justify-between hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
type="button"
|
>
|
||||||
key={account}
|
<User.Provider pubkey={account}>
|
||||||
onClick={() => select(account)}
|
<User.Root className="flex items-center gap-2.5 p-3">
|
||||||
>
|
<User.Avatar className="object-cover rounded-full size-10 shrink-0" />
|
||||||
<User.Provider pubkey={account}>
|
<div className="inline-flex flex-col items-start">
|
||||||
<User.Root className="flex h-36 w-32 flex-col items-center justify-center gap-4 rounded-2xl p-2 hover:bg-white/10 dark:hover:bg-black/10">
|
<User.Name className="max-w-[6rem] truncate font-medium leading-tight" />
|
||||||
<User.Avatar className="size-20 rounded-full object-cover" />
|
<span className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
<User.Name className="max-w-[5rem] truncate text-lg font-medium leading-tight text-white" />
|
{displayNpub(account, 16)}
|
||||||
</User.Root>
|
</span>
|
||||||
</User.Provider>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<Link to="/landing">
|
|
||||||
<div className="flex h-36 w-32 flex-col items-center justify-center gap-4 rounded-2xl p-2 text-white hover:bg-white/10 dark:hover:bg-black/10">
|
|
||||||
<div className="flex size-20 items-center justify-center rounded-full bg-white/20 dark:bg-black/20">
|
|
||||||
<PlusIcon className="size-5" />
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-medium leading-tight">Add</p>
|
</User.Root>
|
||||||
</div>
|
</User.Provider>
|
||||||
</Link>
|
<div className="inline-flex items-center justify-center size-10">
|
||||||
</>
|
{loading.npub === account ? (
|
||||||
)}
|
loading.status ? (
|
||||||
|
<Spinner />
|
||||||
|
) : null
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Link
|
||||||
|
to="/landing"
|
||||||
|
className="flex items-center justify-between hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5 p-3">
|
||||||
|
<div className="inline-flex items-center justify-center rounded-full size-10 bg-neutral-200 dark:bg-white/10">
|
||||||
|
<PlusIcon className="size-5" />
|
||||||
|
</div>
|
||||||
|
<span className="max-w-[6rem] truncate text-sm font-medium leading-tight">
|
||||||
|
Add account
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="w-full max-w-sm mx-auto">
|
||||||
|
<Link
|
||||||
|
to="/bootstrap-relays"
|
||||||
|
className="inline-flex items-center justify-center w-full h-8 gap-2 px-2 text-xs font-medium rounded-lg bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 text-neutral-700 dark:text-white/40"
|
||||||
|
>
|
||||||
|
<RelayIcon className="size-4" />
|
||||||
|
Custom Bootstrap Relays
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute z-10 h-full w-full bg-white/10 backdrop-blur-lg dark:bg-black/10" />
|
<div className="flex-1" />
|
||||||
<div className="absolute inset-0 h-full w-full">
|
|
||||||
<img
|
|
||||||
src="/lock-screen.jpg"
|
|
||||||
srcSet="/lock-screen@2x.jpg 2x"
|
|
||||||
alt="Lock Screen Background"
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
<a
|
|
||||||
href="https://njump.me/nprofile1qqs9tuz9jpn57djg7nxunhyvuvk69g5zqaxdpvpqt9hwqv7395u9rpg6zq5uw"
|
|
||||||
target="_blank"
|
|
||||||
className="absolute bottom-3 right-3 z-50 rounded-md bg-white/20 px-2 py-1 text-xs font-medium text-white dark:bg-black/20"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
Design by NoGood
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||