Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
135d0918b3 | ||
|
|
e1fbcf0460 | ||
|
|
99aaf3da82 | ||
|
|
3ef13e43f1 | ||
|
|
8939196ae4 | ||
|
|
571d4b4004 | ||
|
|
73f80f27fb | ||
|
|
b46a5cf68f | ||
|
|
8c0d03aed0 | ||
|
|
777eb15b4f | ||
|
|
c8e1b8b8bd | ||
|
|
437cd71f7e | ||
|
|
afb7c87fa3 | ||
|
|
c843626bca | ||
|
|
28337e5915 | ||
|
|
a4aef25adb | ||
|
|
61d1f095d4 | ||
|
|
f027eae52d | ||
|
|
174a3cc74e | ||
|
|
c00a7749b4 | ||
|
|
c755b8d137 | ||
|
|
17766d29d6 | ||
|
|
3b13dfeed8 | ||
|
|
17ba79e01b | ||
|
|
bafad544e9 | ||
|
|
89c36423ae | ||
|
|
cd31b99559 | ||
|
|
f3c52237fa | ||
|
|
413d8d82df | ||
|
|
2eb2010d43 | ||
|
|
94d400cab2 | ||
|
|
09b143cb08 | ||
|
|
e3ede34108 | ||
|
|
ed6aca41ea |
+36
-35
@@ -1,53 +1,54 @@
|
|||||||
name: 'Publish'
|
name: "Publish"
|
||||||
on: workflow_dispatch
|
on: workflow_dispatch
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_INCREMENTAL: 0
|
CARGO_INCREMENTAL: 0
|
||||||
RUST_BACKTRACE: short
|
RUST_BACKTRACE: short
|
||||||
RUSTFLAGS: '-W unreachable-pub -W rust-2021-compatibility'
|
RUSTFLAGS: "-W unreachable-pub -W rust-2021-compatibility"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish-tauri:
|
publish-tauri:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
settings:
|
include:
|
||||||
- platform: 'macos-latest'
|
- platform: "macos-latest" # for Arm based macs (M1 and above).
|
||||||
args: '--target universal-apple-darwin'
|
args: "--target aarch64-apple-darwin"
|
||||||
- platform: 'ubuntu-22.04'
|
- platform: "macos-latest" # for Intel based macs.
|
||||||
args: ''
|
args: "--target x86_64-apple-darwin"
|
||||||
- platform: 'windows-latest'
|
#- platform: 'ubuntu-22.04'
|
||||||
args: '--target x86_64-pc-windows-msvc'
|
# args: ''
|
||||||
runs-on: ${{ matrix.settings.platform }}
|
#- platform: 'windows-latest'
|
||||||
|
# args: '--target x86_64-pc-windows-msvc'
|
||||||
|
runs-on: ${{ matrix.platform }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- name: setup node
|
|
||||||
uses: actions/setup-node@v3
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: "lts/*"
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
- name: Install PNPM
|
||||||
targets: aarch64-apple-darwin
|
|
||||||
- name: install dependencies (ubuntu only)
|
|
||||||
if: matrix.settings.platform == 'ubuntu-22.04'
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y build-essential libssl-dev javascriptcoregtk-4.1 libayatana-appindicator3-dev libsoup-3.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev webkit2gtk-4.1 librsvg2-dev patchelf
|
|
||||||
- name: Install pnpm
|
|
||||||
uses: pnpm/action-setup@v2
|
uses: pnpm/action-setup@v2
|
||||||
with:
|
with:
|
||||||
version: 8.x.x
|
version: 8.x.x
|
||||||
run_install: false
|
run_install: false
|
||||||
- name: Setup node and cache for package data
|
|
||||||
uses: actions/setup-node@v3
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
node-version: 'lts/*'
|
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||||
cache: 'pnpm'
|
|
||||||
cache-dependency-path: pnpm-lock.yaml
|
- name: Install dependencies (ubuntu only)
|
||||||
- uses: Swatinem/rust-cache@v2
|
if: matrix.platform == 'ubuntu-22.04'
|
||||||
with:
|
run: |
|
||||||
cache-on-failure: true
|
sudo apt-get update
|
||||||
- run: pnpm install
|
sudo apt-get install -y build-essential libssl-dev javascriptcoregtk-4.1 libayatana-appindicator3-dev libsoup-3.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev webkit2gtk-4.1 librsvg2-dev patchelf
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
- uses: tauri-apps/tauri-action@dev
|
- uses: tauri-apps/tauri-action@dev
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -62,9 +63,9 @@ jobs:
|
|||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
with:
|
with:
|
||||||
tagName: v__VERSION__
|
tagName: v__VERSION__
|
||||||
releaseName: 'v__VERSION__'
|
releaseName: "v__VERSION__"
|
||||||
releaseBody: 'See the assets to download this version and install.'
|
releaseBody: "See the assets to download this version and install."
|
||||||
releaseDraft: true
|
releaseDraft: true
|
||||||
prerelease: false
|
prerelease: false
|
||||||
args: ${{ matrix.settings.args }}
|
args: ${{ matrix.args }}
|
||||||
includeDebug: false
|
includeDebug: false
|
||||||
|
|||||||
+17
-16
@@ -19,41 +19,42 @@
|
|||||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
"@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-switch": "^1.0.3",
|
"@radix-ui/react-switch": "^1.0.3",
|
||||||
"@tanstack/query-sync-storage-persister": "^5.29.0",
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
"@tanstack/react-query": "^5.29.0",
|
"@tanstack/query-sync-storage-persister": "^5.32.0",
|
||||||
"@tanstack/react-query-persist-client": "^5.29.0",
|
"@tanstack/react-query": "^5.32.0",
|
||||||
"@tanstack/react-router": "^1.26.19",
|
"@tanstack/react-query-persist-client": "^5.32.0",
|
||||||
"i18next": "^23.11.1",
|
"@tanstack/react-router": "1.29.2",
|
||||||
|
"i18next": "^23.11.3",
|
||||||
"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.4.0",
|
"nostr-tools": "^2.5.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.3.1",
|
||||||
"react-currency-input-field": "^3.8.0",
|
"react-currency-input-field": "^3.8.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.51.2",
|
"react-hook-form": "^7.51.3",
|
||||||
"react-hotkeys-hook": "^4.5.0",
|
"react-hotkeys-hook": "^4.5.0",
|
||||||
"react-i18next": "^14.1.0",
|
"react-i18next": "^14.1.1",
|
||||||
"slate": "^0.102.0",
|
"slate": "^0.102.0",
|
||||||
"slate-react": "^0.102.0",
|
"slate-react": "^0.102.0",
|
||||||
"sonner": "^1.4.41",
|
"sonner": "^1.4.41",
|
||||||
"use-debounce": "^10.0.0",
|
"use-debounce": "^10.0.0",
|
||||||
"virtua": "^0.29.2"
|
"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.26.19",
|
"@tanstack/router-devtools": "^1.31.3",
|
||||||
"@tanstack/router-vite-plugin": "^1.26.16",
|
"@tanstack/router-vite-plugin": "^1.30.0",
|
||||||
"@types/react": "^18.2.75",
|
"@types/react": "^18.3.1",
|
||||||
"@types/react-dom": "^18.2.24",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"tailwindcss": "^3.4.3",
|
"tailwindcss": "^3.4.3",
|
||||||
"typescript": "^5.4.5",
|
"typescript": "^5.4.5",
|
||||||
"vite": "^5.2.8",
|
"vite": "^5.2.10",
|
||||||
"vite-plugin-top-level-await": "^1.4.1",
|
"vite-plugin-top-level-await": "^1.4.1",
|
||||||
"vite-tsconfig-paths": "^4.3.2"
|
"vite-tsconfig-paths": "^4.3.2"
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 210 KiB |
@@ -60,3 +60,63 @@ input::-ms-clear {
|
|||||||
::-webkit-input-placeholder {
|
::-webkit-input-placeholder {
|
||||||
line-height: normal;
|
line-height: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.spinner-leaf {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: calc(50% - 12.5% / 2);
|
||||||
|
width: 12.5%;
|
||||||
|
height: 100%;
|
||||||
|
animation: spinner-leaf-fade 800ms linear infinite;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 30%;
|
||||||
|
background-color: currentColor;
|
||||||
|
@apply rounded;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:where(:nth-child(1)) {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
animation-delay: -800ms;
|
||||||
|
}
|
||||||
|
&:where(:nth-child(2)) {
|
||||||
|
transform: rotate(45deg);
|
||||||
|
animation-delay: -700ms;
|
||||||
|
}
|
||||||
|
&:where(:nth-child(3)) {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
animation-delay: -600ms;
|
||||||
|
}
|
||||||
|
&:where(:nth-child(4)) {
|
||||||
|
transform: rotate(135deg);
|
||||||
|
animation-delay: -500ms;
|
||||||
|
}
|
||||||
|
&:where(:nth-child(5)) {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
animation-delay: -400ms;
|
||||||
|
}
|
||||||
|
&:where(:nth-child(6)) {
|
||||||
|
transform: rotate(225deg);
|
||||||
|
animation-delay: -300ms;
|
||||||
|
}
|
||||||
|
&:where(:nth-child(7)) {
|
||||||
|
transform: rotate(270deg);
|
||||||
|
animation-delay: -200ms;
|
||||||
|
}
|
||||||
|
&:where(:nth-child(8)) {
|
||||||
|
transform: rotate(315deg);
|
||||||
|
animation-delay: -100ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spinner-leaf-fade {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+10
-15
@@ -1,38 +1,33 @@
|
|||||||
|
import { Ark } from "@lume/ark";
|
||||||
|
import { CancelCircleIcon, CheckCircleIcon, InfoCircleIcon } from "@lume/icons";
|
||||||
|
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
||||||
import { QueryClient } from "@tanstack/react-query";
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||||
|
import { 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 i18n from "./locale";
|
import i18n from "./locale";
|
||||||
import { Toaster } from "sonner";
|
|
||||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
|
||||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
|
||||||
import { routeTree } from "./router.gen"; // auto generated file
|
import { routeTree } from "./router.gen"; // auto generated file
|
||||||
import { CancelCircleIcon, CheckCircleIcon, InfoCircleIcon } from "@lume/icons";
|
|
||||||
import { Ark } from "@lume/ark";
|
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const ark = new Ark();
|
||||||
defaultOptions: {
|
const queryClient = new QueryClient();
|
||||||
queries: {
|
const platformName = await platform();
|
||||||
gcTime: 1000 * 60 * 60 * 24, // 24 hours
|
|
||||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const persister = createSyncStoragePersister({
|
const persister = createSyncStoragePersister({
|
||||||
storage: window.localStorage,
|
storage: window.localStorage,
|
||||||
});
|
});
|
||||||
|
|
||||||
const ark = new Ark();
|
|
||||||
|
|
||||||
// Set up a Router instance
|
// Set up a Router instance
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
routeTree,
|
routeTree,
|
||||||
context: {
|
context: {
|
||||||
ark,
|
ark,
|
||||||
queryClient,
|
queryClient,
|
||||||
|
platform: platformName,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
import { Account } from "@lume/types";
|
|
||||||
import { User } from "@lume/ui";
|
|
||||||
import {
|
|
||||||
useNavigate,
|
|
||||||
useParams,
|
|
||||||
useRouteContext,
|
|
||||||
} from "@tanstack/react-router";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
export function Accounts() {
|
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const params = useParams({ strict: false });
|
|
||||||
|
|
||||||
const [accounts, setAccounts] = useState<Account[]>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function getAllAccounts() {
|
|
||||||
const data = await ark.get_all_accounts();
|
|
||||||
if (data) setAccounts(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
getAllAccounts();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div data-tauri-drag-region className="flex items-center gap-3">
|
|
||||||
{accounts
|
|
||||||
? accounts.map((account) =>
|
|
||||||
// @ts-ignore, useless
|
|
||||||
account.npub === params.account ? (
|
|
||||||
<Active key={account.npub} pubkey={account.npub} />
|
|
||||||
) : (
|
|
||||||
<Inactive key={account.npub} pubkey={account.npub} />
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Inactive({ pubkey }: { pubkey: string }) {
|
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const changeAccount = async (npub: string) => {
|
|
||||||
const select = await ark.load_selected_account(npub);
|
|
||||||
if (select) navigate({ to: "/$account/home", params: { account: npub } });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button type="button" onClick={() => changeAccount(pubkey)}>
|
|
||||||
<User.Provider pubkey={pubkey}>
|
|
||||||
<User.Root className="rounded-full">
|
|
||||||
<User.Avatar className="aspect-square h-auto w-8 rounded-full object-cover" />
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Active({ pubkey }: { pubkey: string }) {
|
|
||||||
return (
|
|
||||||
<User.Provider pubkey={pubkey}>
|
|
||||||
<User.Root className="rounded-full ring-1 ring-teal-500 ring-offset-2 ring-offset-neutral-200 dark:ring-offset-neutral-950">
|
|
||||||
<User.Avatar className="aspect-square h-auto w-7 rounded-full object-cover" />
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
import { LoaderIcon } from "@lume/icons";
|
import { Spinner } from "@lume/ui";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
import { Dispatch, ReactNode, SetStateAction, useState } from "react";
|
import {
|
||||||
|
type Dispatch,
|
||||||
|
type ReactNode,
|
||||||
|
type SetStateAction,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export function AvatarUploader({
|
export function AvatarUploader({
|
||||||
@@ -36,7 +41,7 @@ export function AvatarUploader({
|
|||||||
onClick={() => uploadAvatar()}
|
onClick={() => uploadAvatar()}
|
||||||
className={cn("size-4", className)}
|
className={cn("size-4", className)}
|
||||||
>
|
>
|
||||||
{loading ? <LoaderIcon className="size-4 animate-spin" /> : children}
|
{loading ? <Spinner className="size-4" /> : children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,64 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { CancelIcon, CheckIcon } from "@lume/icons";
|
||||||
import { LumeColumn } from "@lume/types";
|
import type { LumeColumn } from "@lume/types";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { LoaderIcon } from "@lume/icons";
|
import { getCurrent } from "@tauri-apps/api/webviewWindow";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
export function Col({
|
export function Col({
|
||||||
column,
|
column,
|
||||||
account,
|
account,
|
||||||
isScroll,
|
isScroll,
|
||||||
|
isResize,
|
||||||
}: {
|
}: {
|
||||||
column: LumeColumn;
|
column: LumeColumn;
|
||||||
account: string;
|
account: string;
|
||||||
isScroll: boolean;
|
isScroll: boolean;
|
||||||
|
isResize: boolean;
|
||||||
}) {
|
}) {
|
||||||
const webview = useRef<string | undefined>(undefined);
|
|
||||||
const container = useRef<HTMLDivElement>(null);
|
const container = useRef<HTMLDivElement>(null);
|
||||||
|
const [webview, setWebview] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
const repositionWebview = async () => {
|
const repositionWebview = async () => {
|
||||||
if (webview.current && webview.current.length > 1) {
|
if (webview && webview.length > 1) {
|
||||||
const newRect = container.current.getBoundingClientRect();
|
const newRect = container.current.getBoundingClientRect();
|
||||||
await invoke("reposition_column", {
|
await invoke("reposition_column", {
|
||||||
label: webview.current,
|
label: webview,
|
||||||
x: newRect.x,
|
x: newRect.x,
|
||||||
y: newRect.y,
|
y: newRect.y,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const resizeWebview = async () => {
|
||||||
if (isScroll) {
|
if (webview && webview.length > 1) {
|
||||||
repositionWebview();
|
const newRect = container.current.getBoundingClientRect();
|
||||||
|
await invoke("resize_column", {
|
||||||
|
label: webview,
|
||||||
|
width: newRect.width,
|
||||||
|
height: newRect.height,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resizeWebview();
|
||||||
|
}, [isResize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isScroll) repositionWebview();
|
||||||
}, [isScroll]);
|
}, [isScroll]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
|
if (webview && webview.length > 1) return;
|
||||||
|
|
||||||
const rect = container.current.getBoundingClientRect();
|
const rect = container.current.getBoundingClientRect();
|
||||||
const windowLabel = `column-${column.label}`;
|
const windowLabel = `column-${column.label}`;
|
||||||
const url =
|
const url = `${column.content}?account=${account}&label=${column.label}&name=${column.name}`;
|
||||||
column.content +
|
|
||||||
`?account=${account}&label=${column.label}&name=${column.name}`;
|
|
||||||
|
|
||||||
// create new webview
|
// create new webview
|
||||||
webview.current = await invoke("create_column", {
|
const label: string = await invoke("create_column", {
|
||||||
label: windowLabel,
|
label: windowLabel,
|
||||||
x: rect.x,
|
x: rect.x,
|
||||||
y: rect.y,
|
y: rect.y,
|
||||||
@@ -49,29 +66,95 @@ export function Col({
|
|||||||
height: rect.height,
|
height: rect.height,
|
||||||
url,
|
url,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setWebview(label);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// close webview when unmounted
|
// close webview when unmounted
|
||||||
return () => {
|
return () => {
|
||||||
if (webview.current && webview.current.length > 1) {
|
if (webview && webview.length > 1) {
|
||||||
invoke("close_column", {
|
invoke("close_column", {
|
||||||
label: webview.current,
|
label: webview,
|
||||||
}).then(() => {
|
|
||||||
webview.current = undefined;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, [webview]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={container} className="h-full w-[440px] shrink-0 p-2">
|
<div className="h-full w-[440px] shrink-0 p-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col w-full h-full rounded-xl",
|
||||||
|
column.label !== "open"
|
||||||
|
? "bg-black/5 dark:bg-white/5 backdrop-blur-sm"
|
||||||
|
: "",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{column.label !== "open" ? (
|
{column.label !== "open" ? (
|
||||||
<div className="w-full h-full flex items-center justify-center rounded-xl flex-col bg-black/5 dark:bg-white/5 backdrop-blur-lg">
|
<Header label={column.label} name={column.name} />
|
||||||
<button type="button" className="size-5" disabled>
|
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
<div ref={container} className="flex-1 w-full h-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Header({ label, name }: { label: string; name: string }) {
|
||||||
|
const [title, setTitle] = useState(name);
|
||||||
|
const [isChanged, setIsChanged] = useState(false);
|
||||||
|
|
||||||
|
const saveNewTitle = async () => {
|
||||||
|
const mainWindow = getCurrent();
|
||||||
|
await mainWindow.emit("columns", { type: "set_title", label, title });
|
||||||
|
|
||||||
|
// update search params
|
||||||
|
// @ts-ignore, hahaha
|
||||||
|
search.name = title;
|
||||||
|
|
||||||
|
// reset state
|
||||||
|
setIsChanged(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = async () => {
|
||||||
|
const mainWindow = getCurrent();
|
||||||
|
await mainWindow.emit("columns", { type: "remove", label });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (title.length !== name.length) setIsChanged(true);
|
||||||
|
}, [title]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-9 w-full flex items-center justify-between shrink-0 px-1">
|
||||||
|
<div className="size-7" />
|
||||||
|
<div className="shrink-0 h-9 flex items-center justify-center">
|
||||||
|
<div className="relative flex gap-2 items-center">
|
||||||
|
<div
|
||||||
|
contentEditable
|
||||||
|
suppressContentEditableWarning={true}
|
||||||
|
onBlur={(e) => setTitle(e.currentTarget.textContent)}
|
||||||
|
className="text-sm font-medium focus:outline-none"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
{isChanged ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => saveNewTitle()}
|
||||||
|
className="text-teal-500 hover:text-teal-600"
|
||||||
|
>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => close()}
|
||||||
|
className="size-7 inline-flex hover:bg-black/10 rounded-lg dark:hover:bg-white/10 items-center justify-center text-neutral-600 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200"
|
||||||
|
>
|
||||||
|
<CancelIcon className="size-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { ThreadIcon } from "@lume/icons";
|
||||||
|
import type { Event } from "@lume/types";
|
||||||
|
import { Note } from "@lume/ui";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
export function Conversation({
|
||||||
|
event,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
event: Event;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const { ark } = useRouteContext({ strict: false });
|
||||||
|
const thread = ark.parse_event_thread(event.tags);
|
||||||
|
|
||||||
|
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 className="flex flex-col gap-3">
|
||||||
|
{thread?.root ? <Note.Child eventId={thread?.root} isRoot /> : null}
|
||||||
|
<div className="flex items-center gap-2 px-3">
|
||||||
|
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||||
|
<ThreadIcon className="size-4" />
|
||||||
|
Thread
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
|
||||||
|
</div>
|
||||||
|
{thread?.reply ? <Note.Child eventId={thread?.reply} /> : null}
|
||||||
|
<div>
|
||||||
|
<div className="px-3 h-14 flex items-center justify-between">
|
||||||
|
<Note.User />
|
||||||
|
</div>
|
||||||
|
<Note.Content className="px-3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center h-14 px-3">
|
||||||
|
<Note.Open />
|
||||||
|
</div>
|
||||||
|
</Note.Root>
|
||||||
|
</Note.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { QuoteIcon } from "@lume/icons";
|
||||||
|
import type { Event } from "@lume/types";
|
||||||
|
import { Note } from "@lume/ui";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
|
|
||||||
|
export function Quote({
|
||||||
|
event,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
event: Event;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const quoteEventId = event.tags.find(
|
||||||
|
(tag) => tag[0] === "q" || tag[3] === "mention",
|
||||||
|
)?.[1];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Note.Provider event={event}>
|
||||||
|
<Note.Root
|
||||||
|
className={cn(
|
||||||
|
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Note.Child eventId={quoteEventId} isRoot />
|
||||||
|
<div className="flex items-center gap-2 px-3">
|
||||||
|
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||||
|
<QuoteIcon className="size-4" />
|
||||||
|
Quote
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="px-3 h-14 flex items-center justify-between">
|
||||||
|
<Note.User />
|
||||||
|
</div>
|
||||||
|
<Note.Content className="px-3" quote={false} clean />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center h-14 px-3">
|
||||||
|
<Note.Open />
|
||||||
|
</div>
|
||||||
|
</Note.Root>
|
||||||
|
</Note.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import { RepostIcon } from "@lume/icons";
|
import type { Event } from "@lume/types";
|
||||||
import { Event } from "@lume/types";
|
import { Note, Spinner, User } from "@lume/ui";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Note, User } from "@lume/ui";
|
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
|
|
||||||
export function RepostNote({
|
export function RepostNote({
|
||||||
@@ -13,8 +11,7 @@ export function RepostNote({
|
|||||||
event: Event;
|
event: Event;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { ark, settings } = useRouteContext({ strict: false });
|
const { ark } = useRouteContext({ strict: false });
|
||||||
const { t } = useTranslation();
|
|
||||||
const {
|
const {
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
@@ -27,91 +24,60 @@ export function RepostNote({
|
|||||||
const embed: Event = JSON.parse(event.content);
|
const embed: Event = JSON.parse(event.content);
|
||||||
return embed;
|
return embed;
|
||||||
}
|
}
|
||||||
const id = event.tags.find((el) => el[0] === "e")[1];
|
|
||||||
return await ark.get_event(id);
|
const id = event.tags.find((el) => el[0] === "e")?.[1];
|
||||||
} catch {
|
const repostEvent = await ark.get_event(id);
|
||||||
throw new Error("Failed to get repost event");
|
|
||||||
|
return repostEvent;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <div className="w-full px-3 pb-3">Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError || !repostEvent) {
|
|
||||||
return (
|
return (
|
||||||
<Note.Root
|
<Note.Root
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col gap-2 border-b border-neutral-100 px-3 py-5 dark:border-neutral-900",
|
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl mb-3 shadow-primary dark:ring-1 ring-neutral-800/50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<User.Provider pubkey={event.pubkey}>
|
<User.Provider pubkey={event.pubkey}>
|
||||||
<User.Root className="flex h-14 gap-2 px-3">
|
<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="inline-flex w-10 shrink-0 items-center justify-center">
|
<div className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
||||||
<RepostIcon className="h-5 w-5 text-blue-500" />
|
Reposted by
|
||||||
</div>
|
|
||||||
<div className="inline-flex items-center gap-2">
|
|
||||||
<User.Avatar className="size-6 shrink-0 rounded object-cover" />
|
|
||||||
<div className="inline-flex items-baseline gap-1">
|
|
||||||
<User.Name className="font-medium text-neutral-900 dark:text-neutral-100" />
|
|
||||||
<span className="text-blue-500">{t("note.reposted")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</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.Root>
|
||||||
</User.Provider>
|
</User.Provider>
|
||||||
<div className="mb-3 select-text px-3">
|
{isLoading ? (
|
||||||
<div className="flex flex-col items-start justify-start rounded-lg bg-red-100 px-3 py-3 dark:bg-red-900">
|
<div className="flex h-20 items-center justify-center gap-2">
|
||||||
<p className="text-red-500">Failed to get event</p>
|
<Spinner />
|
||||||
|
Loading event...
|
||||||
</div>
|
</div>
|
||||||
|
) : isError || !repostEvent ? (
|
||||||
|
<div className="flex h-20 items-center justify-center">
|
||||||
|
Event not found within your current relay set
|
||||||
</div>
|
</div>
|
||||||
</Note.Root>
|
) : (
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Note.Root
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col gap-2 border-b border-neutral-100 px-3 py-5 dark:border-neutral-900",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<User.Provider pubkey={event.pubkey}>
|
|
||||||
<User.Root className="flex gap-3">
|
|
||||||
<div className="inline-flex w-11 shrink-0 items-center justify-center">
|
|
||||||
<RepostIcon className="h-5 w-5 text-blue-500" />
|
|
||||||
</div>
|
|
||||||
<div className="inline-flex items-center gap-2">
|
|
||||||
<User.Avatar className="size-6 shrink-0 rounded-full object-cover" />
|
|
||||||
<div className="inline-flex items-baseline gap-1">
|
|
||||||
<User.Name className="font-medium text-neutral-900 dark:text-neutral-100" />
|
|
||||||
<span className="text-blue-500">{t("note.reposted")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
<Note.Provider event={repostEvent}>
|
<Note.Provider event={repostEvent}>
|
||||||
<div className="flex flex-col gap-2">
|
<Note.Root>
|
||||||
|
<div className="px-3 h-14 flex items-center justify-between">
|
||||||
<Note.User />
|
<Note.User />
|
||||||
<div className="flex gap-3">
|
|
||||||
<div className="size-11 shrink-0" />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<Note.Content />
|
|
||||||
<div className="mt-4 flex items-center justify-between">
|
|
||||||
<div className="-ml-1 inline-flex items-center gap-4">
|
|
||||||
<Note.Reply />
|
|
||||||
<Note.Repost />
|
|
||||||
{settings.zap ? <Note.Zap /> : null}
|
|
||||||
</div>
|
|
||||||
<Note.Menu />
|
<Note.Menu />
|
||||||
</div>
|
</div>
|
||||||
|
<Note.Content className="px-3" />
|
||||||
|
<div className="mt-3 flex items-center gap-4 h-14 px-3">
|
||||||
|
<Note.Open />
|
||||||
|
<Note.Reply />
|
||||||
|
<Note.Repost />
|
||||||
|
<Note.Zap />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Note.Root>
|
||||||
</div>
|
|
||||||
</Note.Provider>
|
</Note.Provider>
|
||||||
|
)}
|
||||||
</Note.Root>
|
</Note.Root>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Event } from "@lume/types";
|
import type { Event } from "@lume/types";
|
||||||
import { Note } from "@lume/ui";
|
import { Note } from "@lume/ui";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export function TextNote({
|
export function TextNote({
|
||||||
event,
|
event,
|
||||||
@@ -10,31 +9,24 @@ export function TextNote({
|
|||||||
event: Event;
|
event: Event;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { settings } = useRouteContext({ strict: false });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Note.Provider event={event}>
|
<Note.Provider event={event}>
|
||||||
<Note.Root
|
<Note.Root
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col gap-2 border-b border-neutral-100 px-3 py-5 dark:border-neutral-900",
|
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<div className="px-3 h-14 flex items-center justify-between">
|
||||||
<Note.User />
|
<Note.User />
|
||||||
<div className="flex gap-3">
|
|
||||||
<div className="size-11 shrink-0" />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<Note.Content className="mb-2" />
|
|
||||||
<Note.Thread />
|
|
||||||
<div className="mt-4 flex items-center justify-between">
|
|
||||||
<div className="-ml-1 inline-flex items-center gap-4">
|
|
||||||
<Note.Reply />
|
|
||||||
<Note.Repost />
|
|
||||||
{settings.zap ? <Note.Zap /> : null}
|
|
||||||
</div>
|
|
||||||
<Note.Menu />
|
<Note.Menu />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Note.Content className="px-3" />
|
||||||
|
<div className="mt-3 flex items-center gap-4 h-14 px-3">
|
||||||
|
<Note.Open />
|
||||||
|
<Note.Reply />
|
||||||
|
<Note.Repost />
|
||||||
|
<Note.Zap />
|
||||||
</div>
|
</div>
|
||||||
</Note.Root>
|
</Note.Root>
|
||||||
</Note.Provider>
|
</Note.Provider>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ReactNode } from "@tanstack/react-router";
|
import type { ReactNode } from "@tanstack/react-router";
|
||||||
import { useLayoutEffect, useState } from "react";
|
import { useLayoutEffect, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,24 @@
|
|||||||
import { Col } from "@/components/col";
|
import { Col } from "@/components/col";
|
||||||
import { Toolbar } from "@/components/toolbar";
|
import { Toolbar } from "@/components/toolbar";
|
||||||
import { ArrowLeftIcon, ArrowRightIcon, LoaderIcon } from "@lume/icons";
|
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
|
||||||
import { EventColumns, LumeColumn } from "@lume/types";
|
import type { EventColumns, 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 { resolveResource } from "@tauri-apps/api/path";
|
||||||
|
import { getCurrent } from "@tauri-apps/api/window";
|
||||||
import { readTextFile } from "@tauri-apps/plugin-fs";
|
import { readTextFile } from "@tauri-apps/plugin-fs";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
import { VList, VListHandle } from "virtua";
|
import { VList, type VListHandle } from "virtua";
|
||||||
|
|
||||||
export const Route = createFileRoute("/$account/home")({
|
export const Route = createFileRoute("/$account/home")({
|
||||||
component: Screen,
|
|
||||||
pendingComponent: Pending,
|
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: async ({ context }) => {
|
||||||
|
try {
|
||||||
const ark = context.ark;
|
const ark = context.ark;
|
||||||
const resourcePath = await resolveResource("resources/system_columns.json");
|
const resourcePath = await resolveResource(
|
||||||
|
"resources/system_columns.json",
|
||||||
|
);
|
||||||
const systemColumns: LumeColumn[] = JSON.parse(
|
const systemColumns: LumeColumn[] = JSON.parse(
|
||||||
await readTextFile(resourcePath),
|
await readTextFile(resourcePath),
|
||||||
);
|
);
|
||||||
@@ -25,18 +27,23 @@ export const Route = createFileRoute("/$account/home")({
|
|||||||
return {
|
return {
|
||||||
storedColumns: !userColumns.length ? systemColumns : userColumns,
|
storedColumns: !userColumns.length ? systemColumns : userColumns,
|
||||||
};
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error(String(e));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
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 { ark, storedColumns } = Route.useRouteContext();
|
||||||
|
|
||||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||||
const [isScroll, setIsScroll] = useState(false);
|
|
||||||
const [columns, setColumns] = useState(storedColumns);
|
const [columns, setColumns] = useState(storedColumns);
|
||||||
|
const [isScroll, setIsScroll] = useState(false);
|
||||||
const vlistRef = useRef<VListHandle>(null);
|
const [isResize, setIsResize] = useState(false);
|
||||||
|
|
||||||
const goLeft = () => {
|
const goLeft = () => {
|
||||||
const prevIndex = Math.max(selectedIndex - 1, 0);
|
const prevIndex = Math.max(selectedIndex - 1, 0);
|
||||||
@@ -55,45 +62,78 @@ function Screen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const add = useDebouncedCallback((column: LumeColumn) => {
|
const add = useDebouncedCallback((column: LumeColumn) => {
|
||||||
column["label"] = column.label + "-" + nanoid();
|
// update col label
|
||||||
|
column.label = `${column.label}-${nanoid()}`;
|
||||||
|
|
||||||
setColumns((state) => [...state, column]);
|
// create new cols
|
||||||
setSelectedIndex(columns.length + 1);
|
const cols = [...columns];
|
||||||
|
const openColIndex = cols.findIndex((col) => col.label === "open");
|
||||||
|
const newCols = [
|
||||||
|
...cols.slice(0, openColIndex),
|
||||||
|
column,
|
||||||
|
...cols.slice(openColIndex),
|
||||||
|
];
|
||||||
|
|
||||||
// scroll to the last column
|
setColumns(newCols);
|
||||||
vlistRef.current.scrollToIndex(columns.length + 1, {
|
setSelectedIndex(newCols.length);
|
||||||
|
setIsScroll(true);
|
||||||
|
|
||||||
|
// scroll to the newest column
|
||||||
|
vlistRef.current.scrollToIndex(newCols.length - 1, {
|
||||||
align: "end",
|
align: "end",
|
||||||
});
|
});
|
||||||
}, 150);
|
}, 150);
|
||||||
|
|
||||||
const remove = useDebouncedCallback((label: string) => {
|
const remove = useDebouncedCallback((label: string) => {
|
||||||
setColumns((state) => state.filter((t) => t.label !== label));
|
const newCols = columns.filter((t) => t.label !== label);
|
||||||
setSelectedIndex(columns.length - 1);
|
|
||||||
|
setColumns(newCols);
|
||||||
|
setSelectedIndex(newCols.length);
|
||||||
|
setIsScroll(true);
|
||||||
|
|
||||||
// scroll to the first column
|
// scroll to the first column
|
||||||
vlistRef.current.scrollToIndex(0, {
|
vlistRef.current.scrollToIndex(newCols.length - 1, {
|
||||||
align: "start",
|
align: "start",
|
||||||
});
|
});
|
||||||
}, 150);
|
}, 150);
|
||||||
|
|
||||||
|
const updateName = useDebouncedCallback((label: string, title: string) => {
|
||||||
|
const currentColIndex = columns.findIndex((col) => col.label === label);
|
||||||
|
|
||||||
|
const updatedCol = Object.assign({}, columns[currentColIndex]);
|
||||||
|
updatedCol.name = title;
|
||||||
|
|
||||||
|
const newCols = columns.slice();
|
||||||
|
newCols[currentColIndex] = updatedCol;
|
||||||
|
|
||||||
|
setColumns(newCols);
|
||||||
|
}, 150);
|
||||||
|
|
||||||
|
const startResize = useDebouncedCallback(
|
||||||
|
() => setIsResize((prev) => !prev),
|
||||||
|
150,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// save state
|
// save state
|
||||||
ark.set_columns(columns);
|
ark.set_columns(columns);
|
||||||
}, [columns]);
|
}, [columns]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unlisten: Awaited<ReturnType<typeof listen>> | undefined = undefined;
|
const unlistenColEvent = listen<EventColumns>("columns", (data) => {
|
||||||
|
|
||||||
(async () => {
|
|
||||||
if (unlisten) return;
|
|
||||||
unlisten = await listen<EventColumns>("columns", (data) => {
|
|
||||||
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")
|
||||||
|
updateName(data.payload.label, data.payload.title);
|
||||||
|
});
|
||||||
|
|
||||||
|
const unlistenWindowResize = getCurrent().listen("tauri://resize", () => {
|
||||||
|
startResize();
|
||||||
});
|
});
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (unlisten) unlisten();
|
unlistenColEvent.then((f) => f());
|
||||||
|
unlistenWindowResize.then((f) => f());
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -105,20 +145,17 @@ function Screen() {
|
|||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
itemSize={440}
|
itemSize={440}
|
||||||
overscan={3}
|
overscan={3}
|
||||||
onScroll={() => {
|
onScroll={() => setIsScroll(true)}
|
||||||
setIsScroll(true);
|
onScrollEnd={() => setIsScroll(false)}
|
||||||
}}
|
|
||||||
onScrollEnd={() => {
|
|
||||||
setIsScroll(false);
|
|
||||||
}}
|
|
||||||
className="scrollbar-none h-full w-full overflow-x-auto focus:outline-none"
|
className="scrollbar-none h-full w-full overflow-x-auto focus:outline-none"
|
||||||
>
|
>
|
||||||
{columns.map((column, index) => (
|
{columns.map((column) => (
|
||||||
<Col
|
<Col
|
||||||
key={column.label + index}
|
key={column.label}
|
||||||
column={column}
|
column={column}
|
||||||
account={account}
|
account={account}
|
||||||
isScroll={isScroll}
|
isScroll={isScroll}
|
||||||
|
isResize={isResize}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</VList>
|
</VList>
|
||||||
@@ -127,14 +164,14 @@ function Screen() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => goLeft()}
|
onClick={() => goLeft()}
|
||||||
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-neutral-200 dark:text-neutral-200 dark:hover:bg-neutral-800"
|
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
||||||
>
|
>
|
||||||
<ArrowLeftIcon className="size-5" />
|
<ArrowLeftIcon className="size-5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => goRight()}
|
onClick={() => goRight()}
|
||||||
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-neutral-200 dark:text-neutral-200 dark:hover:bg-neutral-800"
|
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
||||||
>
|
>
|
||||||
<ArrowRightIcon className="size-5" />
|
<ArrowRightIcon className="size-5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -143,13 +180,3 @@ function Screen() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Pending() {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<button type="button" className="size-5" disabled>
|
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,20 +1,30 @@
|
|||||||
import { ComposeFilledIcon, PlusIcon } from "@lume/icons";
|
import { BellIcon, ComposeFilledIcon, PlusIcon, SearchIcon } from "@lume/icons";
|
||||||
import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router";
|
import { Event, Kind } from "@lume/types";
|
||||||
import { cn } from "@lume/utils";
|
import { User } from "@lume/ui";
|
||||||
import { Accounts } from "@/components/accounts";
|
import {
|
||||||
import { platform } from "@tauri-apps/plugin-os";
|
cn,
|
||||||
|
decodeZapInvoice,
|
||||||
|
displayNpub,
|
||||||
|
sendNativeNotification,
|
||||||
|
} from "@lume/utils";
|
||||||
|
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { getCurrent } from "@tauri-apps/api/window";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
export const Route = createFileRoute("/$account")({
|
export const Route = createFileRoute("/$account")({
|
||||||
component: App,
|
beforeLoad: async ({ context }) => {
|
||||||
beforeLoad: async () => {
|
const ark = context.ark;
|
||||||
const platformName = await platform();
|
const accounts = await ark.get_all_accounts();
|
||||||
return { platform: platformName };
|
|
||||||
|
return { accounts };
|
||||||
},
|
},
|
||||||
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function App() {
|
function Screen() {
|
||||||
const navigate = useNavigate();
|
|
||||||
const { ark, platform } = Route.useRouteContext();
|
const { ark, platform } = Route.useRouteContext();
|
||||||
|
const navigate = Route.useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-screen flex-col">
|
<div className="flex h-screen w-screen flex-col">
|
||||||
@@ -30,12 +40,12 @@ function App() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate({ to: "/landing" })}
|
onClick={() => navigate({ to: "/landing" })}
|
||||||
className="inline-flex size-8 items-center justify-center rounded-full bg-neutral-200 text-neutral-800 hover:bg-neutral-400 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:bg-neutral-600"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => ark.open_editor()}
|
onClick={() => ark.open_editor()}
|
||||||
@@ -44,6 +54,14 @@ function App() {
|
|||||||
<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>
|
||||||
@@ -53,3 +71,108 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Accounts() {
|
||||||
|
const navigate = Route.useNavigate();
|
||||||
|
const { ark, accounts } = Route.useRouteContext();
|
||||||
|
const { account } = Route.useParams();
|
||||||
|
|
||||||
|
const changeAccount = async (npub: string) => {
|
||||||
|
if (npub === account) return;
|
||||||
|
|
||||||
|
const select = await ark.load_selected_account(npub);
|
||||||
|
|
||||||
|
if (select) {
|
||||||
|
return navigate({ to: "/$account/home", params: { account: npub } });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-tauri-drag-region className="flex items-center gap-3">
|
||||||
|
{accounts.map((user) => (
|
||||||
|
<button key={user} type="button" onClick={() => changeAccount(user)}>
|
||||||
|
<User.Provider pubkey={user}>
|
||||||
|
<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(() => {
|
||||||
|
const unlisten = getCurrent().listen<string>(
|
||||||
|
"activity",
|
||||||
|
async (payload) => {
|
||||||
|
setCount((prevCount) => prevCount + 1);
|
||||||
|
await invoke("set_badge", { count });
|
||||||
|
|
||||||
|
const event: Event = JSON.parse(payload.payload);
|
||||||
|
const user = await ark.get_profile(event.pubkey);
|
||||||
|
const userName =
|
||||||
|
user.display_name || user.name || displayNpub(event.pubkey, 16);
|
||||||
|
|
||||||
|
switch (event.kind) {
|
||||||
|
case Kind.Text: {
|
||||||
|
sendNativeNotification("Mentioned you in a note", userName);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case Kind.Repost: {
|
||||||
|
sendNativeNotification("Reposted your note", userName);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case Kind.ZapReceipt: {
|
||||||
|
const amount = decodeZapInvoice(event.tags);
|
||||||
|
sendNativeNotification(
|
||||||
|
`Zapped ₿ ${amount.bitcoinFormatted}`,
|
||||||
|
userName,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unlisten.then((f) => f());
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setCount(0);
|
||||||
|
ark.open_activity(account);
|
||||||
|
}}
|
||||||
|
className="relative inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<BellIcon className="size-5" />
|
||||||
|
{count > 0 ? (
|
||||||
|
<span className="absolute right-0 top-0 block size-2 rounded-full bg-teal-500 ring-1 ring-black/5" />
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,18 +1,33 @@
|
|||||||
import { LoaderIcon } from "@lume/icons";
|
import type { Ark } from "@lume/ark";
|
||||||
|
import type { Interests, Metadata, Settings } from "@lume/types";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
|
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
|
||||||
import { type Ark } from "@lume/ark";
|
import type { Platform } from "@tauri-apps/plugin-os";
|
||||||
import { type QueryClient } from "@tanstack/react-query";
|
import type { Descendant } from "slate";
|
||||||
import { type Platform } from "@tauri-apps/plugin-os";
|
|
||||||
import { Account, Interests, Settings } from "@lume/types";
|
type EditorElement = {
|
||||||
|
type: string;
|
||||||
|
children: Descendant[];
|
||||||
|
eventId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
interface RouterContext {
|
interface RouterContext {
|
||||||
|
// System
|
||||||
ark: Ark;
|
ark: Ark;
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
|
// App info
|
||||||
platform?: Platform;
|
platform?: Platform;
|
||||||
locale?: string;
|
locale?: string;
|
||||||
|
// Settings
|
||||||
settings?: Settings;
|
settings?: Settings;
|
||||||
interests?: Interests;
|
interests?: Interests;
|
||||||
accounts?: Account[];
|
// Profile
|
||||||
|
accounts?: string[];
|
||||||
|
profile?: Metadata;
|
||||||
|
isNewUser?: boolean;
|
||||||
|
// Editor
|
||||||
|
initialValue?: EditorElement[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||||
@@ -24,9 +39,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 h-screen w-screen flex-col items-center justify-center">
|
||||||
<button type="button" className="size-5" disabled>
|
<Spinner className="size-5" />
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/activity/$account/messages")({
|
||||||
|
component: () => <div>Hello /activity/$account/messages!</div>,
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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,119 +0,0 @@
|
|||||||
import { RepostNote } from "@/components/repost";
|
|
||||||
import { TextNote } from "@/components/text";
|
|
||||||
import { LoaderIcon, ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
|
||||||
import { ColumnRouteSearch, Event, Kind } from "@lume/types";
|
|
||||||
import { Column } from "@lume/ui";
|
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
||||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { Virtualizer } from "virtua";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/antenas")({
|
|
||||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
|
||||||
return {
|
|
||||||
account: search.account,
|
|
||||||
label: search.label,
|
|
||||||
name: search.name,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function Screen() {
|
|
||||||
const { label, name, account } = Route.useSearch();
|
|
||||||
const { ark } = Route.useRouteContext();
|
|
||||||
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
|
||||||
useInfiniteQuery({
|
|
||||||
queryKey: [name, account],
|
|
||||||
initialPageParam: 0,
|
|
||||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
|
||||||
const events = await ark.get_events(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 (
|
|
||||||
<Column.Root>
|
|
||||||
<Column.Header label={label} name={name} />
|
|
||||||
<Column.Content>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : !data.length ? (
|
|
||||||
<Empty />
|
|
||||||
) : (
|
|
||||||
<Virtualizer overscan={3}>
|
|
||||||
{data.map((item) => renderItem(item))}
|
|
||||||
</Virtualizer>
|
|
||||||
)}
|
|
||||||
<div className="flex h-20 items-center justify-center">
|
|
||||||
{hasNextPage ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => fetchNextPage()}
|
|
||||||
disabled={!hasNextPage || isFetchingNextPage}
|
|
||||||
className="inline-flex h-12 w-36 items-center justify-center gap-2 rounded-full bg-neutral-100 px-3 font-medium hover:bg-neutral-200 focus:outline-none dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
|
||||||
>
|
|
||||||
{isFetchingNextPage ? (
|
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ArrowRightCircleIcon className="size-5" />
|
|
||||||
Load more
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</Column.Content>
|
|
||||||
</Column.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
|
import { CheckIcon } from "@lume/icons";
|
||||||
|
import type { AppRouteSearch } from "@lume/types";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
import { displayNsec } from "@lume/utils";
|
import { displayNsec } from "@lume/utils";
|
||||||
|
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 { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import * as Checkbox from "@radix-ui/react-checkbox";
|
|
||||||
import { CheckIcon } from "@lume/icons";
|
|
||||||
import { AppRouteSearch } from "@lume/types";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/auth/new/backup")({
|
export const Route = createFileRoute("/auth/new/backup")({
|
||||||
validateSearch: (search: Record<string, string>): AppRouteSearch => {
|
validateSearch: (search: Record<string, string>): AppRouteSearch => {
|
||||||
@@ -25,6 +26,7 @@ function Screen() {
|
|||||||
const [key, setKey] = useState(null);
|
const [key, setKey] = useState(null);
|
||||||
const [passphase, setPassphase] = useState("");
|
const [passphase, setPassphase] = useState("");
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
const [confirm, setConfirm] = useState({ c1: false, c2: false, c3: false });
|
const [confirm, setConfirm] = useState({ c1: false, c2: false, c3: false });
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -34,21 +36,27 @@ function Screen() {
|
|||||||
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 toast.warning("You need to confirm before continue");
|
||||||
} else {
|
}
|
||||||
|
|
||||||
return navigate({
|
return navigate({
|
||||||
to: "/auth/settings",
|
to: "/auth/settings",
|
||||||
search: { account },
|
search: { account },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const encrypted: string = await invoke("get_encrypted_key", {
|
// start loading
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
invoke("get_encrypted_key", {
|
||||||
npub: account,
|
npub: account,
|
||||||
password: passphase,
|
password: passphase,
|
||||||
});
|
}).then((encrypted: string) => {
|
||||||
|
// update state
|
||||||
setKey(encrypted);
|
setKey(encrypted);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
setLoading(false);
|
||||||
toast.error(String(e));
|
toast.error(String(e));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -82,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="h-11 w-full resize-none rounded-lg border-transparent bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,12 +106,12 @@ function Screen() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={displayNsec(key, 36)}
|
value={displayNsec(key, 36)}
|
||||||
readOnly
|
readOnly
|
||||||
className="h-11 w-full resize-none rounded-lg border-transparent bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900"
|
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"
|
||||||
/>
|
/>
|
||||||
<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-neutral-900 dark:hover:bg-neutral-700"
|
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"
|
||||||
>
|
>
|
||||||
{copied ? "Copied" : "Copy"}
|
{copied ? "Copied" : "Copy"}
|
||||||
</button>
|
</button>
|
||||||
@@ -118,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-neutral-900"
|
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||||
id="confirm1"
|
id="confirm1"
|
||||||
>
|
>
|
||||||
<Checkbox.Indicator className="text-blue-500">
|
<Checkbox.Indicator className="text-blue-500">
|
||||||
@@ -138,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-neutral-900"
|
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||||
id="confirm2"
|
id="confirm2"
|
||||||
>
|
>
|
||||||
<Checkbox.Indicator className="text-blue-500">
|
<Checkbox.Indicator className="text-blue-500">
|
||||||
@@ -158,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-neutral-900"
|
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||||
id="confirm3"
|
id="confirm3"
|
||||||
>
|
>
|
||||||
<Checkbox.Indicator className="text-blue-500">
|
<Checkbox.Indicator className="text-blue-500">
|
||||||
@@ -179,10 +187,11 @@ function Screen() {
|
|||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={submit}
|
onClick={() => submit()}
|
||||||
|
disabled={loading}
|
||||||
className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{t("global.continue")}
|
{loading ? <Spinner /> : t("global.continue")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { AvatarUploader } from "@/components/avatarUploader";
|
import { AvatarUploader } from "@/components/avatarUploader";
|
||||||
import { LoaderIcon, PlusIcon } from "@lume/icons";
|
import { PlusIcon } from "@lume/icons";
|
||||||
import { Metadata } from "@lume/types";
|
import type { Metadata } from "@lume/types";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -73,7 +74,7 @@ function Screen() {
|
|||||||
) : null}
|
) : null}
|
||||||
<AvatarUploader
|
<AvatarUploader
|
||||||
setPicture={setPicture}
|
setPicture={setPicture}
|
||||||
className="absolute inset-0 z-20 flex h-full w-full items-center justify-center rounded-full bg-black/10 text-white hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
className="absolute inset-0 z-20 flex h-full w-full items-center justify-center rounded-full dark:text-black bg-black/10 text-white hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
||||||
>
|
>
|
||||||
<PlusIcon className="size-8" />
|
<PlusIcon className="size-8" />
|
||||||
</AvatarUploader>
|
</AvatarUploader>
|
||||||
@@ -92,7 +93,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 focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -104,7 +105,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 focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -115,7 +116,7 @@ function Screen() {
|
|||||||
{...register("about")}
|
{...register("about")}
|
||||||
placeholder="e.g. Artist, anime-lover, and k-pop fan"
|
placeholder="e.g. Artist, anime-lover, and k-pop fan"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className="relative h-24 w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-2 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
className="relative h-24 w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-2 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -127,18 +128,14 @@ 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 focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? <Spinner /> : t("global.continue")}
|
||||||
<LoaderIcon className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
t("global.continue")
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { LoaderIcon } from "@lume/icons";
|
import { Spinner } from "@lume/ui";
|
||||||
import { createLazyFileRoute, useNavigate } from "@tanstack/react-router";
|
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ export const Route = createLazyFileRoute("/auth/privkey")({
|
|||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const { ark } = Route.useRouteContext();
|
const { ark } = Route.useRouteContext();
|
||||||
const navigate = useNavigate();
|
const navigate = Route.useNavigate();
|
||||||
|
|
||||||
const [key, setKey] = useState("");
|
const [key, setKey] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@@ -20,23 +20,23 @@ function Screen() {
|
|||||||
return toast.warning(
|
return toast.warning(
|
||||||
"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",
|
||||||
);
|
);
|
||||||
if (key.length < 30)
|
|
||||||
return toast.warning("You need to enter a valid private key");
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
const npub = await ark.save_account(key, password);
|
const npub = await ark.save_account(key, password);
|
||||||
|
|
||||||
|
if (npub) {
|
||||||
navigate({
|
navigate({
|
||||||
to: "/auth/settings",
|
to: "/auth/settings",
|
||||||
search: { account: npub, new: false },
|
search: { account: npub },
|
||||||
replace: true,
|
replace: true,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
setLoading(false);
|
||||||
toast.error(e);
|
toast.error(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -58,7 +58,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-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -73,16 +73,16 @@ 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-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{loading ? <LoaderIcon className="size-4 animate-spin" /> : "Login"}
|
{loading ? <Spinner /> : "Login"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,74 @@
|
|||||||
|
import { Spinner } from "@lume/ui";
|
||||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||||
|
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() {
|
||||||
return <div>#todo</div>;
|
const { ark } = Route.useRouteContext();
|
||||||
|
const navigate = Route.useNavigate();
|
||||||
|
|
||||||
|
const [uri, setUri] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!uri.startsWith("bunker://"))
|
||||||
|
return toast.warning(
|
||||||
|
"You need to enter a valid Connect URI starts with bunker://",
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const npub = await ark.nostr_connect(uri);
|
||||||
|
|
||||||
|
if (npub) {
|
||||||
|
navigate({
|
||||||
|
to: "/auth/settings",
|
||||||
|
search: { account: npub },
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setLoading(false);
|
||||||
|
toast.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-xl font-semibold">Continue with Nostr Connect</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-col gap-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="uri"
|
||||||
|
className="font-medium text-neutral-900 dark:text-neutral-100"
|
||||||
|
>
|
||||||
|
Connect URI
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
name="uri"
|
||||||
|
type="text"
|
||||||
|
placeholder="bunker://..."
|
||||||
|
value={uri}
|
||||||
|
onChange={(e) => setUri(e.target.value)}
|
||||||
|
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => submit()}
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? <Spinner /> : "Login"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { LaurelIcon, LoaderIcon } from "@lume/icons";
|
import { LaurelIcon } from "@lume/icons";
|
||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
import type { AppRouteSearch, Settings } from "@lume/types";
|
||||||
import { useTranslation } from "react-i18next";
|
import { Spinner } from "@lume/ui";
|
||||||
import * as Switch from "@radix-ui/react-switch";
|
import * as Switch from "@radix-ui/react-switch";
|
||||||
import { useState } from "react";
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { AppRouteSearch, Settings } from "@lume/types";
|
|
||||||
import {
|
import {
|
||||||
isPermissionGranted,
|
isPermissionGranted,
|
||||||
requestPermission,
|
requestPermission,
|
||||||
} from "@tauri-apps/plugin-notification";
|
} from "@tauri-apps/plugin-notification";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export const Route = createFileRoute("/auth/settings")({
|
export const Route = createFileRoute("/auth/settings")({
|
||||||
@@ -37,6 +38,7 @@ function Screen() {
|
|||||||
const { ark, settings } = Route.useRouteContext();
|
const { ark, settings } = Route.useRouteContext();
|
||||||
|
|
||||||
const [newSettings, setNewSettings] = useState<Settings>(settings);
|
const [newSettings, setNewSettings] = useState<Settings>(settings);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const toggleNofitication = async () => {
|
const toggleNofitication = async () => {
|
||||||
await requestPermission();
|
await requestPermission();
|
||||||
@@ -67,13 +69,30 @@ function Screen() {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleNsfw = () => {
|
||||||
|
setNewSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
nsfw: !newSettings.nsfw,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
try {
|
try {
|
||||||
const eventId = await ark.set_settings(settings);
|
// start loading
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// publish settings
|
||||||
|
const eventId = await ark.set_settings(newSettings);
|
||||||
|
|
||||||
if (eventId) {
|
if (eventId) {
|
||||||
navigate({ to: "/$account/home", params: { account }, replace: true });
|
return navigate({
|
||||||
|
to: "/$account/home",
|
||||||
|
params: { account },
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
setLoading(false);
|
||||||
toast.error(e);
|
toast.error(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -81,7 +100,7 @@ function Screen() {
|
|||||||
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="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 flex-col items-center gap-5 text-center">
|
||||||
<div className="flex size-20 items-center justify-center rounded-full bg-teal-100 text-teal-500">
|
<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" />
|
<LaurelIcon className="size-8" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -95,14 +114,7 @@ function Screen() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<div className="flex flex-col gap-3">
|
<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-neutral-900">
|
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
||||||
<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-neutral-800"
|
|
||||||
>
|
|
||||||
<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 className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="font-semibold">Push Notification</h3>
|
<h3 className="font-semibold">Push Notification</h3>
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
@@ -110,15 +122,15 @@ function Screen() {
|
|||||||
notifications from Lume.
|
notifications from Lume.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900">
|
|
||||||
<Switch.Root
|
<Switch.Root
|
||||||
checked={newSettings.enhancedPrivacy}
|
checked={newSettings.notification}
|
||||||
onClick={() => toggleEnhancedPrivacy()}
|
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-neutral-800"
|
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.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||||
</Switch.Root>
|
</Switch.Root>
|
||||||
|
</div>
|
||||||
|
<div 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">
|
<div className="flex-1">
|
||||||
<h3 className="font-semibold">Enhanced Privacy</h3>
|
<h3 className="font-semibold">Enhanced Privacy</h3>
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
@@ -126,30 +138,30 @@ function Screen() {
|
|||||||
preview as plain text.
|
preview as plain text.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900">
|
|
||||||
<Switch.Root
|
<Switch.Root
|
||||||
checked={newSettings.autoUpdate}
|
checked={newSettings.enhancedPrivacy}
|
||||||
onClick={() => toggleAutoUpdate()}
|
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-neutral-800"
|
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.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||||
</Switch.Root>
|
</Switch.Root>
|
||||||
|
</div>
|
||||||
|
<div 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">
|
<div className="flex-1">
|
||||||
<h3 className="font-semibold">Auto Update</h3>
|
<h3 className="font-semibold">Auto Update</h3>
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
Automatically download and install new version.
|
Automatically download and install new version.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900">
|
|
||||||
<Switch.Root
|
<Switch.Root
|
||||||
checked={newSettings.zap}
|
checked={newSettings.autoUpdate}
|
||||||
onClick={() => toggleZap()}
|
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-neutral-800"
|
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.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||||
</Switch.Root>
|
</Switch.Root>
|
||||||
|
</div>
|
||||||
|
<div 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">
|
<div className="flex-1">
|
||||||
<h3 className="font-semibold">Zap</h3>
|
<h3 className="font-semibold">Zap</h3>
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
@@ -157,18 +169,36 @@ function Screen() {
|
|||||||
for send Bitcoin tip to other users.
|
for send Bitcoin tip to other users.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-50 px-5 py-4 dark:bg-neutral-950">
|
<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">
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
There are many more settings you can configure from the 'Settings'
|
By default, Lume will display all content which have Content
|
||||||
Screen. Be sure to visit it later.
|
Warning tag, it's may include NSFW content.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={submit}
|
onClick={() => submit()}
|
||||||
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"
|
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")}
|
{t("global.continue")}
|
||||||
</button>
|
</button>
|
||||||
@@ -181,7 +211,7 @@ function Pending() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
<button type="button" className="size-5" disabled>
|
<button type="button" className="size-5" disabled>
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
<Spinner className="size-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { CheckCircleIcon } from "@lume/icons";
|
import { CheckCircleIcon } from "@lume/icons";
|
||||||
import { ColumnRouteSearch } from "@lume/types";
|
import type { ColumnRouteSearch } from "@lume/types";
|
||||||
import { Column, User } from "@lume/ui";
|
import { Spinner, User } from "@lume/ui";
|
||||||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -26,10 +26,11 @@ function Screen() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { ark } = Route.useRouteContext();
|
const { ark } = Route.useRouteContext();
|
||||||
const { label, name, redirect } = Route.useSearch();
|
const { label, redirect } = Route.useSearch();
|
||||||
|
|
||||||
const [title, setTitle] = useState<string>("Just a new group");
|
const [title, setTitle] = useState<string>("Just a new group");
|
||||||
const [users, setUsers] = useState<Array<string>>([]);
|
const [users, setUsers] = useState<Array<string>>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
const [isDone, setIsDone] = useState(false);
|
const [isDone, setIsDone] = useState(false);
|
||||||
|
|
||||||
const toggleUser = (pubkey: string) => {
|
const toggleUser = (pubkey: string) => {
|
||||||
@@ -43,21 +44,28 @@ function Screen() {
|
|||||||
try {
|
try {
|
||||||
if (isDone) return router.history.push(redirect);
|
if (isDone) return router.history.push(redirect);
|
||||||
|
|
||||||
|
// start loading
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
const groups = await ark.set_nstore(
|
const groups = await ark.set_nstore(
|
||||||
`lume_group_${label}`,
|
`lume_group_${label}`,
|
||||||
JSON.stringify(users),
|
JSON.stringify(users),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (groups) setIsDone(true);
|
if (groups) {
|
||||||
|
toast.success("Group has been created successfully.");
|
||||||
|
// start loading
|
||||||
|
setIsDone(true);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
setLoading(false);
|
||||||
toast.error(e);
|
toast.error(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column.Root>
|
<div className="h-full overflow-y-auto scrollbar-none">
|
||||||
<Column.Header label={label} name={name} />
|
|
||||||
<Column.Content>
|
|
||||||
<div className="flex flex-col gap-5 p-3">
|
<div className="flex flex-col gap-5 p-3">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label htmlFor="name" className="font-medium">
|
<label htmlFor="name" className="font-medium">
|
||||||
@@ -68,13 +76,13 @@ function Screen() {
|
|||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="Nostrichs..."
|
placeholder="Nostrichs..."
|
||||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="inline-flex items-center justify-between">
|
<div className="inline-flex items-center justify-between">
|
||||||
<span className="font-medium">Pick user</span>
|
<span className="font-medium">Pick user</span>
|
||||||
<span className="text-xs text-neutral-600 dark:text-neutral-400">{`${users.length} / ∞`}</span>
|
<span className="text-neutral-600 dark:text-neutral-400">{`${users.length} / ∞`}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{contacts.map((item: string) => (
|
{contacts.map((item: string) => (
|
||||||
@@ -82,14 +90,14 @@ function Screen() {
|
|||||||
key={item}
|
key={item}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleUser(item)}
|
onClick={() => toggleUser(item)}
|
||||||
className="inline-flex items-center justify-between px-3 py-2 rounded-xl bg-neutral-50 dark:bg-neutral-950 hover:bg-neutral-100 dark:hover:bg-neutral-900"
|
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"
|
||||||
>
|
>
|
||||||
<User.Provider pubkey={item}>
|
<User.Provider pubkey={item}>
|
||||||
<User.Root className="flex items-center gap-2.5">
|
<User.Root className="flex items-center gap-2.5">
|
||||||
<User.Avatar className="size-10 rounded-full object-cover" />
|
<User.Avatar className="size-10 rounded-full object-cover" />
|
||||||
<div className="flex flex-col items-start">
|
<div className="flex items-center gap-1">
|
||||||
<User.Name className="font-medium" />
|
<User.Name className="font-medium" />
|
||||||
<User.NIP05 className="text-neutral-700 dark:text-neutral-300" />
|
<User.NIP05 />
|
||||||
</div>
|
</div>
|
||||||
</User.Root>
|
</User.Root>
|
||||||
</User.Provider>
|
</User.Provider>
|
||||||
@@ -101,17 +109,18 @@ function Screen() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="fixed z-10 flex items-center justify-center w-full bottom-3">
|
<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={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-36 h-11 hover:bg-blue-600 focus:outline-none disabled:cursor-not-allowed"
|
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"
|
||||||
>
|
>
|
||||||
{isDone ? "Back" : "Update"}
|
{isDone ? "Back" : loading ? <Spinner /> : "Update"}
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Column.Content>
|
|
||||||
</Column.Root>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
import { AddMediaIcon, LoaderIcon } from "@lume/icons";
|
import { AddMediaIcon } from "@lume/icons";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
import { cn, insertImage, isImagePath } from "@lume/utils";
|
import { cn, insertImage, isImagePath } from "@lume/utils";
|
||||||
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
|
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||||
|
import { getCurrent } from "@tauri-apps/api/window";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useSlateStatic } from "slate-react";
|
import { useSlateStatic } from "slate-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getCurrent } from "@tauri-apps/api/window";
|
|
||||||
import { UnlistenFn } from "@tauri-apps/api/event";
|
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export function MediaButton({ className }: { className?: string }) {
|
export function MediaButton({ className }: { className?: string }) {
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
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 uploadToNostrBuild = async () => {
|
||||||
try {
|
try {
|
||||||
|
// start loading
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const image = await ark.upload();
|
const image = await ark.upload();
|
||||||
|
|
||||||
if (image) {
|
|
||||||
insertImage(editor, image);
|
insertImage(editor, image);
|
||||||
}
|
|
||||||
|
|
||||||
|
// reset loading
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -62,6 +62,9 @@ export function MediaButton({ className }: { className?: string }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Tooltip.Provider>
|
||||||
|
<Tooltip.Root delayDuration={150}>
|
||||||
|
<Tooltip.Trigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => uploadToNostrBuild()}
|
onClick={() => uploadToNostrBuild()}
|
||||||
@@ -69,10 +72,19 @@ export function MediaButton({ className }: { className?: string }) {
|
|||||||
className={cn("inline-flex items-center justify-center", className)}
|
className={cn("inline-flex items-center justify-center", className)}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
<Spinner className="size-4" />
|
||||||
) : (
|
) : (
|
||||||
<AddMediaIcon className="size-5" />
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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 PowToggle({
|
||||||
|
pow,
|
||||||
|
setPow,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
pow: boolean;
|
||||||
|
setPow: Dispatch<SetStateAction<boolean>>;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Tooltip.Provider>
|
||||||
|
<Tooltip.Root delayDuration={150}>
|
||||||
|
<Tooltip.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPow((prev) => !prev)}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center",
|
||||||
|
className,
|
||||||
|
pow ? "bg-blue-500 text-white" : "",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<NsfwIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Portal>
|
||||||
|
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
||||||
|
Proof of Work
|
||||||
|
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Portal>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</Tooltip.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,113 +1,84 @@
|
|||||||
import { LoaderIcon, TrashIcon } from "@lume/icons";
|
import { ComposeFilledIcon, TrashIcon } from "@lume/icons";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { MentionNote } from "@lume/ui/src/note/mentions/note";
|
||||||
import {
|
import {
|
||||||
Portal,
|
|
||||||
cn,
|
cn,
|
||||||
insertImage,
|
insertImage,
|
||||||
insertMention,
|
|
||||||
insertNostrEvent,
|
insertNostrEvent,
|
||||||
isImageUrl,
|
isImageUrl,
|
||||||
sendNativeNotification,
|
sendNativeNotification,
|
||||||
} from "@lume/utils";
|
} from "@lume/utils";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { MediaButton } from "./-components/media";
|
|
||||||
import { MentionNote } from "@lume/ui/src/note/mentions/note";
|
|
||||||
import {
|
|
||||||
Descendant,
|
|
||||||
Editor,
|
|
||||||
Node,
|
|
||||||
Range,
|
|
||||||
Transforms,
|
|
||||||
createEditor,
|
|
||||||
} from "slate";
|
|
||||||
import {
|
|
||||||
ReactEditor,
|
|
||||||
useSlateStatic,
|
|
||||||
useSelected,
|
|
||||||
useFocused,
|
|
||||||
withReact,
|
|
||||||
Slate,
|
|
||||||
Editable,
|
|
||||||
} from "slate-react";
|
|
||||||
import { Contact } from "@lume/types";
|
|
||||||
import { User } from "@lume/ui";
|
|
||||||
import { nip19 } from "nostr-tools";
|
import { nip19 } from "nostr-tools";
|
||||||
import { queryOptions, useSuspenseQuery } from "@tanstack/react-query";
|
import { useState } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { type Descendant, Node, Transforms, createEditor } from "slate";
|
||||||
|
import {
|
||||||
|
Editable,
|
||||||
|
ReactEditor,
|
||||||
|
Slate,
|
||||||
|
useFocused,
|
||||||
|
useSelected,
|
||||||
|
useSlateStatic,
|
||||||
|
withReact,
|
||||||
|
} from "slate-react";
|
||||||
|
import { MediaButton } from "./-components/media";
|
||||||
|
import { NsfwToggle } from "./-components/nsfw";
|
||||||
|
import { MentionButton } from "./-components/mention";
|
||||||
|
|
||||||
type EditorElement = {
|
type EditorSearch = {
|
||||||
type: string;
|
reply_to: string;
|
||||||
children: Descendant[];
|
quote: boolean;
|
||||||
eventId?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const contactQueryOptions = queryOptions({
|
|
||||||
queryKey: ["contacts"],
|
|
||||||
queryFn: () => invoke("get_contact_metadata"),
|
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnReconnect: false,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/editor/")({
|
export const Route = createFileRoute("/editor/")({
|
||||||
loader: ({ context }) =>
|
validateSearch: (search: Record<string, string>): EditorSearch => {
|
||||||
context.queryClient.ensureQueryData(contactQueryOptions),
|
return {
|
||||||
component: Screen,
|
reply_to: search.reply_to,
|
||||||
pendingComponent: Pending,
|
quote: search.quote === "true" ?? false,
|
||||||
});
|
};
|
||||||
|
},
|
||||||
function Screen() {
|
beforeLoad: async ({ search }) => {
|
||||||
// @ts-ignore, useless
|
return {
|
||||||
const { reply_to, quote } = Route.useSearch();
|
initialValue: search.quote
|
||||||
const { ark } = Route.useRouteContext();
|
? [
|
||||||
|
|
||||||
let initialValue: EditorElement[];
|
|
||||||
|
|
||||||
if (quote) {
|
|
||||||
initialValue = [
|
|
||||||
{
|
{
|
||||||
type: "paragraph",
|
type: "paragraph",
|
||||||
children: [{ text: "" }],
|
children: [{ text: "" }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "event",
|
type: "event",
|
||||||
eventId: `nostr:${nip19.noteEncode(reply_to)}`,
|
eventId: `nostr:${nip19.noteEncode(search.reply_to)}`,
|
||||||
children: [{ text: "" }],
|
children: [{ text: "" }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "paragraph",
|
type: "paragraph",
|
||||||
children: [{ text: "" }],
|
children: [{ text: "" }],
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
} else {
|
: [
|
||||||
initialValue = [
|
|
||||||
{
|
{
|
||||||
type: "paragraph",
|
type: "paragraph",
|
||||||
children: [{ text: "" }],
|
children: [{ text: "" }],
|
||||||
},
|
},
|
||||||
];
|
],
|
||||||
}
|
};
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
const ref = useRef<HTMLDivElement | null>();
|
function Screen() {
|
||||||
const contacts = useSuspenseQuery(contactQueryOptions).data as Contact[];
|
const { reply_to, quote } = Route.useSearch();
|
||||||
|
const { ark, initialValue } = Route.useRouteContext();
|
||||||
|
|
||||||
const [t] = useTranslation();
|
const [t] = useTranslation();
|
||||||
const [editorValue, setEditorValue] = useState(initialValue);
|
const [editorValue, setEditorValue] = useState(initialValue);
|
||||||
const [target, setTarget] = useState<Range | undefined>();
|
|
||||||
const [index, setIndex] = useState(0);
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [nsfw, setNsfw] = useState(false);
|
||||||
const [editor] = useState(() =>
|
const [editor] = useState(() =>
|
||||||
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
|
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
|
||||||
);
|
);
|
||||||
|
|
||||||
const filters = contacts
|
|
||||||
?.filter((c) =>
|
|
||||||
c?.profile.name?.toLowerCase().startsWith(search.toLowerCase()),
|
|
||||||
)
|
|
||||||
?.slice(0, 5);
|
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
// @ts-expect-error, backlog
|
// @ts-expect-error, backlog
|
||||||
editor.children = [{ type: "paragraph", children: [{ text: "" }] }];
|
editor.children = [{ type: "paragraph", children: [{ text: "" }] }];
|
||||||
@@ -147,11 +118,15 @@ function Screen() {
|
|||||||
const eventId = await ark.publish(content, reply_to, quote);
|
const eventId = await ark.publish(content, reply_to, quote);
|
||||||
|
|
||||||
if (eventId) {
|
if (eventId) {
|
||||||
await sendNativeNotification("You've publish new post successfully.");
|
await sendNativeNotification(
|
||||||
|
"Your note has been published successfully.",
|
||||||
|
"Lume",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// stop loading
|
// stop loading
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
// reset form
|
// reset form
|
||||||
reset();
|
reset();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -160,69 +135,40 @@ function Screen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (target && filters.length > 0) {
|
|
||||||
const el = ref.current;
|
|
||||||
const domRange = ReactEditor.toDOMRange(editor, target);
|
|
||||||
const rect = domRange.getBoundingClientRect();
|
|
||||||
el.style.top = `${rect.top + window.scrollY + 24}px`;
|
|
||||||
el.style.left = `${rect.left + window.scrollX}px`;
|
|
||||||
}
|
|
||||||
}, [filters.length, editor, index, search, target]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-screen flex-col bg-gradient-to-tr from-neutral-200 to-neutral-100 dark:from-neutral-950 dark:to-neutral-900">
|
<div className="w-full h-full">
|
||||||
<Slate
|
<Slate editor={editor} initialValue={editorValue}>
|
||||||
editor={editor}
|
|
||||||
initialValue={editorValue}
|
|
||||||
onChange={() => {
|
|
||||||
const { selection } = editor;
|
|
||||||
|
|
||||||
if (selection && Range.isCollapsed(selection)) {
|
|
||||||
const [start] = Range.edges(selection);
|
|
||||||
const wordBefore = Editor.before(editor, start, { unit: "word" });
|
|
||||||
const before = wordBefore && Editor.before(editor, wordBefore);
|
|
||||||
const beforeRange = before && Editor.range(editor, before, start);
|
|
||||||
const beforeText =
|
|
||||||
beforeRange && Editor.string(editor, beforeRange);
|
|
||||||
const beforeMatch = beforeText?.match(/^@(\w+)$/);
|
|
||||||
const after = Editor.after(editor, start);
|
|
||||||
const afterRange = Editor.range(editor, start, after);
|
|
||||||
const afterText = Editor.string(editor, afterRange);
|
|
||||||
const afterMatch = afterText.match(/^(\s|$)/);
|
|
||||||
|
|
||||||
if (beforeMatch && afterMatch) {
|
|
||||||
setTarget(beforeRange);
|
|
||||||
setSearch(beforeMatch[1]);
|
|
||||||
setIndex(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setTarget(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
className="flex h-16 w-full shrink-0 items-center justify-end gap-3 px-2"
|
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"
|
||||||
>
|
>
|
||||||
<MediaButton className="size-9 rounded-full bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700" />
|
<NsfwToggle
|
||||||
|
nsfw={nsfw}
|
||||||
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={publish}
|
onClick={() => publish()}
|
||||||
className="inline-flex h-9 w-24 items-center justify-center rounded-full bg-blue-500 px-3 font-medium text-white hover:bg-blue-600"
|
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 ? (
|
{loading ? (
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
<Spinner className="size-4" />
|
||||||
) : (
|
) : (
|
||||||
t("global.post")
|
<ComposeFilledIcon className="size-4" />
|
||||||
)}
|
)}
|
||||||
|
{t("global.post")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-full min-h-0 w-full">
|
<div className="flex h-full w-full flex-1 flex-col">
|
||||||
<div className="flex h-full w-full flex-1 flex-col gap-2 px-2 pb-2">
|
{reply_to && !quote ? (
|
||||||
{reply_to && !quote ? <MentionNote eventId={reply_to} /> : null}
|
<div className="px-4 py-2">
|
||||||
<div className="h-full w-full flex-1 overflow-hidden overflow-y-auto rounded-xl bg-white p-5 shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:bg-black dark:shadow-none dark:ring-1 dark:ring-white/5">
|
<MentionNote eventId={reply_to} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="overflow-y-auto p-4">
|
||||||
<Editable
|
<Editable
|
||||||
key={JSON.stringify(editorValue)}
|
key={JSON.stringify(editorValue)}
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
@@ -235,37 +181,6 @@ function Screen() {
|
|||||||
}
|
}
|
||||||
className="focus:outline-none"
|
className="focus:outline-none"
|
||||||
/>
|
/>
|
||||||
{target && filters.length > 0 && (
|
|
||||||
<Portal>
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className="absolute left-[-9999px] top-[-9999px] z-10 w-[250px] rounded-xl border border-neutral-50 bg-white p-2 shadow-lg dark:border-neutral-900 dark:bg-neutral-950"
|
|
||||||
>
|
|
||||||
{filters.map((contact) => (
|
|
||||||
<button
|
|
||||||
key={contact.pubkey}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
Transforms.select(editor, target);
|
|
||||||
insertMention(editor, contact);
|
|
||||||
setTarget(null);
|
|
||||||
}}
|
|
||||||
className="flex w-full flex-col rounded-lg p-2 hover:bg-neutral-100 dark:hover:bg-neutral-900"
|
|
||||||
>
|
|
||||||
<User.Provider pubkey={contact.pubkey}>
|
|
||||||
<User.Root className="flex w-full items-center gap-2">
|
|
||||||
<User.Avatar className="size-7 shrink-0 rounded-full object-cover" />
|
|
||||||
<div className="flex w-full flex-col items-start">
|
|
||||||
<User.Name className="max-w-[8rem] truncate text-sm font-medium" />
|
|
||||||
</div>
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Portal>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Slate>
|
</Slate>
|
||||||
@@ -273,20 +188,6 @@ function Screen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Pending() {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-tauri-drag-region
|
|
||||||
className="flex h-full w-full items-center justify-center gap-2.5"
|
|
||||||
>
|
|
||||||
<button type="button" disabled>
|
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
</button>
|
|
||||||
<p>Loading cache...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const withNostrEvent = (editor: ReactEditor) => {
|
const withNostrEvent = (editor: ReactEditor) => {
|
||||||
const { insertData, isVoid } = editor;
|
const { insertData, isVoid } = editor;
|
||||||
|
|
||||||
@@ -432,7 +333,7 @@ const Element = (props) => {
|
|||||||
return <Event {...props} />;
|
return <Event {...props} />;
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<p {...attributes} className="text-lg">
|
<p {...attributes} className="text-[15px]">
|
||||||
{children}
|
{children}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
import { useEvent } from "@lume/ark";
|
|
||||||
import { LoaderIcon } from "@lume/icons";
|
|
||||||
import { Box, Container, Note, User } from "@lume/ui";
|
|
||||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
|
||||||
import { ReplyList } from "./-components/replyList";
|
|
||||||
import { WindowVirtualizer } from "virtua";
|
|
||||||
import { type Event } from "@lume/types";
|
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/events/$eventId")({
|
|
||||||
component: Event,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Event() {
|
|
||||||
const { eventId } = Route.useParams();
|
|
||||||
const { isLoading, isError, data } = useEvent(eventId);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<p>Not found.</p>
|
|
||||||
</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container withDrag>
|
|
||||||
<Box className="px-3 pt-3 scrollbar-none">
|
|
||||||
<WindowVirtualizer>
|
|
||||||
<MainNote data={data} />
|
|
||||||
{data ? <ReplyList eventId={eventId} /> : null}
|
|
||||||
</WindowVirtualizer>
|
|
||||||
</Box>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MainNote({ data }: { data: Event }) {
|
|
||||||
return (
|
|
||||||
<Note.Provider event={data}>
|
|
||||||
<Note.Root className="flex flex-col pb-3">
|
|
||||||
<User.Provider pubkey={data.pubkey}>
|
|
||||||
<User.Root className="mb-3 flex flex-1 items-center gap-3">
|
|
||||||
<User.Avatar className="size-11 shrink-0 rounded-full object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
|
|
||||||
<div className="flex flex-1 flex-col">
|
|
||||||
<User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" />
|
|
||||||
<div className="inline-flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400">
|
|
||||||
<User.Time time={data.created_at} />
|
|
||||||
<span>·</span>
|
|
||||||
<User.NIP05 />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
<Note.Thread className="mb-2" />
|
|
||||||
<Note.Content className="min-w-0" compact={false} />
|
|
||||||
<div className="mt-4 flex items-center justify-between">
|
|
||||||
<div className="-ml-1 inline-flex items-center gap-4">
|
|
||||||
<Note.Repost />
|
|
||||||
<Note.Zap />
|
|
||||||
</div>
|
|
||||||
<Note.Menu />
|
|
||||||
</div>
|
|
||||||
</Note.Root>
|
|
||||||
</Note.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { useEvent } from "@lume/ark";
|
||||||
|
import type { Event } from "@lume/types";
|
||||||
|
import { Box, Container, Note, Spinner } from "@lume/ui";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { WindowVirtualizer } from "virtua";
|
||||||
|
import { ReplyList } from "./-components/replyList";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/events/$eventId")({
|
||||||
|
beforeLoad: async ({ context }) => {
|
||||||
|
const ark = context.ark;
|
||||||
|
const settings = await ark.get_settings();
|
||||||
|
|
||||||
|
return { settings };
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const { eventId } = Route.useParams();
|
||||||
|
const { isLoading, isError, data } = useEvent(eventId);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<Spinner className="size-5" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<p>Not found.</p>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container withDrag>
|
||||||
|
<Box className="scrollbar-none">
|
||||||
|
<WindowVirtualizer>
|
||||||
|
<MainNote data={data} />
|
||||||
|
{data ? (
|
||||||
|
<ReplyList eventId={eventId} />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<Spinner className="size-5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</WindowVirtualizer>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MainNote({ data }: { data: 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,37 +1,26 @@
|
|||||||
import { EventWithReplies } from "@lume/types";
|
import type { EventWithReplies } from "@lume/types";
|
||||||
|
import { Note } from "@lume/ui";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { Note, User } from "@lume/ui";
|
|
||||||
import { SubReply } from "./subReply";
|
import { SubReply } from "./subReply";
|
||||||
|
|
||||||
export function Reply({ event }: { event: EventWithReplies }) {
|
export function Reply({ event }: { event: EventWithReplies }) {
|
||||||
return (
|
return (
|
||||||
<Note.Provider event={event}>
|
<Note.Provider event={event}>
|
||||||
<Note.Root className="border-t border-neutral-100 pt-3 dark:border-neutral-900">
|
<Note.Root className="border-t border-neutral-100 dark:border-neutral-900">
|
||||||
<User.Provider pubkey={event.pubkey}>
|
<div className="px-3 h-14 flex items-center justify-between">
|
||||||
<User.Root className="mb-2 flex items-center justify-between">
|
<Note.User />
|
||||||
<div className="inline-flex gap-2">
|
<Note.Menu />
|
||||||
<User.Avatar className="size-6 rounded-full" />
|
|
||||||
<div className="inline-flex items-center gap-2">
|
|
||||||
<User.Name className="font-semibold" />
|
|
||||||
<User.NIP05 className="text-base lowercase text-neutral-600 dark:text-neutral-400" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Note.ContentLarge className="px-3" />
|
||||||
<User.Time time={event.created_at} />
|
<div className="mt-3 flex items-center gap-4 px-3 h-14">
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
<Note.Content />
|
|
||||||
<div className="mt-4 flex items-center justify-between">
|
|
||||||
<div className="-ml-1 inline-flex items-center gap-4">
|
|
||||||
<Note.Reply />
|
<Note.Reply />
|
||||||
<Note.Repost />
|
<Note.Repost />
|
||||||
<Note.Zap />
|
<Note.Zap />
|
||||||
</div>
|
</div>
|
||||||
<Note.Menu />
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
event.replies?.length > 0
|
event.replies?.length > 0
|
||||||
? "my-3 mt-6 flex flex-col gap-3 divide-y divide-neutral-100 border-l-2 border-neutral-100 pl-6 dark:divide-neutral-900 dark:border-neutral-900"
|
? "py-2 pl-3 flex flex-col gap-3 divide-y divide-neutral-100 bg-neutral-50 dark:bg-white/5 border-l-2 border-blue-500 dark:divide-neutral-900"
|
||||||
: "",
|
: "",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { LoaderIcon } from "@lume/icons";
|
import type { EventWithReplies } from "@lume/types";
|
||||||
|
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 { EventWithReplies } from "@lume/types";
|
|
||||||
import { Reply } from "./reply";
|
import { Reply } from "./reply";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export function ReplyList({
|
export function ReplyList({
|
||||||
eventId,
|
eventId,
|
||||||
@@ -26,13 +26,16 @@ export function ReplyList({
|
|||||||
}, [eventId]);
|
}, [eventId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col gap-3", className)}>
|
<div className={cn("flex flex-col", className)}>
|
||||||
|
<div className="h-11 flex px-3 items-center text-sm font-semibold text-neutral-700 dark:text-neutral-300 border-t border-neutral-100 dark:border-neutral-900">
|
||||||
|
Replies ({data?.length ?? 0})
|
||||||
|
</div>
|
||||||
{!data ? (
|
{!data ? (
|
||||||
<div className="mt-4 flex h-16 items-center justify-center p-3">
|
<div className="flex h-16 items-center justify-center p-3">
|
||||||
<LoaderIcon className="h-5 w-5 animate-spin" />
|
<Spinner className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
) : data.length === 0 ? (
|
) : data.length === 0 ? (
|
||||||
<div className="mt-4 flex w-full items-center justify-center">
|
<div className="flex w-full items-center justify-center">
|
||||||
<div className="flex flex-col items-center justify-center gap-2 py-6">
|
<div className="flex flex-col items-center justify-center gap-2 py-6">
|
||||||
<h3 className="text-3xl">👋</h3>
|
<h3 className="text-3xl">👋</h3>
|
||||||
<p className="leading-none text-neutral-600 dark:text-neutral-400">
|
<p className="leading-none text-neutral-600 dark:text-neutral-400">
|
||||||
|
|||||||
@@ -1,31 +1,20 @@
|
|||||||
import { Event } from "@lume/types";
|
import type { Event } from "@lume/types";
|
||||||
import { Note, User } from "@lume/ui";
|
import { Note } from "@lume/ui";
|
||||||
|
|
||||||
export function SubReply({ event }: { event: Event; rootEventId?: string }) {
|
export function SubReply({ event }: { event: Event; rootEventId?: string }) {
|
||||||
return (
|
return (
|
||||||
<Note.Provider event={event}>
|
<Note.Provider event={event}>
|
||||||
<Note.Root className="pt-3">
|
<Note.Root>
|
||||||
<User.Provider pubkey={event.pubkey}>
|
<div className="px-3 h-14 flex items-center justify-between">
|
||||||
<User.Root className="mb-2 flex items-center justify-between">
|
<Note.User />
|
||||||
<div className="inline-flex gap-2">
|
<Note.Menu />
|
||||||
<User.Avatar className="size-6 rounded-full" />
|
|
||||||
<div className="inline-flex items-center gap-2">
|
|
||||||
<User.Name className="font-semibold" />
|
|
||||||
<User.NIP05 className="text-base lowercase text-neutral-600 dark:text-neutral-400" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Note.ContentLarge className="px-3" />
|
||||||
<User.Time time={event.created_at} />
|
<div className="mt-3 flex items-center gap-4 px-3">
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
<Note.Content />
|
|
||||||
<div className="mt-4 flex items-center justify-between">
|
|
||||||
<div className="-ml-1 inline-flex items-center gap-4">
|
|
||||||
<Note.Reply />
|
<Note.Reply />
|
||||||
<Note.Repost />
|
<Note.Repost />
|
||||||
<Note.Zap />
|
<Note.Zap />
|
||||||
</div>
|
</div>
|
||||||
<Note.Menu />
|
|
||||||
</div>
|
|
||||||
</Note.Root>
|
</Note.Root>
|
||||||
</Note.Provider>
|
</Note.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { RepostNote } from "@/components/repost";
|
import { RepostNote } from "@/components/repost";
|
||||||
import { TextNote } from "@/components/text";
|
import { TextNote } from "@/components/text";
|
||||||
import { LoaderIcon, ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
||||||
import { ColumnRouteSearch, Event, Kind } from "@lume/types";
|
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types";
|
||||||
import { Column } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { Link, createFileRoute, redirect } from "@tanstack/react-router";
|
import { Link, createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
import { Virtualizer } from "virtua";
|
import { Virtualizer } from "virtua";
|
||||||
@@ -18,6 +18,7 @@ export const Route = createFileRoute("/foryou")({
|
|||||||
beforeLoad: async ({ search, context }) => {
|
beforeLoad: async ({ search, context }) => {
|
||||||
const ark = context.ark;
|
const ark = context.ark;
|
||||||
const interests = await ark.get_interest();
|
const interests = await ark.get_interest();
|
||||||
|
const settings = await ark.get_settings();
|
||||||
|
|
||||||
if (!interests) {
|
if (!interests) {
|
||||||
throw redirect({
|
throw redirect({
|
||||||
@@ -31,16 +32,23 @@ export const Route = createFileRoute("/foryou")({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
interests,
|
interests,
|
||||||
|
settings,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function Screen() {
|
export function Screen() {
|
||||||
const { label, name, account } = Route.useSearch();
|
const { name, account } = Route.useSearch();
|
||||||
const { ark, interests } = Route.useRouteContext();
|
const { ark, interests } = Route.useRouteContext();
|
||||||
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
const {
|
||||||
useInfiniteQuery({
|
data,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
isFetchingNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
fetchNextPage,
|
||||||
|
} = useInfiniteQuery({
|
||||||
queryKey: [name, account],
|
queryKey: [name, account],
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||||
@@ -48,7 +56,6 @@ export function Screen() {
|
|||||||
interests.hashtags,
|
interests.hashtags,
|
||||||
20,
|
20,
|
||||||
pageParam,
|
pageParam,
|
||||||
true,
|
|
||||||
);
|
);
|
||||||
return events;
|
return events;
|
||||||
},
|
},
|
||||||
@@ -71,14 +78,19 @@ export function Screen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column.Root>
|
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
|
||||||
<Column.Header label={label} name={name} />
|
{isFetching && !isLoading && !isFetchingNextPage ? (
|
||||||
<Column.Content>
|
<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 ? (
|
{isLoading ? (
|
||||||
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
<div className="flex h-16 w-full items-center justify-center gap-2">
|
||||||
<button type="button" className="size-5" disabled>
|
<Spinner className="size-5" />
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
<span className="text-sm font-medium">Loading...</span>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
) : !data.length ? (
|
) : !data.length ? (
|
||||||
<Empty />
|
<Empty />
|
||||||
@@ -87,17 +99,16 @@ export function Screen() {
|
|||||||
{data.map((item) => renderItem(item))}
|
{data.map((item) => renderItem(item))}
|
||||||
</Virtualizer>
|
</Virtualizer>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data?.length && hasNextPage ? (
|
{data?.length && hasNextPage ? (
|
||||||
<div className="flex h-20 items-center justify-center">
|
<div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => fetchNextPage()}
|
onClick={() => fetchNextPage()}
|
||||||
disabled={isFetchingNextPage || isFetchingNextPage}
|
disabled={isFetchingNextPage || isLoading}
|
||||||
className="inline-flex h-12 w-36 items-center justify-center gap-2 rounded-full bg-neutral-100 px-3 font-medium hover:bg-neutral-200 focus:outline-none dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
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 ? (
|
{isFetchingNextPage ? (
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
<Spinner className="size-5" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ArrowRightCircleIcon className="size-5" />
|
<ArrowRightCircleIcon className="size-5" />
|
||||||
@@ -107,8 +118,7 @@ export function Screen() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</Column.Content>
|
</div>
|
||||||
</Column.Root>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
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 { LoaderIcon, ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
||||||
import { ColumnRouteSearch, Event, Kind } from "@lume/types";
|
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types";
|
||||||
import { Column } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||||
import { Virtualizer } from "virtua";
|
import { Virtualizer } from "virtua";
|
||||||
@@ -25,10 +27,16 @@ export const Route = createFileRoute("/global")({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function Screen() {
|
export function Screen() {
|
||||||
const { label, name, account } = Route.useSearch();
|
const { account } = Route.useSearch();
|
||||||
const { ark } = Route.useRouteContext();
|
const { ark } = Route.useRouteContext();
|
||||||
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
const {
|
||||||
useInfiniteQuery({
|
data,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
isFetchingNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
fetchNextPage,
|
||||||
|
} = useInfiniteQuery({
|
||||||
queryKey: ["global", account],
|
queryKey: ["global", account],
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||||
@@ -48,20 +56,39 @@ export function Screen() {
|
|||||||
switch (event.kind) {
|
switch (event.kind) {
|
||||||
case Kind.Repost:
|
case Kind.Repost:
|
||||||
return <RepostNote key={event.id} event={event} />;
|
return <RepostNote key={event.id} event={event} />;
|
||||||
default:
|
default: {
|
||||||
return <TextNote key={event.id} event={event} />;
|
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) {
|
||||||
|
return <Conversation 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" />;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column.Root>
|
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
|
||||||
<Column.Header label={label} name={name} />
|
{isFetching && !isLoading && !isFetchingNextPage ? (
|
||||||
<Column.Content>
|
<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 ? (
|
{isLoading ? (
|
||||||
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
<div className="flex h-16 w-full items-center justify-center gap-2">
|
||||||
<button type="button" className="size-5" disabled>
|
<Spinner className="size-5" />
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
<span className="text-sm font-medium">Loading...</span>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
) : !data.length ? (
|
) : !data.length ? (
|
||||||
<Empty />
|
<Empty />
|
||||||
@@ -71,15 +98,15 @@ export function Screen() {
|
|||||||
</Virtualizer>
|
</Virtualizer>
|
||||||
)}
|
)}
|
||||||
{data?.length && hasNextPage ? (
|
{data?.length && hasNextPage ? (
|
||||||
<div className="flex h-20 items-center justify-center">
|
<div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => fetchNextPage()}
|
onClick={() => fetchNextPage()}
|
||||||
disabled={isFetchingNextPage || isLoading}
|
disabled={isFetchingNextPage || isLoading}
|
||||||
className="inline-flex h-12 w-36 items-center justify-center gap-2 rounded-full bg-neutral-100 px-3 font-medium hover:bg-neutral-200 focus:outline-none dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
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 ? (
|
{isFetchingNextPage ? (
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
<Spinner className="size-5" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ArrowRightCircleIcon className="size-5" />
|
<ArrowRightCircleIcon className="size-5" />
|
||||||
@@ -89,8 +116,7 @@ export function Screen() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</Column.Content>
|
</div>
|
||||||
</Column.Root>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { RepostNote } from "@/components/repost";
|
import { RepostNote } from "@/components/repost";
|
||||||
import { TextNote } from "@/components/text";
|
import { TextNote } from "@/components/text";
|
||||||
import { LoaderIcon, ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
||||||
import { ColumnRouteSearch, Event, Kind } from "@lume/types";
|
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types";
|
||||||
import { Column } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { Link, createFileRoute, redirect } from "@tanstack/react-router";
|
import { Link, createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
import { Virtualizer } from "virtua";
|
import { Virtualizer } from "virtua";
|
||||||
@@ -17,7 +17,10 @@ export const Route = createFileRoute("/group")({
|
|||||||
},
|
},
|
||||||
beforeLoad: async ({ search, context }) => {
|
beforeLoad: async ({ search, context }) => {
|
||||||
const ark = context.ark;
|
const ark = context.ark;
|
||||||
const groups = await ark.get_nstore(`lume_group_${search.label}`);
|
const groups = (await ark.get_nstore(
|
||||||
|
`lume_group_${search.label}`,
|
||||||
|
)) as string[];
|
||||||
|
const settings = await ark.get_settings();
|
||||||
|
|
||||||
if (!groups) {
|
if (!groups) {
|
||||||
throw redirect({
|
throw redirect({
|
||||||
@@ -31,27 +34,35 @@ export const Route = createFileRoute("/group")({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
groups,
|
groups,
|
||||||
|
settings,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function Screen() {
|
export function Screen() {
|
||||||
const { label, name, account } = Route.useSearch();
|
const { name, account } = Route.useSearch();
|
||||||
const { ark } = Route.useRouteContext();
|
const { ark, groups } = Route.useRouteContext();
|
||||||
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
const {
|
||||||
useInfiniteQuery({
|
data,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
isFetchingNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
fetchNextPage,
|
||||||
|
} = useInfiniteQuery({
|
||||||
queryKey: [name, account],
|
queryKey: [name, account],
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||||
const events = await ark.get_events(20, pageParam);
|
const events = await ark.get_events(20, pageParam, groups);
|
||||||
return events;
|
return events;
|
||||||
},
|
},
|
||||||
getNextPageParam: (lastPage) => {
|
getNextPageParam: (lastPage) => {
|
||||||
const lastEvent = lastPage?.at(-1);
|
const lastEvent = lastPage?.at(-1);
|
||||||
return lastEvent ? lastEvent.created_at - 1 : null;
|
return lastEvent ? lastEvent.created_at - 1 : null;
|
||||||
},
|
},
|
||||||
select: (data) => data?.pages.flatMap((page) => page),
|
select: (data) =>
|
||||||
|
data?.pages.flatMap((page) => page.filter((ev) => ev.kind === Kind.Text)),
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,12 +77,19 @@ export function Screen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column.Root>
|
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
|
||||||
<Column.Header label={label} name={name} />
|
{isFetching && !isLoading && !isFetchingNextPage ? (
|
||||||
<Column.Content>
|
<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 ? (
|
{isLoading ? (
|
||||||
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
<div className="flex h-16 w-full items-center justify-center gap-2">
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
<Spinner className="size-5" />
|
||||||
|
<span className="text-sm font-medium">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
) : !data.length ? (
|
) : !data.length ? (
|
||||||
<Empty />
|
<Empty />
|
||||||
@@ -80,16 +98,16 @@ export function Screen() {
|
|||||||
{data.map((item) => renderItem(item))}
|
{data.map((item) => renderItem(item))}
|
||||||
</Virtualizer>
|
</Virtualizer>
|
||||||
)}
|
)}
|
||||||
<div className="flex h-20 items-center justify-center">
|
{data?.length && hasNextPage ? (
|
||||||
{hasNextPage ? (
|
<div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => fetchNextPage()}
|
onClick={() => fetchNextPage()}
|
||||||
disabled={!hasNextPage || isFetchingNextPage}
|
disabled={isFetchingNextPage || isLoading}
|
||||||
className="inline-flex h-12 w-36 items-center justify-center gap-2 rounded-full bg-neutral-100 px-3 font-medium hover:bg-neutral-200 focus:outline-none dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
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 ? (
|
{isFetchingNextPage ? (
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
<Spinner className="size-5" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ArrowRightCircleIcon className="size-5" />
|
<ArrowRightCircleIcon className="size-5" />
|
||||||
@@ -97,10 +115,9 @@ export function Screen() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</Column.Content>
|
|
||||||
</Column.Root>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,53 +1,46 @@
|
|||||||
import { LoaderIcon, PlusIcon } from "@lume/icons";
|
import { PlusIcon } from "@lume/icons";
|
||||||
import { User } from "@lume/ui";
|
import { Spinner, User } from "@lume/ui";
|
||||||
|
import { checkForAppUpdates } from "@lume/utils";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
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 ({ context }) => {
|
||||||
|
// check for app updates
|
||||||
|
await checkForAppUpdates(true);
|
||||||
|
|
||||||
const ark = context.ark;
|
const ark = context.ark;
|
||||||
const accounts = await ark.get_all_accounts();
|
const accounts = await ark.get_all_accounts();
|
||||||
|
|
||||||
switch (accounts.length) {
|
if (!accounts.length) {
|
||||||
// Guest account
|
|
||||||
case 0:
|
|
||||||
throw redirect({
|
throw redirect({
|
||||||
to: "/landing",
|
to: "/landing",
|
||||||
replace: true,
|
replace: true,
|
||||||
});
|
});
|
||||||
// Only 1 account, skip account selection screen
|
}
|
||||||
case 1:
|
|
||||||
const account = accounts[0].npub;
|
// Run notification service
|
||||||
const loadedAccount = await ark.load_selected_account(account);
|
await invoke("run_notification", { accounts });
|
||||||
|
|
||||||
if (loadedAccount) {
|
|
||||||
throw redirect({
|
|
||||||
to: "/$account/home",
|
|
||||||
params: { account },
|
|
||||||
replace: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Account selection
|
|
||||||
default:
|
|
||||||
return { accounts };
|
return { accounts };
|
||||||
}
|
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const navigate = useNavigate();
|
const navigate = Route.useNavigate();
|
||||||
const context = Route.useRouteContext();
|
const { ark, accounts } = Route.useRouteContext();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const select = async (npub: string) => {
|
const select = async (npub: string) => {
|
||||||
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const ark = context.ark;
|
|
||||||
const loadAccount = await ark.load_selected_account(npub);
|
const loadAccount = await ark.load_selected_account(npub);
|
||||||
|
|
||||||
if (loadAccount) {
|
if (loadAccount) {
|
||||||
return navigate({
|
return navigate({
|
||||||
to: "/$account/home",
|
to: "/$account/home",
|
||||||
@@ -55,6 +48,10 @@ function Screen() {
|
|||||||
replace: true,
|
replace: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setLoading(false);
|
||||||
|
toast.error(String(e));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentDate = new Date().toLocaleString("default", {
|
const currentDate = new Date().toLocaleString("default", {
|
||||||
@@ -73,17 +70,17 @@ function Screen() {
|
|||||||
<div className="flex items-center justify-center gap-6">
|
<div className="flex items-center justify-center gap-6">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="inline-flex size-6 items-center justify-center">
|
<div className="inline-flex size-6 items-center justify-center">
|
||||||
<LoaderIcon className="size-6 animate-spin text-white" />
|
<Spinner className="size-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{context.accounts.map((account) => (
|
{accounts.map((account) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
key={account.npub}
|
key={account}
|
||||||
onClick={() => select(account.npub)}
|
onClick={() => select(account)}
|
||||||
>
|
>
|
||||||
<User.Provider pubkey={account.npub}>
|
<User.Provider pubkey={account}>
|
||||||
<User.Root className="flex h-36 w-32 flex-col items-center justify-center gap-4 rounded-2xl p-2 hover:bg-white/10 dark:hover:bg-black/10">
|
<User.Root className="flex h-36 w-32 flex-col items-center justify-center gap-4 rounded-2xl p-2 hover:bg-white/10 dark:hover:bg-black/10">
|
||||||
<User.Avatar className="size-20 rounded-full object-cover" />
|
<User.Avatar className="size-20 rounded-full object-cover" />
|
||||||
<User.Name className="max-w-[5rem] truncate text-lg font-medium leading-tight text-white" />
|
<User.Name className="max-w-[5rem] truncate text-lg font-medium leading-tight text-white" />
|
||||||
@@ -115,6 +112,7 @@ function Screen() {
|
|||||||
href="https://njump.me/nprofile1qqs9tuz9jpn57djg7nxunhyvuvk69g5zqaxdpvpqt9hwqv7395u9rpg6zq5uw"
|
href="https://njump.me/nprofile1qqs9tuz9jpn57djg7nxunhyvuvk69g5zqaxdpvpqt9hwqv7395u9rpg6zq5uw"
|
||||||
target="_blank"
|
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"
|
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
|
Design by NoGood
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { ColumnRouteSearch } from "@lume/types";
|
import type { ColumnRouteSearch } from "@lume/types";
|
||||||
import { Column } from "@lume/ui";
|
|
||||||
import { TOPICS, cn } from "@lume/utils";
|
import { TOPICS, cn } from "@lume/utils";
|
||||||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -7,7 +6,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export const Route = createFileRoute("/interests")({
|
export const Route = createFileRoute("/interests")({
|
||||||
component: Screen,
|
|
||||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||||
return {
|
return {
|
||||||
account: search.account,
|
account: search.account,
|
||||||
@@ -15,6 +13,7 @@ export const Route = createFileRoute("/interests")({
|
|||||||
name: search.name,
|
name: search.name,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
@@ -56,10 +55,8 @@ function Screen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column.Root>
|
<div className="h-full flex flex-col px-2">
|
||||||
<Column.Header label={label} name={name} />
|
<div className="shrink-0 flex h-16 items-center justify-between">
|
||||||
<Column.Content>
|
|
||||||
<div className="sticky left-0 top-0 flex h-16 w-full items-center justify-between border-b border-neutral-100 bg-white px-3 dark:border-neutral-900 dark:bg-black">
|
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
<h3 className="font-semibold">Interests</h3>
|
<h3 className="font-semibold">Interests</h3>
|
||||||
<p className="text-sm leading-tight text-neutral-700 dark:text-neutral-300">
|
<p className="text-sm leading-tight text-neutral-700 dark:text-neutral-300">
|
||||||
@@ -74,11 +71,13 @@ function Screen() {
|
|||||||
{isDone ? t("global.back") : t("global.update")}
|
{isDone ? t("global.back") : t("global.update")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-col p-3">
|
<div className="flex-1 flex flex-col gap-3 pb-2 scrollbar-none overflow-y-auto">
|
||||||
<div className="flex flex-col gap-8">
|
|
||||||
{TOPICS.map((topic) => (
|
{TOPICS.map((topic) => (
|
||||||
<div key={topic.title} className="flex flex-col gap-4">
|
<div
|
||||||
<div className="flex w-full items-center justify-between">
|
key={topic.title}
|
||||||
|
className="flex flex-col gap-4 bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||||
|
>
|
||||||
|
<div className="px-3 flex w-full items-center justify-between h-14 shrink-0 border-b border-neutral-100 dark:border-neutral-900">
|
||||||
<div className="inline-flex items-center gap-2.5">
|
<div className="inline-flex items-center gap-2.5">
|
||||||
<img
|
<img
|
||||||
src={topic.icon}
|
src={topic.icon}
|
||||||
@@ -95,7 +94,7 @@ function Screen() {
|
|||||||
{t("interests.followAll")}
|
{t("interests.followAll")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="px-3 pb-3 flex flex-wrap items-center gap-3">
|
||||||
{topic.content.map((hashtag) => (
|
{topic.content.map((hashtag) => (
|
||||||
<button
|
<button
|
||||||
key={hashtag}
|
key={hashtag}
|
||||||
@@ -116,7 +115,5 @@ function Screen() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Column.Content>
|
|
||||||
</Column.Root>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ function Screen() {
|
|||||||
<div className="mx-auto flex w-full max-w-4xl flex-col items-center gap-10">
|
<div className="mx-auto flex w-full max-w-4xl flex-col items-center gap-10">
|
||||||
<div className="flex flex-col items-center text-center">
|
<div className="flex flex-col items-center text-center">
|
||||||
<img
|
<img
|
||||||
src={`/heading-en.png`}
|
src="/heading-en.png"
|
||||||
srcSet={`/heading-en@2x.png 2x`}
|
srcSet="/heading-en@2x.png 2x"
|
||||||
alt="lume"
|
alt="lume"
|
||||||
className="xl:w-2/3"
|
className="xl:w-2/3"
|
||||||
/>
|
/>
|
||||||
@@ -61,7 +61,7 @@ function Screen() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-11 items-center justify-center"></div>
|
<div className="flex h-11 items-center justify-center" />
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute z-10 h-full w-full bg-black/5 backdrop-blur-sm" />
|
<div className="absolute z-10 h-full w-full bg-black/5 backdrop-blur-sm" />
|
||||||
<div className="absolute inset-0 h-full w-full">
|
<div className="absolute inset-0 h-full w-full">
|
||||||
@@ -75,6 +75,7 @@ function Screen() {
|
|||||||
href="https://njump.me/nprofile1qqs9tuz9jpn57djg7nxunhyvuvk69g5zqaxdpvpqt9hwqv7395u9rpg6zq5uw"
|
href="https://njump.me/nprofile1qqs9tuz9jpn57djg7nxunhyvuvk69g5zqaxdpvpqt9hwqv7395u9rpg6zq5uw"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="absolute bottom-3 right-3 z-50 rounded-md bg-white/20 px-2 py-1 text-xs font-medium text-white backdrop-blur-md dark:bg-black/20"
|
className="absolute bottom-3 right-3 z-50 rounded-md bg-white/20 px-2 py-1 text-xs font-medium text-white backdrop-blur-md dark:bg-black/20"
|
||||||
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
Design by NoGood
|
Design by NoGood
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
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 { LoaderIcon, ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
||||||
import { ColumnRouteSearch, Event, Kind } from "@lume/types";
|
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types";
|
||||||
import { Column } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { Virtualizer } from "virtua";
|
import { Virtualizer } from "virtua";
|
||||||
|
|
||||||
export const Route = createFileRoute("/newsfeed")({
|
export const Route = createFileRoute("/newsfeed")({
|
||||||
@@ -25,10 +28,16 @@ export const Route = createFileRoute("/newsfeed")({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function Screen() {
|
export function Screen() {
|
||||||
const { label, name, account } = Route.useSearch();
|
const { label, account } = Route.useSearch();
|
||||||
const { ark } = Route.useRouteContext();
|
const { ark } = Route.useRouteContext();
|
||||||
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
const {
|
||||||
useInfiniteQuery({
|
data,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
isFetchingNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
fetchNextPage,
|
||||||
|
} = useInfiniteQuery({
|
||||||
queryKey: [label, account],
|
queryKey: [label, account],
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||||
@@ -48,20 +57,39 @@ export function Screen() {
|
|||||||
switch (event.kind) {
|
switch (event.kind) {
|
||||||
case Kind.Repost:
|
case Kind.Repost:
|
||||||
return <RepostNote key={event.id} event={event} />;
|
return <RepostNote key={event.id} event={event} />;
|
||||||
default:
|
default: {
|
||||||
return <TextNote key={event.id} event={event} />;
|
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) {
|
||||||
|
return <Conversation 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" />;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column.Root>
|
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
|
||||||
<Column.Header label={label} name={name} />
|
{isFetching && !isLoading && !isFetchingNextPage ? (
|
||||||
<Column.Content>
|
<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 ? (
|
{isLoading ? (
|
||||||
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
<div className="flex h-16 w-full items-center justify-center gap-2">
|
||||||
<button type="button" className="size-5" disabled>
|
<Spinner className="size-5" />
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
<span className="text-sm font-medium">Loading...</span>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
) : !data.length ? (
|
) : !data.length ? (
|
||||||
<Empty />
|
<Empty />
|
||||||
@@ -71,15 +99,15 @@ export function Screen() {
|
|||||||
</Virtualizer>
|
</Virtualizer>
|
||||||
)}
|
)}
|
||||||
{data?.length && hasNextPage ? (
|
{data?.length && hasNextPage ? (
|
||||||
<div className="flex h-20 items-center justify-center">
|
<div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => fetchNextPage()}
|
onClick={() => fetchNextPage()}
|
||||||
disabled={isFetchingNextPage || isLoading}
|
disabled={isFetchingNextPage || isLoading}
|
||||||
className="inline-flex h-12 w-36 items-center justify-center gap-2 rounded-full bg-neutral-100 px-3 font-medium hover:bg-neutral-200 focus:outline-none dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
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 ? (
|
{isFetchingNextPage ? (
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
<Spinner className="size-5" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ArrowRightCircleIcon className="size-5" />
|
<ArrowRightCircleIcon className="size-5" />
|
||||||
@@ -89,8 +117,7 @@ export function Screen() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</Column.Content>
|
</div>
|
||||||
</Column.Root>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,13 +134,6 @@ function Empty() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col px-3 gap-2">
|
<div className="flex flex-col px-3 gap-2">
|
||||||
<Link
|
|
||||||
to="/global"
|
|
||||||
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 global newsfeed
|
|
||||||
</Link>
|
|
||||||
<Link
|
<Link
|
||||||
to="/trending/notes"
|
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"
|
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"
|
||||||
|
|||||||
@@ -0,0 +1,445 @@
|
|||||||
|
import { ArrowRightIcon, CancelIcon } from "@lume/icons";
|
||||||
|
import type { ColumnRouteSearch, LumeColumn } from "@lume/types";
|
||||||
|
import { Spinner, User } from "@lume/ui";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { getCurrent } from "@tauri-apps/api/window";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/onboarding")({
|
||||||
|
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||||
|
return {
|
||||||
|
account: search.account,
|
||||||
|
label: search.label,
|
||||||
|
name: search.name,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const { label } = Route.useSearch();
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { isValid, isSubmitting },
|
||||||
|
} = useForm();
|
||||||
|
|
||||||
|
const [userType, setUserType] = useState<"new" | "veteran">(null);
|
||||||
|
|
||||||
|
const install = async (column: LumeColumn) => {
|
||||||
|
const mainWindow = getCurrent();
|
||||||
|
await mainWindow.emit("columns", { type: "add", column });
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = async () => {
|
||||||
|
const mainWindow = getCurrent();
|
||||||
|
await mainWindow.emit("columns", { type: "remove", label });
|
||||||
|
};
|
||||||
|
|
||||||
|
const friendToFriend = async (data: { npub: string }) => {
|
||||||
|
if (!data.npub.startsWith("npub1"))
|
||||||
|
return toast.warning(
|
||||||
|
"NPUB is invalid. NPUB must be starts with npub1...",
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const connect: boolean = await invoke("friend_to_friend", {
|
||||||
|
npub: data.npub,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (connect) {
|
||||||
|
const column = {
|
||||||
|
label: "newsfeed",
|
||||||
|
name: "Newsfeed",
|
||||||
|
content: "/newsfeed",
|
||||||
|
};
|
||||||
|
const mainWindow = getCurrent();
|
||||||
|
await mainWindow.emit("columns", { type: "add", column });
|
||||||
|
|
||||||
|
// reset form
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col py-6 gap-6 overflow-y-auto scrollbar-none">
|
||||||
|
<div className="text-center flex flex-col items-center justify-center">
|
||||||
|
<h1 className="text-2xl font-serif font-medium">Welcome to Lume</h1>
|
||||||
|
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
||||||
|
Here are a few suggestions to help you get started.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-3">
|
||||||
|
<div className="mb-6 w-full h-44">
|
||||||
|
<img
|
||||||
|
src="/lock-screen.jpg"
|
||||||
|
srcSet="/lock-screen@2x.jpg 2x"
|
||||||
|
alt="background"
|
||||||
|
className="h-full w-full object-cover rounded-xl outline outline-1 -outline-offset-1 outline-black/15"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-start gap-2 text-[13px]">
|
||||||
|
<Mide />
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<h3 className="font-semibold">Mide</h3>
|
||||||
|
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||||
|
👋 Yo! I'm Mide, and I'll be your friendly guide to Nostr and
|
||||||
|
beyond. Looking forward to our adventure together!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start flex-row-reverse gap-2 text-[13px]">
|
||||||
|
<CurrentUser />
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<h3 className="font-semibold text-end">You</h3>
|
||||||
|
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||||
|
How can I get started?
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUserType("new")}
|
||||||
|
className={cn(
|
||||||
|
"mt-1 px-3 py-2 shadow-primary flex items-center justify-between gap-6 bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg",
|
||||||
|
userType === "new"
|
||||||
|
? "bg-blue-500 text-white hover:bg-blue-600"
|
||||||
|
: "",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
I'm completely new to Nostr.
|
||||||
|
<ArrowRightIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUserType("veteran")}
|
||||||
|
className={cn(
|
||||||
|
"mt-1 px-3 py-2 shadow-primary flex items-center justify-between gap-6 bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg",
|
||||||
|
userType === "veteran"
|
||||||
|
? "bg-blue-500 text-white hover:bg-blue-600"
|
||||||
|
: "",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
I've already been using another Nostr client.
|
||||||
|
<ArrowRightIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{userType === "veteran" ? (
|
||||||
|
<div className="flex items-start gap-2 text-[13px]">
|
||||||
|
<Mide />
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<h3 className="font-semibold">Mide</h3>
|
||||||
|
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||||
|
So, I'm excited to give you a quick intro to Lume and all the
|
||||||
|
awesome features it has to offer. Let's dive in!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{userType === "veteran" ? (
|
||||||
|
<div className="flex items-start flex-row-reverse gap-2 text-[13px]">
|
||||||
|
<CurrentUser />
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<h3 className="font-semibold text-end">You</h3>
|
||||||
|
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||||
|
Thanks! But I already know about Lume.
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
install({
|
||||||
|
label: "newsfeed",
|
||||||
|
name: "Newsfeed",
|
||||||
|
content: "/newsfeed",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between gap-6 bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
|
||||||
|
>
|
||||||
|
Skip! Show my newsfeed
|
||||||
|
<ArrowRightIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{userType === "veteran" ? (
|
||||||
|
<div className="flex items-start gap-2 text-[13px]">
|
||||||
|
<Mide />
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<h3 className="font-semibold">Mide</h3>
|
||||||
|
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||||
|
First off, Lume is a social media client for Nostr. It's a
|
||||||
|
place where you can follow friends, dive into chats, and post
|
||||||
|
what's on your mind.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{userType === "veteran" ? (
|
||||||
|
<div className="flex items-start gap-2 text-[13px]">
|
||||||
|
<Mide />
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<h3 className="font-semibold">Mide</h3>
|
||||||
|
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||||
|
That's not all! What makes Lume unique is the column system.
|
||||||
|
You can enhance your experience by adding new columns from the
|
||||||
|
Lume Store.
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||||
|
If you're confused about the term "Column," you can imagine it
|
||||||
|
as mini-apps, with each column providing its own experience.
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||||
|
Here is a quick guide for how to add a new column:
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 rounded-lg">
|
||||||
|
<video
|
||||||
|
className="h-auto w-full rounded-lg object-cover aspect-video outline outline-1 -outline-offset-1 outline-black/15"
|
||||||
|
controls
|
||||||
|
muted
|
||||||
|
>
|
||||||
|
<source
|
||||||
|
src="https://samplelib.com/lib/preview/mp4/sample-5s.mp4"
|
||||||
|
type="video/mp4"
|
||||||
|
/>
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{userType === "veteran" ? (
|
||||||
|
<div className="flex items-start flex-row-reverse gap-2 text-[13px]">
|
||||||
|
<CurrentUser />
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<h3 className="font-semibold text-end">You</h3>
|
||||||
|
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||||
|
Can you introduce me to the UI? I am still confused.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{userType === "veteran" ? (
|
||||||
|
<div className="flex items-start gap-2 text-[13px]">
|
||||||
|
<Mide />
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<h3 className="font-semibold">Mide</h3>
|
||||||
|
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||||
|
Of course, here is a quick introduction video for Lume.
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 rounded-lg">
|
||||||
|
<video
|
||||||
|
className="h-auto w-full rounded-lg object-cover aspect-video outline outline-1 -outline-offset-1 outline-black/15"
|
||||||
|
controls
|
||||||
|
muted
|
||||||
|
>
|
||||||
|
<source
|
||||||
|
src="https://samplelib.com/lib/preview/mp4/sample-5s.mp4"
|
||||||
|
type="video/mp4"
|
||||||
|
/>
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{userType === "new" ? (
|
||||||
|
<div className="flex items-start gap-2 text-[13px]">
|
||||||
|
<Mide />
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<h3 className="font-semibold">Mide</h3>
|
||||||
|
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||||
|
Diving into new social media platforms like Nostr can be a bit
|
||||||
|
overwhelming, but don't worry! Here are some handy tips to
|
||||||
|
help you navigate and discover what interests you.
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
install({
|
||||||
|
label: "foryou",
|
||||||
|
name: "For you",
|
||||||
|
content: "/foryou",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
|
||||||
|
>
|
||||||
|
Add some topics that you're interested in.
|
||||||
|
<ArrowRightIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
install({
|
||||||
|
label: "trending_users",
|
||||||
|
name: "Trending",
|
||||||
|
content: "/trending/users",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
|
||||||
|
>
|
||||||
|
Follow some users.
|
||||||
|
<ArrowRightIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{userType === "new" ? (
|
||||||
|
<div className="flex items-start flex-row-reverse gap-2 text-[13px]">
|
||||||
|
<CurrentUser />
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<h3 className="font-semibold text-end">You</h3>
|
||||||
|
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||||
|
My girlfriend introduced Nostr to me, and I have her NPUB. Can
|
||||||
|
I get the same experiences as her?
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{userType === "new" ? (
|
||||||
|
<div className="flex items-start gap-2 text-[13px]">
|
||||||
|
<Mide />
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<h3 className="font-semibold">Mide</h3>
|
||||||
|
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||||
|
Absolutely! Since your girlfriend shared her NPUB with you,
|
||||||
|
you can dive into Nostr and explore it just like she does.
|
||||||
|
It's a great way to share experiences and discover what Nostr
|
||||||
|
has to offer together!
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(friendToFriend)}
|
||||||
|
className="mt-1 flex flex-col items-end bg-white dark:bg-white/10 rounded-lg shadow-primary"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
{...register("npub", { required: true })}
|
||||||
|
name="npub"
|
||||||
|
placeholder="Enter npub here..."
|
||||||
|
className="w-full h-14 px-3 rounded-t-lg bg-transparent border-b border-x-0 border-t-0 border-neutral-100 dark:border-white/5 focus:border-neutral-200 dark:focus:border-white/20 focus:outline-none focus:ring-0 placeholder:text-neutral-600 dark:placeholder:text-neutral-400"
|
||||||
|
/>
|
||||||
|
<div className="h-10 flex items-center px-1">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isValid || isSubmitting}
|
||||||
|
className="px-2 h-8 w-20 inline-flex items-center justify-center bg-blue-500 text-white rounded-md text-sm font-medium hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
{isSubmitting ? <Spinner className="size-4" /> : "Submit"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{userType ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-start flex-row-reverse gap-2 text-[13px]">
|
||||||
|
<CurrentUser />
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<h3 className="font-semibold text-end">You</h3>
|
||||||
|
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||||
|
Thank you. I can use Lume and explore Nostr by myself from
|
||||||
|
now on.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2 text-[13px]">
|
||||||
|
<Mide />
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<h3 className="font-semibold">Mide</h3>
|
||||||
|
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||||
|
I really hope you enjoy your time on Nostr! If you're keen
|
||||||
|
to dive deeper, here are some helpful resources to get you
|
||||||
|
started:
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="https://nostr.org"
|
||||||
|
target="_blank"
|
||||||
|
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
[Website] nostr.org
|
||||||
|
<ArrowRightIcon className="size-4" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://www.youtube.com/watch?v=5W-jtbbh3eA"
|
||||||
|
target="_blank"
|
||||||
|
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
[Video] What is Nostr?
|
||||||
|
<ArrowRightIcon className="size-4" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://github.com/nostr-protocol/nostr"
|
||||||
|
target="_blank"
|
||||||
|
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
[Develop] Github
|
||||||
|
<ArrowRightIcon className="size-4" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://www.nostrapps.com/"
|
||||||
|
target="_blank"
|
||||||
|
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
[Ecosystem] nostrapps.com
|
||||||
|
<ArrowRightIcon className="size-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2 text-[13px]">
|
||||||
|
<Mide />
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<h3 className="font-semibold">Mide</h3>
|
||||||
|
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||||
|
If you want to close this onboarding board, you can click
|
||||||
|
the button below.
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => close()}
|
||||||
|
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
<CancelIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Mide() {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src="/ai.jpg"
|
||||||
|
alt="Ai-chan"
|
||||||
|
className="shrink-0 size-10 rounded-full outline outline-1 -outline-offset-1 outline-black/15"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CurrentUser() {
|
||||||
|
const { account } = Route.useSearch();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<User.Provider pubkey={account}>
|
||||||
|
<User.Root className="shrink-0">
|
||||||
|
<User.Avatar className="size-10 rounded-full outline outline-1 -outline-offset-1 outline-black/15" />
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { PlusIcon } from "@lume/icons";
|
import { PlusIcon } from "@lume/icons";
|
||||||
import { LumeColumn } from "@lume/types";
|
import type { LumeColumn } from "@lume/types";
|
||||||
import { Column } from "@lume/ui";
|
|
||||||
import { createLazyRoute } from "@tanstack/react-router";
|
import { createLazyRoute } from "@tanstack/react-router";
|
||||||
import { getCurrent } from "@tauri-apps/api/window";
|
import { getCurrent } from "@tauri-apps/api/window";
|
||||||
|
|
||||||
@@ -15,8 +14,7 @@ function Screen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column.Root shadow={false} background={false}>
|
<div className="relative flex h-full w-full items-center justify-center">
|
||||||
<Column.Content className="relative flex h-full w-full items-center justify-center">
|
|
||||||
<div className="group absolute left-0 top-0 z-10 h-full w-12">
|
<div className="group absolute left-0 top-0 z-10 h-full w-12">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -45,7 +43,6 @@ function Screen() {
|
|||||||
>
|
>
|
||||||
<PlusIcon className="size-8" />
|
<PlusIcon className="size-8" />
|
||||||
</button>
|
</button>
|
||||||
</Column.Content>
|
</div>
|
||||||
</Column.Root>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { SearchIcon } from "@lume/icons";
|
||||||
|
import { type Event, Kind } from "@lume/types";
|
||||||
|
import { Note, Spinner, User } from "@lume/ui";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useDebounce } from "use-debounce";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/search")({
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [searchValue] = useDebounce(search, 500);
|
||||||
|
|
||||||
|
const searchEvents = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const query = `https://api.nostr.wine/search?query=${searchValue}&kind=0,1`;
|
||||||
|
const res = await fetch(query);
|
||||||
|
const content = await res.json();
|
||||||
|
const events = content.data as Event[];
|
||||||
|
const sorted = events.sort((a, b) => b.created_at - a.created_at);
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
setEvents(sorted);
|
||||||
|
} catch (e) {
|
||||||
|
setLoading(false);
|
||||||
|
toast.error(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchValue.length >= 3 && searchValue.length < 500) {
|
||||||
|
searchEvents();
|
||||||
|
}
|
||||||
|
}, [searchValue]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-tauri-drag-region className="flex flex-col w-full h-full">
|
||||||
|
<div className="relative h-24 shrink-0 flex flex-col border-b border-black/5 dark:border-white/5">
|
||||||
|
<div data-tauri-drag-region className="w-full h-4 shrink-0" />
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") searchEvents();
|
||||||
|
}}
|
||||||
|
placeholder="Search anything..."
|
||||||
|
className="w-full h-20 pt-10 px-3 text-lg bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-neutral-500 dark:placeholder:text-neutral-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 p-3 overflow-y-auto scrollbar-none">
|
||||||
|
{loading ? (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
) : events.length ? (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 shrink-0">
|
||||||
|
Users
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col gap-1">
|
||||||
|
{events
|
||||||
|
.filter((ev) => ev.kind === Kind.Metadata)
|
||||||
|
.map((event, index) => (
|
||||||
|
<SearchUser key={event.pubkey + index} event={event} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 shrink-0">
|
||||||
|
Notes
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col gap-3">
|
||||||
|
{events
|
||||||
|
.filter((ev) => ev.kind === Kind.Text)
|
||||||
|
.map((event) => (
|
||||||
|
<SearchNote key={event.id} event={event} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!loading && !events.length ? (
|
||||||
|
<div className="h-full flex items-center justify-center flex-col gap-3">
|
||||||
|
<div className="size-16 bg-black/10 dark:bg-white/10 rounded-full inline-flex items-center justify-center">
|
||||||
|
<SearchIcon className="size-6" />
|
||||||
|
</div>
|
||||||
|
Try searching for people, notes, or keywords
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchUser({ event }: { event: Event }) {
|
||||||
|
const { ark } = Route.useRouteContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={event.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => ark.open_profile(event.pubkey)}
|
||||||
|
className="col-span-1 p-2 hover:bg-black/10 dark:hover:bg-white/10 rounded-lg"
|
||||||
|
>
|
||||||
|
<User.Provider pubkey={event.pubkey} embedProfile={event.content}>
|
||||||
|
<User.Root className="flex items-center gap-2">
|
||||||
|
<User.Avatar className="size-9 rounded-full shrink-0" />
|
||||||
|
<div className="inline-flex items-center gap-1.5">
|
||||||
|
<User.Name className="font-semibold" />
|
||||||
|
<User.NIP05 />
|
||||||
|
</div>
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchNote({ event }: { event: Event }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
|
||||||
|
<Note.Provider event={event}>
|
||||||
|
<Note.Root>
|
||||||
|
<div className="px-3 h-14 flex items-center justify-between">
|
||||||
|
<Note.User />
|
||||||
|
<Note.Menu />
|
||||||
|
</div>
|
||||||
|
<Note.Content className="px-3" quote={false} mention={false} />
|
||||||
|
<div className="mt-3 flex items-center gap-4 h-14 px-3">
|
||||||
|
<Note.Open />
|
||||||
|
</div>
|
||||||
|
</Note.Root>
|
||||||
|
</Note.Provider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
import { SettingsIcon, UserIcon, ZapIcon, SecureIcon } from "@lume/icons";
|
import {
|
||||||
|
RelayIcon,
|
||||||
|
SecureIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
UserIcon,
|
||||||
|
ZapIcon,
|
||||||
|
} from "@lume/icons";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
||||||
@@ -12,10 +18,10 @@ function Screen() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col bg-neutral-100 dark:bg-neutral-950">
|
<div className="flex h-full w-full flex-col">
|
||||||
<div
|
<div
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
className="flex h-20 w-full shrink-0 items-center justify-center border-b border-neutral-200 dark:border-neutral-800"
|
className="flex h-20 w-full shrink-0 items-center justify-center border-b border-black/10 dark:border-white/10"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Link to="/settings/general">
|
<Link to="/settings/general">
|
||||||
@@ -25,8 +31,8 @@ function Screen() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
|
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
|
||||||
isActive
|
isActive
|
||||||
? "bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700"
|
? "bg-black/10 hover:bg-black/20 dark:bg-white/10 text-neutral-900 dark:text-neutral-100 dark:hover:bg-bg-white/20"
|
||||||
: "text-neutral-700 hover:bg-neutral-200 dark:text-neutral-300 dark:hover:bg-neutral-800",
|
: "text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SettingsIcon className="size-5 shrink-0" />
|
<SettingsIcon className="size-5 shrink-0" />
|
||||||
@@ -44,8 +50,8 @@ function Screen() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
|
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
|
||||||
isActive
|
isActive
|
||||||
? "bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700"
|
? "bg-black/10 hover:bg-black/20 dark:bg-white/10 text-neutral-900 dark:text-neutral-100 dark:hover:bg-bg-white/20"
|
||||||
: "text-neutral-700 hover:bg-neutral-200 dark:text-neutral-300 dark:hover:bg-neutral-800",
|
: "text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<UserIcon className="size-5 shrink-0" />
|
<UserIcon className="size-5 shrink-0" />
|
||||||
@@ -56,6 +62,23 @@ function Screen() {
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/settings/relay">
|
||||||
|
{({ isActive }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
|
||||||
|
isActive
|
||||||
|
? "bg-black/10 hover:bg-black/20 dark:bg-white/10 text-neutral-900 dark:text-neutral-100 dark:hover:bg-bg-white/20"
|
||||||
|
: "text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RelayIcon className="size-5 shrink-0" />
|
||||||
|
<p className="text-sm font-medium">Relay</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Link>
|
||||||
<Link to="/settings/zap">
|
<Link to="/settings/zap">
|
||||||
{({ isActive }) => {
|
{({ isActive }) => {
|
||||||
return (
|
return (
|
||||||
@@ -63,8 +86,8 @@ function Screen() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
|
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
|
||||||
isActive
|
isActive
|
||||||
? "bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700"
|
? "bg-black/10 hover:bg-black/20 dark:bg-white/10 text-neutral-900 dark:text-neutral-100 dark:hover:bg-bg-white/20"
|
||||||
: "text-neutral-700 hover:bg-neutral-200 dark:text-neutral-300 dark:hover:bg-neutral-800",
|
: "text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ZapIcon className="size-5 shrink-0" />
|
<ZapIcon className="size-5 shrink-0" />
|
||||||
@@ -82,8 +105,8 @@ function Screen() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
|
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
|
||||||
isActive
|
isActive
|
||||||
? "bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700"
|
? "bg-black/10 hover:bg-black/20 dark:bg-white/10 text-neutral-900 dark:text-neutral-100 dark:hover:bg-bg-white/20"
|
||||||
: "text-neutral-700 hover:bg-neutral-200 dark:text-neutral-300 dark:hover:bg-neutral-800",
|
: "text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SecureIcon className="size-5 shrink-0" />
|
<SecureIcon className="size-5 shrink-0" />
|
||||||
@@ -96,11 +119,9 @@ function Screen() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex-1 overflow-y-auto px-5 py-4">
|
<div className="w-full flex-1 overflow-y-auto scrollbar-none px-5 py-4">
|
||||||
<div className="mx-auto w-full max-w-xl">
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type Account } from "@lume/types";
|
import type { Account } from "@lume/types";
|
||||||
import { User } from "@lume/ui";
|
import { User } from "@lume/ui";
|
||||||
import { displayNsec } from "@lume/utils";
|
import { displayNsec } from "@lume/utils";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
@@ -13,7 +13,7 @@ export const Route = createFileRoute("/settings/backup")({
|
|||||||
const ark = context.ark;
|
const ark = context.ark;
|
||||||
const npubs = await ark.get_all_accounts();
|
const npubs = await ark.get_all_accounts();
|
||||||
|
|
||||||
let accounts: Account[] = [];
|
const accounts: Account[] = [];
|
||||||
|
|
||||||
for (const account of npubs) {
|
for (const account of npubs) {
|
||||||
const nsec: string = await invoke("get_stored_nsec", {
|
const nsec: string = await invoke("get_stored_nsec", {
|
||||||
@@ -30,20 +30,17 @@ function Screen() {
|
|||||||
const accounts = Route.useLoaderData();
|
const accounts = Route.useLoaderData();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-xl">
|
||||||
<div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700">
|
<div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700">
|
||||||
{accounts.map((account, index) => (
|
{accounts.map((account) => (
|
||||||
<div key={account.npub} className="flex items-start gap-6 py-3">
|
<NostrAccount key={account.npub} account={account} />
|
||||||
<div className="w-36 shrink-0 text-end font-medium">
|
|
||||||
Account {index}
|
|
||||||
</div>
|
|
||||||
<Account account={account} />
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Account({ account }: { account: Account }) {
|
function NostrAccount({ account }: { account: Account }) {
|
||||||
const [key, setKey] = useState(account.nsec);
|
const [key, setKey] = useState(account.nsec);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [passphase, setPassphase] = useState("");
|
const [passphase, setPassphase] = useState("");
|
||||||
@@ -66,32 +63,41 @@ function Account({ account }: { account: Account }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col gap-2">
|
<div className="flex flex-1 flex-col gap-2 py-3">
|
||||||
<User.Provider pubkey={account.npub}>
|
<User.Provider pubkey={account.npub}>
|
||||||
<User.Root className="flex items-center gap-2">
|
<User.Root className="flex items-center gap-2">
|
||||||
<User.Avatar className="size-8 rounded-full object-cover" />
|
<User.Avatar className="size-8 rounded-full object-cover" />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<User.Name className="text-sm leading-tight" />
|
<User.Name className="text-sm leading-tight" />
|
||||||
<User.NIP05 className="text-sm leading-tight text-neutral-700 dark:text-neutral-300" />
|
<User.NIP05 />
|
||||||
</div>
|
</div>
|
||||||
</User.Root>
|
</User.Root>
|
||||||
</User.Provider>
|
</User.Provider>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex w-full flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="nsec"
|
||||||
|
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||||
|
>
|
||||||
|
Private Key
|
||||||
|
</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
readOnly
|
readOnly
|
||||||
|
name="nsec"
|
||||||
type="text"
|
type="text"
|
||||||
value={displayNsec(key, 36)}
|
value={displayNsec(key, 36)}
|
||||||
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={copyKey}
|
onClick={() => copyKey()}
|
||||||
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
|
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
|
||||||
>
|
>
|
||||||
{copied ? "Copied" : "Copy"}
|
{copied ? "Copied" : "Copy"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex w-full flex-col gap-1">
|
<div className="flex w-full flex-col gap-1">
|
||||||
<label
|
<label
|
||||||
htmlFor="passphase"
|
htmlFor="passphase"
|
||||||
@@ -109,7 +115,7 @@ function Account({ account }: { account: Account }) {
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={encrypt}
|
onClick={() => encrypt()}
|
||||||
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
|
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
|
||||||
>
|
>
|
||||||
Update
|
Update
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { createLazyFileRoute } from '@tanstack/react-router'
|
|
||||||
|
|
||||||
export const Route = createLazyFileRoute('/settings/general')({
|
|
||||||
component: () => <div>Hello /settings/general!</div>
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import type { Settings } from "@lume/types";
|
||||||
|
import * as Switch from "@radix-ui/react-switch";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import {
|
||||||
|
isPermissionGranted,
|
||||||
|
requestPermission,
|
||||||
|
} from "@tauri-apps/plugin-notification";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/settings/general")({
|
||||||
|
beforeLoad: async ({ context }) => {
|
||||||
|
const permissionGranted = await isPermissionGranted(); // get notification permission
|
||||||
|
const ark = context.ark;
|
||||||
|
const settings = await ark.get_settings();
|
||||||
|
|
||||||
|
return {
|
||||||
|
settings: { ...settings, notification: permissionGranted },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const { ark, settings } = Route.useRouteContext();
|
||||||
|
const [newSettings, setNewSettings] = useState<Settings>(settings);
|
||||||
|
|
||||||
|
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 updateSettings = useDebouncedCallback(() => {
|
||||||
|
ark.set_settings(newSettings);
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateSettings();
|
||||||
|
}, [newSettings]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-xl">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h2 className="font-semibold text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
|
General
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-col divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl px-3">
|
||||||
|
<div className="flex w-full items-start justify-between gap-4 py-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-medium">Notification</h3>
|
||||||
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
|
By turning on push notifications, you'll start getting
|
||||||
|
notifications from Lume directly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-36 flex justify-end shrink-0">
|
||||||
|
<Switch.Root
|
||||||
|
checked={newSettings.notification}
|
||||||
|
onClick={() => toggleNofitication()}
|
||||||
|
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
|
||||||
|
>
|
||||||
|
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||||
|
</Switch.Root>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full items-start justify-between gap-4 py-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-medium">Enhanced Privacy</h3>
|
||||||
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
|
Lume presents external resources like images, videos, or link
|
||||||
|
previews in plain text.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-36 flex justify-end shrink-0">
|
||||||
|
<Switch.Root
|
||||||
|
checked={newSettings.enhancedPrivacy}
|
||||||
|
onClick={() => toggleEnhancedPrivacy()}
|
||||||
|
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
|
||||||
|
>
|
||||||
|
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||||
|
</Switch.Root>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full items-start justify-between gap-4 py-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-medium">Auto Update</h3>
|
||||||
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
|
Automatically download and install new version.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-36 flex justify-end shrink-0">
|
||||||
|
<Switch.Root
|
||||||
|
checked={newSettings.autoUpdate}
|
||||||
|
onClick={() => toggleAutoUpdate()}
|
||||||
|
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
|
||||||
|
>
|
||||||
|
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||||
|
</Switch.Root>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full items-start justify-between gap-4 py-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold">Filter sensitive content</h3>
|
||||||
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
|
By default, Lume will display all content which have Content
|
||||||
|
Warning tag, it's may include NSFW content.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-36 flex justify-end shrink-0">
|
||||||
|
<Switch.Root
|
||||||
|
checked={newSettings.nsfw}
|
||||||
|
onClick={() => toggleNsfw()}
|
||||||
|
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
|
||||||
|
>
|
||||||
|
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||||
|
</Switch.Root>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h2 className="font-semibold text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
|
Interface
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-col divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl px-3">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex w-full items-start justify-between gap-4 py-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold">Zap</h3>
|
||||||
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
|
Show the Zap button in each note and user's profile screen,
|
||||||
|
use for send bitcoin tip to other users.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-36 flex justify-end shrink-0">
|
||||||
|
<Switch.Root
|
||||||
|
checked={newSettings.zap}
|
||||||
|
onClick={() => toggleZap()}
|
||||||
|
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
|
||||||
|
>
|
||||||
|
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||||
|
</Switch.Root>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { CancelIcon, PlusIcon } from "@lume/icons";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/settings/relay")({
|
||||||
|
loader: async ({ context }) => {
|
||||||
|
const ark = context.ark;
|
||||||
|
const relays = await ark.get_relays();
|
||||||
|
|
||||||
|
return relays;
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const relayList = Route.useLoaderData();
|
||||||
|
const [relays, setRelays] = useState(relayList.connected);
|
||||||
|
|
||||||
|
const { ark } = Route.useRouteContext();
|
||||||
|
const { register, reset, handleSubmit } = useForm();
|
||||||
|
|
||||||
|
const onSubmit = async (data: { url: string }) => {
|
||||||
|
try {
|
||||||
|
const add = await ark.add_relay(data.url);
|
||||||
|
if (add) {
|
||||||
|
setRelays((prev) => [...prev, data.url]);
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-xl">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h2 className="font-semibold text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
|
Connected Relays
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-col divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl px-3">
|
||||||
|
{relays.map((relay) => (
|
||||||
|
<div
|
||||||
|
key={relay}
|
||||||
|
className="flex justify-between items-center h-11"
|
||||||
|
>
|
||||||
|
<div className="inline-flex items-center gap-2 text-sm font-medium">
|
||||||
|
<span className="relative flex size-2">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-teal-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full size-2 bg-teal-500"></span>
|
||||||
|
</span>
|
||||||
|
{relay}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center justify-center size-7 rounded-md hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<CancelIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center h-14">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
className="w-full flex items-center gap-2 mb-0"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
{...register("url", {
|
||||||
|
required: true,
|
||||||
|
minLength: 1,
|
||||||
|
})}
|
||||||
|
name="url"
|
||||||
|
placeholder="wss://..."
|
||||||
|
spellCheck={false}
|
||||||
|
className="h-9 flex-1 rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring-0 dark:border-neutral-700 dark:placeholder:text-neutral-400"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="shrink-0 inline-flex h-9 w-16 px-2 items-center justify-center rounded-lg bg-black/20 dark:bg-white/20 font-medium text-sm text-white hover:bg-blue-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-7" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h2 className="font-semibold text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
|
User Relays (NIP-65)
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-col py-2 bg-black/5 dark:bg-white/5 rounded-xl px-3">
|
||||||
|
<p className="text-sm text-yellow-500">
|
||||||
|
Lume will automatically connect to the user's relay list, but the
|
||||||
|
manager function (like adding, removing, changing relay purpose)
|
||||||
|
is not yet available.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl px-3">
|
||||||
|
{relayList.read?.map((relay) => (
|
||||||
|
<div
|
||||||
|
key={relay}
|
||||||
|
className="flex justify-between items-center h-11"
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium">{relay}</div>
|
||||||
|
<div className="text-xs font-semibold">READ</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{relayList.write?.map((relay) => (
|
||||||
|
<div
|
||||||
|
key={relay}
|
||||||
|
className="flex justify-between items-center h-11"
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium">{relay}</div>
|
||||||
|
<div className="text-xs font-semibold">WRITE</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{relayList.both?.map((relay) => (
|
||||||
|
<div
|
||||||
|
key={relay}
|
||||||
|
className="flex justify-between items-center h-11"
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium">{relay}</div>
|
||||||
|
<div className="text-xs font-semibold">READ + WRITE</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { createLazyFileRoute } from '@tanstack/react-router'
|
|
||||||
|
|
||||||
export const Route = createLazyFileRoute('/settings/user')({
|
|
||||||
component: () => <div>Hello /settings/user!</div>
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { AvatarUploader } from "@/components/avatarUploader";
|
||||||
|
import { PlusIcon } from "@lume/icons";
|
||||||
|
import type { Metadata } from "@lume/types";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/settings/user")({
|
||||||
|
beforeLoad: async ({ context }) => {
|
||||||
|
const ark = context.ark;
|
||||||
|
const profile = await ark.get_current_user_profile();
|
||||||
|
return { profile };
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const { ark, profile } = Route.useRouteContext();
|
||||||
|
const { register, handleSubmit } = useForm();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [picture, setPicture] = useState<string>("");
|
||||||
|
|
||||||
|
const onSubmit = async (data: Metadata) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const profile = { ...data, picture };
|
||||||
|
await ark.create_profile(profile);
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
} catch (e) {
|
||||||
|
setLoading(false);
|
||||||
|
toast.error(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full h-full">
|
||||||
|
<div className="flex-1 h-full flex items-center flex-col justify-center gap-3">
|
||||||
|
<div className="relative size-24 rounded-full bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200">
|
||||||
|
{profile.picture ? (
|
||||||
|
<img
|
||||||
|
src={picture || profile.picture}
|
||||||
|
alt="avatar"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
className="absolute inset-0 z-10 h-full w-full rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<AvatarUploader
|
||||||
|
setPicture={setPicture}
|
||||||
|
className="absolute inset-0 z-20 flex h-full w-full items-center justify-center rounded-full bg-black/10 text-white hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-8" />
|
||||||
|
</AvatarUploader>
|
||||||
|
</div>
|
||||||
|
<div className="text-center flex flex-col items-center">
|
||||||
|
<div className="text-lg font-semibold">{profile.display_name}</div>
|
||||||
|
<div className="text-neutral-800 dark:text-neutral-200">
|
||||||
|
{profile.nip05}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<Link
|
||||||
|
to="/settings/backup"
|
||||||
|
className="px-5 h-9 border border-blue-300 text-sm font-medium hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800 rounded-full bg-blue-100 text-blue-500 inline-flex items-center justify-center"
|
||||||
|
>
|
||||||
|
Backup Account
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 h-full">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
className="flex flex-col gap-3 mb-0"
|
||||||
|
>
|
||||||
|
<div className="flex w-full flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="display_name"
|
||||||
|
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||||
|
>
|
||||||
|
Display Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
name="display_name"
|
||||||
|
{...register("display_name", { required: true, minLength: 1 })}
|
||||||
|
spellCheck={false}
|
||||||
|
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="name"
|
||||||
|
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||||
|
>
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
{...register("name")}
|
||||||
|
spellCheck={false}
|
||||||
|
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="website"
|
||||||
|
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||||
|
>
|
||||||
|
Website
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
name="website"
|
||||||
|
type="url"
|
||||||
|
{...register("website")}
|
||||||
|
spellCheck={false}
|
||||||
|
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="banner"
|
||||||
|
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||||
|
>
|
||||||
|
Cover
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
name="banner"
|
||||||
|
type="url"
|
||||||
|
{...register("banner")}
|
||||||
|
spellCheck={false}
|
||||||
|
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="nip05"
|
||||||
|
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||||
|
>
|
||||||
|
NIP-05
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
name="nip05"
|
||||||
|
type="email"
|
||||||
|
{...register("nip05")}
|
||||||
|
spellCheck={false}
|
||||||
|
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="lnaddress"
|
||||||
|
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||||
|
>
|
||||||
|
Lightning Address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
name="lnaddress"
|
||||||
|
type="email"
|
||||||
|
{...register("lud16")}
|
||||||
|
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="inline-flex h-9 w-32 px-2 items-center justify-center rounded-lg bg-blue-500 font-medium text-sm text-white hover:bg-blue-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? <Spinner className="size-4" /> : "Update Profile"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,12 +9,14 @@ export const Route = createLazyFileRoute("/settings/zap")({
|
|||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
return (
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-xl">
|
||||||
<div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700">
|
<div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700">
|
||||||
<div className="flex flex-col gap-6 py-3">
|
<div className="flex flex-col gap-6 py-3">
|
||||||
<Connection />
|
<Connection />
|
||||||
<DefaultAmount />
|
<DefaultAmount />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +33,9 @@ function Connection() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-6">
|
<div className="flex items-start gap-6">
|
||||||
<div className="w-36 shrink-0 text-end font-medium">Connection</div>
|
<div className="w-36 shrink-0 text-end font-medium text-sm">
|
||||||
|
Connection
|
||||||
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex w-full flex-col gap-1">
|
<div className="flex w-full flex-col gap-1">
|
||||||
<label
|
<label
|
||||||
@@ -51,7 +55,7 @@ function Connection() {
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={connect}
|
onClick={() => connect()}
|
||||||
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
|
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
|
||||||
>
|
>
|
||||||
Connect
|
Connect
|
||||||
@@ -66,7 +70,9 @@ function Connection() {
|
|||||||
function DefaultAmount() {
|
function DefaultAmount() {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-6">
|
<div className="flex items-start gap-6">
|
||||||
<div className="w-36 shrink-0 text-end font-medium">Default amount</div>
|
<div className="w-36 shrink-0 text-end font-medium text-sm">
|
||||||
|
Default amount
|
||||||
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex w-full flex-col gap-1">
|
<div className="flex w-full flex-col gap-1">
|
||||||
<label
|
<label
|
||||||
|
|||||||
@@ -6,8 +6,16 @@ export const Route = createFileRoute("/store/community")({
|
|||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 p-3">
|
<div className="flex h-full flex-col items-center justify-center gap-3 p-3">
|
||||||
<p>Coming Soon</p>
|
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full">
|
||||||
|
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="font-semibold text-lg">Coming Soon</h1>
|
||||||
|
<p className="text-sm text-neutral-700 dark:text-neutral-300 leading-tight">
|
||||||
|
Enhance your experience <br /> by adding column shared by community.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { LumeColumn } from "@lume/types";
|
import type { LumeColumn } from "@lume/types";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { resolveResource } from "@tauri-apps/api/path";
|
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 { readTextFile } from "@tauri-apps/plugin-fs";
|
||||||
|
|
||||||
export const Route = createFileRoute("/store/official")({
|
export const Route = createFileRoute("/store/official")({
|
||||||
component: Screen,
|
|
||||||
beforeLoad: async () => {
|
beforeLoad: async () => {
|
||||||
const resourcePath = await resolveResource(
|
const resourcePath = await resolveResource(
|
||||||
"resources/official_columns.json",
|
"resources/official_columns.json",
|
||||||
@@ -18,6 +17,7 @@ export const Route = createFileRoute("/store/official")({
|
|||||||
officialColumns,
|
officialColumns,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { GlobalIcon, LaurelIcon } from "@lume/icons";
|
import { GlobalIcon, LaurelIcon } from "@lume/icons";
|
||||||
import { ColumnRouteSearch } from "@lume/types";
|
import type { ColumnRouteSearch } from "@lume/types";
|
||||||
import { Column } from "@lume/ui";
|
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
||||||
@@ -17,20 +16,16 @@ export const Route = createFileRoute("/store")({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const { label, name } = Route.useSearch();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column.Root>
|
<div className="flex flex-col h-full">
|
||||||
<Column.Header label={label} name={name}>
|
<div className="px-3 mt-2 mb-1">
|
||||||
<div className="inline-flex h-full w-full items-center gap-1">
|
<div className="p-1 shrink-0 inline-flex w-full rounded-lg items-center gap-1 bg-black/5 dark:bg-white/5">
|
||||||
<Link to="/store/official">
|
<Link to="/store/official" className="flex-1">
|
||||||
{({ isActive }) => (
|
{({ isActive }) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
|
"inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-md text-sm font-medium leading-tight",
|
||||||
isActive
|
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
|
||||||
? "bg-neutral-100 dark:bg-neutral-900"
|
|
||||||
: "opacity-50",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<LaurelIcon className="size-4" />
|
<LaurelIcon className="size-4" />
|
||||||
@@ -38,14 +33,12 @@ function Screen() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/store/community">
|
<Link to="/store/community" className="flex-1">
|
||||||
{({ isActive }) => (
|
{({ isActive }) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
|
"inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-md text-sm font-medium leading-tight",
|
||||||
isActive
|
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
|
||||||
? "bg-neutral-100 dark:bg-neutral-900"
|
|
||||||
: "opacity-50",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<GlobalIcon className="size-4" />
|
<GlobalIcon className="size-4" />
|
||||||
@@ -54,10 +47,10 @@ function Screen() {
|
|||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</Column.Header>
|
</div>
|
||||||
<Column.Content>
|
<div className="flex-1 overflow-y-auto scrollbar-none">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Column.Content>
|
</div>
|
||||||
</Column.Root>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { RepostNote } from "@/components/repost";
|
|
||||||
import { TextNote } from "@/components/text";
|
import { TextNote } from "@/components/text";
|
||||||
import { LoaderIcon } from "@lume/icons";
|
import { type Event } from "@lume/types";
|
||||||
import { Event, Kind } from "@lume/types";
|
import { Spinner } from "@lume/ui";
|
||||||
import { Await, createFileRoute } from "@tanstack/react-router";
|
import { Await, createFileRoute } from "@tanstack/react-router";
|
||||||
import { Virtualizer } from "virtua";
|
|
||||||
import { defer } from "@tanstack/react-router";
|
import { defer } from "@tanstack/react-router";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
import { Virtualizer } from "virtua";
|
||||||
|
|
||||||
export const Route = createFileRoute("/trending/notes")({
|
export const Route = createFileRoute("/trending/notes")({
|
||||||
loader: async ({ abortController }) => {
|
loader: async ({ abortController }) => {
|
||||||
@@ -29,16 +28,6 @@ export const Route = createFileRoute("/trending/notes")({
|
|||||||
export function Screen() {
|
export function Screen() {
|
||||||
const { data } = Route.useLoaderData();
|
const { data } = Route.useLoaderData();
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className="w-full h-full">
|
<div className="w-full h-full">
|
||||||
<Virtualizer overscan={3}>
|
<Virtualizer overscan={3}>
|
||||||
@@ -50,14 +39,18 @@ export function Screen() {
|
|||||||
className="inline-flex items-center gap-2 text-sm font-medium"
|
className="inline-flex items-center gap-2 text-sm font-medium"
|
||||||
disabled
|
disabled
|
||||||
>
|
>
|
||||||
<LoaderIcon className="animate-spin size-5" />
|
<Spinner className="size-5" />
|
||||||
Loading...
|
Loading...
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Await promise={data}>
|
<Await promise={data}>
|
||||||
{(notes) => notes.map((event) => renderItem(event))}
|
{(notes) =>
|
||||||
|
notes.map((event) => (
|
||||||
|
<TextNote key={event.id} event={event} className="mb-3" />
|
||||||
|
))
|
||||||
|
}
|
||||||
</Await>
|
</Await>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Virtualizer>
|
</Virtualizer>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { ArticleIcon, GroupFeedsIcon } from "@lume/icons";
|
import { ArticleIcon, GroupFeedsIcon } from "@lume/icons";
|
||||||
import { ColumnRouteSearch } from "@lume/types";
|
import type { ColumnRouteSearch } from "@lume/types";
|
||||||
import { Column } from "@lume/ui";
|
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { Link, Outlet } from "@tanstack/react-router";
|
import { Link, Outlet } from "@tanstack/react-router";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
@@ -22,21 +21,17 @@ export const Route = createFileRoute("/trending")({
|
|||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function Screen() {
|
function Screen() {
|
||||||
const { label, name } = Route.useSearch();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column.Root>
|
<div className="flex flex-col h-full">
|
||||||
<Column.Header label={label} name={name}>
|
<div className="h-11 shrink-0 inline-flex w-full items-center gap-1 px-3">
|
||||||
<div className="inline-flex h-full w-full items-center gap-1">
|
<div className="inline-flex h-full w-full items-center gap-1">
|
||||||
<Link to="/trending/notes">
|
<Link to="/trending/notes">
|
||||||
{({ isActive }) => (
|
{({ isActive }) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
|
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
|
||||||
isActive
|
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
|
||||||
? "bg-neutral-100 dark:bg-neutral-900"
|
|
||||||
: "opacity-50",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ArticleIcon className="size-4" />
|
<ArticleIcon className="size-4" />
|
||||||
@@ -49,9 +44,7 @@ export function Screen() {
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
|
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
|
||||||
isActive
|
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
|
||||||
? "bg-neutral-100 dark:bg-neutral-900"
|
|
||||||
: "opacity-50",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<GroupFeedsIcon className="size-4" />
|
<GroupFeedsIcon className="size-4" />
|
||||||
@@ -60,10 +53,10 @@ export function Screen() {
|
|||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</Column.Header>
|
</div>
|
||||||
<Column.Content>
|
<div className="p-2 flex-1 overflow-y-auto w-full h-full scrollbar-none">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Column.Content>
|
</div>
|
||||||
</Column.Root>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { LoaderIcon } from "@lume/icons";
|
import { Spinner, User } from "@lume/ui";
|
||||||
import { User } from "@lume/ui";
|
|
||||||
import { Await, defer } from "@tanstack/react-router";
|
import { Await, defer } from "@tanstack/react-router";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
@@ -25,7 +24,7 @@ export function Screen() {
|
|||||||
const { data } = Route.useLoaderData();
|
const { data } = Route.useLoaderData();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full px-3">
|
<div className="w-full h-full">
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
||||||
@@ -34,7 +33,7 @@ export function Screen() {
|
|||||||
className="inline-flex items-center gap-2 text-sm font-medium"
|
className="inline-flex items-center gap-2 text-sm font-medium"
|
||||||
disabled
|
disabled
|
||||||
>
|
>
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
<Spinner className="size-5" />
|
||||||
Loading...
|
Loading...
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -45,7 +44,7 @@ export function Screen() {
|
|||||||
users.profiles.map((item) => (
|
users.profiles.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.pubkey}
|
key={item.pubkey}
|
||||||
className="h-max w-full overflow-hidden py-5 border-b border-neutral-100 dark:border-neutral-900"
|
className="h-max w-full overflow-hidden mb-3 p-2 bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl"
|
||||||
>
|
>
|
||||||
<User.Provider pubkey={item.pubkey}>
|
<User.Provider pubkey={item.pubkey}>
|
||||||
<User.Root>
|
<User.Root>
|
||||||
@@ -55,7 +54,7 @@ export function Screen() {
|
|||||||
<User.Avatar className="size-10 shrink-0 rounded-full object-cover" />
|
<User.Avatar className="size-10 shrink-0 rounded-full object-cover" />
|
||||||
<User.Name className="leadning-tight max-w-[15rem] truncate font-semibold" />
|
<User.Name className="leadning-tight max-w-[15rem] truncate font-semibold" />
|
||||||
</div>
|
</div>
|
||||||
<User.Button className="inline-flex h-8 w-20 items-center justify-center rounded-lg bg-neutral-100 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800" />
|
<User.Button className="inline-flex h-8 w-20 items-center justify-center rounded-lg bg-black/10 text-sm font-medium hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" />
|
||||||
</div>
|
</div>
|
||||||
<User.About className="mt-1 line-clamp-3 max-w-none select-text text-neutral-800 dark:text-neutral-400" />
|
<User.About className="mt-1 line-clamp-3 max-w-none select-text text-neutral-800 dark:text-neutral-400" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
|
||||||
import { WindowVirtualizer } from "virtua";
|
|
||||||
import { Box, Container, User } from "@lume/ui";
|
|
||||||
import { EventList } from "./-components/eventList";
|
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/users/$pubkey")({
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const { pubkey } = Route.useParams();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container withDrag>
|
|
||||||
<Box className="px-0 scrollbar-none">
|
|
||||||
<WindowVirtualizer>
|
|
||||||
<User.Provider pubkey={pubkey}>
|
|
||||||
<User.Root>
|
|
||||||
<User.Cover className="h-44 w-full object-cover" />
|
|
||||||
<div className="relative -mt-8 flex flex-col gap-4 px-3">
|
|
||||||
<User.Avatar className="size-14 rounded-full" />
|
|
||||||
<div className="inline-flex items-start justify-between">
|
|
||||||
<div>
|
|
||||||
<User.Name className="font-semibold leading-tight" />
|
|
||||||
<User.NIP05 className="text-sm leading-tight text-neutral-600 dark:text-neutral-400" />
|
|
||||||
</div>
|
|
||||||
<User.Button className="h-9 w-24 rounded-full bg-black text-sm font-medium text-white hover:bg-neutral-900 dark:bg-neutral-900" />
|
|
||||||
</div>
|
|
||||||
<User.About />
|
|
||||||
</div>
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
<div className="mt-4">
|
|
||||||
<div className="px-3">
|
|
||||||
<h3 className="text-lg font-semibold">Latest notes</h3>
|
|
||||||
</div>
|
|
||||||
<EventList id={pubkey} />
|
|
||||||
</div>
|
|
||||||
</WindowVirtualizer>
|
|
||||||
</Box>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { Box, Container, Spinner, User } from "@lume/ui";
|
||||||
|
import { createFileRoute, defer } from "@tanstack/react-router";
|
||||||
|
import { WindowVirtualizer } from "virtua";
|
||||||
|
import { Conversation } from "@/components/conversation";
|
||||||
|
import { Quote } from "@/components/quote";
|
||||||
|
import { RepostNote } from "@/components/repost";
|
||||||
|
import { TextNote } from "@/components/text";
|
||||||
|
import { Event, Kind } from "@lume/types";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { Await } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/users/$pubkey")({
|
||||||
|
beforeLoad: async ({ context }) => {
|
||||||
|
const ark = context.ark;
|
||||||
|
const settings = await ark.get_settings();
|
||||||
|
|
||||||
|
return { settings };
|
||||||
|
},
|
||||||
|
loader: async ({ params, context }) => {
|
||||||
|
const ark = context.ark;
|
||||||
|
return { data: defer(ark.get_events_from(params.pubkey, 50)) };
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const { pubkey } = Route.useParams();
|
||||||
|
const { data } = Route.useLoaderData();
|
||||||
|
|
||||||
|
const renderItem = (event: Event) => {
|
||||||
|
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) {
|
||||||
|
return <Conversation 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" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container withDrag>
|
||||||
|
<Box className="px-0 scrollbar-none bg-black/5 dark:bg-white/5 backdrop-blur-sm">
|
||||||
|
<WindowVirtualizer>
|
||||||
|
<User.Provider pubkey={pubkey}>
|
||||||
|
<User.Root>
|
||||||
|
<User.Cover className="h-44 w-full object-cover" />
|
||||||
|
<div className="relative -mt-8 flex flex-col px-3">
|
||||||
|
<User.Avatar className="size-14 rounded-full" />
|
||||||
|
<div className="mb-4 inline-flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<User.Name className="text-lg font-semibold leading-tight" />
|
||||||
|
<User.NIP05 />
|
||||||
|
</div>
|
||||||
|
<User.Button className="h-9 w-24 rounded-full inline-flex items-center justify-center bg-black text-sm font-medium text-white hover:bg-neutral-900 dark:bg-neutral-900" />
|
||||||
|
</div>
|
||||||
|
<User.About />
|
||||||
|
</div>
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
<div className="px-3 mt-5">
|
||||||
|
<div className="mb-3">
|
||||||
|
<h3 className="text-lg font-semibold">Latest notes</h3>
|
||||||
|
</div>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="flex h-20 w-full items-center justify-center gap-1.5 text-sm font-medium">
|
||||||
|
<Spinner className="size-5" />
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Await promise={data}>
|
||||||
|
{(events) => events.map((event) => renderItem(event))}
|
||||||
|
</Await>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</WindowVirtualizer>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { TextNote } from "@/components/text";
|
|
||||||
import { RepostNote } from "@/components/repost";
|
import { RepostNote } from "@/components/repost";
|
||||||
import { ArrowRightCircleIcon, InfoIcon, LoaderIcon } from "@lume/icons";
|
import { TextNote } from "@/components/text";
|
||||||
import { Event, Kind } from "@lume/types";
|
import { ArrowRightCircleIcon, InfoIcon } from "@lume/icons";
|
||||||
|
import { type Event, Kind } from "@lume/types";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
import { FETCH_LIMIT } from "@lume/utils";
|
import { FETCH_LIMIT } from "@lume/utils";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
@@ -18,10 +19,8 @@ export function EventList({ id }: { id: string }) {
|
|||||||
},
|
},
|
||||||
getNextPageParam: (lastPage) => {
|
getNextPageParam: (lastPage) => {
|
||||||
const lastEvent = lastPage?.at(-1);
|
const lastEvent = lastPage?.at(-1);
|
||||||
if (!lastEvent) return;
|
return lastEvent ? lastEvent.created_at - 1 : null;
|
||||||
return lastEvent.created_at - 1;
|
|
||||||
},
|
},
|
||||||
select: (data) => data?.pages.flatMap((page) => page),
|
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,7 +38,7 @@ export function EventList({ id }: { id: string }) {
|
|||||||
<div>
|
<div>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
<Spinner className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
) : !data.length ? (
|
) : !data.length ? (
|
||||||
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-5 dark:bg-neutral-950">
|
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-5 dark:bg-neutral-950">
|
||||||
@@ -58,7 +57,7 @@ export function EventList({ id }: { id: string }) {
|
|||||||
className="inline-flex h-12 w-36 items-center justify-center gap-2 rounded-full bg-neutral-100 px-3 font-medium hover:bg-neutral-200 focus:outline-none dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
className="inline-flex h-12 w-36 items-center justify-center gap-2 rounded-full bg-neutral-100 px-3 font-medium hover:bg-neutral-200 focus:outline-none dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||||
>
|
>
|
||||||
{isFetchingNextPage ? (
|
{isFetchingNextPage ? (
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
<Spinner className="size-5" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ArrowRightCircleIcon className="size-5" />
|
<ArrowRightCircleIcon className="size-5" />
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Balance } from "@/components/balance";
|
import { Balance } from "@/components/balance";
|
||||||
import { Box, Container, User } from "@lume/ui";
|
import { Box, Container, User } from "@lume/ui";
|
||||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { getCurrent } from "@tauri-apps/api/webviewWindow";
|
import { getCurrent } from "@tauri-apps/api/webviewWindow";
|
||||||
import { toast } from "sonner";
|
import { useState } from "react";
|
||||||
import CurrencyInput from "react-currency-input-field";
|
import CurrencyInput from "react-currency-input-field";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
const DEFAULT_VALUES = [69, 100, 200, 500];
|
const DEFAULT_VALUES = [69, 100, 200, 500];
|
||||||
|
|
||||||
@@ -79,6 +79,7 @@ function Screen() {
|
|||||||
<div className="inline-flex items-center justify-center gap-2">
|
<div className="inline-flex items-center justify-center gap-2">
|
||||||
{DEFAULT_VALUES.map((value) => (
|
{DEFAULT_VALUES.map((value) => (
|
||||||
<button
|
<button
|
||||||
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setAmount(value)}
|
onClick={() => setAmount(value)}
|
||||||
className="w-max rounded-full bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
className="w-max rounded-full bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { defineConfig } from 'astro/config';
|
import { defineConfig } from "astro/config";
|
||||||
|
|
||||||
import tailwind from "@astrojs/tailwind";
|
import tailwind from "@astrojs/tailwind";
|
||||||
|
|
||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
integrations: [tailwind()]
|
integrations: [tailwind()],
|
||||||
});
|
});
|
||||||
@@ -12,15 +12,15 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/check": "^0.5.10",
|
"@astrojs/check": "^0.5.10",
|
||||||
"@astrojs/tailwind": "^5.1.0",
|
"@astrojs/tailwind": "^5.1.0",
|
||||||
"@fontsource/geist-mono": "^5.0.2",
|
"@fontsource/geist-mono": "^5.0.3",
|
||||||
"astro": "^4.5.18",
|
"astro": "^4.7.0",
|
||||||
"astro-seo-meta": "^4.1.0",
|
"astro-seo-meta": "^4.1.1",
|
||||||
"astro-seo-schema": "^4.0.0",
|
"astro-seo-schema": "^4.0.2",
|
||||||
"schema-dts": "^1.1.2",
|
"schema-dts": "^1.1.2",
|
||||||
"tailwindcss": "^3.4.3",
|
"tailwindcss": "^3.4.3",
|
||||||
"typescript": "^5.4.5"
|
"typescript": "^5.4.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/typography": "^0.5.12"
|
"@tailwindcss/typography": "^0.5.13"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
"$schema": "https://biomejs.dev/schemas/1.4.1/schema.json",
|
"$schema": "https://biomejs.dev/schemas/1.4.1/schema.json",
|
||||||
"organizeImports": {
|
"organizeImports": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"ignore": ["apps/desktop2/src/router.gen.ts"]
|
||||||
},
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
+4
-4
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "lume",
|
"name": "lume",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "4.0.0-alpha.0",
|
"version": "4.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"dev": "turbo run dev",
|
"dev": "turbo run dev",
|
||||||
@@ -11,9 +11,9 @@
|
|||||||
"tauri": "tauri"
|
"tauri": "tauri"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^1.6.4",
|
"@biomejs/biome": "1.7.3",
|
||||||
"@tauri-apps/cli": "2.0.0-beta.12",
|
"@tauri-apps/cli": "2.0.0-beta.12",
|
||||||
"turbo": "^1.13.2"
|
"turbo": "^1.13.3"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@8.9.0",
|
"packageManager": "pnpm@8.9.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -31,6 +31,6 @@
|
|||||||
"@tauri-apps/plugin-process": "2.0.0-beta.2",
|
"@tauri-apps/plugin-process": "2.0.0-beta.2",
|
||||||
"@tauri-apps/plugin-shell": "2.0.0-beta.2",
|
"@tauri-apps/plugin-shell": "2.0.0-beta.2",
|
||||||
"@tauri-apps/plugin-updater": "2.0.0-beta.2",
|
"@tauri-apps/plugin-updater": "2.0.0-beta.2",
|
||||||
"@tauri-apps/plugin-upload": "2.0.0-beta.2"
|
"@tauri-apps/plugin-upload": "2.0.0-beta.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,38 +4,14 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getalby/sdk": "^3.4.3",
|
|
||||||
"@lume/icons": "workspace:^",
|
|
||||||
"@lume/utils": "workspace:^",
|
"@lume/utils": "workspace:^",
|
||||||
"@radix-ui/react-avatar": "^1.0.4",
|
"@tanstack/react-query": "^5.32.0",
|
||||||
"@radix-ui/react-collapsible": "^1.0.3",
|
"react": "^18.3.1"
|
||||||
"@radix-ui/react-dialog": "^1.0.5",
|
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
|
||||||
"@radix-ui/react-hover-card": "^1.0.7",
|
|
||||||
"@radix-ui/react-popover": "^1.0.7",
|
|
||||||
"@radix-ui/react-tooltip": "^1.0.7",
|
|
||||||
"@tanstack/react-query": "^5.29.0",
|
|
||||||
"@tanstack/react-router": "^1.26.19",
|
|
||||||
"get-urls": "^12.1.0",
|
|
||||||
"media-chrome": "^3.2.0",
|
|
||||||
"minidenticons": "^4.2.1",
|
|
||||||
"nanoid": "^5.0.7",
|
|
||||||
"qrcode.react": "^3.1.0",
|
|
||||||
"re-resizable": "^6.9.11",
|
|
||||||
"react": "^18.2.0",
|
|
||||||
"react-currency-input-field": "^3.8.0",
|
|
||||||
"react-i18next": "^14.1.0",
|
|
||||||
"react-string-replace": "^1.1.1",
|
|
||||||
"sonner": "^1.4.41",
|
|
||||||
"string-strip-html": "^13.4.8",
|
|
||||||
"virtua": "^0.29.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lume/tailwindcss": "workspace:^",
|
|
||||||
"@lume/tsconfig": "workspace:^",
|
"@lume/tsconfig": "workspace:^",
|
||||||
"@lume/types": "workspace:^",
|
"@lume/types": "workspace:^",
|
||||||
"@types/react": "^18.2.75",
|
"@types/react": "^18.3.1",
|
||||||
"tailwindcss": "^3.4.3",
|
|
||||||
"typescript": "^5.4.5"
|
"typescript": "^5.4.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+275
-163
@@ -1,19 +1,20 @@
|
|||||||
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
|
import {
|
||||||
import type {
|
type Contact,
|
||||||
Account,
|
type Event,
|
||||||
Contact,
|
type EventWithReplies,
|
||||||
Event,
|
type Interests,
|
||||||
EventWithReplies,
|
type Keys,
|
||||||
Interests,
|
Kind,
|
||||||
Keys,
|
type LumeColumn,
|
||||||
LumeColumn,
|
type Metadata,
|
||||||
Metadata,
|
type Settings,
|
||||||
Settings,
|
Relays,
|
||||||
} from "@lume/types";
|
} from "@lume/types";
|
||||||
|
import { generateContentTags } from "@lume/utils";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import type { WebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import { readFile } from "@tauri-apps/plugin-fs";
|
import { readFile } from "@tauri-apps/plugin-fs";
|
||||||
import { generateContentTags } from "@lume/utils";
|
|
||||||
|
|
||||||
enum NSTORE_KEYS {
|
enum NSTORE_KEYS {
|
||||||
settings = "lume_user_settings",
|
settings = "lume_user_settings",
|
||||||
@@ -23,24 +24,24 @@ enum NSTORE_KEYS {
|
|||||||
|
|
||||||
export class Ark {
|
export class Ark {
|
||||||
public windows: WebviewWindow[];
|
public windows: WebviewWindow[];
|
||||||
|
public settings: Settings;
|
||||||
|
public accounts: string[];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.windows = [];
|
this.windows = [];
|
||||||
|
this.settings = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async get_all_accounts() {
|
public async get_all_accounts() {
|
||||||
try {
|
try {
|
||||||
const accounts: Account[] = [];
|
|
||||||
const cmd: string[] = await invoke("get_accounts");
|
const cmd: string[] = await invoke("get_accounts");
|
||||||
|
const accounts: string[] = cmd.map((item) => item.replace(".npub", ""));
|
||||||
|
|
||||||
|
if (!this.accounts) this.accounts = accounts;
|
||||||
|
|
||||||
if (cmd) {
|
|
||||||
for (const item of cmd) {
|
|
||||||
accounts.push({ npub: item.replace(".npub", "") });
|
|
||||||
}
|
|
||||||
return accounts;
|
return accounts;
|
||||||
}
|
} catch (e) {
|
||||||
} catch {
|
throw new Error(String(e));
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,19 +50,25 @@ export class Ark {
|
|||||||
const cmd: boolean = await invoke("load_selected_account", {
|
const cmd: boolean = await invoke("load_selected_account", {
|
||||||
npub,
|
npub,
|
||||||
});
|
});
|
||||||
|
|
||||||
return cmd;
|
return cmd;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(String(e));
|
throw new Error(String(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async create_guest_account() {
|
public async nostr_connect(uri: string) {
|
||||||
try {
|
try {
|
||||||
const keys = await this.create_keys();
|
const remoteKey = uri.replace("bunker://", "").split("?")[0];
|
||||||
await this.save_account(keys.nsec, "");
|
const npub: string = await invoke("to_npub", { hex: remoteKey });
|
||||||
|
|
||||||
return keys.npub;
|
if (npub) {
|
||||||
|
const connect: string = await invoke("nostr_connect", {
|
||||||
|
npub,
|
||||||
|
uri,
|
||||||
|
});
|
||||||
|
|
||||||
|
return connect;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(String(e));
|
throw new Error(String(e));
|
||||||
}
|
}
|
||||||
@@ -76,7 +83,7 @@ export class Ark {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async save_account(nsec: string, password: string = "") {
|
public async save_account(nsec: string, password = "") {
|
||||||
try {
|
try {
|
||||||
const cmd: string = await invoke("save_key", {
|
const cmd: string = await invoke("save_key", {
|
||||||
nsec,
|
nsec,
|
||||||
@@ -101,33 +108,94 @@ export class Ark {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async get_event(id: string) {
|
public async get_relays() {
|
||||||
try {
|
try {
|
||||||
const eventId: string = id
|
const cmd: Relays = await invoke("get_relays");
|
||||||
.replace("nostr:", "")
|
return cmd;
|
||||||
.split("'")[0]
|
} catch (e) {
|
||||||
.split(".")[0];
|
console.error(String(e));
|
||||||
const cmd: string = await invoke("get_event", { id: eventId });
|
return null;
|
||||||
const event: Event = JSON.parse(cmd);
|
}
|
||||||
return event;
|
}
|
||||||
|
|
||||||
|
public async add_relay(url: string) {
|
||||||
|
try {
|
||||||
|
const relayUrl = new URL(url);
|
||||||
|
|
||||||
|
if (relayUrl.protocol === "wss:" || relayUrl.protocol === "ws:") {
|
||||||
|
const cmd: boolean = await invoke("connect_relay", { relay: relayUrl });
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(String(e));
|
throw new Error(String(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async get_events_from(id: string, limit: number, asOf?: number) {
|
public async remove_relay(url: string) {
|
||||||
|
try {
|
||||||
|
const relayUrl = new URL(url);
|
||||||
|
|
||||||
|
if (relayUrl.protocol === "wss:" || relayUrl.protocol === "ws:") {
|
||||||
|
const cmd: boolean = await invoke("remove_relay", { relay: relayUrl });
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async get_activities(account: string, kind: "1" | "6" | "9735" = "1") {
|
||||||
|
try {
|
||||||
|
const events: Event[] = await invoke("get_activities", { account, kind });
|
||||||
|
return events;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(String(e));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async get_event(id: string) {
|
||||||
|
try {
|
||||||
|
const eventId: string = id.replace("nostr:", "").replace(/[^\w\s]/gi, "");
|
||||||
|
const cmd: string = await invoke("get_event", { id: eventId });
|
||||||
|
const event: Event = JSON.parse(cmd);
|
||||||
|
return event;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(id, String(e));
|
||||||
|
throw new Error(String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async get_events_from(pubkey: string, limit: number, asOf?: number) {
|
||||||
try {
|
try {
|
||||||
let until: string = undefined;
|
let until: string = undefined;
|
||||||
if (asOf && asOf > 0) until = asOf.toString();
|
if (asOf && asOf > 0) until = asOf.toString();
|
||||||
|
|
||||||
const nostrEvents: Event[] = await invoke("get_events_from", {
|
const nostrEvents: Event[] = await invoke("get_events_from", {
|
||||||
id,
|
publicKey: pubkey,
|
||||||
limit,
|
limit,
|
||||||
until,
|
as_of: until,
|
||||||
});
|
});
|
||||||
|
|
||||||
return nostrEvents.sort((a, b) => b.created_at - a.created_at);
|
return nostrEvents.sort((a, b) => b.created_at - a.created_at);
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.error(String(e));
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async search(content: string, limit: number) {
|
||||||
|
try {
|
||||||
|
if (content.length < 1) return [];
|
||||||
|
|
||||||
|
const events: Event[] = await invoke("search", {
|
||||||
|
content: content.trim(),
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return events;
|
||||||
|
} catch (e) {
|
||||||
|
console.info(String(e));
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,14 +208,11 @@ export class Ark {
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
let until: string = undefined;
|
let until: string = undefined;
|
||||||
let isGlobal = global ?? false;
|
const isGlobal = global ?? false;
|
||||||
|
|
||||||
if (asOf && asOf > 0) until = asOf.toString();
|
if (asOf && asOf > 0) until = asOf.toString();
|
||||||
|
|
||||||
const dedup = true;
|
|
||||||
const seenIds = new Set<string>();
|
const seenIds = new Set<string>();
|
||||||
const dedupQueue = new Set<string>();
|
|
||||||
|
|
||||||
const nostrEvents: Event[] = await invoke("get_events", {
|
const nostrEvents: Event[] = await invoke("get_events", {
|
||||||
limit,
|
limit,
|
||||||
until,
|
until,
|
||||||
@@ -155,31 +220,39 @@ export class Ark {
|
|||||||
global: isGlobal,
|
global: isGlobal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dedup) {
|
// remove duplicate event
|
||||||
for (const event of nostrEvents) {
|
for (const event of nostrEvents) {
|
||||||
const tags = event.tags
|
if (event.kind === Kind.Repost) {
|
||||||
|
const repostId = event.tags.find((tag) => tag[0] === "e")?.[1];
|
||||||
|
seenIds.add(repostId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventIds = event.tags
|
||||||
.filter((el) => el[0] === "e")
|
.filter((el) => el[0] === "e")
|
||||||
?.map((item) => item[1]);
|
?.map((item) => item[1]);
|
||||||
|
|
||||||
if (tags.length) {
|
if (eventIds?.length) {
|
||||||
for (const tag of tags) {
|
for (const id of eventIds) {
|
||||||
if (seenIds.has(tag)) {
|
seenIds.add(id);
|
||||||
dedupQueue.add(event.id);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
seenIds.add(tag);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nostrEvents
|
const events = nostrEvents
|
||||||
.filter((event) => !dedupQueue.has(event.id))
|
.filter((event) => !seenIds.has(event.id))
|
||||||
.sort((a, b) => b.created_at - a.created_at);
|
.sort((a, b) => b.created_at - a.created_at);
|
||||||
|
|
||||||
|
if (this.settings?.nsfw) {
|
||||||
|
return events.filter(
|
||||||
|
(event) =>
|
||||||
|
event.tags.filter((event) => event[0] === "content-warning")
|
||||||
|
.length > 0,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return nostrEvents;
|
return events;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(String(e));
|
console.info(String(e));
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,23 +261,20 @@ export class Ark {
|
|||||||
hashtags: string[],
|
hashtags: string[],
|
||||||
limit: number,
|
limit: number,
|
||||||
asOf?: number,
|
asOf?: number,
|
||||||
global?: boolean,
|
|
||||||
) {
|
) {
|
||||||
let until: string = undefined;
|
let until: string = undefined;
|
||||||
if (asOf && asOf > 0) until = asOf.toString();
|
if (asOf && asOf > 0) until = asOf.toString();
|
||||||
|
|
||||||
const dedup = true;
|
|
||||||
const seenIds = new Set<string>();
|
const seenIds = new Set<string>();
|
||||||
const dedupQueue = new Set<string>();
|
const dedupQueue = new Set<string>();
|
||||||
|
const nostrTags = hashtags.map((tag) => tag.replace("#", "").toLowerCase());
|
||||||
|
|
||||||
const nostrEvents: Event[] = await invoke("get_events_from_interests", {
|
const nostrEvents: Event[] = await invoke("get_events_from_interests", {
|
||||||
hashtags,
|
hashtags: nostrTags,
|
||||||
limit,
|
limit,
|
||||||
until,
|
until,
|
||||||
global,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dedup) {
|
|
||||||
for (const event of nostrEvents) {
|
for (const event of nostrEvents) {
|
||||||
const tags = event.tags
|
const tags = event.tags
|
||||||
.filter((el) => el[0] === "e")
|
.filter((el) => el[0] === "e")
|
||||||
@@ -226,10 +296,12 @@ export class Ark {
|
|||||||
.sort((a, b) => b.created_at - a.created_at);
|
.sort((a, b) => b.created_at - a.created_at);
|
||||||
}
|
}
|
||||||
|
|
||||||
return nostrEvents.sort((a, b) => b.created_at - a.created_at);
|
public async publish(
|
||||||
}
|
content: string,
|
||||||
|
reply_to?: string,
|
||||||
public async publish(content: string, reply_to?: string, quote?: boolean) {
|
quote?: boolean,
|
||||||
|
nsfw?: boolean,
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const g = await generateContentTags(content);
|
const g = await generateContentTags(content);
|
||||||
|
|
||||||
@@ -238,26 +310,35 @@ export class Ark {
|
|||||||
|
|
||||||
if (reply_to) {
|
if (reply_to) {
|
||||||
const replyEvent = await this.get_event(reply_to);
|
const replyEvent = await this.get_event(reply_to);
|
||||||
|
const relayHint =
|
||||||
|
replyEvent.tags.find((ev) => ev[0] === "e")?.[0][2] ?? "";
|
||||||
|
|
||||||
if (quote) {
|
if (quote) {
|
||||||
eventTags.push([
|
eventTags.push(["e", replyEvent.id, relayHint, "mention"]);
|
||||||
"e",
|
eventTags.push(["q", replyEvent.id]);
|
||||||
replyEvent.id,
|
|
||||||
replyEvent.relay || "",
|
|
||||||
"mention",
|
|
||||||
]);
|
|
||||||
} else {
|
} else {
|
||||||
const rootEvent = replyEvent.tags.find((ev) => ev[3] === "root");
|
const rootEvent = replyEvent.tags.find((ev) => ev[3] === "root");
|
||||||
|
|
||||||
if (rootEvent) {
|
if (rootEvent) {
|
||||||
eventTags.push(["e", rootEvent[1], rootEvent[2] || "", "root"]);
|
eventTags.push([
|
||||||
|
"e",
|
||||||
|
rootEvent[1],
|
||||||
|
rootEvent[2] || relayHint,
|
||||||
|
"root",
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
eventTags.push(["e", replyEvent.id, replyEvent.relay || "", "reply"]);
|
eventTags.push(["e", replyEvent.id, relayHint, "reply"]);
|
||||||
eventTags.push(["p", replyEvent.pubkey]);
|
eventTags.push(["p", replyEvent.pubkey]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (nsfw) {
|
||||||
|
eventTags.push(["L", "content-warning"]);
|
||||||
|
eventTags.push(["l", "reason", "content-warning"]);
|
||||||
|
eventTags.push(["content-warning", "nsfw"]);
|
||||||
|
}
|
||||||
|
|
||||||
const cmd: string = await invoke("publish", {
|
const cmd: string = await invoke("publish", {
|
||||||
content: eventContent,
|
content: eventContent,
|
||||||
tags: eventTags,
|
tags: eventTags,
|
||||||
@@ -342,53 +423,48 @@ export class Ark {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public parse_event_thread({
|
public parse_event_thread(tags: string[][]) {
|
||||||
content,
|
let root: string = null;
|
||||||
tags,
|
let reply: string = null;
|
||||||
}: { content: string; tags: string[][] }) {
|
|
||||||
let rootEventId: string = null;
|
|
||||||
let replyEventId: string = null;
|
|
||||||
|
|
||||||
// Ignore quote repost
|
|
||||||
if (content.includes("nostr:note1") || content.includes("nostr:nevent1"))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
// Get all event references from tags, ignore mention
|
// Get all event references from tags, ignore mention
|
||||||
const events = tags.filter((el) => el[0] === "e" && el[3] !== "mention");
|
const events = tags.filter((el) => el[0] === "e" && el[3] !== "mention");
|
||||||
|
|
||||||
if (!events.length) return null;
|
|
||||||
if (events.length === 1) {
|
if (events.length === 1) {
|
||||||
return {
|
root = events[0][1];
|
||||||
rootEventId: events[0][1],
|
|
||||||
replyEventId: null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (events.length > 1) {
|
if (events.length > 1) {
|
||||||
rootEventId = events.find((el) => el[3] === "root")?.[1];
|
root = events.find((el) => el[3] === "root")?.[1] ?? events[0][1];
|
||||||
replyEventId = events.find((el) => el[3] === "reply")?.[1];
|
reply = events.find((el) => el[3] === "reply")?.[1] ?? events[1][1];
|
||||||
|
|
||||||
if (!rootEventId && !replyEventId) {
|
|
||||||
rootEventId = events[0][1];
|
|
||||||
replyEventId = events[1][1];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fix some rare case when root === reply
|
||||||
|
if (root && reply && root === reply) {
|
||||||
|
reply = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rootEventId,
|
root,
|
||||||
replyEventId,
|
reply,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async get_profile(pubkey: string) {
|
public async get_profile(pubkey: string) {
|
||||||
try {
|
try {
|
||||||
const id = pubkey
|
const id = pubkey.replace("nostr:", "").replace(/[^\w\s]/gi, "");
|
||||||
.replace("nostr:", "")
|
|
||||||
.split("'")[0]
|
|
||||||
.split(".")[0]
|
|
||||||
.split(",")[0]
|
|
||||||
.split("?")[0];
|
|
||||||
const cmd: Metadata = await invoke("get_profile", { id });
|
const cmd: Metadata = await invoke("get_profile", { id });
|
||||||
|
|
||||||
|
return cmd;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(pubkey, String(e));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async get_current_user_profile() {
|
||||||
|
try {
|
||||||
|
const cmd: Metadata = await invoke("get_current_user_profile");
|
||||||
return cmd;
|
return cmd;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -424,16 +500,6 @@ export class Ark {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async get_contact_metadata() {
|
|
||||||
try {
|
|
||||||
const cmd: Contact[] = await invoke("get_contact_metadata");
|
|
||||||
return cmd;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async follow(id: string, alias?: string) {
|
public async follow(id: string, alias?: string) {
|
||||||
try {
|
try {
|
||||||
const cmd: string = await invoke("follow", { id, alias });
|
const cmd: string = await invoke("follow", { id, alias });
|
||||||
@@ -522,7 +588,6 @@ export class Ark {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async upload(filePath?: string) {
|
public async upload(filePath?: string) {
|
||||||
try {
|
|
||||||
const allowExts = [
|
const allowExts = [
|
||||||
"png",
|
"png",
|
||||||
"jpeg",
|
"jpeg",
|
||||||
@@ -550,8 +615,10 @@ export class Ark {
|
|||||||
})
|
})
|
||||||
).path;
|
).path;
|
||||||
|
|
||||||
|
// User cancelled action
|
||||||
if (!selected) return null;
|
if (!selected) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
const file = await readFile(selected);
|
const file = await readFile(selected);
|
||||||
const blob = new Blob([file]);
|
const blob = new Blob([file]);
|
||||||
|
|
||||||
@@ -601,10 +668,15 @@ export class Ark {
|
|||||||
|
|
||||||
public async get_settings() {
|
public async get_settings() {
|
||||||
try {
|
try {
|
||||||
|
if (this.settings) return this.settings;
|
||||||
|
|
||||||
const cmd: string = await invoke("get_nstore", {
|
const cmd: string = await invoke("get_nstore", {
|
||||||
key: NSTORE_KEYS.settings,
|
key: NSTORE_KEYS.settings,
|
||||||
});
|
});
|
||||||
const settings: Settings = cmd ? JSON.parse(cmd) : null;
|
const settings: Settings = cmd ? JSON.parse(cmd) : null;
|
||||||
|
|
||||||
|
this.settings = settings;
|
||||||
|
|
||||||
return settings;
|
return settings;
|
||||||
} catch {
|
} catch {
|
||||||
const defaultSettings: Settings = {
|
const defaultSettings: Settings = {
|
||||||
@@ -612,7 +684,9 @@ export class Ark {
|
|||||||
enhancedPrivacy: false,
|
enhancedPrivacy: false,
|
||||||
notification: false,
|
notification: false,
|
||||||
zap: false,
|
zap: false,
|
||||||
|
nsfw: false,
|
||||||
};
|
};
|
||||||
|
this.settings = defaultSettings;
|
||||||
return defaultSettings;
|
return defaultSettings;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -687,46 +761,69 @@ export class Ark {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public open_thread(id: string) {
|
public async open_event_id(id: string) {
|
||||||
try {
|
try {
|
||||||
const window = new WebviewWindow(`event-${id}`, {
|
const label = `event-${id}`;
|
||||||
|
const url = `/events/${id}`;
|
||||||
|
|
||||||
|
await invoke("open_window", {
|
||||||
|
label,
|
||||||
title: "Thread",
|
title: "Thread",
|
||||||
url: `/events/${id}`,
|
url,
|
||||||
minWidth: 500,
|
|
||||||
minHeight: 800,
|
|
||||||
width: 500,
|
width: 500,
|
||||||
height: 800,
|
height: 800,
|
||||||
hiddenTitle: true,
|
|
||||||
titleBarStyle: "overlay",
|
|
||||||
center: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.windows.push(window);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(String(e));
|
throw new Error(String(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public open_profile(pubkey: string) {
|
public async open_event(event: Event) {
|
||||||
try {
|
try {
|
||||||
const window = new WebviewWindow(`user-${pubkey}`, {
|
let root: string = undefined;
|
||||||
|
let reply: string = undefined;
|
||||||
|
|
||||||
|
const eTags = event.tags.filter(
|
||||||
|
(tag) => tag[0] === "e" || tag[0] === "q",
|
||||||
|
);
|
||||||
|
|
||||||
|
root = eTags.find((el) => el[3] === "root")?.[1];
|
||||||
|
reply = eTags.find((el) => el[3] === "reply")?.[1];
|
||||||
|
|
||||||
|
if (!root) root = eTags[0]?.[1];
|
||||||
|
if (!reply) reply = eTags[1]?.[1];
|
||||||
|
|
||||||
|
const label = `event-${event.id}`;
|
||||||
|
const url = `/events/${root ?? reply ?? event.id}`;
|
||||||
|
|
||||||
|
await invoke("open_window", {
|
||||||
|
label,
|
||||||
|
title: "Thread",
|
||||||
|
url,
|
||||||
|
width: 500,
|
||||||
|
height: 800,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async open_profile(pubkey: string) {
|
||||||
|
try {
|
||||||
|
const label = `user-${pubkey}`;
|
||||||
|
await invoke("open_window", {
|
||||||
|
label,
|
||||||
title: "Profile",
|
title: "Profile",
|
||||||
url: `/users/${pubkey}`,
|
url: `/users/${pubkey}`,
|
||||||
minWidth: 500,
|
|
||||||
minHeight: 800,
|
|
||||||
width: 500,
|
width: 500,
|
||||||
height: 800,
|
height: 800,
|
||||||
hiddenTitle: true,
|
|
||||||
titleBarStyle: "overlay",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.windows.push(window);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(String(e));
|
throw new Error(String(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public open_editor(reply_to?: string, quote: boolean = false) {
|
public async open_editor(reply_to?: string, quote = false) {
|
||||||
try {
|
try {
|
||||||
let url: string;
|
let url: string;
|
||||||
|
|
||||||
@@ -736,75 +833,90 @@ export class Ark {
|
|||||||
url = "/editor";
|
url = "/editor";
|
||||||
}
|
}
|
||||||
|
|
||||||
const window = new WebviewWindow(`editor-${reply_to ? reply_to : 0}`, {
|
const label = `editor-${reply_to ? reply_to : 0}`;
|
||||||
|
|
||||||
|
await invoke("open_window", {
|
||||||
|
label,
|
||||||
title: "Editor",
|
title: "Editor",
|
||||||
url,
|
url,
|
||||||
minWidth: 500,
|
width: 560,
|
||||||
minHeight: 400,
|
height: 340,
|
||||||
width: 600,
|
|
||||||
height: 400,
|
|
||||||
hiddenTitle: true,
|
|
||||||
titleBarStyle: "overlay",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.windows.push(window);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(String(e));
|
throw new Error(String(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public open_nwc() {
|
public async open_nwc() {
|
||||||
try {
|
try {
|
||||||
const window = new WebviewWindow("nwc", {
|
const label = "nwc";
|
||||||
|
await invoke("open_window", {
|
||||||
|
label,
|
||||||
title: "Nostr Wallet Connect",
|
title: "Nostr Wallet Connect",
|
||||||
url: "/nwc",
|
url: "/nwc",
|
||||||
minWidth: 400,
|
|
||||||
minHeight: 600,
|
|
||||||
width: 400,
|
width: 400,
|
||||||
height: 600,
|
height: 600,
|
||||||
hiddenTitle: true,
|
|
||||||
titleBarStyle: "overlay",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.windows.push(window);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(String(e));
|
throw new Error(String(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public open_zap(id: string, pubkey: string, account: string) {
|
public async open_zap(id: string, pubkey: string, account: string) {
|
||||||
try {
|
try {
|
||||||
const window = new WebviewWindow(`zap-${id}`, {
|
const label = `zap-${id}`;
|
||||||
|
await invoke("open_window", {
|
||||||
|
label,
|
||||||
title: "Zap",
|
title: "Zap",
|
||||||
url: `/zap/${id}?pubkey=${pubkey}&account=${account}`,
|
url: `/zap/${id}?pubkey=${pubkey}&account=${account}`,
|
||||||
minWidth: 400,
|
|
||||||
minHeight: 500,
|
|
||||||
width: 400,
|
width: 400,
|
||||||
height: 500,
|
height: 500,
|
||||||
hiddenTitle: true,
|
|
||||||
titleBarStyle: "overlay",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.windows.push(window);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(String(e));
|
throw new Error(String(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public open_settings() {
|
public async open_settings() {
|
||||||
try {
|
try {
|
||||||
const window = new WebviewWindow("settings", {
|
const label = "settings";
|
||||||
|
await invoke("open_window", {
|
||||||
|
label,
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
url: "/settings",
|
url: "/settings",
|
||||||
minWidth: 600,
|
|
||||||
minHeight: 500,
|
|
||||||
width: 800,
|
width: 800,
|
||||||
height: 500,
|
height: 500,
|
||||||
hiddenTitle: true,
|
|
||||||
titleBarStyle: "overlay",
|
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.windows.push(window);
|
public async open_search() {
|
||||||
|
try {
|
||||||
|
const label = "search";
|
||||||
|
await invoke("open_window", {
|
||||||
|
label,
|
||||||
|
title: "Search",
|
||||||
|
url: "/search",
|
||||||
|
width: 400,
|
||||||
|
height: 600,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async open_activity(account: string) {
|
||||||
|
try {
|
||||||
|
const label = "activity";
|
||||||
|
await invoke("open_window", {
|
||||||
|
label,
|
||||||
|
title: "Activity",
|
||||||
|
url: `/activity/${account}/texts`,
|
||||||
|
width: 400,
|
||||||
|
height: 600,
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(String(e));
|
throw new Error(String(e));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Event } from "@lume/types";
|
import type { Event } from "@lume/types";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ export function useEvent(id: string) {
|
|||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
staleTime: Infinity,
|
staleTime: Number.POSITIVE_INFINITY,
|
||||||
retry: 2,
|
retry: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function usePreview(url: string) {
|
|||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
staleTime: Infinity,
|
staleTime: Number.POSITIVE_INFINITY,
|
||||||
retry: 2,
|
retry: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import type { Metadata } from "@lume/types";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Metadata } from "@lume/types";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
export function useProfile(pubkey: string) {
|
export function useProfile(pubkey: string, embed?: string) {
|
||||||
const {
|
const {
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
@@ -11,13 +11,14 @@ export function useProfile(pubkey: string) {
|
|||||||
queryKey: ["user", pubkey],
|
queryKey: ["user", pubkey],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
const id = pubkey
|
if (embed) {
|
||||||
.replace("nostr:", "")
|
const profile: Metadata = JSON.parse(embed);
|
||||||
.split("'")[0]
|
return profile;
|
||||||
.split(".")[0]
|
}
|
||||||
.split(",")[0]
|
|
||||||
.split("?")[0];
|
const id = pubkey.replace("nostr:", "").replace(/[^\w\s]/gi, "");
|
||||||
const cmd: Metadata = await invoke("get_profile", { id });
|
const cmd: Metadata = await invoke("get_profile", { id });
|
||||||
|
|
||||||
return cmd;
|
return cmd;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(e);
|
throw new Error(e);
|
||||||
@@ -26,7 +27,7 @@ export function useProfile(pubkey: string) {
|
|||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
staleTime: Infinity,
|
staleTime: Number.POSITIVE_INFINITY,
|
||||||
retry: 2,
|
retry: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ export * from "./src/threads";
|
|||||||
export * from "./src/trash";
|
export * from "./src/trash";
|
||||||
export * from "./src/world";
|
export * from "./src/world";
|
||||||
export * from "./src/zap";
|
export * from "./src/zap";
|
||||||
export * from "./src/loader";
|
|
||||||
export * from "./src/trending";
|
export * from "./src/trending";
|
||||||
export * from "./src/empty";
|
export * from "./src/empty";
|
||||||
export * from "./src/cmd";
|
export * from "./src/cmd";
|
||||||
@@ -124,3 +123,5 @@ export * from "./src/laurel";
|
|||||||
export * from "./src/quote";
|
export * from "./src/quote";
|
||||||
export * from "./src/key";
|
export * from "./src/key";
|
||||||
export * from "./src/remote";
|
export * from "./src/remote";
|
||||||
|
export * from "./src/nsfw";
|
||||||
|
export * from "./src/visit";
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"main": "./index.ts",
|
"main": "./index.ts",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.2.0"
|
"react": "^18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lume/tsconfig": "workspace:*",
|
"@lume/tsconfig": "workspace:*",
|
||||||
"@types/react": "^18.2.75",
|
"@types/react": "^18.3.1",
|
||||||
"typescript": "^5.4.5"
|
"typescript": "^5.4.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,12 @@
|
|||||||
export function AddMediaIcon(props: JSX.IntrinsicElements["svg"]) {
|
export function AddMediaIcon(props: JSX.IntrinsicElements["svg"]) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path
|
<path
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth="2"
|
strokeWidth="1.5"
|
||||||
d="M19 22v-3m0 0v-3m0 3h-3m3 0h3m0-5.648V11l-.001-1m-9.464 11H10c-.756 0-1.41 0-1.983-.01M22 10H21c-1.393 0-2.09 0-2.676.06A11.5 11.5 0 008.06 20.324c-.02.2-.034.415-.043.665M22 10c-.008-2.15-.068-3.336-.544-4.27a5 5 0 00-2.185-2.185C18.2 3 16.8 3 14 3h-4c-2.8 0-4.2 0-5.27.545A5 5 0 002.545 5.73C2 6.8 2 8.2 2 11v2c0 2.8 0 4.2.545 5.27a5 5 0 002.185 2.185c.78.398 1.738.505 3.287.534M7.5 9.5a1 1 0 110-2 1 1 0 010 2z"
|
d="M15.25 8.75v-4a2 2 0 0 0-2-2h-8.5a2 2 0 0 0-2 2v8.5a2 2 0 0 0 2 2h4M3.1 11.9l1.794-1.176a2 2 0 0 1 2.206.01l1.279.852M6 6.25h.5m8 8.75h.5M6.75 6.25a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0Zm7 6.95v3.6l2.8-1.8-2.8-1.8Zm5.5 8.05h-8.5a2 2 0 0 1-2-2v-8.5a2 2 0 0 1 2-2h8.5a2 2 0 0 1 2 2v8.5a2 2 0 0 1-2 2Z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { SVGProps } from 'react';
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
export function AddWidgetIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
export function AddWidgetIcon(
|
||||||
|
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { SVGProps } from 'react';
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
export function AdvancedSettingsIcon(
|
export function AdvancedSettingsIcon(
|
||||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>
|
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { SVGProps } from 'react';
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
export function AlbyIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
export function AlbyIcon(
|
||||||
|
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export function AnnouncementIcon(props: JSX.IntrinsicElements['svg']) {
|
export function AnnouncementIcon(props: JSX.IntrinsicElements["svg"]) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SVGProps } from "react";
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
export function ArrowDownIcon(
|
export function ArrowDownIcon(
|
||||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { SVGProps } from 'react';
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
export function ArrowRightCircleIcon(
|
export function ArrowRightCircleIcon(
|
||||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>
|
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SVGProps } from "react";
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
export function ArrowUpIcon(
|
export function ArrowUpIcon(
|
||||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SVGProps } from "react";
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
export function ArrowUpSquareIcon(
|
export function ArrowUpSquareIcon(
|
||||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SVGProps } from "react";
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
export function ArticleIcon(
|
export function ArticleIcon(
|
||||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SVGProps } from "react";
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
export function BellIcon(
|
export function BellIcon(
|
||||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||||
@@ -7,9 +7,8 @@ export function BellIcon(
|
|||||||
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
|
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
|
||||||
<path
|
<path
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeLinejoin="round"
|
strokeWidth="1.5"
|
||||||
strokeWidth="2"
|
d="M16 18.25c-.673 1.766-2.21 3-4 3s-3.327-1.234-4-3m-2.152 0h12.304a2 2 0 0 0 1.974-2.319l-1.17-7.258a7.045 7.045 0 0 0-13.911 0l-1.171 7.258a2 2 0 0 0 1.974 2.319Z"
|
||||||
d="M16 18c-.673 1.766-2.21 3-4 3s-3.327-1.234-4-3m-1.716 0h11.432a2 2 0 0 0 1.982-2.264l-.905-6.789a6.853 6.853 0 0 0-13.586 0l-.905 6.789A2 2 0 0 0 6.284 18Z"
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SVGProps } from "react";
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
export function BellFilledIcon(
|
export function BellFilledIcon(
|
||||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { SVGProps } from 'react';
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
export function BoldIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
export function BoldIcon(
|
||||||
|
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SVGProps } from "react";
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
export function ChatsIcon(
|
export function ChatsIcon(
|
||||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SVGProps } from "react";
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
export function CheckIcon(
|
export function CheckIcon(
|
||||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SVGProps } from "react";
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
export function CheckCircleIcon(
|
export function CheckCircleIcon(
|
||||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SVGProps } from "react";
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
export function ChevronDownIcon(
|
export function ChevronDownIcon(
|
||||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { SVGProps } from 'react';
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
export function ChevronRightIcon(
|
export function ChevronRightIcon(
|
||||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>
|
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SVGProps } from "react";
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
export function ChevronUpIcon(
|
export function ChevronUpIcon(
|
||||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { SVGProps } from 'react';
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
export function CommandIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
export function CommandIcon(
|
||||||
|
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { SVGProps } from 'react';
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
export function CommunityIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
export function CommunityIcon(
|
||||||
|
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user