Compare commits
41
Commits
v4.0.0-beta
...
v4.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d84647bc6b | ||
|
|
7724eccd72 | ||
|
|
8ea2335225 | ||
|
|
b60d4db0df | ||
|
|
f1e17ff3c4 | ||
|
|
32954f17b6 | ||
|
|
cf70b0f882 | ||
|
|
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
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
_Note_: Lume is under rewrite to using Rust Nostr as back-end and more lightweight front-end. If you need stable version, you can download v3 and below.
|
|
||||||
|
|
||||||
Source code for v3 is stored here: https://github.com/lumehq/lume/tree/old
|
|
||||||
|
|
||||||
--
|
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
Lume is a Nostr client for desktop include Linux, Windows and macOS. It is free and open source, you can look at source code on Github. Lume is actively improving the app and adding new features, you can expect new update every month.
|
Lume is a Nostr client for desktop include Linux, Windows and macOS. It is free and open source, you can look at source code on Github. Lume is actively improving the app and adding new features, you can expect new update every month.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Download Lume v3 (v3.0.1-stable) for your platform here: [https://github.com/lumehq/lume/releases](https://github.com/lumehq/lume/releases)
|
Download Lume v4 for your platform here: [https://github.com/lumehq/lume/releases](https://github.com/lumehq/lume/releases)
|
||||||
|
|
||||||
Supported platform: macOS, Windows and Linux
|
Supported platform: macOS. Windows and Linux are coming soon.
|
||||||
|
|
||||||
|
Windows and Linux are availabel on v3 and below.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
|
|||||||
+62
-58
@@ -1,60 +1,64 @@
|
|||||||
{
|
{
|
||||||
"name": "@lume/desktop2",
|
"name": "@lume/desktop2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lume/ark": "workspace:^",
|
"@lume/ark": "workspace:^",
|
||||||
"@lume/icons": "workspace:^",
|
"@lume/icons": "workspace:^",
|
||||||
"@lume/ui": "workspace:^",
|
"@lume/ui": "workspace:^",
|
||||||
"@lume/utils": "workspace:^",
|
"@lume/utils": "workspace:^",
|
||||||
"@radix-ui/react-checkbox": "^1.0.4",
|
"@radix-ui/react-avatar": "^1.0.4",
|
||||||
"@radix-ui/react-collapsible": "^1.0.3",
|
"@radix-ui/react-checkbox": "^1.0.4",
|
||||||
"@radix-ui/react-dialog": "^1.0.5",
|
"@radix-ui/react-collapsible": "^1.0.3",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
"@radix-ui/react-dialog": "^1.0.5",
|
||||||
"@radix-ui/react-popover": "^1.0.7",
|
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||||
"@radix-ui/react-switch": "^1.0.3",
|
"@radix-ui/react-hover-card": "^1.0.7",
|
||||||
"@tanstack/query-sync-storage-persister": "^5.29.0",
|
"@radix-ui/react-popover": "^1.0.7",
|
||||||
"@tanstack/react-query": "^5.29.0",
|
"@radix-ui/react-switch": "^1.0.3",
|
||||||
"@tanstack/react-query-persist-client": "^5.29.0",
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
"@tanstack/react-router": "^1.26.19",
|
"@tanstack/query-sync-storage-persister": "^5.36.0",
|
||||||
"i18next": "^23.11.1",
|
"@tanstack/react-query": "^5.36.0",
|
||||||
"i18next-resources-to-backend": "^1.2.1",
|
"@tanstack/react-query-persist-client": "^5.36.0",
|
||||||
"minidenticons": "^4.2.1",
|
"@tanstack/react-router": "1.32.5",
|
||||||
"nanoid": "^5.0.7",
|
"i18next": "^23.11.4",
|
||||||
"nostr-tools": "^2.4.0",
|
"i18next-resources-to-backend": "^1.2.1",
|
||||||
"react": "^18.2.0",
|
"minidenticons": "^4.2.1",
|
||||||
"react-currency-input-field": "^3.8.0",
|
"nanoid": "^5.0.7",
|
||||||
"react-dom": "^18.2.0",
|
"nostr-tools": "^2.5.2",
|
||||||
"react-hook-form": "^7.51.2",
|
"react": "^18.3.1",
|
||||||
"react-hotkeys-hook": "^4.5.0",
|
"react-currency-input-field": "^3.8.0",
|
||||||
"react-i18next": "^14.1.0",
|
"react-dom": "^18.3.1",
|
||||||
"slate": "^0.102.0",
|
"react-hook-form": "^7.51.4",
|
||||||
"slate-react": "^0.102.0",
|
"react-hotkeys-hook": "^4.5.0",
|
||||||
"sonner": "^1.4.41",
|
"react-i18next": "^14.1.1",
|
||||||
"use-debounce": "^10.0.0",
|
"react-string-replace": "^1.1.1",
|
||||||
"virtua": "^0.29.2"
|
"slate": "^0.103.0",
|
||||||
},
|
"slate-react": "^0.102.0",
|
||||||
"devDependencies": {
|
"sonner": "^1.4.41",
|
||||||
"@lume/tailwindcss": "workspace:^",
|
"use-debounce": "^10.0.0",
|
||||||
"@lume/tsconfig": "workspace:^",
|
"virtua": "^0.31.0"
|
||||||
"@lume/types": "workspace:^",
|
},
|
||||||
"@tanstack/router-devtools": "^1.26.19",
|
"devDependencies": {
|
||||||
"@tanstack/router-vite-plugin": "^1.26.16",
|
"@lume/tailwindcss": "workspace:^",
|
||||||
"@types/react": "^18.2.75",
|
"@lume/tsconfig": "workspace:^",
|
||||||
"@types/react-dom": "^18.2.24",
|
"@lume/types": "workspace:^",
|
||||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
"@tanstack/router-devtools": "^1.32.5",
|
||||||
"autoprefixer": "^10.4.19",
|
"@tanstack/router-vite-plugin": "^1.32.2",
|
||||||
"postcss": "^8.4.38",
|
"@types/react": "^18.3.2",
|
||||||
"tailwindcss": "^3.4.3",
|
"@types/react-dom": "^18.3.0",
|
||||||
"typescript": "^5.4.5",
|
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||||
"vite": "^5.2.8",
|
"autoprefixer": "^10.4.19",
|
||||||
"vite-plugin-top-level-await": "^1.4.1",
|
"postcss": "^8.4.38",
|
||||||
"vite-tsconfig-paths": "^4.3.2"
|
"tailwindcss": "^3.4.3",
|
||||||
}
|
"typescript": "^5.4.5",
|
||||||
|
"vite": "^5.2.11",
|
||||||
|
"vite-plugin-top-level-await": "^1.4.1",
|
||||||
|
"vite-tsconfig-paths": "^4.3.2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+38
-50
@@ -1,77 +1,65 @@
|
|||||||
|
import { Ark } from "@lume/ark";
|
||||||
|
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 "./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({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
gcTime: 1000 * 60 * 60 * 24, // 24 hours
|
|
||||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const persister = createSyncStoragePersister({
|
|
||||||
storage: window.localStorage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const ark = new Ark();
|
const ark = new Ark();
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
const platformName = await platform();
|
||||||
|
|
||||||
|
const persister = createSyncStoragePersister({
|
||||||
|
storage: window.localStorage,
|
||||||
|
});
|
||||||
|
|
||||||
// Set up a Router instance
|
// Set up a Router instance
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
routeTree,
|
routeTree,
|
||||||
context: {
|
context: {
|
||||||
ark,
|
ark,
|
||||||
queryClient,
|
queryClient,
|
||||||
},
|
platform: platformName,
|
||||||
|
},
|
||||||
|
Wrap: ({ children }) => {
|
||||||
|
return (
|
||||||
|
<I18nextProvider i18n={i18n} defaultNS={"translation"}>
|
||||||
|
<PersistQueryClientProvider
|
||||||
|
client={queryClient}
|
||||||
|
persistOptions={{ persister }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</PersistQueryClientProvider>
|
||||||
|
</I18nextProvider>
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register things for typesafety
|
// Register things for typesafety
|
||||||
declare module "@tanstack/react-router" {
|
declare module "@tanstack/react-router" {
|
||||||
interface Register {
|
interface Register {
|
||||||
router: typeof router;
|
router: typeof router;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return <RouterProvider router={router} />;
|
return <RouterProvider router={router} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// biome-ignore lint/style/noNonNullAssertion: idk
|
// biome-ignore lint/style/noNonNullAssertion: idk
|
||||||
const rootElement = document.getElementById("root")!;
|
const rootElement = document.getElementById("root")!;
|
||||||
|
|
||||||
if (!rootElement.innerHTML) {
|
if (!rootElement.innerHTML) {
|
||||||
const root = ReactDOM.createRoot(rootElement);
|
const root = ReactDOM.createRoot(rootElement);
|
||||||
root.render(
|
root.render(
|
||||||
<I18nextProvider i18n={i18n} defaultNS={"translation"}>
|
<StrictMode>
|
||||||
<PersistQueryClientProvider
|
<App />
|
||||||
client={queryClient}
|
</StrictMode>,
|
||||||
persistOptions={{ persister }}
|
);
|
||||||
>
|
|
||||||
<StrictMode>
|
|
||||||
<Toaster
|
|
||||||
position="bottom-right"
|
|
||||||
icons={{
|
|
||||||
success: <CheckCircleIcon className="size-5" />,
|
|
||||||
info: <InfoCircleIcon className="size-5" />,
|
|
||||||
error: <CancelCircleIcon className="size-5" />,
|
|
||||||
}}
|
|
||||||
closeButton
|
|
||||||
theme="system"
|
|
||||||
/>
|
|
||||||
<App />
|
|
||||||
</StrictMode>
|
|
||||||
</PersistQueryClientProvider>
|
|
||||||
</I18nextProvider>,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,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,42 +1,47 @@
|
|||||||
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({
|
||||||
setPicture,
|
setPicture,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
setPicture: Dispatch<SetStateAction<string>>;
|
setPicture: Dispatch<SetStateAction<string>>;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { ark } = useRouteContext({ strict: false });
|
const { ark } = useRouteContext({ strict: false });
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const uploadAvatar = async () => {
|
const uploadAvatar = async () => {
|
||||||
// start loading
|
// start loading
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const image = await ark.upload();
|
const image = await ark.upload();
|
||||||
setPicture(image);
|
setPicture(image);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(String(e));
|
toast.error(String(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
// stop loading
|
// stop loading
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
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,42 +1,42 @@
|
|||||||
import { User } from "@lume/ui";
|
import { User } from "@/components/user";
|
||||||
import { getBitcoinDisplayValues } from "@lume/utils";
|
import { getBitcoinDisplayValues } from "@lume/utils";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
export function Balance({ account }: { account: string }) {
|
export function Balance({ account }: { account: string }) {
|
||||||
const { ark } = useRouteContext({ strict: false });
|
const { ark } = useRouteContext({ strict: false });
|
||||||
const [balance, setBalance] = useState(0);
|
const [balance, setBalance] = useState(0);
|
||||||
const value = useMemo(() => getBitcoinDisplayValues(balance), [balance]);
|
const value = useMemo(() => getBitcoinDisplayValues(balance), [balance]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function getBalance() {
|
async function getBalance() {
|
||||||
const val = await ark.get_balance();
|
const val = await ark.get_balance();
|
||||||
setBalance(val);
|
setBalance(val);
|
||||||
}
|
}
|
||||||
|
|
||||||
getBalance();
|
getBalance();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
className="flex h-16 items-center justify-end px-3"
|
className="flex h-16 items-center justify-end px-3"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="text-end">
|
<div className="text-end">
|
||||||
<div className="text-sm leading-tight text-neutral-700 dark:text-neutral-300">
|
<div className="text-sm leading-tight text-neutral-700 dark:text-neutral-300">
|
||||||
Your balance
|
Your balance
|
||||||
</div>
|
</div>
|
||||||
<div className="font-medium leading-tight">
|
<div className="font-medium leading-tight">
|
||||||
₿ {value.bitcoinFormatted}
|
₿ {value.bitcoinFormatted}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<User.Provider pubkey={account}>
|
<User.Provider pubkey={account}>
|
||||||
<User.Root>
|
<User.Root>
|
||||||
<User.Avatar className="size-9 rounded-full" />
|
<User.Avatar className="size-9 rounded-full" />
|
||||||
</User.Root>
|
</User.Root>
|
||||||
</User.Provider>
|
</User.Provider>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { LumeColumn } from "@lume/types";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { LoaderIcon } from "@lume/icons";
|
|
||||||
|
|
||||||
export function Col({
|
|
||||||
column,
|
|
||||||
account,
|
|
||||||
isScroll,
|
|
||||||
}: {
|
|
||||||
column: LumeColumn;
|
|
||||||
account: string;
|
|
||||||
isScroll: boolean;
|
|
||||||
}) {
|
|
||||||
const webview = useRef<string | undefined>(undefined);
|
|
||||||
const container = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const repositionWebview = async () => {
|
|
||||||
if (webview.current && webview.current.length > 1) {
|
|
||||||
const newRect = container.current.getBoundingClientRect();
|
|
||||||
await invoke("reposition_column", {
|
|
||||||
label: webview.current,
|
|
||||||
x: newRect.x,
|
|
||||||
y: newRect.y,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isScroll) {
|
|
||||||
repositionWebview();
|
|
||||||
}
|
|
||||||
}, [isScroll]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
const rect = container.current.getBoundingClientRect();
|
|
||||||
const windowLabel = `column-${column.label}`;
|
|
||||||
const url =
|
|
||||||
column.content +
|
|
||||||
`?account=${account}&label=${column.label}&name=${column.name}`;
|
|
||||||
|
|
||||||
// create new webview
|
|
||||||
webview.current = await invoke("create_column", {
|
|
||||||
label: windowLabel,
|
|
||||||
x: rect.x,
|
|
||||||
y: rect.y,
|
|
||||||
width: rect.width,
|
|
||||||
height: rect.height,
|
|
||||||
url,
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
// close webview when unmounted
|
|
||||||
return () => {
|
|
||||||
if (webview.current && webview.current.length > 1) {
|
|
||||||
invoke("close_column", {
|
|
||||||
label: webview.current,
|
|
||||||
}).then(() => {
|
|
||||||
webview.current = undefined;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={container} className="h-full w-[440px] shrink-0 p-2">
|
|
||||||
{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">
|
|
||||||
<button type="button" className="size-5" disabled>
|
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { CancelIcon, CheckIcon } from "@lume/icons";
|
||||||
|
import type { LumeColumn } from "@lume/types";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { getCurrent } from "@tauri-apps/api/webviewWindow";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export function Column({
|
||||||
|
column,
|
||||||
|
account,
|
||||||
|
isScroll,
|
||||||
|
isResize,
|
||||||
|
}: {
|
||||||
|
column: LumeColumn;
|
||||||
|
account: string;
|
||||||
|
isScroll: boolean;
|
||||||
|
isResize: boolean;
|
||||||
|
}) {
|
||||||
|
const container = useRef<HTMLDivElement>(null);
|
||||||
|
const webviewLabel = `column-${account}_${column.label}`;
|
||||||
|
|
||||||
|
const [isCreated, setIsCreated] = useState(false);
|
||||||
|
|
||||||
|
const repositionWebview = async () => {
|
||||||
|
const newRect = container.current.getBoundingClientRect();
|
||||||
|
await invoke("reposition_column", {
|
||||||
|
label: webviewLabel,
|
||||||
|
x: newRect.x,
|
||||||
|
y: newRect.y,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const resizeWebview = async () => {
|
||||||
|
const newRect = container.current.getBoundingClientRect();
|
||||||
|
await invoke("resize_column", {
|
||||||
|
label: webviewLabel,
|
||||||
|
width: newRect.width,
|
||||||
|
height: newRect.height,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCreated) resizeWebview();
|
||||||
|
}, [isResize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isScroll && isCreated) repositionWebview();
|
||||||
|
}, [isScroll]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const rect = container.current.getBoundingClientRect();
|
||||||
|
const url = `${column.content}?account=${account}&label=${column.label}&name=${column.name}`;
|
||||||
|
|
||||||
|
// create new webview
|
||||||
|
invoke("create_column", {
|
||||||
|
label: webviewLabel,
|
||||||
|
x: rect.x,
|
||||||
|
y: rect.y,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
url,
|
||||||
|
}).then(() => setIsCreated(true));
|
||||||
|
|
||||||
|
// close webview when unmounted
|
||||||
|
return () => {
|
||||||
|
invoke("close_column", { label: webviewLabel });
|
||||||
|
};
|
||||||
|
}, [account]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-[440px] shrink-0 p-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col w-full h-full rounded-xl",
|
||||||
|
column.label !== "open"
|
||||||
|
? "bg-black/5 dark:bg-white/5 backdrop-blur-sm"
|
||||||
|
: "",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{column.label !== "open" ? (
|
||||||
|
<Header label={column.label} name={column.name} />
|
||||||
|
) : null}
|
||||||
|
<div 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { ThreadIcon } from "@lume/icons";
|
||||||
|
import type { Event } from "@lume/types";
|
||||||
|
import { Note } from "@/components/note";
|
||||||
|
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,26 @@
|
|||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { useNoteContext } from "./provider";
|
||||||
|
import { User } from "../user";
|
||||||
|
|
||||||
|
export function NoteActivity({ className }: { className?: string }) {
|
||||||
|
const event = useNoteContext();
|
||||||
|
const mentions = event.tags
|
||||||
|
.filter((tag) => tag[0] === "p")
|
||||||
|
.map((tag) => tag[1]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("-mt-3 mb-2", className)}>
|
||||||
|
<div className="text-neutral-700 dark:text-neutral-300 inline-flex items-baseline gap-2 w-full overflow-hidden">
|
||||||
|
<div className="shrink-0 text-sm font-medium">To:</div>
|
||||||
|
{mentions.splice(0, 4).map((mention) => (
|
||||||
|
<User.Provider key={mention} pubkey={mention}>
|
||||||
|
<User.Root>
|
||||||
|
<User.Name className="text-sm font-medium" prefix="@" />
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
))}
|
||||||
|
{mentions.length > 4 ? "..." : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+8
-8
@@ -1,11 +1,11 @@
|
|||||||
import { PinIcon } from "@lume/icons";
|
import { VisitIcon } from "@lume/icons";
|
||||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
import { useNoteContext } from "../provider";
|
import { useNoteContext } from "../provider";
|
||||||
|
|
||||||
export function NotePin() {
|
export function NoteOpenThread() {
|
||||||
const event = useNoteContext();
|
const event = useNoteContext();
|
||||||
const { t } = useTranslation();
|
const { ark } = useRouteContext({ strict: false });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip.Provider>
|
<Tooltip.Provider>
|
||||||
@@ -13,15 +13,15 @@ export function NotePin() {
|
|||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Trigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex items-center justify-center gap-2 pl-2 pr-3 text-sm font-medium rounded-full h-7 w-max bg-neutral-100 hover:bg-neutral-200 dark:hover:bg-neutral-800 dark:bg-neutral-900"
|
onClick={() => ark.open_event(event)}
|
||||||
|
className="group inline-flex h-7 w-14 bg-neutral-100 dark:bg-white/10 rounded-full items-center justify-center text-sm font-medium text-neutral-800 dark:text-neutral-200 hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
|
||||||
>
|
>
|
||||||
<PinIcon className="size-4" />
|
<VisitIcon className="shrink-0 size-4" />
|
||||||
{t("note.buttons.pin")}
|
|
||||||
</button>
|
</button>
|
||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
<Tooltip.Portal>
|
<Tooltip.Portal>
|
||||||
<Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm 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">
|
<Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm 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">
|
||||||
{t("note.buttons.pinTooltip")}
|
Open
|
||||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||||
</Tooltip.Content>
|
</Tooltip.Content>
|
||||||
</Tooltip.Portal>
|
</Tooltip.Portal>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { ReplyIcon } from "@lume/icons";
|
||||||
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
|
import { useNoteContext } from "../provider";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
|
|
||||||
|
export function NoteReply({ large = false }: { large?: boolean }) {
|
||||||
|
const event = useNoteContext();
|
||||||
|
const { ark } = useRouteContext({ strict: false });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip.Provider>
|
||||||
|
<Tooltip.Root delayDuration={150}>
|
||||||
|
<Tooltip.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => ark.open_editor(event.id)}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
||||||
|
large
|
||||||
|
? "rounded-full bg-neutral-100 dark:bg-white/10 h-7 gap-1.5 w-24 text-sm font-medium hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
|
||||||
|
: "size-7",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ReplyIcon className="shrink-0 size-4" />
|
||||||
|
{large ? "Reply" : null}
|
||||||
|
</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">
|
||||||
|
Reply
|
||||||
|
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Portal>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</Tooltip.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { QuoteIcon, RepostIcon } from "@lume/icons";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||||
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { useNoteContext } from "../provider";
|
||||||
|
|
||||||
|
export function NoteRepost({ large = false }: { large?: boolean }) {
|
||||||
|
const { ark } = useRouteContext({ strict: false });
|
||||||
|
const event = useNoteContext();
|
||||||
|
|
||||||
|
const [t] = useTranslation();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [isRepost, setIsRepost] = useState(false);
|
||||||
|
|
||||||
|
const repost = async () => {
|
||||||
|
try {
|
||||||
|
if (isRepost) return;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// repost
|
||||||
|
await ark.repost(event.id, event.pubkey);
|
||||||
|
|
||||||
|
// update state
|
||||||
|
setLoading(false);
|
||||||
|
setIsRepost(true);
|
||||||
|
|
||||||
|
// notify
|
||||||
|
toast.success("You've reposted this post successfully");
|
||||||
|
} catch (e) {
|
||||||
|
setLoading(false);
|
||||||
|
toast.error("Repost failed, try again later");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<Tooltip.Provider>
|
||||||
|
<Tooltip.Root delayDuration={150}>
|
||||||
|
<DropdownMenu.Trigger asChild>
|
||||||
|
<Tooltip.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200 rounded-full",
|
||||||
|
large
|
||||||
|
? "bg-neutral-100 dark:bg-white/10 h-7 gap-1.5 w-24 text-sm font-medium hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
|
||||||
|
: "size-7",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
) : (
|
||||||
|
<RepostIcon
|
||||||
|
className={cn("size-4", isRepost ? "text-blue-500" : "")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{large ? "Repost" : null}
|
||||||
|
</button>
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<Tooltip.Portal>
|
||||||
|
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
||||||
|
{t("note.buttons.repost")}
|
||||||
|
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Portal>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</Tooltip.Provider>
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl bg-black p-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
|
||||||
|
<DropdownMenu.Item asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => repost()}
|
||||||
|
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||||
|
>
|
||||||
|
<RepostIcon className="size-4" />
|
||||||
|
{t("note.buttons.repost")}
|
||||||
|
</button>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => ark.open_editor(event.id, true)}
|
||||||
|
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||||
|
>
|
||||||
|
<QuoteIcon className="size-4" />
|
||||||
|
{t("note.buttons.quote")}
|
||||||
|
</button>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Arrow className="fill-black dark:fill-white" />
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { ZapIcon } from "@lume/icons";
|
||||||
|
import { useRouteContext, useSearch } from "@tanstack/react-router";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useNoteContext } from "../provider";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
|
|
||||||
|
export function NoteZap({ large = false }: { large?: boolean }) {
|
||||||
|
const event = useNoteContext();
|
||||||
|
const { ark, settings } = useRouteContext({ strict: false });
|
||||||
|
const { account } = useSearch({ strict: false });
|
||||||
|
|
||||||
|
const zap = async () => {
|
||||||
|
try {
|
||||||
|
const nwc = await ark.load_nwc();
|
||||||
|
if (!nwc) {
|
||||||
|
ark.open_nwc();
|
||||||
|
} else {
|
||||||
|
ark.open_zap(event.id, event.pubkey, account);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!settings.zap) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => zap()}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
||||||
|
large
|
||||||
|
? "rounded-full bg-neutral-100 dark:bg-white/10 h-7 gap-1.5 w-24 text-sm font-medium hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
|
||||||
|
: "size-7",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ZapIcon className="size-4" />
|
||||||
|
{large ? "Zap" : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useEvent } from "@lume/ark";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { Note } from ".";
|
||||||
|
|
||||||
|
export function NoteChild({
|
||||||
|
eventId,
|
||||||
|
isRoot,
|
||||||
|
}: {
|
||||||
|
eventId: string;
|
||||||
|
isRoot?: boolean;
|
||||||
|
}) {
|
||||||
|
const { isLoading, isError, data } = useEvent(eventId);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return <div>Error</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Note.Provider event={data}>
|
||||||
|
<Note.Root className={cn(isRoot ? "mb-3" : "")}>
|
||||||
|
<div className="h-14 px-3 flex items-center justify-between">
|
||||||
|
<Note.User />
|
||||||
|
</div>
|
||||||
|
<Note.Content className="px-3" />
|
||||||
|
</Note.Root>
|
||||||
|
</Note.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { NOSTR_EVENTS, NOSTR_MENTIONS, cn, parser } from "@lume/utils";
|
||||||
|
import { type ReactNode, useMemo } from "react";
|
||||||
|
import reactStringReplace from "react-string-replace";
|
||||||
|
import { Hashtag } from "./mentions/hashtag";
|
||||||
|
import { MentionNote } from "./mentions/note";
|
||||||
|
import { MentionUser } from "./mentions/user";
|
||||||
|
import { Images } from "./preview/images";
|
||||||
|
import { Videos } from "./preview/videos";
|
||||||
|
import { useNoteContext } from "./provider";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
|
export function NoteContent({
|
||||||
|
quote = true,
|
||||||
|
mention = true,
|
||||||
|
clean,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
quote?: boolean;
|
||||||
|
mention?: boolean;
|
||||||
|
clean?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const event = useNoteContext();
|
||||||
|
const data = useMemo(() => {
|
||||||
|
const { content, images, videos } = parser(event.content);
|
||||||
|
const words = content.split(/( |\n)/);
|
||||||
|
const hashtags = words.filter((word) => word.startsWith("#"));
|
||||||
|
const events = words.filter((word) =>
|
||||||
|
NOSTR_EVENTS.some((el) => word.startsWith(el)),
|
||||||
|
);
|
||||||
|
const mentions = words.filter((word) =>
|
||||||
|
NOSTR_MENTIONS.some((el) => word.startsWith(el)),
|
||||||
|
);
|
||||||
|
|
||||||
|
let richContent: ReactNode[] | string = content;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (hashtags.length) {
|
||||||
|
for (const hashtag of hashtags) {
|
||||||
|
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
|
||||||
|
richContent = reactStringReplace(richContent, regex, (_, index) => {
|
||||||
|
return <Hashtag key={hashtag + index} tag={hashtag} />;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (events.length) {
|
||||||
|
for (const event of events) {
|
||||||
|
if (quote) {
|
||||||
|
richContent = reactStringReplace(richContent, event, (_, index) => (
|
||||||
|
<MentionNote key={event + index} eventId={event} />
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!quote && clean) {
|
||||||
|
richContent = reactStringReplace(richContent, event, () => null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mentions.length) {
|
||||||
|
for (const user of mentions) {
|
||||||
|
if (mention) {
|
||||||
|
richContent = reactStringReplace(richContent, user, (_, index) => (
|
||||||
|
<MentionUser key={user + index} pubkey={user} />
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mention && clean) {
|
||||||
|
richContent = reactStringReplace(richContent, user, () => null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
richContent = reactStringReplace(
|
||||||
|
richContent,
|
||||||
|
/(https?:\/\/\S+)/gi,
|
||||||
|
(match, index) => (
|
||||||
|
<a
|
||||||
|
key={match + index}
|
||||||
|
href={match}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="line-clamp-1 text-blue-500 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
{match}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
richContent = reactStringReplace(richContent, /(\r\n|\r|\n)+/g, () => (
|
||||||
|
<div key={nanoid()} className="h-3" />
|
||||||
|
));
|
||||||
|
|
||||||
|
return { content: richContent, images, videos };
|
||||||
|
} catch (e) {
|
||||||
|
return { content, images, videos };
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"select-text text-[15px] text-balance break-words overflow-hidden",
|
||||||
|
event.content.length > 500 ? "max-h-[300px] gradient-mask-b-0" : "",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{data.content}
|
||||||
|
</div>
|
||||||
|
{data.images.length ? <Images urls={data.images} /> : null}
|
||||||
|
{data.videos.length ? <Videos urls={data.videos} /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import type { Settings } from "@lume/types";
|
||||||
|
import {
|
||||||
|
AUDIOS,
|
||||||
|
IMAGES,
|
||||||
|
NOSTR_EVENTS,
|
||||||
|
NOSTR_MENTIONS,
|
||||||
|
VIDEOS,
|
||||||
|
cn,
|
||||||
|
} from "@lume/utils";
|
||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { type ReactNode, useMemo } from "react";
|
||||||
|
import reactStringReplace from "react-string-replace";
|
||||||
|
import { Hashtag } from "./mentions/hashtag";
|
||||||
|
import { MentionNote } from "./mentions/note";
|
||||||
|
import { MentionUser } from "./mentions/user";
|
||||||
|
import { ImagePreview } from "./preview/image";
|
||||||
|
import { VideoPreview } from "./preview/video";
|
||||||
|
import { useNoteContext } from "./provider";
|
||||||
|
|
||||||
|
export function NoteContentLarge({
|
||||||
|
compact = true,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
compact?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const { settings }: { settings: Settings } = useRouteContext({
|
||||||
|
strict: false,
|
||||||
|
});
|
||||||
|
const event = useNoteContext();
|
||||||
|
const content = useMemo(() => {
|
||||||
|
const text = event.content.trim();
|
||||||
|
const words = text.split(/( |\n)/);
|
||||||
|
|
||||||
|
// @ts-ignore, kaboom !!!
|
||||||
|
let parsedContent: ReactNode[] = compact
|
||||||
|
? text.replace(/\n\s*\n/g, "\n")
|
||||||
|
: text;
|
||||||
|
|
||||||
|
const hashtags = words.filter((word) => word.startsWith("#"));
|
||||||
|
const events = words.filter((word) =>
|
||||||
|
NOSTR_EVENTS.some((el) => word.startsWith(el)),
|
||||||
|
);
|
||||||
|
const mentions = words.filter((word) =>
|
||||||
|
NOSTR_MENTIONS.some((el) => word.startsWith(el)),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (hashtags.length) {
|
||||||
|
for (const hashtag of hashtags) {
|
||||||
|
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
|
||||||
|
parsedContent = reactStringReplace(parsedContent, regex, () => {
|
||||||
|
return <Hashtag key={nanoid()} tag={hashtag} />;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (events.length) {
|
||||||
|
for (const event of events) {
|
||||||
|
parsedContent = reactStringReplace(
|
||||||
|
parsedContent,
|
||||||
|
event,
|
||||||
|
(match, i) => <MentionNote key={match + i} eventId={event} />,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mentions.length) {
|
||||||
|
for (const mention of mentions) {
|
||||||
|
parsedContent = reactStringReplace(
|
||||||
|
parsedContent,
|
||||||
|
mention,
|
||||||
|
(match, i) => <MentionUser key={match + i} pubkey={mention} />,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedContent = reactStringReplace(
|
||||||
|
parsedContent,
|
||||||
|
/(https?:\/\/\S+)/gi,
|
||||||
|
(match, i) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(match);
|
||||||
|
const ext = url.pathname.split(".")[1];
|
||||||
|
|
||||||
|
if (!settings.enhancedPrivacy) {
|
||||||
|
if (IMAGES.includes(ext)) {
|
||||||
|
return <ImagePreview key={match + i} url={url.toString()} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (VIDEOS.includes(ext)) {
|
||||||
|
return <VideoPreview key={match + i} url={url.toString()} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (AUDIOS.includes(ext)) {
|
||||||
|
return <VideoPreview key={match + i} url={url.toString()} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={match + i}
|
||||||
|
href={match}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="content-break w-full font-normal text-blue-500 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
{match}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={match + i}
|
||||||
|
href={match}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="content-break w-full font-normal text-blue-500 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
{match}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
parsedContent = reactStringReplace(parsedContent, /\n*\n/g, () => (
|
||||||
|
<div key={nanoid()} className="h-1.5" />
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedContent = reactStringReplace(
|
||||||
|
parsedContent,
|
||||||
|
/[\r]?\n[\r]?\n/g,
|
||||||
|
(_, index) => <br key={event.id + "_br_" + index} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
return parsedContent;
|
||||||
|
} catch (e) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("select-text", className)}>
|
||||||
|
<div className="text-[15px] text-balance content-break leading-normal">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import { NotePin } from "./buttons/pin";
|
import { NoteActivity } from "./activity";
|
||||||
import { NoteReaction } from "./buttons/reaction";
|
import { NoteOpenThread } from "./buttons/open";
|
||||||
import { NoteReply } from "./buttons/reply";
|
import { NoteReply } from "./buttons/reply";
|
||||||
import { NoteRepost } from "./buttons/repost";
|
import { NoteRepost } from "./buttons/repost";
|
||||||
import { NoteZap } from "./buttons/zap";
|
import { NoteZap } from "./buttons/zap";
|
||||||
import { NoteChild } from "./child";
|
import { NoteChild } from "./child";
|
||||||
import { NoteContent } from "./content";
|
import { NoteContent } from "./content";
|
||||||
|
import { NoteContentLarge } from "./contentLarge";
|
||||||
import { NoteMenu } from "./menu";
|
import { NoteMenu } from "./menu";
|
||||||
import { NoteProvider } from "./provider";
|
import { NoteProvider } from "./provider";
|
||||||
import { NoteRoot } from "./root";
|
import { NoteRoot } from "./root";
|
||||||
import { NoteThread } from "./thread";
|
|
||||||
import { NoteUser } from "./user";
|
import { NoteUser } from "./user";
|
||||||
|
|
||||||
export const Note = {
|
export const Note = {
|
||||||
@@ -18,10 +18,10 @@ export const Note = {
|
|||||||
Menu: NoteMenu,
|
Menu: NoteMenu,
|
||||||
Reply: NoteReply,
|
Reply: NoteReply,
|
||||||
Repost: NoteRepost,
|
Repost: NoteRepost,
|
||||||
Reaction: NoteReaction,
|
|
||||||
Content: NoteContent,
|
Content: NoteContent,
|
||||||
|
ContentLarge: NoteContentLarge,
|
||||||
Zap: NoteZap,
|
Zap: NoteZap,
|
||||||
Pin: NotePin,
|
Open: NoteOpenThread,
|
||||||
Child: NoteChild,
|
Child: NoteChild,
|
||||||
Thread: NoteThread,
|
Activity: NoteActivity,
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export function Hashtag({ tag }: { tag: string }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="break-all cursor-default leading-normal group"
|
||||||
|
>
|
||||||
|
<span className="text-blue-500">#</span>
|
||||||
|
<span className="underline-offset-1 underline decoration-2 decoration-blue-200 dark:decoration-blue-800 group-hover:decoration-blue-500">
|
||||||
|
{tag.replace("#", "")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useEvent } from "@lume/ark";
|
||||||
|
import { LinkIcon } from "@lume/icons";
|
||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { User } from "@/components/user";
|
||||||
|
|
||||||
|
export function MentionNote({
|
||||||
|
eventId,
|
||||||
|
openable = true,
|
||||||
|
}: {
|
||||||
|
eventId: string;
|
||||||
|
openable?: boolean;
|
||||||
|
}) {
|
||||||
|
const { ark } = useRouteContext({ strict: false });
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isLoading, isError, data } = useEvent(eventId);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full cursor-default items-center justify-between rounded-xl border border-black/10 p-3 dark:border-white/10">
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return (
|
||||||
|
<div className="w-full cursor-default rounded-xl border border-black/10 p-3 dark:border-white/10">
|
||||||
|
{t("note.error")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 flex w-full cursor-default flex-col rounded-xl border border-black/10 dark:border-white/10">
|
||||||
|
<User.Provider pubkey={data.pubkey}>
|
||||||
|
<User.Root className="flex h-12 items-center gap-2 px-3">
|
||||||
|
<User.Avatar className="size-6 shrink-0 rounded-full object-cover" />
|
||||||
|
<div className="inline-flex flex-1 items-center gap-2">
|
||||||
|
<User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" />
|
||||||
|
<span className="text-neutral-600 dark:text-neutral-400">·</span>
|
||||||
|
<User.Time
|
||||||
|
time={data.created_at}
|
||||||
|
className="text-neutral-600 dark:text-neutral-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"px-3 select-text content-break whitespace-normal text-balance leading-normal",
|
||||||
|
data.content.length > 100 ? "max-h-[150px] gradient-mask-b-0" : "",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{data.content}
|
||||||
|
</div>
|
||||||
|
{openable ? (
|
||||||
|
<div className="flex h-14 items-center justify-end px-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
ark.open_event_id(data.id);
|
||||||
|
}}
|
||||||
|
className="z-10 h-7 w-28 inline-flex items-center justify-center gap-1 text-sm bg-neutral-100 dark:bg-white/10 rounded-full text-neutral-600 hover:text-blue-500 dark:text-neutral-400"
|
||||||
|
>
|
||||||
|
View post
|
||||||
|
<LinkIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-3" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useProfile } from "@lume/ark";
|
||||||
|
import { displayNpub } from "@lume/utils";
|
||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
export function MentionUser({ pubkey }: { pubkey: string }) {
|
||||||
|
const { ark } = useRouteContext({ strict: false });
|
||||||
|
const { isLoading, isError, profile } = useProfile(pubkey);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => ark.open_profile(pubkey)}
|
||||||
|
className="break-words text-start text-blue-500 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
{isLoading
|
||||||
|
? "@anon"
|
||||||
|
: isError
|
||||||
|
? displayNpub(pubkey, 16)
|
||||||
|
: `@${
|
||||||
|
profile?.name || profile?.display_name || profile?.name || "anon"
|
||||||
|
}`}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { HorizontalDotsIcon } from "@lume/icons";
|
||||||
|
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
|
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useNoteContext } from "./provider";
|
||||||
|
|
||||||
|
export function NoteMenu() {
|
||||||
|
const event = useNoteContext();
|
||||||
|
|
||||||
|
const { ark } = useRouteContext({ strict: false });
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const copyID = async () => {
|
||||||
|
await writeText(await ark.event_to_bech32(event.id, [""]));
|
||||||
|
toast.success("Copied");
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyRaw = async () => {
|
||||||
|
await writeText(JSON.stringify(event));
|
||||||
|
toast.success("Copied");
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyNpub = async () => {
|
||||||
|
await writeText(await ark.user_to_bech32(event.pubkey, [""]));
|
||||||
|
toast.success("Copied");
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyLink = async () => {
|
||||||
|
await writeText(
|
||||||
|
`https://njump.me/${await ark.event_to_bech32(event.id, [""])}`,
|
||||||
|
);
|
||||||
|
toast.success("Copied");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="group inline-flex size-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
|
||||||
|
>
|
||||||
|
<HorizontalDotsIcon className="size-5" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl bg-black p-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
|
||||||
|
<DropdownMenu.Item asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => ark.open_event(event)}
|
||||||
|
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||||
|
>
|
||||||
|
{t("note.menu.viewThread")}
|
||||||
|
</button>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => copyLink()}
|
||||||
|
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||||
|
>
|
||||||
|
{t("note.menu.copyLink")}
|
||||||
|
</button>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => copyID()}
|
||||||
|
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||||
|
>
|
||||||
|
{t("note.menu.copyNoteId")}
|
||||||
|
</button>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => copyNpub()}
|
||||||
|
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||||
|
>
|
||||||
|
{t("note.menu.copyAuthorId")}
|
||||||
|
</button>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item asChild>
|
||||||
|
<button
|
||||||
|
onClick={() => ark.open_profile(event.pubkey)}
|
||||||
|
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||||
|
>
|
||||||
|
{t("note.menu.viewAuthor")}
|
||||||
|
</button>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Separator className="my-1 h-px bg-neutral-900 dark:bg-neutral-100" />
|
||||||
|
<DropdownMenu.Item asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => copyRaw()}
|
||||||
|
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||||
|
>
|
||||||
|
{t("note.menu.copyRaw")}
|
||||||
|
</button>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Arrow className="fill-black dark:fill-white" />
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { CheckCircleIcon, DownloadIcon } from "@lume/icons";
|
||||||
|
import { downloadDir } from "@tauri-apps/api/path";
|
||||||
|
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||||
|
import { download } from "@tauri-apps/plugin-upload";
|
||||||
|
import { type SyntheticEvent, useState } from "react";
|
||||||
|
|
||||||
|
export function ImagePreview({ url }: { url: string }) {
|
||||||
|
const [downloaded, setDownloaded] = useState(false);
|
||||||
|
|
||||||
|
const downloadImage = async (e: { stopPropagation: () => void }) => {
|
||||||
|
try {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const downloadDirPath = await downloadDir();
|
||||||
|
const filename = url.substring(url.lastIndexOf("/") + 1);
|
||||||
|
await download(url, `${downloadDirPath}/${filename}`);
|
||||||
|
|
||||||
|
setDownloaded(true);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const open = async () => {
|
||||||
|
const name = new URL(url).pathname.split("/").pop();
|
||||||
|
return new WebviewWindow("image-viewer", {
|
||||||
|
url,
|
||||||
|
title: name,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const fallback = (event: SyntheticEvent<HTMLImageElement, Event>) => {
|
||||||
|
event.currentTarget.src = "/fallback-image.jpg";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
|
||||||
|
<div onClick={() => open()} className="group relative my-1">
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={url}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
style={{ contentVisibility: "auto" }}
|
||||||
|
onError={fallback}
|
||||||
|
className="max-h-[600px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => downloadImage(e)}
|
||||||
|
className="absolute right-2 top-2 z-20 hidden size-8 items-center justify-center rounded-md bg-white/10 text-white/70 backdrop-blur-2xl hover:bg-blue-500 hover:text-white group-hover:inline-flex"
|
||||||
|
>
|
||||||
|
{downloaded ? (
|
||||||
|
<CheckCircleIcon className="size-4" />
|
||||||
|
) : (
|
||||||
|
<DownloadIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||||
|
import { Carousel, CarouselItem } from "@lume/ui";
|
||||||
|
|
||||||
|
export function Images({ urls }: { urls: string[] }) {
|
||||||
|
const open = async (url: string) => {
|
||||||
|
const name = new URL(url).pathname
|
||||||
|
.split("/")
|
||||||
|
.pop()
|
||||||
|
.replace(/[^a-zA-Z ]/g, "");
|
||||||
|
const label = `viewer-${name}`;
|
||||||
|
const window = WebviewWindow.getByLabel(label);
|
||||||
|
|
||||||
|
if (!window) {
|
||||||
|
const newWindow = new WebviewWindow(label, {
|
||||||
|
url,
|
||||||
|
title: "Image Viewer",
|
||||||
|
width: 800,
|
||||||
|
height: 800,
|
||||||
|
titleBarStyle: "overlay",
|
||||||
|
});
|
||||||
|
|
||||||
|
return newWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await window.setFocus();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (urls.length === 1) {
|
||||||
|
return (
|
||||||
|
<div className="group px-3">
|
||||||
|
<img
|
||||||
|
src={urls[0]}
|
||||||
|
alt={urls[0]}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
style={{ contentVisibility: "auto" }}
|
||||||
|
className="max-h-[400px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||||
|
onClick={() => open(urls[0])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Carousel
|
||||||
|
items={urls}
|
||||||
|
renderItem={({ item, isSnapPoint }) => (
|
||||||
|
<CarouselItem key={item} isSnapPoint={isSnapPoint}>
|
||||||
|
<img
|
||||||
|
src={item}
|
||||||
|
alt={item}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
style={{ contentVisibility: "auto" }}
|
||||||
|
className="w-full h-full object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||||
|
onClick={() => open(item)}
|
||||||
|
/>
|
||||||
|
</CarouselItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useOpenGraph } from "@lume/utils";
|
||||||
|
|
||||||
|
function isImage(url: string) {
|
||||||
|
return /^https?:\/\/.+\.(jpg|jpeg|png|webp|avif)$/.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LinkPreview({ url }: { url: string }) {
|
||||||
|
const domain = new URL(url);
|
||||||
|
const { isLoading, isError, data } = useOpenGraph(url);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="my-1.5 flex w-full flex-col overflow-hidden rounded-2xl border border-black/10 p-3 dark:border-white/10">
|
||||||
|
<div className="h-48 w-full shrink-0 animate-pulse bg-neutral-300 dark:bg-neutral-700" />
|
||||||
|
<div className="flex flex-col gap-2 px-3 py-3">
|
||||||
|
<div className="h-3 w-2/3 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
|
||||||
|
<div className="h-3 w-3/4 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
|
||||||
|
<span className="mt-2.5 text-sm leading-none text-neutral-600 dark:text-neutral-400">
|
||||||
|
{domain.hostname}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.title && !data.image && !data.description) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-block text-blue-500 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
{url}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-block text-blue-500 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
{url}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="my-1 flex w-full flex-col overflow-hidden rounded-2xl border border-black/10 dark:border-white/10"
|
||||||
|
>
|
||||||
|
{isImage(data.image) ? (
|
||||||
|
<img
|
||||||
|
src={data.image}
|
||||||
|
alt={url}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
className="h-48 w-full shrink-0 rounded-t-lg bg-white object-cover"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className="flex flex-col items-start p-3">
|
||||||
|
<div className="flex flex-col items-start text-left">
|
||||||
|
{data.title ? (
|
||||||
|
<div className="content-break line-clamp-1 text-base font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
|
{data.title}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{data.description ? (
|
||||||
|
<div className="content-break mb-2 line-clamp-3 text-balance text-sm text-neutral-700 dark:text-neutral-400">
|
||||||
|
{data.description}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="break-all text-sm font-semibold text-blue-500">
|
||||||
|
{domain.hostname}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export function VideoPreview({ url }: { url: string }) {
|
||||||
|
return (
|
||||||
|
<div className="my-1 overflow-hidden rounded-xl">
|
||||||
|
<video
|
||||||
|
className="h-auto w-full bg-neutral-100 text-sm dark:bg-neutral-900"
|
||||||
|
controls
|
||||||
|
muted
|
||||||
|
>
|
||||||
|
<source src={url} type="video/mp4" />
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Carousel, CarouselItem } from "@lume/ui";
|
||||||
|
|
||||||
|
export function Videos({ urls }: { urls: string[] }) {
|
||||||
|
if (urls.length === 1) {
|
||||||
|
return (
|
||||||
|
<div className="group px-3">
|
||||||
|
<video
|
||||||
|
className="w-full h-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||||
|
controls
|
||||||
|
muted
|
||||||
|
>
|
||||||
|
<source src={urls[0]} type="video/mp4" />
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Carousel
|
||||||
|
items={urls}
|
||||||
|
renderItem={({ item, isSnapPoint }) => (
|
||||||
|
<CarouselItem key={item} isSnapPoint={isSnapPoint}>
|
||||||
|
<video
|
||||||
|
className="w-full h-full object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||||
|
controls={false}
|
||||||
|
muted
|
||||||
|
>
|
||||||
|
<source src={item} type="video/mp4" />
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
</CarouselItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Event } from "@lume/types";
|
import type { Event } from "@lume/types";
|
||||||
import { ReactNode, createContext, useContext } from "react";
|
import { type ReactNode, createContext, useContext } from "react";
|
||||||
|
|
||||||
const EventContext = createContext<Event>(null);
|
const EventContext = createContext<Event>(null);
|
||||||
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export function NoteRoot({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn("h-min w-full", className)} contentEditable={false}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import * as HoverCard from "@radix-ui/react-hover-card";
|
||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
|
import { User } from "../user";
|
||||||
|
import { useNoteContext } from "./provider";
|
||||||
|
|
||||||
|
export function NoteUser({ className }: { className?: string }) {
|
||||||
|
const { ark } = useRouteContext({ strict: false });
|
||||||
|
const event = useNoteContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<User.Provider pubkey={event.pubkey}>
|
||||||
|
<HoverCard.Root>
|
||||||
|
<User.Root
|
||||||
|
className={cn("flex items-start justify-between", className)}
|
||||||
|
>
|
||||||
|
<div className="flex w-full gap-2">
|
||||||
|
<HoverCard.Trigger className="shrink-0">
|
||||||
|
<User.Avatar className="size-8 rounded-full object-cover outline outline-1 -outline-offset-1 outline-black/15" />
|
||||||
|
</HoverCard.Trigger>
|
||||||
|
<div className="flex w-full items-center gap-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<User.Name className="font-semibold text-neutral-950 dark:text-neutral-50" />
|
||||||
|
<User.NIP05 />
|
||||||
|
</div>
|
||||||
|
<div className="text-neutral-600 dark:text-neutral-400">·</div>
|
||||||
|
<User.Time
|
||||||
|
time={event.created_at}
|
||||||
|
className="text-neutral-600 dark:text-neutral-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</User.Root>
|
||||||
|
<HoverCard.Portal>
|
||||||
|
<HoverCard.Content
|
||||||
|
className="w-[300px] rounded-xl bg-black p-3 data-[side=bottom]:animate-slideUpAndFade data-[state=open]:transition-all dark:bg-white dark:shadow-none"
|
||||||
|
sideOffset={5}
|
||||||
|
side="right"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<User.Avatar className="size-11 rounded-lg object-cover" />
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="inline-flex items-center gap-1">
|
||||||
|
<User.Name className="font-semibold leading-tight text-white dark:text-neutral-900" />
|
||||||
|
<User.NIP05 />
|
||||||
|
</div>
|
||||||
|
<User.About className="line-clamp-3 text-sm text-white dark:text-neutral-900" />
|
||||||
|
<button
|
||||||
|
onClick={() => ark.open_profile(event.pubkey)}
|
||||||
|
className="mt-2 inline-flex h-9 w-full items-center justify-center rounded-lg bg-white text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-200"
|
||||||
|
>
|
||||||
|
View profile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<HoverCard.Arrow className="fill-black dark:fill-white" />
|
||||||
|
</HoverCard.Content>
|
||||||
|
</HoverCard.Portal>
|
||||||
|
</HoverCard.Root>
|
||||||
|
</User.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { Event } from "@lume/types";
|
||||||
|
import { Note } from "@/components/note";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
|
|
||||||
|
export function Notification({
|
||||||
|
event,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
event: Event;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Note.Provider event={event}>
|
||||||
|
<Note.Root
|
||||||
|
className={cn(
|
||||||
|
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="px-3 h-14 flex items-center justify-between">
|
||||||
|
<Note.User />
|
||||||
|
</div>
|
||||||
|
<Note.Content className="px-3" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center h-14 px-3">
|
||||||
|
<Note.Open />
|
||||||
|
</div>
|
||||||
|
</Note.Root>
|
||||||
|
</Note.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { QuoteIcon } from "@lume/icons";
|
||||||
|
import type { Event } from "@lume/types";
|
||||||
|
import { Note } from "@/components/note";
|
||||||
|
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,117 +1,85 @@
|
|||||||
import { RepostIcon } from "@lume/icons";
|
import type { Event } from "@lume/types";
|
||||||
import { Event } from "@lume/types";
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { Note } from "@/components/note";
|
||||||
|
import { User } from "@/components/user";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { 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({
|
||||||
event,
|
event,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
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,
|
data: repostEvent,
|
||||||
data: repostEvent,
|
} = useQuery({
|
||||||
} = useQuery({
|
queryKey: ["repost", event.id],
|
||||||
queryKey: ["repost", event.id],
|
queryFn: async () => {
|
||||||
queryFn: async () => {
|
try {
|
||||||
try {
|
if (event.content.length > 50) {
|
||||||
if (event.content.length > 50) {
|
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);
|
|
||||||
} catch {
|
|
||||||
throw new Error("Failed to get repost event");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
refetchOnMount: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) {
|
const id = event.tags.find((el) => el[0] === "e")?.[1];
|
||||||
return <div className="w-full px-3 pb-3">Loading...</div>;
|
const repostEvent = await ark.get_event(id);
|
||||||
}
|
|
||||||
|
|
||||||
if (isError || !repostEvent) {
|
return repostEvent;
|
||||||
return (
|
} catch (e) {
|
||||||
<Note.Root
|
throw new Error(e);
|
||||||
className={cn(
|
}
|
||||||
"flex flex-col gap-2 border-b border-neutral-100 px-3 py-5 dark:border-neutral-900",
|
},
|
||||||
className,
|
refetchOnWindowFocus: false,
|
||||||
)}
|
refetchOnMount: false,
|
||||||
>
|
});
|
||||||
<User.Provider pubkey={event.pubkey}>
|
|
||||||
<User.Root className="flex h-14 gap-2 px-3">
|
|
||||||
<div className="inline-flex w-10 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 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>
|
|
||||||
<div className="mb-3 select-text px-3">
|
|
||||||
<div className="flex flex-col items-start justify-start rounded-lg bg-red-100 px-3 py-3 dark:bg-red-900">
|
|
||||||
<p className="text-red-500">Failed to get event</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Note.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 gap-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-11 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>
|
||||||
<div className="inline-flex items-center gap-2">
|
<User.Avatar className="size-6 shrink-0 rounded-full object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
|
||||||
<User.Avatar className="size-6 shrink-0 rounded-full object-cover" />
|
</User.Root>
|
||||||
<div className="inline-flex items-baseline gap-1">
|
</User.Provider>
|
||||||
<User.Name className="font-medium text-neutral-900 dark:text-neutral-100" />
|
{isLoading ? (
|
||||||
<span className="text-blue-500">{t("note.reposted")}</span>
|
<div className="flex h-20 items-center justify-center gap-2">
|
||||||
</div>
|
<Spinner />
|
||||||
</div>
|
Loading event...
|
||||||
</User.Root>
|
</div>
|
||||||
</User.Provider>
|
) : isError || !repostEvent ? (
|
||||||
<Note.Provider event={repostEvent}>
|
<div className="flex h-20 items-center justify-center">
|
||||||
<div className="flex flex-col gap-2">
|
Event not found within your current relay set
|
||||||
<Note.User />
|
</div>
|
||||||
<div className="flex gap-3">
|
) : (
|
||||||
<div className="size-11 shrink-0" />
|
<Note.Provider event={repostEvent}>
|
||||||
<div className="min-w-0 flex-1">
|
<Note.Root>
|
||||||
<Note.Content />
|
<div className="px-3 h-14 flex items-center justify-between">
|
||||||
<div className="mt-4 flex items-center justify-between">
|
<Note.User />
|
||||||
<div className="-ml-1 inline-flex items-center gap-4">
|
<Note.Menu />
|
||||||
<Note.Reply />
|
</div>
|
||||||
<Note.Repost />
|
<Note.Content className="px-3" />
|
||||||
{settings.zap ? <Note.Zap /> : null}
|
<div className="mt-3 flex items-center gap-4 h-14 px-3">
|
||||||
</div>
|
<Note.Open />
|
||||||
<Note.Menu />
|
<Note.Reply />
|
||||||
</div>
|
<Note.Repost />
|
||||||
</div>
|
<Note.Zap />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Note.Root>
|
||||||
</Note.Provider>
|
</Note.Provider>
|
||||||
</Note.Root>
|
)}
|
||||||
);
|
</Note.Root>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,34 @@
|
|||||||
import { Event } from "@lume/types";
|
import type { Event } from "@lume/types";
|
||||||
import { Note } from "@lume/ui";
|
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
import { Note } from "@/components/note";
|
||||||
|
|
||||||
export function TextNote({
|
export function TextNote({
|
||||||
event,
|
event,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
event: Event;
|
event: Event;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { settings } = useRouteContext({ strict: false });
|
return (
|
||||||
|
<Note.Provider event={event}>
|
||||||
return (
|
<Note.Root
|
||||||
<Note.Provider event={event}>
|
className={cn(
|
||||||
<Note.Root
|
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50",
|
||||||
className={cn(
|
className,
|
||||||
"flex flex-col gap-2 border-b border-neutral-100 px-3 py-5 dark:border-neutral-900",
|
)}
|
||||||
className,
|
>
|
||||||
)}
|
<div className="px-3 h-14 flex items-center justify-between">
|
||||||
>
|
<Note.User />
|
||||||
<Note.User />
|
<Note.Menu />
|
||||||
<div className="flex gap-3">
|
</div>
|
||||||
<div className="size-11 shrink-0" />
|
<Note.Content className="px-3" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="mt-3 flex items-center gap-4 h-14 px-3">
|
||||||
<Note.Content className="mb-2" />
|
<Note.Open />
|
||||||
<Note.Thread />
|
<Note.Reply />
|
||||||
<div className="mt-4 flex items-center justify-between">
|
<Note.Repost />
|
||||||
<div className="-ml-1 inline-flex items-center gap-4">
|
<Note.Zap />
|
||||||
<Note.Reply />
|
</div>
|
||||||
<Note.Repost />
|
</Note.Root>
|
||||||
{settings.zap ? <Note.Zap /> : null}
|
</Note.Provider>
|
||||||
</div>
|
);
|
||||||
<Note.Menu />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Note.Root>
|
|
||||||
</Note.Provider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
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";
|
||||||
|
|
||||||
export function Toolbar({ children }: { children: ReactNode }) {
|
export function Toolbar({ children }: { children: ReactNode }) {
|
||||||
const [domReady, setDomReady] = useState(false);
|
const [domReady, setDomReady] = useState(false);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
setDomReady(true);
|
setDomReady(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return domReady
|
return domReady
|
||||||
? createPortal(children, document.getElementById("toolbar"))
|
? createPortal(children, document.getElementById("toolbar"))
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { useUserContext } from "./provider";
|
||||||
|
|
||||||
|
export function UserAbout({ className }: { className?: string }) {
|
||||||
|
const user = useUserContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("content-break select-text", className)}>
|
||||||
|
{user.profile?.about?.trim() || "No bio"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import * as Avatar from "@radix-ui/react-avatar";
|
||||||
|
import { minidenticon } from "minidenticons";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useUserContext } from "./provider";
|
||||||
|
|
||||||
|
export function UserAvatar({ className }: { className?: string }) {
|
||||||
|
const user = useUserContext();
|
||||||
|
|
||||||
|
const fallbackAvatar = useMemo(
|
||||||
|
() =>
|
||||||
|
`data:image/svg+xml;utf8,${encodeURIComponent(
|
||||||
|
minidenticon(user.pubkey || nanoid(), 90, 50),
|
||||||
|
)}`,
|
||||||
|
[user],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Avatar.Root className="shrink-0">
|
||||||
|
<Avatar.Image
|
||||||
|
src={user.profile?.picture}
|
||||||
|
alt={user.pubkey}
|
||||||
|
loading="eager"
|
||||||
|
decoding="async"
|
||||||
|
className={cn("outline-[.5px] outline-black/5", className)}
|
||||||
|
/>
|
||||||
|
<Avatar.Fallback delayMs={120}>
|
||||||
|
<img
|
||||||
|
src={fallbackAvatar}
|
||||||
|
alt={user.pubkey}
|
||||||
|
className={cn("bg-black dark:bg-white", className)}
|
||||||
|
/>
|
||||||
|
</Avatar.Fallback>
|
||||||
|
</Avatar.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { useUserContext } from "./provider";
|
||||||
|
|
||||||
|
export function UserFollowButton({
|
||||||
|
simple = false,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
simple?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const { ark } = useRouteContext({ strict: false });
|
||||||
|
const user = useUserContext();
|
||||||
|
|
||||||
|
const [t] = useTranslation();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [followed, setFollowed] = useState(false);
|
||||||
|
|
||||||
|
const toggleFollow = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
if (!followed) {
|
||||||
|
const add = await ark.follow(user.pubkey);
|
||||||
|
if (add) setFollowed(true);
|
||||||
|
} else {
|
||||||
|
const remove = await ark.unfollow(user.pubkey);
|
||||||
|
if (remove) setFollowed(false);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function status() {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const contacts = await ark.get_contact_list();
|
||||||
|
if (contacts?.includes(user.pubkey)) {
|
||||||
|
setFollowed(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
status();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => toggleFollow()}
|
||||||
|
className={cn("w-max", className)}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
) : followed ? (
|
||||||
|
!simple ? (
|
||||||
|
t("user.unfollow")
|
||||||
|
) : null
|
||||||
|
) : (
|
||||||
|
t("user.follow")
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { cn, displayNpub } from "@lume/utils";
|
||||||
|
import { useUserContext } from "./provider";
|
||||||
|
|
||||||
|
export function UserName({
|
||||||
|
className,
|
||||||
|
prefix,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
prefix?: string;
|
||||||
|
}) {
|
||||||
|
const user = useUserContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("max-w-[12rem] truncate", className)}>
|
||||||
|
{prefix}
|
||||||
|
{user.profile?.display_name ||
|
||||||
|
user.profile?.name ||
|
||||||
|
displayNpub(user.pubkey, 16)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { VerifiedIcon } from "@lume/icons";
|
||||||
|
import { displayLongHandle, displayNpub } from "@lume/utils";
|
||||||
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
|
import { useUserContext } from "./provider";
|
||||||
|
|
||||||
|
export function UserNip05() {
|
||||||
|
const user = useUserContext();
|
||||||
|
|
||||||
|
const { ark } = useRouteContext({ strict: false });
|
||||||
|
const { isLoading, data: verified } = useQuery({
|
||||||
|
queryKey: ["nip05", user?.pubkey],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!user.profile?.nip05) return false;
|
||||||
|
const verify = await ark.verify_nip05(user.pubkey, user.profile?.nip05);
|
||||||
|
return verify;
|
||||||
|
},
|
||||||
|
enabled: !!user.profile,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user.profile?.nip05?.length) return;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip.Provider>
|
||||||
|
<Tooltip.Root delayDuration={150}>
|
||||||
|
<Tooltip.Trigger>
|
||||||
|
{!isLoading && verified ? (
|
||||||
|
<VerifiedIcon className="size-4 text-teal-500" />
|
||||||
|
) : null}
|
||||||
|
</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 font-medium 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">
|
||||||
|
{!user.profile?.nip05
|
||||||
|
? displayNpub(user.pubkey, 16)
|
||||||
|
: user.profile?.nip05.length > 50
|
||||||
|
? displayLongHandle(user.profile?.nip05)
|
||||||
|
: user.profile.nip05?.replace("_@", "")}
|
||||||
|
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Portal>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</Tooltip.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useProfile } from "@lume/ark";
|
||||||
|
import type { Metadata } from "@lume/types";
|
||||||
|
import { type ReactNode, createContext, useContext } from "react";
|
||||||
|
|
||||||
|
const UserContext = createContext<{
|
||||||
|
pubkey: string;
|
||||||
|
isError: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
profile: Metadata;
|
||||||
|
}>(null);
|
||||||
|
|
||||||
|
export function UserProvider({
|
||||||
|
pubkey,
|
||||||
|
children,
|
||||||
|
embedProfile,
|
||||||
|
}: {
|
||||||
|
pubkey: string;
|
||||||
|
children: ReactNode;
|
||||||
|
embedProfile?: string;
|
||||||
|
}) {
|
||||||
|
const { isLoading, isError, profile } = useProfile(pubkey, embedProfile);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserContext.Provider value={{ pubkey, isError, isLoading, profile }}>
|
||||||
|
{children}
|
||||||
|
</UserContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUserContext() {
|
||||||
|
const context = useContext(UserContext);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
export function UserRoot({
|
export function UserRoot({
|
||||||
children,
|
children,
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { cn, formatCreatedAt } from "@lume/utils";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
export function UserTime({
|
||||||
|
time,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
time: number;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const createdAt = useMemo(() => formatCreatedAt(time), [time]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("text-neutral-600 dark:text-neutral-400", className)}>
|
||||||
|
{createdAt}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,155 +1,187 @@
|
|||||||
import { Col } from "@/components/col";
|
import { Column } from "@/components/column";
|
||||||
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,
|
loader: async ({ context }) => {
|
||||||
pendingComponent: Pending,
|
try {
|
||||||
beforeLoad: async ({ context }) => {
|
const userColumns = await context.ark.get_columns();
|
||||||
const ark = context.ark;
|
if (userColumns.length > 0) {
|
||||||
const resourcePath = await resolveResource("resources/system_columns.json");
|
return userColumns;
|
||||||
const systemColumns: LumeColumn[] = JSON.parse(
|
} else {
|
||||||
await readTextFile(resourcePath),
|
const systemPath = "resources/system_columns.json";
|
||||||
);
|
const resourcePath = await resolveResource(systemPath);
|
||||||
const userColumns = await ark.get_columns();
|
const resourceFile = await readTextFile(resourcePath);
|
||||||
|
const systemColumns: LumeColumn[] = JSON.parse(resourceFile);
|
||||||
|
|
||||||
return {
|
return systemColumns;
|
||||||
storedColumns: !userColumns.length ? systemColumns : userColumns,
|
}
|
||||||
};
|
} catch (e) {
|
||||||
},
|
console.error(String(e));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const { account } = Route.useParams();
|
const userSavedColumns = Route.useLoaderData();
|
||||||
const { ark, storedColumns } = Route.useRouteContext();
|
const vlistRef = useRef<VListHandle>(null);
|
||||||
|
|
||||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
const { account } = Route.useParams();
|
||||||
const [isScroll, setIsScroll] = useState(false);
|
const { ark } = Route.useRouteContext();
|
||||||
const [columns, setColumns] = useState(storedColumns);
|
|
||||||
|
|
||||||
const vlistRef = useRef<VListHandle>(null);
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||||
|
const [columns, setColumns] = useState([]);
|
||||||
|
const [isScroll, setIsScroll] = useState(false);
|
||||||
|
const [isResize, setIsResize] = useState(false);
|
||||||
|
|
||||||
const goLeft = () => {
|
const goLeft = () => {
|
||||||
const prevIndex = Math.max(selectedIndex - 1, 0);
|
const prevIndex = Math.max(selectedIndex - 1, 0);
|
||||||
setSelectedIndex(prevIndex);
|
setSelectedIndex(prevIndex);
|
||||||
vlistRef.current.scrollToIndex(prevIndex, {
|
vlistRef.current.scrollToIndex(prevIndex, {
|
||||||
align: "center",
|
align: "center",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const goRight = () => {
|
const goRight = () => {
|
||||||
const nextIndex = Math.min(selectedIndex + 1, columns.length - 1);
|
const nextIndex = Math.min(selectedIndex + 1, columns.length - 1);
|
||||||
setSelectedIndex(nextIndex);
|
setSelectedIndex(nextIndex);
|
||||||
vlistRef.current.scrollToIndex(nextIndex, {
|
vlistRef.current.scrollToIndex(nextIndex, {
|
||||||
align: "center",
|
align: "center",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
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);
|
||||||
align: "end",
|
setIsScroll(true);
|
||||||
});
|
|
||||||
}, 150);
|
|
||||||
|
|
||||||
const remove = useDebouncedCallback((label: string) => {
|
// scroll to the newest column
|
||||||
setColumns((state) => state.filter((t) => t.label !== label));
|
vlistRef.current.scrollToIndex(newCols.length - 1, {
|
||||||
setSelectedIndex(columns.length - 1);
|
align: "end",
|
||||||
|
});
|
||||||
|
}, 150);
|
||||||
|
|
||||||
// scroll to the first column
|
const remove = useDebouncedCallback((label: string) => {
|
||||||
vlistRef.current.scrollToIndex(0, {
|
const newCols = columns.filter((t) => t.label !== label);
|
||||||
align: "start",
|
|
||||||
});
|
|
||||||
}, 150);
|
|
||||||
|
|
||||||
useEffect(() => {
|
setColumns(newCols);
|
||||||
// save state
|
setSelectedIndex(newCols.length);
|
||||||
ark.set_columns(columns);
|
setIsScroll(true);
|
||||||
}, [columns]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
// scroll to the first column
|
||||||
let unlisten: Awaited<ReturnType<typeof listen>> | undefined = undefined;
|
vlistRef.current.scrollToIndex(newCols.length - 1, {
|
||||||
|
align: "start",
|
||||||
|
});
|
||||||
|
}, 150);
|
||||||
|
|
||||||
(async () => {
|
const updateName = useDebouncedCallback((label: string, title: string) => {
|
||||||
if (unlisten) return;
|
const currentColIndex = columns.findIndex((col) => col.label === label);
|
||||||
unlisten = await listen<EventColumns>("columns", (data) => {
|
|
||||||
if (data.payload.type === "add") add(data.payload.column);
|
|
||||||
if (data.payload.type === "remove") remove(data.payload.label);
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
const updatedCol = Object.assign({}, columns[currentColIndex]);
|
||||||
if (unlisten) unlisten();
|
updatedCol.name = title;
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
const newCols = columns.slice();
|
||||||
<div className="h-full w-full">
|
newCols[currentColIndex] = updatedCol;
|
||||||
<VList
|
|
||||||
ref={vlistRef}
|
setColumns(newCols);
|
||||||
horizontal
|
}, 150);
|
||||||
tabIndex={-1}
|
|
||||||
itemSize={440}
|
const startResize = useDebouncedCallback(
|
||||||
overscan={3}
|
() => setIsResize((prev) => !prev),
|
||||||
onScroll={() => {
|
150,
|
||||||
setIsScroll(true);
|
);
|
||||||
}}
|
|
||||||
onScrollEnd={() => {
|
useEffect(() => {
|
||||||
setIsScroll(false);
|
setColumns(userSavedColumns);
|
||||||
}}
|
}, [userSavedColumns]);
|
||||||
className="scrollbar-none h-full w-full overflow-x-auto focus:outline-none"
|
|
||||||
>
|
useEffect(() => {
|
||||||
{columns.map((column, index) => (
|
// save state
|
||||||
<Col
|
ark.set_columns(columns);
|
||||||
key={column.label + index}
|
}, [columns]);
|
||||||
column={column}
|
|
||||||
account={account}
|
useEffect(() => {
|
||||||
isScroll={isScroll}
|
const unlistenColEvent = listen<EventColumns>("columns", (data) => {
|
||||||
/>
|
if (data.payload.type === "add") add(data.payload.column);
|
||||||
))}
|
if (data.payload.type === "remove") remove(data.payload.label);
|
||||||
</VList>
|
if (data.payload.type === "set_title")
|
||||||
<Toolbar>
|
updateName(data.payload.label, data.payload.title);
|
||||||
<div className="flex items-center gap-1">
|
});
|
||||||
<button
|
|
||||||
type="button"
|
const unlistenWindowResize = getCurrent().listen("tauri://resize", () => {
|
||||||
onClick={() => goLeft()}
|
startResize();
|
||||||
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"
|
});
|
||||||
>
|
|
||||||
<ArrowLeftIcon className="size-5" />
|
return () => {
|
||||||
</button>
|
unlistenColEvent.then((f) => f());
|
||||||
<button
|
unlistenWindowResize.then((f) => f());
|
||||||
type="button"
|
};
|
||||||
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"
|
|
||||||
>
|
return (
|
||||||
<ArrowRightIcon className="size-5" />
|
<div className="h-full w-full">
|
||||||
</button>
|
<VList
|
||||||
</div>
|
ref={vlistRef}
|
||||||
</Toolbar>
|
horizontal
|
||||||
</div>
|
tabIndex={-1}
|
||||||
);
|
itemSize={440}
|
||||||
}
|
overscan={3}
|
||||||
|
onScroll={() => setIsScroll(true)}
|
||||||
function Pending() {
|
onScrollEnd={() => setIsScroll(false)}
|
||||||
return (
|
className="scrollbar-none h-full w-full overflow-x-auto focus:outline-none"
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
cache={null}
|
||||||
<button type="button" className="size-5" disabled>
|
>
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
{columns.map((column) => (
|
||||||
</button>
|
<Column
|
||||||
</div>
|
key={column.label}
|
||||||
);
|
column={column}
|
||||||
|
account={account}
|
||||||
|
isScroll={isScroll}
|
||||||
|
isResize={isResize}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</VList>
|
||||||
|
<Toolbar>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => goLeft()}
|
||||||
|
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="size-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => goRight()}
|
||||||
|
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<ArrowRightIcon className="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Toolbar>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +1,184 @@
|
|||||||
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 "@/components/user";
|
||||||
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";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
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">
|
||||||
<div
|
<div
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-11 shrink-0 items-center justify-between pr-2",
|
"flex h-11 shrink-0 items-center justify-between pr-2",
|
||||||
platform === "macos" ? "ml-2 pl-20" : "pl-4",
|
platform === "macos" ? "ml-2 pl-20" : "pl-4",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Accounts />
|
<Accounts />
|
||||||
<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()}
|
||||||
className="inline-flex h-8 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600"
|
className="inline-flex 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"
|
||||||
>
|
>
|
||||||
<ComposeFilledIcon className="size-4" />
|
<ComposeFilledIcon className="size-4" />
|
||||||
New Post
|
New Post
|
||||||
</button>
|
</button>
|
||||||
<div id="toolbar" />
|
<Bell />
|
||||||
</div>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
<div className="flex-1">
|
onClick={() => ark.open_search()}
|
||||||
<Outlet />
|
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"
|
||||||
</div>
|
>
|
||||||
</div>
|
<SearchIcon className="size-5" />
|
||||||
);
|
</button>
|
||||||
|
<div id="toolbar" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Outlet />
|
||||||
|
</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 await ark.open_profile(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
// change current account and update signer
|
||||||
|
const select = await ark.load_selected_account(npub);
|
||||||
|
|
||||||
|
if (select) {
|
||||||
|
return navigate({ to: "/$account/home", params: { account: npub } });
|
||||||
|
} else {
|
||||||
|
toast.warning("Something wrong.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 transition-all ease-in-out duration-150 will-change-auto",
|
||||||
|
user === account
|
||||||
|
? "ring-1 ring-teal-500 ring-offset-2 ring-offset-neutral-200 dark:ring-offset-neutral-950"
|
||||||
|
: "",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<User.Avatar
|
||||||
|
className={cn(
|
||||||
|
"aspect-square h-auto rounded-full object-cover transition-all ease-in-out duration-150 will-change-auto",
|
||||||
|
user === account ? "w-7" : "w-8",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</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,32 +1,61 @@
|
|||||||
import { LoaderIcon } from "@lume/icons";
|
import type { Ark } from "@lume/ark";
|
||||||
|
import { CheckCircleIcon, InfoCircleIcon, CancelCircleIcon } from "@lume/icons";
|
||||||
|
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 { Toaster } from "sonner";
|
||||||
import { Account, Interests, Settings } from "@lume/types";
|
|
||||||
|
type EditorElement = {
|
||||||
|
type: string;
|
||||||
|
children: Descendant[];
|
||||||
|
eventId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
interface RouterContext {
|
interface RouterContext {
|
||||||
ark: Ark;
|
// System
|
||||||
queryClient: QueryClient;
|
ark: Ark;
|
||||||
platform?: Platform;
|
queryClient: QueryClient;
|
||||||
locale?: string;
|
// App info
|
||||||
settings?: Settings;
|
platform?: Platform;
|
||||||
interests?: Interests;
|
locale?: string;
|
||||||
accounts?: Account[];
|
// Settings
|
||||||
|
settings?: Settings;
|
||||||
|
interests?: Interests;
|
||||||
|
// Profile
|
||||||
|
accounts?: string[];
|
||||||
|
profile?: Metadata;
|
||||||
|
isNewUser?: boolean;
|
||||||
|
// Editor
|
||||||
|
initialValue?: EditorElement[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||||
component: () => <Outlet />,
|
component: () => (
|
||||||
pendingComponent: Pending,
|
<>
|
||||||
wrapInSuspense: true,
|
<Toaster
|
||||||
|
position="bottom-right"
|
||||||
|
icons={{
|
||||||
|
success: <CheckCircleIcon className="size-5" />,
|
||||||
|
info: <InfoCircleIcon className="size-5" />,
|
||||||
|
error: <CancelCircleIcon className="size-5" />,
|
||||||
|
}}
|
||||||
|
closeButton
|
||||||
|
theme="system"
|
||||||
|
/>
|
||||||
|
<Outlet />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
pendingComponent: Pending,
|
||||||
|
wrapInSuspense: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
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" />
|
</div>
|
||||||
</button>
|
);
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { Note } from "@/components/note";
|
||||||
|
import { Await, createFileRoute, defer } from "@tanstack/react-router";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { Virtualizer } from "virtua";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/activity/$account/texts")({
|
||||||
|
loader: async ({ context, params }) => {
|
||||||
|
const ark = context.ark;
|
||||||
|
return { data: defer(ark.get_activities(params.account, "1")) };
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const { data } = Route.useLoaderData();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Virtualizer overscan={3}>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 text-sm font-medium"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<Spinner className="size-5" />
|
||||||
|
Loading...
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Await promise={data}>
|
||||||
|
{(events) =>
|
||||||
|
events.map((event) => (
|
||||||
|
<div
|
||||||
|
key={event.id}
|
||||||
|
className="flex flex-col gap-2 mb-3 bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||||
|
>
|
||||||
|
<Note.Provider event={event}>
|
||||||
|
<Note.Root>
|
||||||
|
<div className="px-3 h-14 flex items-center justify-between">
|
||||||
|
<Note.User />
|
||||||
|
<Note.Menu />
|
||||||
|
</div>
|
||||||
|
<Note.Activity className="px-3" />
|
||||||
|
<Note.Content className="px-3" quote={false} clean />
|
||||||
|
<div className="mt-3 flex items-center gap-4 h-14 px-3">
|
||||||
|
<Note.Open />
|
||||||
|
</div>
|
||||||
|
</Note.Root>
|
||||||
|
</Note.Provider>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</Await>
|
||||||
|
</Suspense>
|
||||||
|
</Virtualizer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,65 @@
|
|||||||
|
import { User } from "@/components/user";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { decodeZapInvoice } from "@lume/utils";
|
||||||
|
import { Await, createFileRoute, defer } from "@tanstack/react-router";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { Virtualizer } from "virtua";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/activity/$account/zaps")({
|
||||||
|
loader: async ({ context, params }) => {
|
||||||
|
const ark = context.ark;
|
||||||
|
return { data: defer(ark.get_activities(params.account, "9735")) };
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const { data } = Route.useLoaderData();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Virtualizer overscan={3}>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 text-sm font-medium"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<Spinner className="size-5" />
|
||||||
|
Loading...
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Await promise={data}>
|
||||||
|
{(events) =>
|
||||||
|
events.map((event) => (
|
||||||
|
<div
|
||||||
|
key={event.id}
|
||||||
|
className="flex flex-col gap-2 mb-3 bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||||
|
>
|
||||||
|
<User.Provider pubkey={event.pubkey}>
|
||||||
|
<User.Root className="flex flex-col">
|
||||||
|
<div className="text-lg h-20 font-medium leading-tight flex w-full items-center justify-center">
|
||||||
|
₿ {decodeZapInvoice(event.tags).bitcoinFormatted}
|
||||||
|
</div>
|
||||||
|
<div className="h-11 border-t border-neutral-100 dark:border-neutral-900 flex items-center gap-1 px-2">
|
||||||
|
<div className="inline-flex items-center gap-2">
|
||||||
|
<User.Avatar className="size-7 rounded-full shrink-0" />
|
||||||
|
<User.Name className="text-sm font-medium" />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
|
zapped you
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</Await>
|
||||||
|
</Suspense>
|
||||||
|
</Virtualizer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,15 +2,15 @@ import { Box, Container } from "@lume/ui";
|
|||||||
import { Outlet, createLazyFileRoute } from "@tanstack/react-router";
|
import { Outlet, createLazyFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/auth")({
|
export const Route = createLazyFileRoute("/auth")({
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
return (
|
return (
|
||||||
<Container withDrag>
|
<Container withDrag>
|
||||||
<Box className="px-3 pt-3">
|
<Box className="px-3 pt-3">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,191 +1,200 @@
|
|||||||
|
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 => {
|
||||||
return {
|
return {
|
||||||
account: search.account,
|
account: search.account,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const { account } = Route.useSearch();
|
const { account } = Route.useSearch();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
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 [confirm, setConfirm] = useState({ c1: false, c2: false, c3: false });
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [confirm, setConfirm] = useState({ c1: false, c2: false, c3: false });
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
try {
|
try {
|
||||||
if (key) {
|
if (key) {
|
||||||
if (!confirm.c1 || !confirm.c2 || !confirm.c3) {
|
if (!confirm.c1 || !confirm.c2 || !confirm.c3) {
|
||||||
return toast.warning("You need to confirm before continue");
|
return toast.warning("You need to confirm before continue");
|
||||||
} else {
|
}
|
||||||
return navigate({
|
|
||||||
to: "/auth/settings",
|
|
||||||
search: { account },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const encrypted: string = await invoke("get_encrypted_key", {
|
return navigate({
|
||||||
npub: account,
|
to: "/auth/settings",
|
||||||
password: passphase,
|
search: { account },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setKey(encrypted);
|
// start loading
|
||||||
} catch (e) {
|
setLoading(true);
|
||||||
toast.error(String(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyKey = async () => {
|
invoke("get_encrypted_key", {
|
||||||
try {
|
npub: account,
|
||||||
await writeText(key);
|
password: passphase,
|
||||||
setCopied(true);
|
}).then((encrypted: string) => {
|
||||||
} catch (e) {
|
// update state
|
||||||
toast.error(e);
|
setKey(encrypted);
|
||||||
}
|
setLoading(false);
|
||||||
};
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setLoading(false);
|
||||||
|
toast.error(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
const copyKey = async () => {
|
||||||
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
|
try {
|
||||||
<div className="flex flex-col text-center">
|
await writeText(key);
|
||||||
<h3 className="text-xl font-semibold">Backup your sign in keys</h3>
|
setCopied(true);
|
||||||
<p className="text-neutral-700 dark:text-neutral-300">
|
} catch (e) {
|
||||||
It's use for login to Lume or other Nostr clients. You will lost
|
toast.error(e);
|
||||||
access to your account if you lose this key.
|
}
|
||||||
</p>
|
};
|
||||||
</div>
|
|
||||||
<div className="flex w-full flex-col gap-5">
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
|
||||||
<label htmlFor="passphase" className="font-medium">
|
<div className="flex flex-col text-center">
|
||||||
Set a passphase to secure your key
|
<h3 className="text-xl font-semibold">Backup your sign in keys</h3>
|
||||||
</label>
|
<p className="text-neutral-700 dark:text-neutral-300">
|
||||||
<div className="relative">
|
It's use for login to Lume or other Nostr clients. You will lost
|
||||||
<input
|
access to your account if you lose this key.
|
||||||
name="passphase"
|
</p>
|
||||||
type="password"
|
</div>
|
||||||
value={passphase}
|
<div className="flex w-full flex-col gap-5">
|
||||||
onChange={(e) => setPassphase(e.target.value)}
|
<div className="flex flex-col gap-2">
|
||||||
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"
|
<label htmlFor="passphase" className="font-medium">
|
||||||
/>
|
Set a passphase to secure your key
|
||||||
</div>
|
</label>
|
||||||
</div>
|
<div className="relative">
|
||||||
{key ? (
|
<input
|
||||||
<>
|
name="passphase"
|
||||||
<div className="flex flex-col gap-2">
|
type="password"
|
||||||
<label htmlFor="nsec" className="font-medium">
|
value={passphase}
|
||||||
Copy this key and keep it in safe place
|
onChange={(e) => setPassphase(e.target.value)}
|
||||||
</label>
|
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 className="flex items-center gap-2">
|
/>
|
||||||
<input
|
</div>
|
||||||
name="nsec"
|
</div>
|
||||||
type="text"
|
{key ? (
|
||||||
value={displayNsec(key, 36)}
|
<>
|
||||||
readOnly
|
<div className="flex flex-col gap-2">
|
||||||
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"
|
<label htmlFor="nsec" className="font-medium">
|
||||||
/>
|
Copy this key and keep it in safe place
|
||||||
<button
|
</label>
|
||||||
type="button"
|
<div className="flex items-center gap-2">
|
||||||
onClick={copyKey}
|
<input
|
||||||
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"
|
name="nsec"
|
||||||
>
|
type="text"
|
||||||
{copied ? "Copied" : "Copy"}
|
value={displayNsec(key, 36)}
|
||||||
</button>
|
readOnly
|
||||||
</div>
|
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 className="flex flex-col gap-2">
|
<button
|
||||||
<div className="font-medium">Before you continue:</div>
|
type="button"
|
||||||
<div className="flex flex-col gap-2">
|
onClick={() => copyKey()}
|
||||||
<div className="flex items-center gap-2">
|
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"
|
||||||
<Checkbox.Root
|
>
|
||||||
checked={confirm.c1}
|
{copied ? "Copied" : "Copy"}
|
||||||
onCheckedChange={() =>
|
</button>
|
||||||
setConfirm((state) => ({ ...state, c1: !state.c1 }))
|
</div>
|
||||||
}
|
</div>
|
||||||
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-neutral-900"
|
<div className="flex flex-col gap-2">
|
||||||
id="confirm1"
|
<div className="font-medium">Before you continue:</div>
|
||||||
>
|
<div className="flex flex-col gap-2">
|
||||||
<Checkbox.Indicator className="text-blue-500">
|
<div className="flex items-center gap-2">
|
||||||
<CheckIcon className="size-4" />
|
<Checkbox.Root
|
||||||
</Checkbox.Indicator>
|
checked={confirm.c1}
|
||||||
</Checkbox.Root>
|
onCheckedChange={() =>
|
||||||
<label
|
setConfirm((state) => ({ ...state, c1: !state.c1 }))
|
||||||
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
|
}
|
||||||
htmlFor="confirm1"
|
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"
|
||||||
{t("backup.confirm1")}
|
>
|
||||||
</label>
|
<Checkbox.Indicator className="text-blue-500">
|
||||||
</div>
|
<CheckIcon className="size-4" />
|
||||||
<div className="flex items-center gap-2">
|
</Checkbox.Indicator>
|
||||||
<Checkbox.Root
|
</Checkbox.Root>
|
||||||
checked={confirm.c2}
|
<label
|
||||||
onCheckedChange={() =>
|
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
|
||||||
setConfirm((state) => ({ ...state, c2: !state.c2 }))
|
htmlFor="confirm1"
|
||||||
}
|
>
|
||||||
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-neutral-900"
|
{t("backup.confirm1")}
|
||||||
id="confirm2"
|
</label>
|
||||||
>
|
</div>
|
||||||
<Checkbox.Indicator className="text-blue-500">
|
<div className="flex items-center gap-2">
|
||||||
<CheckIcon className="size-4" />
|
<Checkbox.Root
|
||||||
</Checkbox.Indicator>
|
checked={confirm.c2}
|
||||||
</Checkbox.Root>
|
onCheckedChange={() =>
|
||||||
<label
|
setConfirm((state) => ({ ...state, c2: !state.c2 }))
|
||||||
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
|
}
|
||||||
htmlFor="confirm2"
|
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"
|
||||||
{t("backup.confirm2")}
|
>
|
||||||
</label>
|
<Checkbox.Indicator className="text-blue-500">
|
||||||
</div>
|
<CheckIcon className="size-4" />
|
||||||
<div className="flex items-center gap-2">
|
</Checkbox.Indicator>
|
||||||
<Checkbox.Root
|
</Checkbox.Root>
|
||||||
checked={confirm.c3}
|
<label
|
||||||
onCheckedChange={() =>
|
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
|
||||||
setConfirm((state) => ({ ...state, c3: !state.c3 }))
|
htmlFor="confirm2"
|
||||||
}
|
>
|
||||||
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-neutral-900"
|
{t("backup.confirm2")}
|
||||||
id="confirm3"
|
</label>
|
||||||
>
|
</div>
|
||||||
<Checkbox.Indicator className="text-blue-500">
|
<div className="flex items-center gap-2">
|
||||||
<CheckIcon className="size-4" />
|
<Checkbox.Root
|
||||||
</Checkbox.Indicator>
|
checked={confirm.c3}
|
||||||
</Checkbox.Root>
|
onCheckedChange={() =>
|
||||||
<label
|
setConfirm((state) => ({ ...state, c3: !state.c3 }))
|
||||||
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
|
}
|
||||||
htmlFor="confirm3"
|
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"
|
||||||
{t("backup.confirm3")}
|
>
|
||||||
</label>
|
<Checkbox.Indicator className="text-blue-500">
|
||||||
</div>
|
<CheckIcon className="size-4" />
|
||||||
</div>
|
</Checkbox.Indicator>
|
||||||
</div>
|
</Checkbox.Root>
|
||||||
</>
|
<label
|
||||||
) : null}
|
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
|
||||||
<div>
|
htmlFor="confirm3"
|
||||||
<button
|
>
|
||||||
type="button"
|
{t("backup.confirm3")}
|
||||||
onClick={submit}
|
</label>
|
||||||
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"
|
</div>
|
||||||
>
|
</div>
|
||||||
{t("global.continue")}
|
</div>
|
||||||
</button>
|
</>
|
||||||
</div>
|
) : null}
|
||||||
</div>
|
<div>
|
||||||
</div>
|
<button
|
||||||
);
|
type="button"
|
||||||
|
onClick={() => submit()}
|
||||||
|
disabled={loading}
|
||||||
|
className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? <Spinner /> : t("global.continue")}
|
||||||
|
</button>
|
||||||
|
</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";
|
||||||
@@ -8,139 +9,135 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export const Route = createFileRoute("/auth/new/profile")({
|
export const Route = createFileRoute("/auth/new/profile")({
|
||||||
component: Screen,
|
component: Screen,
|
||||||
loader: ({ context }) => {
|
loader: ({ context }) => {
|
||||||
return context.ark.create_keys();
|
return context.ark.create_keys();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const keys = Route.useLoaderData();
|
const keys = Route.useLoaderData();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { ark } = Route.useRouteContext();
|
const { ark } = Route.useRouteContext();
|
||||||
const { register, handleSubmit } = useForm();
|
const { register, handleSubmit } = useForm();
|
||||||
|
|
||||||
const [picture, setPicture] = useState<string>("");
|
const [picture, setPicture] = useState<string>("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const onSubmit = async (data: {
|
const onSubmit = async (data: {
|
||||||
name: string;
|
name: string;
|
||||||
about: string;
|
about: string;
|
||||||
website: string;
|
website: string;
|
||||||
}) => {
|
}) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Save account keys
|
// Save account keys
|
||||||
const save = await ark.save_account(keys.nsec);
|
const save = await ark.save_account(keys.nsec);
|
||||||
|
|
||||||
// Then create profile
|
// Then create profile
|
||||||
if (save) {
|
if (save) {
|
||||||
const profile: Metadata = { ...data, picture };
|
const profile: Metadata = { ...data, picture };
|
||||||
const eventId = await ark.create_profile(profile);
|
const eventId = await ark.create_profile(profile);
|
||||||
|
|
||||||
if (eventId) {
|
if (eventId) {
|
||||||
navigate({
|
navigate({
|
||||||
to: "/auth/new/backup",
|
to: "/auth/new/backup",
|
||||||
search: { account: keys.npub },
|
search: { account: keys.npub },
|
||||||
replace: true,
|
replace: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
toast.error(String(e));
|
toast.error(String(e));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-xl font-semibold">Let's set up your profile.</h3>
|
<h3 className="text-xl font-semibold">Let's set up your profile.</h3>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="relative size-24 rounded-full bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200">
|
<div className="relative size-24 rounded-full bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200">
|
||||||
{picture ? (
|
{picture ? (
|
||||||
<img
|
<img
|
||||||
src={picture}
|
src={picture}
|
||||||
alt="avatar"
|
alt="avatar"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
className="absolute inset-0 z-10 h-full w-full rounded-full object-cover"
|
className="absolute inset-0 z-10 h-full w-full rounded-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : 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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
className="flex w-full flex-col gap-3"
|
className="flex w-full flex-col gap-3"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label htmlFor="display_name" className="font-medium">
|
<label htmlFor="display_name" className="font-medium">
|
||||||
{t("user.displayName")} *
|
{t("user.displayName")} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type={"text"}
|
type={"text"}
|
||||||
{...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">
|
||||||
<label htmlFor="name" className="font-medium">
|
<label htmlFor="name" className="font-medium">
|
||||||
{t("user.name")}
|
{t("user.name")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type={"text"}
|
type={"text"}
|
||||||
{...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">
|
||||||
<label htmlFor="about" className="font-medium">
|
<label htmlFor="about" className="font-medium">
|
||||||
{t("user.bio")}
|
{t("user.bio")}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
{...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">
|
||||||
<label htmlFor="website" className="font-medium">
|
<label htmlFor="website" className="font-medium">
|
||||||
{t("user.website")}
|
{t("user.website")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
{...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" />
|
</button>
|
||||||
) : (
|
</form>
|
||||||
t("global.continue")
|
</div>
|
||||||
)}
|
);
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,90 +1,90 @@
|
|||||||
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";
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/auth/privkey")({
|
export const Route = createLazyFileRoute("/auth/privkey")({
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
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("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!key.startsWith("nsec1"))
|
if (!key.startsWith("nsec1"))
|
||||||
return toast.warning(
|
return 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 {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
const npub = await ark.save_account(key, password);
|
||||||
const npub = await ark.save_account(key, password);
|
|
||||||
navigate({
|
|
||||||
to: "/auth/settings",
|
|
||||||
search: { account: npub, new: false },
|
|
||||||
replace: true,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(false);
|
if (npub) {
|
||||||
};
|
navigate({
|
||||||
|
to: "/auth/settings",
|
||||||
|
search: { account: npub },
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setLoading(false);
|
||||||
|
toast.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
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="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-xl font-semibold">Continue with Private Key</h3>
|
<h3 className="text-xl font-semibold">Continue with Private Key</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-col gap-3">
|
<div className="flex w-full flex-col gap-3">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label
|
<label
|
||||||
htmlFor="key"
|
htmlFor="key"
|
||||||
className="font-medium text-neutral-900 dark:text-neutral-100"
|
className="font-medium text-neutral-900 dark:text-neutral-100"
|
||||||
>
|
>
|
||||||
Private Key
|
Private Key
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
name="key"
|
name="key"
|
||||||
type="text"
|
type="text"
|
||||||
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">
|
||||||
<label
|
<label
|
||||||
htmlFor="password"
|
htmlFor="password"
|
||||||
className="font-medium text-neutral-900 dark:text-neutral-100"
|
className="font-medium text-neutral-900 dark:text-neutral-100"
|
||||||
>
|
>
|
||||||
Password (Optional)
|
Password (Optional)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
name="password"
|
name="password"
|
||||||
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,188 +1,218 @@
|
|||||||
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")({
|
||||||
validateSearch: (search: Record<string, string>): AppRouteSearch => {
|
validateSearch: (search: Record<string, string>): AppRouteSearch => {
|
||||||
return {
|
return {
|
||||||
account: search.account,
|
account: search.account,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: async ({ context }) => {
|
||||||
const permissionGranted = await isPermissionGranted(); // get notification permission
|
const permissionGranted = await isPermissionGranted(); // get notification permission
|
||||||
const ark = context.ark;
|
const ark = context.ark;
|
||||||
const settings = await ark.get_settings();
|
const settings = await ark.get_settings();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
settings: { ...settings, notification: permissionGranted },
|
settings: { ...settings, notification: permissionGranted },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
pendingComponent: Pending,
|
pendingComponent: Pending,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { account } = Route.useSearch();
|
const { account } = Route.useSearch();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
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();
|
||||||
setNewSettings((prev) => ({
|
setNewSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
notification: !newSettings.notification,
|
notification: !newSettings.notification,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleAutoUpdate = () => {
|
const toggleAutoUpdate = () => {
|
||||||
setNewSettings((prev) => ({
|
setNewSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
autoUpdate: !newSettings.autoUpdate,
|
autoUpdate: !newSettings.autoUpdate,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleEnhancedPrivacy = () => {
|
const toggleEnhancedPrivacy = () => {
|
||||||
setNewSettings((prev) => ({
|
setNewSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
enhancedPrivacy: !newSettings.enhancedPrivacy,
|
enhancedPrivacy: !newSettings.enhancedPrivacy,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleZap = () => {
|
const toggleZap = () => {
|
||||||
setNewSettings((prev) => ({
|
setNewSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
zap: !newSettings.zap,
|
zap: !newSettings.zap,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const toggleNsfw = () => {
|
||||||
try {
|
setNewSettings((prev) => ({
|
||||||
const eventId = await ark.set_settings(settings);
|
...prev,
|
||||||
if (eventId) {
|
nsfw: !newSettings.nsfw,
|
||||||
navigate({ to: "/$account/home", params: { account }, replace: true });
|
}));
|
||||||
}
|
};
|
||||||
} catch (e) {
|
|
||||||
toast.error(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
const submit = async () => {
|
||||||
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
|
try {
|
||||||
<div className="flex flex-col items-center gap-5 text-center">
|
// start loading
|
||||||
<div className="flex size-20 items-center justify-center rounded-full bg-teal-100 text-teal-500">
|
setLoading(true);
|
||||||
<LaurelIcon className="size-8" />
|
|
||||||
</div>
|
// publish settings
|
||||||
<div>
|
const eventId = await ark.set_settings(newSettings);
|
||||||
<h1 className="text-xl font-semibold">
|
|
||||||
{t("onboardingSettings.title")}
|
if (eventId) {
|
||||||
</h1>
|
return navigate({
|
||||||
<p className="leading-snug text-neutral-600 dark:text-neutral-400">
|
to: "/$account/home",
|
||||||
{t("onboardingSettings.subtitle")}
|
params: { account },
|
||||||
</p>
|
replace: true,
|
||||||
</div>
|
});
|
||||||
</div>
|
}
|
||||||
<div className="flex flex-col gap-5">
|
} catch (e) {
|
||||||
<div className="flex flex-col gap-3">
|
setLoading(false);
|
||||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900">
|
toast.error(e);
|
||||||
<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"
|
return (
|
||||||
>
|
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
|
||||||
<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]" />
|
<div className="flex flex-col items-center gap-5 text-center">
|
||||||
</Switch.Root>
|
<div className="flex size-20 items-center justify-center rounded-full bg-teal-100 dark:bg-teal-950 text-teal-500">
|
||||||
<div className="flex-1">
|
<LaurelIcon className="size-8" />
|
||||||
<h3 className="font-semibold">Push Notification</h3>
|
</div>
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
<div>
|
||||||
Enabling push notifications will allow you to receive
|
<h1 className="text-xl font-semibold">
|
||||||
notifications from Lume.
|
{t("onboardingSettings.title")}
|
||||||
</p>
|
</h1>
|
||||||
</div>
|
<p className="leading-snug text-neutral-600 dark:text-neutral-400">
|
||||||
</div>
|
{t("onboardingSettings.subtitle")}
|
||||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900">
|
</p>
|
||||||
<Switch.Root
|
</div>
|
||||||
checked={newSettings.enhancedPrivacy}
|
</div>
|
||||||
onClick={() => toggleEnhancedPrivacy()}
|
<div className="flex flex-col gap-5">
|
||||||
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"
|
<div className="flex flex-col gap-3">
|
||||||
>
|
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
||||||
<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]" />
|
<div className="flex-1">
|
||||||
</Switch.Root>
|
<h3 className="font-semibold">Push Notification</h3>
|
||||||
<div className="flex-1">
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
<h3 className="font-semibold">Enhanced Privacy</h3>
|
Enabling push notifications will allow you to receive
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
notifications from Lume.
|
||||||
Lume will display external resources like image, video or link
|
</p>
|
||||||
preview as plain text.
|
</div>
|
||||||
</p>
|
<Switch.Root
|
||||||
</div>
|
checked={newSettings.notification}
|
||||||
</div>
|
onClick={() => toggleNofitication()}
|
||||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900">
|
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.Root
|
>
|
||||||
checked={newSettings.autoUpdate}
|
<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]" />
|
||||||
onClick={() => toggleAutoUpdate()}
|
</Switch.Root>
|
||||||
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"
|
</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">
|
||||||
<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]" />
|
<div className="flex-1">
|
||||||
</Switch.Root>
|
<h3 className="font-semibold">Enhanced Privacy</h3>
|
||||||
<div className="flex-1">
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
<h3 className="font-semibold">Auto Update</h3>
|
Lume will display external resources like image, video or link
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
preview as plain text.
|
||||||
Automatically download and install new version.
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
<Switch.Root
|
||||||
</div>
|
checked={newSettings.enhancedPrivacy}
|
||||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900">
|
onClick={() => toggleEnhancedPrivacy()}
|
||||||
<Switch.Root
|
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"
|
||||||
checked={newSettings.zap}
|
>
|
||||||
onClick={() => toggleZap()}
|
<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]" />
|
||||||
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.Root>
|
||||||
>
|
</div>
|
||||||
<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]" />
|
<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>
|
<div className="flex-1">
|
||||||
<div className="flex-1">
|
<h3 className="font-semibold">Auto Update</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">
|
Automatically download and install new version.
|
||||||
Show the Zap button in each note and user's profile screen, use
|
</p>
|
||||||
for send Bitcoin tip to other users.
|
</div>
|
||||||
</p>
|
<Switch.Root
|
||||||
</div>
|
checked={newSettings.autoUpdate}
|
||||||
</div>
|
onClick={() => toggleAutoUpdate()}
|
||||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-50 px-5 py-4 dark:bg-neutral-950">
|
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"
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
>
|
||||||
There are many more settings you can configure from the 'Settings'
|
<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]" />
|
||||||
Screen. Be sure to visit it later.
|
</Switch.Root>
|
||||||
</p>
|
</div>
|
||||||
</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>
|
<div className="flex-1">
|
||||||
<button
|
<h3 className="font-semibold">Zap</h3>
|
||||||
type="button"
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
onClick={submit}
|
Show the Zap button in each note and user's profile screen, use
|
||||||
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"
|
for send Bitcoin tip to other users.
|
||||||
>
|
</p>
|
||||||
{t("global.continue")}
|
</div>
|
||||||
</button>
|
<Switch.Root
|
||||||
</div>
|
checked={newSettings.zap}
|
||||||
</div>
|
onClick={() => toggleZap()}
|
||||||
);
|
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
|
||||||
|
>
|
||||||
|
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||||
|
</Switch.Root>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold">Filter sensitive content</h3>
|
||||||
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
|
By default, Lume will display all content which have Content
|
||||||
|
Warning tag, it's may include NSFW content.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch.Root
|
||||||
|
checked={newSettings.nsfw}
|
||||||
|
onClick={() => toggleNsfw()}
|
||||||
|
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
|
||||||
|
>
|
||||||
|
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||||
|
</Switch.Root>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => submit()}
|
||||||
|
disabled={loading}
|
||||||
|
className="mb-1 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t("global.continue")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Pending() {
|
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,117 +1,127 @@
|
|||||||
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 } from "@lume/ui";
|
||||||
|
import { User } from "@/components/user";
|
||||||
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";
|
||||||
|
|
||||||
export const Route = createFileRoute("/create-group")({
|
export const Route = createFileRoute("/create-group")({
|
||||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||||
return {
|
return {
|
||||||
account: search.account,
|
account: search.account,
|
||||||
label: search.label,
|
label: search.label,
|
||||||
name: search.name,
|
name: search.name,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
loader: async ({ context }) => {
|
loader: async ({ context }) => {
|
||||||
const ark = context.ark;
|
const ark = context.ark;
|
||||||
const contacts = await ark.get_contact_list();
|
const contacts = await ark.get_contact_list();
|
||||||
return contacts;
|
return contacts;
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const contacts = Route.useLoaderData();
|
const contacts = Route.useLoaderData();
|
||||||
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 [isDone, setIsDone] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [isDone, setIsDone] = useState(false);
|
||||||
|
|
||||||
const toggleUser = (pubkey: string) => {
|
const toggleUser = (pubkey: string) => {
|
||||||
const arr = users.includes(pubkey)
|
const arr = users.includes(pubkey)
|
||||||
? users.filter((i) => i !== pubkey)
|
? users.filter((i) => i !== pubkey)
|
||||||
: [...users, pubkey];
|
: [...users, pubkey];
|
||||||
setUsers(arr);
|
setUsers(arr);
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
try {
|
try {
|
||||||
if (isDone) return router.history.push(redirect);
|
if (isDone) return router.history.push(redirect);
|
||||||
|
|
||||||
const groups = await ark.set_nstore(
|
// start loading
|
||||||
`lume_group_${label}`,
|
setLoading(true);
|
||||||
JSON.stringify(users),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (groups) setIsDone(true);
|
const groups = await ark.set_nstore(
|
||||||
} catch (e) {
|
`lume_group_${label}`,
|
||||||
toast.error(e);
|
JSON.stringify(users),
|
||||||
}
|
);
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
if (groups) {
|
||||||
<Column.Root>
|
toast.success("Group has been created successfully.");
|
||||||
<Column.Header label={label} name={name} />
|
// start loading
|
||||||
<Column.Content>
|
setIsDone(true);
|
||||||
<div className="flex flex-col gap-5 p-3">
|
setLoading(false);
|
||||||
<div className="flex flex-col gap-1">
|
}
|
||||||
<label htmlFor="name" className="font-medium">
|
} catch (e) {
|
||||||
Name
|
setLoading(false);
|
||||||
</label>
|
toast.error(e);
|
||||||
<input
|
}
|
||||||
name="name"
|
};
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
return (
|
||||||
placeholder="Nostrichs..."
|
<div className="h-full overflow-y-auto scrollbar-none">
|
||||||
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"
|
<div className="flex flex-col gap-5 p-3">
|
||||||
/>
|
<div className="flex flex-col gap-1">
|
||||||
</div>
|
<label htmlFor="name" className="font-medium">
|
||||||
<div className="flex flex-col gap-1">
|
Name
|
||||||
<div className="inline-flex items-center justify-between">
|
</label>
|
||||||
<span className="font-medium">Pick user</span>
|
<input
|
||||||
<span className="text-xs text-neutral-600 dark:text-neutral-400">{`${users.length} / ∞`}</span>
|
name="name"
|
||||||
</div>
|
value={title}
|
||||||
<div className="flex flex-col gap-2">
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
{contacts.map((item: string) => (
|
placeholder="Nostrichs..."
|
||||||
<button
|
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"
|
||||||
key={item}
|
/>
|
||||||
type="button"
|
</div>
|
||||||
onClick={() => toggleUser(item)}
|
<div className="flex flex-col gap-1">
|
||||||
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"
|
<div className="inline-flex items-center justify-between">
|
||||||
>
|
<span className="font-medium">Pick user</span>
|
||||||
<User.Provider pubkey={item}>
|
<span className="text-neutral-600 dark:text-neutral-400">{`${users.length} / ∞`}</span>
|
||||||
<User.Root className="flex items-center gap-2.5">
|
</div>
|
||||||
<User.Avatar className="size-10 rounded-full object-cover" />
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex flex-col items-start">
|
{contacts.map((item: string) => (
|
||||||
<User.Name className="font-medium" />
|
<button
|
||||||
<User.NIP05 className="text-neutral-700 dark:text-neutral-300" />
|
key={item}
|
||||||
</div>
|
type="button"
|
||||||
</User.Root>
|
onClick={() => toggleUser(item)}
|
||||||
</User.Provider>
|
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"
|
||||||
{users.includes(item) ? (
|
>
|
||||||
<CheckCircleIcon className="size-5 text-teal-500" />
|
<User.Provider pubkey={item}>
|
||||||
) : null}
|
<User.Root className="flex items-center gap-2.5">
|
||||||
</button>
|
<User.Avatar className="size-10 rounded-full object-cover" />
|
||||||
))}
|
<div className="flex items-center gap-1">
|
||||||
</div>
|
<User.Name className="font-medium" />
|
||||||
</div>
|
<User.NIP05 />
|
||||||
</div>
|
</div>
|
||||||
<div className="fixed z-10 flex items-center justify-center w-full bottom-3">
|
</User.Root>
|
||||||
<button
|
</User.Provider>
|
||||||
type="button"
|
{users.includes(item) ? (
|
||||||
onClick={submit}
|
<CheckCircleIcon className="size-5 text-teal-500" />
|
||||||
disabled={users.length < 1}
|
) : null}
|
||||||
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"
|
</button>
|
||||||
>
|
))}
|
||||||
{isDone ? "Back" : "Update"}
|
</div>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Column.Content>
|
<div className="fixed z-10 flex items-center justify-center w-full bottom-6">
|
||||||
</Column.Root>
|
{users.length >= 1 ? (
|
||||||
);
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => submit()}
|
||||||
|
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-32 h-10 hover:bg-blue-600 focus:outline-none"
|
||||||
|
>
|
||||||
|
{isDone ? "Back" : loading ? <Spinner /> : "Update"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,78 +1,90 @@
|
|||||||
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 () => {
|
||||||
|
try {
|
||||||
|
// start loading
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
const uploadToNostrBuild = async () => {
|
const image = await ark.upload();
|
||||||
try {
|
insertImage(editor, image);
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const image = await ark.upload();
|
// reset loading
|
||||||
|
setLoading(false);
|
||||||
|
} catch (e) {
|
||||||
|
setLoading(false);
|
||||||
|
toast.error(`Upload failed, error: ${e}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (image) {
|
useEffect(() => {
|
||||||
insertImage(editor, image);
|
let unlisten: UnlistenFn = undefined;
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(false);
|
async function listenFileDrop() {
|
||||||
} catch (e) {
|
const window = getCurrent();
|
||||||
setLoading(false);
|
if (!unlisten) {
|
||||||
toast.error(`Upload failed, error: ${e}`);
|
unlisten = await window.listen("tauri://file-drop", async (event) => {
|
||||||
}
|
// @ts-ignore, lfg !!!
|
||||||
};
|
const items: string[] = event.payload.paths;
|
||||||
|
// start loading
|
||||||
|
setLoading(true);
|
||||||
|
// upload all images
|
||||||
|
for (const item of items) {
|
||||||
|
if (isImagePath(item)) {
|
||||||
|
const image = await ark.upload(item);
|
||||||
|
insertImage(editor, image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// stop loading
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
listenFileDrop();
|
||||||
let unlisten: UnlistenFn = undefined;
|
|
||||||
|
|
||||||
async function listenFileDrop() {
|
return () => {
|
||||||
const window = getCurrent();
|
if (unlisten) unlisten();
|
||||||
if (!unlisten) {
|
};
|
||||||
unlisten = await window.listen("tauri://file-drop", async (event) => {
|
}, []);
|
||||||
// @ts-ignore, lfg !!!
|
|
||||||
const items: string[] = event.payload.paths;
|
|
||||||
// start loading
|
|
||||||
setLoading(true);
|
|
||||||
// upload all images
|
|
||||||
for (const item of items) {
|
|
||||||
if (isImagePath(item)) {
|
|
||||||
const image = await ark.upload(item);
|
|
||||||
insertImage(editor, image);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// stop loading
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
listenFileDrop();
|
return (
|
||||||
|
<Tooltip.Provider>
|
||||||
return () => {
|
<Tooltip.Root delayDuration={150}>
|
||||||
if (unlisten) unlisten();
|
<Tooltip.Trigger asChild>
|
||||||
};
|
<button
|
||||||
}, []);
|
type="button"
|
||||||
|
onClick={() => uploadToNostrBuild()}
|
||||||
return (
|
disabled={loading}
|
||||||
<button
|
className={cn("inline-flex items-center justify-center", className)}
|
||||||
type="button"
|
>
|
||||||
onClick={() => uploadToNostrBuild()}
|
{loading ? (
|
||||||
disabled={loading}
|
<Spinner className="size-4" />
|
||||||
className={cn("inline-flex items-center justify-center", className)}
|
) : (
|
||||||
>
|
<AddMediaIcon className="size-4" />
|
||||||
{loading ? (
|
)}
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
</button>
|
||||||
) : (
|
</Tooltip.Trigger>
|
||||||
<AddMediaIcon className="size-5" />
|
<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">
|
||||||
</button>
|
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 { useSlateStatic } from "slate-react";
|
||||||
|
import type { Contact } from "@lume/types";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { User } from "@/components/user";
|
||||||
|
|
||||||
|
export function MentionButton({ className }: { className?: string }) {
|
||||||
|
const editor = useSlateStatic();
|
||||||
|
const { ark } = useRouteContext({ strict: false });
|
||||||
|
const [contacts, setContacts] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const select = async (user: string) => {
|
||||||
|
try {
|
||||||
|
const metadata = await ark.get_profile(user);
|
||||||
|
const contact: Contact = { pubkey: user, profile: metadata };
|
||||||
|
|
||||||
|
insertMention(editor, contact);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function getContacts() {
|
||||||
|
const data = await ark.get_contact_list();
|
||||||
|
setContacts(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
getContacts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<Tooltip.Provider>
|
||||||
|
<Tooltip.Root delayDuration={150}>
|
||||||
|
<DropdownMenu.Trigger asChild>
|
||||||
|
<Tooltip.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MentionIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<Tooltip.Portal>
|
||||||
|
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
||||||
|
Mention
|
||||||
|
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Portal>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</Tooltip.Provider>
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content className="flex w-[220px] h-[220px] scrollbar-none flex-col overflow-y-auto rounded-xl bg-black py-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
|
||||||
|
{contacts.map((contact) => (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key={contact}
|
||||||
|
onClick={() => select(contact)}
|
||||||
|
className="shrink-0 h-11 flex items-center hover:bg-white/10 px-2"
|
||||||
|
>
|
||||||
|
<User.Provider pubkey={contact}>
|
||||||
|
<User.Root className="flex items-center gap-2">
|
||||||
|
<User.Avatar className="shrink-0 size-8 rounded-full" />
|
||||||
|
<User.Name className="text-sm font-medium text-white dark:text-black" />
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
))}
|
||||||
|
<DropdownMenu.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,440 +1,342 @@
|
|||||||
import { LoaderIcon, TrashIcon } from "@lume/icons";
|
import { ComposeFilledIcon, TrashIcon } from "@lume/icons";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
import {
|
import {
|
||||||
Portal,
|
cn,
|
||||||
cn,
|
insertImage,
|
||||||
insertImage,
|
insertNostrEvent,
|
||||||
insertMention,
|
isImageUrl,
|
||||||
insertNostrEvent,
|
sendNativeNotification,
|
||||||
isImageUrl,
|
|
||||||
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";
|
||||||
|
import { MentionNote } from "@/components/note/mentions/note";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
beforeLoad: async ({ search }) => {
|
||||||
|
return {
|
||||||
|
initialValue: search.quote
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
children: [{ text: "" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "event",
|
||||||
|
eventId: `nostr:${nip19.noteEncode(search.reply_to)}`,
|
||||||
|
children: [{ text: "" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
children: [{ text: "" }],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
children: [{ text: "" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
// @ts-ignore, useless
|
const { reply_to, quote } = Route.useSearch();
|
||||||
const { reply_to, quote } = Route.useSearch();
|
const { ark, initialValue } = Route.useRouteContext();
|
||||||
const { ark } = Route.useRouteContext();
|
|
||||||
|
|
||||||
let initialValue: EditorElement[];
|
const [t] = useTranslation();
|
||||||
|
const [editorValue, setEditorValue] = useState(initialValue);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [nsfw, setNsfw] = useState(false);
|
||||||
|
const [editor] = useState(() =>
|
||||||
|
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
|
||||||
|
);
|
||||||
|
|
||||||
if (quote) {
|
const reset = () => {
|
||||||
initialValue = [
|
// @ts-expect-error, backlog
|
||||||
{
|
editor.children = [{ type: "paragraph", children: [{ text: "" }] }];
|
||||||
type: "paragraph",
|
setEditorValue([{ type: "paragraph", children: [{ text: "" }] }]);
|
||||||
children: [{ text: "" }],
|
};
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "event",
|
|
||||||
eventId: `nostr:${nip19.noteEncode(reply_to)}`,
|
|
||||||
children: [{ text: "" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "paragraph",
|
|
||||||
children: [{ text: "" }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
initialValue = [
|
|
||||||
{
|
|
||||||
type: "paragraph",
|
|
||||||
children: [{ text: "" }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const ref = useRef<HTMLDivElement | null>();
|
const serialize = (nodes: Descendant[]) => {
|
||||||
const contacts = useSuspenseQuery(contactQueryOptions).data as Contact[];
|
return nodes
|
||||||
|
.map((n) => {
|
||||||
|
// @ts-expect-error, backlog
|
||||||
|
if (n.type === "image") return n.url;
|
||||||
|
// @ts-expect-error, backlog
|
||||||
|
if (n.type === "event") return n.eventId;
|
||||||
|
|
||||||
const [t] = useTranslation();
|
// @ts-expect-error, backlog
|
||||||
const [editorValue, setEditorValue] = useState(initialValue);
|
if (n.children.length) {
|
||||||
const [target, setTarget] = useState<Range | undefined>();
|
// @ts-expect-error, backlog
|
||||||
const [index, setIndex] = useState(0);
|
return n.children
|
||||||
const [search, setSearch] = useState("");
|
.map((n) => {
|
||||||
const [loading, setLoading] = useState(false);
|
if (n.type === "mention") return n.npub;
|
||||||
const [editor] = useState(() =>
|
return Node.string(n).trim();
|
||||||
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
|
})
|
||||||
);
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
const filters = contacts
|
return Node.string(n);
|
||||||
?.filter((c) =>
|
})
|
||||||
c?.profile.name?.toLowerCase().startsWith(search.toLowerCase()),
|
.join("\n");
|
||||||
)
|
};
|
||||||
?.slice(0, 5);
|
|
||||||
|
|
||||||
const reset = () => {
|
const publish = async () => {
|
||||||
// @ts-expect-error, backlog
|
try {
|
||||||
editor.children = [{ type: "paragraph", children: [{ text: "" }] }];
|
// start loading
|
||||||
setEditorValue([{ type: "paragraph", children: [{ text: "" }] }]);
|
setLoading(true);
|
||||||
};
|
|
||||||
|
|
||||||
const serialize = (nodes: Descendant[]) => {
|
const content = serialize(editor.children);
|
||||||
return nodes
|
const eventId = await ark.publish(content, reply_to, quote);
|
||||||
.map((n) => {
|
|
||||||
// @ts-expect-error, backlog
|
|
||||||
if (n.type === "image") return n.url;
|
|
||||||
// @ts-expect-error, backlog
|
|
||||||
if (n.type === "event") return n.eventId;
|
|
||||||
|
|
||||||
// @ts-expect-error, backlog
|
if (eventId) {
|
||||||
if (n.children.length) {
|
await sendNativeNotification(
|
||||||
// @ts-expect-error, backlog
|
"Your note has been published successfully.",
|
||||||
return n.children
|
"Lume",
|
||||||
.map((n) => {
|
);
|
||||||
if (n.type === "mention") return n.npub;
|
}
|
||||||
return Node.string(n).trim();
|
|
||||||
})
|
|
||||||
.join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Node.string(n);
|
// stop loading
|
||||||
})
|
setLoading(false);
|
||||||
.join("\n");
|
|
||||||
};
|
|
||||||
|
|
||||||
const publish = async () => {
|
// reset form
|
||||||
try {
|
reset();
|
||||||
// start loading
|
} catch (e) {
|
||||||
setLoading(true);
|
setLoading(false);
|
||||||
|
await sendNativeNotification(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const content = serialize(editor.children);
|
return (
|
||||||
const eventId = await ark.publish(content, reply_to, quote);
|
<div className="w-full h-full">
|
||||||
|
<Slate editor={editor} initialValue={editorValue}>
|
||||||
if (eventId) {
|
<div
|
||||||
await sendNativeNotification("You've publish new post successfully.");
|
data-tauri-drag-region
|
||||||
}
|
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"
|
||||||
|
>
|
||||||
// stop loading
|
<NsfwToggle
|
||||||
setLoading(false);
|
nsfw={nsfw}
|
||||||
// reset form
|
setNsfw={setNsfw}
|
||||||
reset();
|
className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
||||||
} catch (e) {
|
/>
|
||||||
setLoading(false);
|
<MentionButton className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" />
|
||||||
await sendNativeNotification(String(e));
|
<MediaButton className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" />
|
||||||
}
|
<button
|
||||||
};
|
type="button"
|
||||||
|
onClick={() => publish()}
|
||||||
useEffect(() => {
|
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"
|
||||||
if (target && filters.length > 0) {
|
>
|
||||||
const el = ref.current;
|
{loading ? (
|
||||||
const domRange = ReactEditor.toDOMRange(editor, target);
|
<Spinner className="size-4" />
|
||||||
const rect = domRange.getBoundingClientRect();
|
) : (
|
||||||
el.style.top = `${rect.top + window.scrollY + 24}px`;
|
<ComposeFilledIcon className="size-4" />
|
||||||
el.style.left = `${rect.left + window.scrollX}px`;
|
)}
|
||||||
}
|
{t("global.post")}
|
||||||
}, [filters.length, editor, index, search, target]);
|
</button>
|
||||||
|
</div>
|
||||||
return (
|
<div className="flex h-full w-full flex-1 flex-col">
|
||||||
<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">
|
{reply_to && !quote ? (
|
||||||
<Slate
|
<div className="px-4 py-2">
|
||||||
editor={editor}
|
<MentionNote eventId={reply_to} />
|
||||||
initialValue={editorValue}
|
</div>
|
||||||
onChange={() => {
|
) : null}
|
||||||
const { selection } = editor;
|
<div className="overflow-y-auto p-4">
|
||||||
|
<Editable
|
||||||
if (selection && Range.isCollapsed(selection)) {
|
key={JSON.stringify(editorValue)}
|
||||||
const [start] = Range.edges(selection);
|
autoFocus={true}
|
||||||
const wordBefore = Editor.before(editor, start, { unit: "word" });
|
autoCapitalize="none"
|
||||||
const before = wordBefore && Editor.before(editor, wordBefore);
|
autoCorrect="none"
|
||||||
const beforeRange = before && Editor.range(editor, before, start);
|
spellCheck={false}
|
||||||
const beforeText =
|
renderElement={(props) => <Element {...props} />}
|
||||||
beforeRange && Editor.string(editor, beforeRange);
|
placeholder={
|
||||||
const beforeMatch = beforeText?.match(/^@(\w+)$/);
|
reply_to ? "Type your reply..." : t("editor.placeholder")
|
||||||
const after = Editor.after(editor, start);
|
}
|
||||||
const afterRange = Editor.range(editor, start, after);
|
className="focus:outline-none"
|
||||||
const afterText = Editor.string(editor, afterRange);
|
/>
|
||||||
const afterMatch = afterText.match(/^(\s|$)/);
|
</div>
|
||||||
|
</div>
|
||||||
if (beforeMatch && afterMatch) {
|
</Slate>
|
||||||
setTarget(beforeRange);
|
</div>
|
||||||
setSearch(beforeMatch[1]);
|
);
|
||||||
setIndex(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setTarget(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
data-tauri-drag-region
|
|
||||||
className="flex h-16 w-full shrink-0 items-center justify-end gap-3 px-2"
|
|
||||||
>
|
|
||||||
<MediaButton className="size-9 rounded-full bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
t("global.post")
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-full min-h-0 w-full">
|
|
||||||
<div className="flex h-full w-full flex-1 flex-col gap-2 px-2 pb-2">
|
|
||||||
{reply_to && !quote ? <MentionNote eventId={reply_to} /> : null}
|
|
||||||
<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">
|
|
||||||
<Editable
|
|
||||||
key={JSON.stringify(editorValue)}
|
|
||||||
autoFocus={true}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect="none"
|
|
||||||
spellCheck={false}
|
|
||||||
renderElement={(props) => <Element {...props} />}
|
|
||||||
placeholder={
|
|
||||||
reply_to ? "Type your reply..." : t("editor.placeholder")
|
|
||||||
}
|
|
||||||
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>
|
|
||||||
</Slate>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
editor.isVoid = (element) => {
|
editor.isVoid = (element) => {
|
||||||
// @ts-expect-error, wtf
|
// @ts-expect-error, wtf
|
||||||
return element.type === "event" ? true : isVoid(element);
|
return element.type === "event" ? true : isVoid(element);
|
||||||
};
|
};
|
||||||
|
|
||||||
editor.insertData = (data) => {
|
editor.insertData = (data) => {
|
||||||
const text = data.getData("text/plain");
|
const text = data.getData("text/plain");
|
||||||
|
|
||||||
if (text.startsWith("nevent1") || text.startsWith("note1")) {
|
if (text.startsWith("nevent1") || text.startsWith("note1")) {
|
||||||
insertNostrEvent(editor, text);
|
insertNostrEvent(editor, text);
|
||||||
} else {
|
} else {
|
||||||
insertData(data);
|
insertData(data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return editor;
|
return editor;
|
||||||
};
|
};
|
||||||
|
|
||||||
const withMentions = (editor: ReactEditor) => {
|
const withMentions = (editor: ReactEditor) => {
|
||||||
const { isInline, isVoid, markableVoid } = editor;
|
const { isInline, isVoid, markableVoid } = editor;
|
||||||
|
|
||||||
editor.isInline = (element) => {
|
editor.isInline = (element) => {
|
||||||
// @ts-expect-error, wtf
|
// @ts-expect-error, wtf
|
||||||
return element.type === "mention" ? true : isInline(element);
|
return element.type === "mention" ? true : isInline(element);
|
||||||
};
|
};
|
||||||
|
|
||||||
editor.isVoid = (element) => {
|
editor.isVoid = (element) => {
|
||||||
// @ts-expect-error, wtf
|
// @ts-expect-error, wtf
|
||||||
return element.type === "mention" ? true : isVoid(element);
|
return element.type === "mention" ? true : isVoid(element);
|
||||||
};
|
};
|
||||||
|
|
||||||
editor.markableVoid = (element) => {
|
editor.markableVoid = (element) => {
|
||||||
// @ts-expect-error, wtf
|
// @ts-expect-error, wtf
|
||||||
return element.type === "mention" || markableVoid(element);
|
return element.type === "mention" || markableVoid(element);
|
||||||
};
|
};
|
||||||
|
|
||||||
return editor;
|
return editor;
|
||||||
};
|
};
|
||||||
|
|
||||||
const withImages = (editor: ReactEditor) => {
|
const withImages = (editor: ReactEditor) => {
|
||||||
const { insertData, isVoid } = editor;
|
const { insertData, isVoid } = editor;
|
||||||
|
|
||||||
editor.isVoid = (element) => {
|
editor.isVoid = (element) => {
|
||||||
// @ts-expect-error, wtf
|
// @ts-expect-error, wtf
|
||||||
return element.type === "image" ? true : isVoid(element);
|
return element.type === "image" ? true : isVoid(element);
|
||||||
};
|
};
|
||||||
|
|
||||||
editor.insertData = (data) => {
|
editor.insertData = (data) => {
|
||||||
const text = data.getData("text/plain");
|
const text = data.getData("text/plain");
|
||||||
|
|
||||||
if (isImageUrl(text)) {
|
if (isImageUrl(text)) {
|
||||||
insertImage(editor, text);
|
insertImage(editor, text);
|
||||||
} else {
|
} else {
|
||||||
insertData(data);
|
insertData(data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return editor;
|
return editor;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Image = ({ attributes, children, element }) => {
|
const Image = ({ attributes, children, element }) => {
|
||||||
const editor = useSlateStatic();
|
const editor = useSlateStatic();
|
||||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
||||||
|
|
||||||
const selected = useSelected();
|
const selected = useSelected();
|
||||||
const focused = useFocused();
|
const focused = useFocused();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div {...attributes}>
|
<div {...attributes}>
|
||||||
{children}
|
{children}
|
||||||
<div contentEditable={false} className="relative my-2">
|
<div contentEditable={false} className="relative my-2">
|
||||||
<img
|
<img
|
||||||
src={element.url}
|
src={element.url}
|
||||||
alt={element.url}
|
alt={element.url}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-auto w-full rounded-lg border border-neutral-100 object-cover ring-2 dark:border-neutral-900",
|
"h-auto w-full rounded-lg border border-neutral-100 object-cover ring-2 dark:border-neutral-900",
|
||||||
selected && focused ? "ring-blue-500" : "ring-transparent",
|
selected && focused ? "ring-blue-500" : "ring-transparent",
|
||||||
)}
|
)}
|
||||||
contentEditable={false}
|
contentEditable={false}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
contentEditable={false}
|
contentEditable={false}
|
||||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||||
className="absolute right-2 top-2 inline-flex size-8 items-center justify-center rounded-lg bg-red-500 text-white hover:bg-red-600"
|
className="absolute right-2 top-2 inline-flex size-8 items-center justify-center rounded-lg bg-red-500 text-white hover:bg-red-600"
|
||||||
>
|
>
|
||||||
<TrashIcon className="size-4" />
|
<TrashIcon className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Mention = ({ attributes, element }) => {
|
const Mention = ({ attributes, element }) => {
|
||||||
const editor = useSlateStatic();
|
const editor = useSlateStatic();
|
||||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
{...attributes}
|
{...attributes}
|
||||||
type="button"
|
type="button"
|
||||||
contentEditable={false}
|
contentEditable={false}
|
||||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||||
className="inline-block align-baseline text-blue-500 hover:text-blue-600"
|
className="inline-block align-baseline text-blue-500 hover:text-blue-600"
|
||||||
>{`@${element.name}`}</span>
|
>{`@${element.name}`}</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Event = ({ attributes, element, children }) => {
|
const Event = ({ attributes, element, children }) => {
|
||||||
const editor = useSlateStatic();
|
const editor = useSlateStatic();
|
||||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div {...attributes}>
|
<div {...attributes}>
|
||||||
{children}
|
{children}
|
||||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> */}
|
{/* biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> */}
|
||||||
<div
|
<div
|
||||||
contentEditable={false}
|
contentEditable={false}
|
||||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||||
className="user-select-none relative my-2"
|
className="user-select-none relative my-2"
|
||||||
>
|
>
|
||||||
<MentionNote
|
<MentionNote
|
||||||
eventId={element.eventId.replace("nostr:", "")}
|
eventId={element.eventId.replace("nostr:", "")}
|
||||||
openable={false}
|
openable={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Element = (props) => {
|
const Element = (props) => {
|
||||||
const { attributes, children, element } = props;
|
const { attributes, children, element } = props;
|
||||||
|
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case "image":
|
case "image":
|
||||||
return <Image {...props} />;
|
return <Image {...props} />;
|
||||||
case "mention":
|
case "mention":
|
||||||
return <Mention {...props} />;
|
return <Mention {...props} />;
|
||||||
case "event":
|
case "event":
|
||||||
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,72 @@
|
|||||||
|
import { useEvent } from "@lume/ark";
|
||||||
|
import type { Event } from "@lume/types";
|
||||||
|
import { Box, Container, Spinner } from "@lume/ui";
|
||||||
|
import { Note } from "@/components/note";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { WindowVirtualizer } from "virtua";
|
||||||
|
import { ReplyList } from "./-components/replyList";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/events/$eventId")({
|
||||||
|
beforeLoad: async ({ 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,47 +1,36 @@
|
|||||||
import { EventWithReplies } from "@lume/types";
|
import type { EventWithReplies } from "@lume/types";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { Note, User } from "@lume/ui";
|
|
||||||
import { SubReply } from "./subReply";
|
import { SubReply } from "./subReply";
|
||||||
|
import { Note } from "@/components/note";
|
||||||
|
|
||||||
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>
|
||||||
<div className="inline-flex items-center gap-2">
|
<Note.ContentLarge className="px-3" />
|
||||||
<User.Name className="font-semibold" />
|
<div className="mt-3 flex items-center gap-4 px-3 h-14">
|
||||||
<User.NIP05 className="text-base lowercase text-neutral-600 dark:text-neutral-400" />
|
<Note.Reply />
|
||||||
</div>
|
<Note.Repost />
|
||||||
</div>
|
<Note.Zap />
|
||||||
<User.Time time={event.created_at} />
|
</div>
|
||||||
</User.Root>
|
<div
|
||||||
</User.Provider>
|
className={cn(
|
||||||
<Note.Content />
|
event.replies?.length > 0
|
||||||
<div className="mt-4 flex items-center justify-between">
|
? "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"
|
||||||
<div className="-ml-1 inline-flex items-center gap-4">
|
: "",
|
||||||
<Note.Reply />
|
)}
|
||||||
<Note.Repost />
|
>
|
||||||
<Note.Zap />
|
{event.replies?.length > 0
|
||||||
</div>
|
? event.replies?.map((childEvent) => (
|
||||||
<Note.Menu />
|
<SubReply key={childEvent.id} event={childEvent} />
|
||||||
</div>
|
))
|
||||||
<div
|
: null}
|
||||||
className={cn(
|
</div>
|
||||||
event.replies?.length > 0
|
</Note.Root>
|
||||||
? "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"
|
</Note.Provider>
|
||||||
: "",
|
);
|
||||||
)}
|
|
||||||
>
|
|
||||||
{event.replies?.length > 0
|
|
||||||
? event.replies?.map((childEvent) => (
|
|
||||||
<SubReply key={childEvent.id} event={childEvent} />
|
|
||||||
))
|
|
||||||
: null}
|
|
||||||
</div>
|
|
||||||
</Note.Root>
|
|
||||||
</Note.Provider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +1,51 @@
|
|||||||
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,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
eventId: string;
|
eventId: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { ark } = useRouteContext({ strict: false });
|
const { ark } = useRouteContext({ strict: false });
|
||||||
const [t] = useTranslation();
|
const [t] = useTranslation();
|
||||||
const [data, setData] = useState<null | EventWithReplies[]>(null);
|
const [data, setData] = useState<null | EventWithReplies[]>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function getReplies() {
|
async function getReplies() {
|
||||||
const events = await ark.get_event_thread(eventId);
|
const events = await ark.get_event_thread(eventId);
|
||||||
setData(events);
|
setData(events);
|
||||||
}
|
}
|
||||||
getReplies();
|
getReplies();
|
||||||
}, [eventId]);
|
}, [eventId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col gap-3", className)}>
|
<div className={cn("flex flex-col", className)}>
|
||||||
{!data ? (
|
<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">
|
||||||
<div className="mt-4 flex h-16 items-center justify-center p-3">
|
Replies ({data?.length ?? 0})
|
||||||
<LoaderIcon className="h-5 w-5 animate-spin" />
|
</div>
|
||||||
</div>
|
{!data ? (
|
||||||
) : data.length === 0 ? (
|
<div className="flex h-16 items-center justify-center p-3">
|
||||||
<div className="mt-4 flex w-full items-center justify-center">
|
<Spinner className="size-5" />
|
||||||
<div className="flex flex-col items-center justify-center gap-2 py-6">
|
</div>
|
||||||
<h3 className="text-3xl">👋</h3>
|
) : data.length === 0 ? (
|
||||||
<p className="leading-none text-neutral-600 dark:text-neutral-400">
|
<div className="flex w-full items-center justify-center">
|
||||||
{t("note.reply.empty")}
|
<div className="flex flex-col items-center justify-center gap-2 py-6">
|
||||||
</p>
|
<h3 className="text-3xl">👋</h3>
|
||||||
</div>
|
<p className="leading-none text-neutral-600 dark:text-neutral-400">
|
||||||
</div>
|
{t("note.reply.empty")}
|
||||||
) : (
|
</p>
|
||||||
data.map((event) => <Reply key={event.id} event={event} />)
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
) : (
|
||||||
);
|
data.map((event) => <Reply key={event.id} event={event} />)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,21 @@
|
|||||||
import { Event } from "@lume/types";
|
import type { Event } from "@lume/types";
|
||||||
import { Note, User } from "@lume/ui";
|
import { Note } from "@/components/note";
|
||||||
|
|
||||||
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>
|
||||||
<div className="inline-flex items-center gap-2">
|
<Note.ContentLarge className="px-3" />
|
||||||
<User.Name className="font-semibold" />
|
<div className="mt-3 flex items-center gap-4 px-3">
|
||||||
<User.NIP05 className="text-base lowercase text-neutral-600 dark:text-neutral-400" />
|
<Note.Reply />
|
||||||
</div>
|
<Note.Repost />
|
||||||
</div>
|
<Note.Zap />
|
||||||
<User.Time time={event.created_at} />
|
</div>
|
||||||
</User.Root>
|
</Note.Root>
|
||||||
</User.Provider>
|
</Note.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.Repost />
|
|
||||||
<Note.Zap />
|
|
||||||
</div>
|
|
||||||
<Note.Menu />
|
|
||||||
</div>
|
|
||||||
</Note.Root>
|
|
||||||
</Note.Provider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+137
-127
@@ -1,145 +1,155 @@
|
|||||||
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";
|
||||||
|
|
||||||
export const Route = createFileRoute("/foryou")({
|
export const Route = createFileRoute("/foryou")({
|
||||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||||
return {
|
return {
|
||||||
account: search.account,
|
account: search.account,
|
||||||
label: search.label,
|
label: search.label,
|
||||||
name: search.name,
|
name: search.name,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
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({
|
||||||
to: "/interests",
|
to: "/interests",
|
||||||
search: {
|
search: {
|
||||||
...search,
|
...search,
|
||||||
redirect: "/foryou",
|
redirect: "/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,
|
||||||
queryKey: [name, account],
|
isLoading,
|
||||||
initialPageParam: 0,
|
isFetching,
|
||||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
isFetchingNextPage,
|
||||||
const events = await ark.get_events_from_interests(
|
hasNextPage,
|
||||||
interests.hashtags,
|
fetchNextPage,
|
||||||
20,
|
} = useInfiniteQuery({
|
||||||
pageParam,
|
queryKey: [name, account],
|
||||||
true,
|
initialPageParam: 0,
|
||||||
);
|
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||||
return events;
|
const events = await ark.get_events_from_interests(
|
||||||
},
|
interests.hashtags,
|
||||||
getNextPageParam: (lastPage) => {
|
20,
|
||||||
const lastEvent = lastPage?.at(-1);
|
pageParam,
|
||||||
return lastEvent ? lastEvent.created_at - 1 : null;
|
);
|
||||||
},
|
return events;
|
||||||
select: (data) => data?.pages.flatMap((page) => page),
|
},
|
||||||
refetchOnWindowFocus: false,
|
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) => {
|
const renderItem = (event: Event) => {
|
||||||
if (!event) return;
|
if (!event) return;
|
||||||
switch (event.kind) {
|
switch (event.kind) {
|
||||||
case Kind.Repost:
|
case Kind.Repost:
|
||||||
return <RepostNote key={event.id} event={event} />;
|
return <RepostNote key={event.id} event={event} />;
|
||||||
default:
|
default:
|
||||||
return <TextNote key={event.id} event={event} />;
|
return <TextNote key={event.id} event={event} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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">
|
||||||
{isLoading ? (
|
<div className="flex items-center justify-center gap-2">
|
||||||
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
<Spinner className="size-5" />
|
||||||
<button type="button" className="size-5" disabled>
|
<span className="text-sm font-medium">Fetching new notes...</span>
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
</div>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
) : !data.length ? (
|
{isLoading ? (
|
||||||
<Empty />
|
<div className="flex h-16 w-full items-center justify-center gap-2">
|
||||||
) : (
|
<Spinner className="size-5" />
|
||||||
<Virtualizer overscan={3}>
|
<span className="text-sm font-medium">Loading...</span>
|
||||||
{data.map((item) => renderItem(item))}
|
</div>
|
||||||
</Virtualizer>
|
) : !data.length ? (
|
||||||
)}
|
<Empty />
|
||||||
|
) : (
|
||||||
{data?.length && hasNextPage ? (
|
<Virtualizer overscan={3}>
|
||||||
<div className="flex h-20 items-center justify-center">
|
{data.map((item) => renderItem(item))}
|
||||||
<button
|
</Virtualizer>
|
||||||
type="button"
|
)}
|
||||||
onClick={() => fetchNextPage()}
|
{data?.length && hasNextPage ? (
|
||||||
disabled={isFetchingNextPage || isFetchingNextPage}
|
<div>
|
||||||
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"
|
<button
|
||||||
>
|
type="button"
|
||||||
{isFetchingNextPage ? (
|
onClick={() => fetchNextPage()}
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
disabled={isFetchingNextPage || isLoading}
|
||||||
) : (
|
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-neutral-100 px-3 font-medium hover:bg-neutral-50 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||||
<>
|
>
|
||||||
<ArrowRightCircleIcon className="size-5" />
|
{isFetchingNextPage ? (
|
||||||
Load more
|
<Spinner className="size-5" />
|
||||||
</>
|
) : (
|
||||||
)}
|
<>
|
||||||
</button>
|
<ArrowRightCircleIcon className="size-5" />
|
||||||
</div>
|
Load more
|
||||||
) : null}
|
</>
|
||||||
</Column.Content>
|
)}
|
||||||
</Column.Root>
|
</button>
|
||||||
);
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Empty() {
|
function Empty() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col py-10 gap-10">
|
<div className="flex flex-col py-10 gap-10">
|
||||||
<div className="text-center flex flex-col items-center justify-center">
|
<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="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 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>
|
||||||
<p className="text-lg font-medium">Your newsfeed is empty</p>
|
<p className="text-lg font-medium">Your newsfeed is empty</p>
|
||||||
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
||||||
Here are few suggestions to get started.
|
Here are few suggestions to get started.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col px-3 gap-2">
|
<div className="flex flex-col px-3 gap-2">
|
||||||
<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"
|
||||||
>
|
>
|
||||||
<ArrowRightIcon className="size-5" />
|
<ArrowRightIcon className="size-5" />
|
||||||
Show trending notes
|
Show trending notes
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/trending/users"
|
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"
|
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" />
|
<ArrowRightIcon className="size-5" />
|
||||||
Discover trending users
|
Discover trending users
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+136
-110
@@ -1,127 +1,153 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
export const Route = createFileRoute("/global")({
|
export const Route = createFileRoute("/global")({
|
||||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||||
return {
|
return {
|
||||||
account: search.account,
|
account: search.account,
|
||||||
label: search.label,
|
label: search.label,
|
||||||
name: search.name,
|
name: search.name,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: async ({ context }) => {
|
||||||
const ark = context.ark;
|
const ark = context.ark;
|
||||||
const settings = await ark.get_settings();
|
const settings = await ark.get_settings();
|
||||||
|
|
||||||
return { settings };
|
return { settings };
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
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,
|
||||||
queryKey: ["global", account],
|
isLoading,
|
||||||
initialPageParam: 0,
|
isFetching,
|
||||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
isFetchingNextPage,
|
||||||
const events = await ark.get_events(20, pageParam, undefined, true);
|
hasNextPage,
|
||||||
return events;
|
fetchNextPage,
|
||||||
},
|
} = useInfiniteQuery({
|
||||||
getNextPageParam: (lastPage) => {
|
queryKey: ["global", account],
|
||||||
const lastEvent = lastPage?.at(-1);
|
initialPageParam: 0,
|
||||||
return lastEvent ? lastEvent.created_at - 1 : null;
|
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||||
},
|
const events = await ark.get_events(20, pageParam, undefined, true);
|
||||||
select: (data) => data?.pages.flatMap((page) => page),
|
return events;
|
||||||
refetchOnWindowFocus: false,
|
},
|
||||||
});
|
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) => {
|
const renderItem = (event: Event) => {
|
||||||
if (!event) return;
|
if (!event) return;
|
||||||
switch (event.kind) {
|
switch (event.kind) {
|
||||||
case Kind.Repost:
|
case Kind.Repost:
|
||||||
return <RepostNote key={event.id} event={event} />;
|
return <RepostNote key={event.id} event={event} />;
|
||||||
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;
|
||||||
|
|
||||||
return (
|
if (isConversation) {
|
||||||
<Column.Root>
|
return <Conversation key={event.id} event={event} className="mb-3" />;
|
||||||
<Column.Header label={label} name={name} />
|
}
|
||||||
<Column.Content>
|
|
||||||
{isLoading ? (
|
if (isQuote) {
|
||||||
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
return <Quote key={event.id} event={event} className="mb-3" />;
|
||||||
<button type="button" className="size-5" disabled>
|
}
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
</button>
|
return <TextNote key={event.id} event={event} className="mb-3" />;
|
||||||
</div>
|
}
|
||||||
) : !data.length ? (
|
}
|
||||||
<Empty />
|
};
|
||||||
) : (
|
|
||||||
<Virtualizer overscan={3}>
|
return (
|
||||||
{data.map((item) => renderItem(item))}
|
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
|
||||||
</Virtualizer>
|
{isFetching && !isLoading && !isFetchingNextPage ? (
|
||||||
)}
|
<div className="w-full h-11 flex items-center justify-center">
|
||||||
{data?.length && hasNextPage ? (
|
<div className="flex items-center justify-center gap-2">
|
||||||
<div className="flex h-20 items-center justify-center">
|
<Spinner className="size-5" />
|
||||||
<button
|
<span className="text-sm font-medium">Fetching new notes...</span>
|
||||||
type="button"
|
</div>
|
||||||
onClick={() => fetchNextPage()}
|
</div>
|
||||||
disabled={isFetchingNextPage || isLoading}
|
) : null}
|
||||||
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"
|
{isLoading ? (
|
||||||
>
|
<div className="flex h-16 w-full items-center justify-center gap-2">
|
||||||
{isFetchingNextPage ? (
|
<Spinner className="size-5" />
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
<span className="text-sm font-medium">Loading...</span>
|
||||||
) : (
|
</div>
|
||||||
<>
|
) : !data.length ? (
|
||||||
<ArrowRightCircleIcon className="size-5" />
|
<Empty />
|
||||||
Load more
|
) : (
|
||||||
</>
|
<Virtualizer overscan={3}>
|
||||||
)}
|
{data.map((item) => renderItem(item))}
|
||||||
</button>
|
</Virtualizer>
|
||||||
</div>
|
)}
|
||||||
) : null}
|
{data?.length && hasNextPage ? (
|
||||||
</Column.Content>
|
<div>
|
||||||
</Column.Root>
|
<button
|
||||||
);
|
type="button"
|
||||||
|
onClick={() => fetchNextPage()}
|
||||||
|
disabled={isFetchingNextPage || isLoading}
|
||||||
|
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-black/5 px-3 font-medium hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||||
|
>
|
||||||
|
{isFetchingNextPage ? (
|
||||||
|
<Spinner className="size-5" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ArrowRightCircleIcon className="size-5" />
|
||||||
|
Load more
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Empty() {
|
function Empty() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col py-10 gap-10">
|
<div className="flex flex-col py-10 gap-10">
|
||||||
<div className="text-center flex flex-col items-center justify-center">
|
<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="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 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>
|
||||||
<p className="text-lg font-medium">Your newsfeed is empty</p>
|
<p className="text-lg font-medium">Your newsfeed is empty</p>
|
||||||
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
||||||
Here are few suggestions to get started.
|
Here are few suggestions to get started.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col px-3 gap-2">
|
<div className="flex flex-col px-3 gap-2">
|
||||||
<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"
|
||||||
>
|
>
|
||||||
<ArrowRightIcon className="size-5" />
|
<ArrowRightIcon className="size-5" />
|
||||||
Show trending notes
|
Show trending notes
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/trending/users"
|
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"
|
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" />
|
<ArrowRightIcon className="size-5" />
|
||||||
Discover trending users
|
Discover trending users
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+136
-119
@@ -1,137 +1,154 @@
|
|||||||
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";
|
||||||
|
|
||||||
export const Route = createFileRoute("/group")({
|
export const Route = createFileRoute("/group")({
|
||||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||||
return {
|
return {
|
||||||
account: search.account,
|
account: search.account,
|
||||||
label: search.label,
|
label: search.label,
|
||||||
name: search.name,
|
name: search.name,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
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({
|
||||||
to: "/create-group",
|
to: "/create-group",
|
||||||
search: {
|
search: {
|
||||||
...search,
|
...search,
|
||||||
redirect: "/group",
|
redirect: "/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,
|
||||||
queryKey: [name, account],
|
isLoading,
|
||||||
initialPageParam: 0,
|
isFetching,
|
||||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
isFetchingNextPage,
|
||||||
const events = await ark.get_events(20, pageParam);
|
hasNextPage,
|
||||||
return events;
|
fetchNextPage,
|
||||||
},
|
} = useInfiniteQuery({
|
||||||
getNextPageParam: (lastPage) => {
|
queryKey: [name, account],
|
||||||
const lastEvent = lastPage?.at(-1);
|
initialPageParam: 0,
|
||||||
return lastEvent ? lastEvent.created_at - 1 : null;
|
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||||
},
|
const events = await ark.get_events(20, pageParam, groups);
|
||||||
select: (data) => data?.pages.flatMap((page) => page),
|
return events;
|
||||||
refetchOnWindowFocus: false,
|
},
|
||||||
});
|
getNextPageParam: (lastPage) => {
|
||||||
|
const lastEvent = lastPage?.at(-1);
|
||||||
|
return lastEvent ? lastEvent.created_at - 1 : null;
|
||||||
|
},
|
||||||
|
select: (data) =>
|
||||||
|
data?.pages.flatMap((page) => page.filter((ev) => ev.kind === Kind.Text)),
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
const renderItem = (event: Event) => {
|
const renderItem = (event: Event) => {
|
||||||
if (!event) return;
|
if (!event) return;
|
||||||
switch (event.kind) {
|
switch (event.kind) {
|
||||||
case Kind.Repost:
|
case Kind.Repost:
|
||||||
return <RepostNote key={event.id} event={event} />;
|
return <RepostNote key={event.id} event={event} />;
|
||||||
default:
|
default:
|
||||||
return <TextNote key={event.id} event={event} />;
|
return <TextNote key={event.id} event={event} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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">
|
||||||
{isLoading ? (
|
<div className="flex items-center justify-center gap-2">
|
||||||
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
<Spinner className="size-5" />
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
<span className="text-sm font-medium">Fetching new notes...</span>
|
||||||
</div>
|
</div>
|
||||||
) : !data.length ? (
|
</div>
|
||||||
<Empty />
|
) : null}
|
||||||
) : (
|
{isLoading ? (
|
||||||
<Virtualizer overscan={3}>
|
<div className="flex h-16 w-full items-center justify-center gap-2">
|
||||||
{data.map((item) => renderItem(item))}
|
<Spinner className="size-5" />
|
||||||
</Virtualizer>
|
<span className="text-sm font-medium">Loading...</span>
|
||||||
)}
|
</div>
|
||||||
<div className="flex h-20 items-center justify-center">
|
) : !data.length ? (
|
||||||
{hasNextPage ? (
|
<Empty />
|
||||||
<button
|
) : (
|
||||||
type="button"
|
<Virtualizer overscan={3}>
|
||||||
onClick={() => fetchNextPage()}
|
{data.map((item) => renderItem(item))}
|
||||||
disabled={!hasNextPage || isFetchingNextPage}
|
</Virtualizer>
|
||||||
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"
|
)}
|
||||||
>
|
{data?.length && hasNextPage ? (
|
||||||
{isFetchingNextPage ? (
|
<div>
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
<button
|
||||||
) : (
|
type="button"
|
||||||
<>
|
onClick={() => fetchNextPage()}
|
||||||
<ArrowRightCircleIcon className="size-5" />
|
disabled={isFetchingNextPage || isLoading}
|
||||||
Load more
|
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 ? (
|
||||||
</button>
|
<Spinner className="size-5" />
|
||||||
) : null}
|
) : (
|
||||||
</div>
|
<>
|
||||||
</Column.Content>
|
<ArrowRightCircleIcon className="size-5" />
|
||||||
</Column.Root>
|
Load more
|
||||||
);
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Empty() {
|
function Empty() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col py-10 gap-10">
|
<div className="flex flex-col py-10 gap-10">
|
||||||
<div className="text-center flex flex-col items-center justify-center">
|
<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="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 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>
|
||||||
<p className="text-lg font-medium">Your newsfeed is empty</p>
|
<p className="text-lg font-medium">Your newsfeed is empty</p>
|
||||||
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
||||||
Here are few suggestions to get started.
|
Here are few suggestions to get started.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col px-3 gap-2">
|
<div className="flex flex-col px-3 gap-2">
|
||||||
<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"
|
||||||
>
|
>
|
||||||
<ArrowRightIcon className="size-5" />
|
<ArrowRightIcon className="size-5" />
|
||||||
Show trending notes
|
Show trending notes
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/trending/users"
|
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"
|
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" />
|
<ArrowRightIcon className="size-5" />
|
||||||
Discover trending users
|
Discover trending users
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+108
-109
@@ -1,124 +1,123 @@
|
|||||||
import { LoaderIcon, PlusIcon } from "@lume/icons";
|
import { PlusIcon } from "@lume/icons";
|
||||||
import { User } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { User } from "@/components/user";
|
||||||
|
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 }) => {
|
||||||
const ark = context.ark;
|
// check for app updates
|
||||||
const accounts = await ark.get_all_accounts();
|
await checkForAppUpdates(true);
|
||||||
|
|
||||||
switch (accounts.length) {
|
const ark = context.ark;
|
||||||
// Guest account
|
const accounts = await ark.get_all_accounts();
|
||||||
case 0:
|
|
||||||
throw redirect({
|
|
||||||
to: "/landing",
|
|
||||||
replace: true,
|
|
||||||
});
|
|
||||||
// Only 1 account, skip account selection screen
|
|
||||||
case 1:
|
|
||||||
const account = accounts[0].npub;
|
|
||||||
const loadedAccount = await ark.load_selected_account(account);
|
|
||||||
|
|
||||||
if (loadedAccount) {
|
if (!accounts.length) {
|
||||||
throw redirect({
|
throw redirect({
|
||||||
to: "/$account/home",
|
to: "/landing",
|
||||||
params: { account },
|
replace: true,
|
||||||
replace: true,
|
});
|
||||||
});
|
}
|
||||||
}
|
|
||||||
// Account selection
|
// Run notification service
|
||||||
default:
|
await invoke("run_notification", { accounts });
|
||||||
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) => {
|
||||||
setLoading(true);
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
const ark = context.ark;
|
const loadAccount = await ark.load_selected_account(npub);
|
||||||
const loadAccount = await ark.load_selected_account(npub);
|
if (loadAccount) {
|
||||||
|
return navigate({
|
||||||
|
to: "/$account/home",
|
||||||
|
params: { account: npub },
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setLoading(false);
|
||||||
|
toast.error(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loadAccount) {
|
const currentDate = new Date().toLocaleString("default", {
|
||||||
return navigate({
|
weekday: "long",
|
||||||
to: "/$account/home",
|
month: "long",
|
||||||
params: { account: npub },
|
day: "numeric",
|
||||||
replace: true,
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentDate = new Date().toLocaleString("default", {
|
return (
|
||||||
weekday: "long",
|
<div className="relative flex h-full w-full items-center justify-center">
|
||||||
month: "long",
|
<div className="relative z-20 flex flex-col items-center gap-16">
|
||||||
day: "numeric",
|
<div className="text-center text-white">
|
||||||
});
|
<h2 className="mb-1 text-2xl">{currentDate}</h2>
|
||||||
|
<h2 className="text-2xl font-semibold">Welcome back!</h2>
|
||||||
return (
|
</div>
|
||||||
<div className="relative flex h-full w-full items-center justify-center">
|
<div className="flex items-center justify-center gap-6">
|
||||||
<div className="relative z-20 flex flex-col items-center gap-16">
|
{loading ? (
|
||||||
<div className="text-center text-white">
|
<div className="inline-flex size-6 items-center justify-center">
|
||||||
<h2 className="mb-1 text-2xl">{currentDate}</h2>
|
<Spinner className="size-6 text-white" />
|
||||||
<h2 className="text-2xl font-semibold">Welcome back!</h2>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
<div className="flex items-center justify-center gap-6">
|
<>
|
||||||
{loading ? (
|
{accounts.map((account) => (
|
||||||
<div className="inline-flex size-6 items-center justify-center">
|
<button
|
||||||
<LoaderIcon className="size-6 animate-spin text-white" />
|
type="button"
|
||||||
</div>
|
key={account}
|
||||||
) : (
|
onClick={() => select(account)}
|
||||||
<>
|
>
|
||||||
{context.accounts.map((account) => (
|
<User.Provider pubkey={account}>
|
||||||
<button
|
<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">
|
||||||
type="button"
|
<User.Avatar className="size-20 rounded-full object-cover" />
|
||||||
key={account.npub}
|
<User.Name className="max-w-[5rem] truncate text-lg font-medium leading-tight text-white" />
|
||||||
onClick={() => select(account.npub)}
|
</User.Root>
|
||||||
>
|
</User.Provider>
|
||||||
<User.Provider pubkey={account.npub}>
|
</button>
|
||||||
<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" />
|
<Link to="/landing">
|
||||||
<User.Name className="max-w-[5rem] truncate text-lg font-medium leading-tight text-white" />
|
<div className="flex h-36 w-32 flex-col items-center justify-center gap-4 rounded-2xl p-2 text-white hover:bg-white/10 dark:hover:bg-black/10">
|
||||||
</User.Root>
|
<div className="flex size-20 items-center justify-center rounded-full bg-white/20 dark:bg-black/20">
|
||||||
</User.Provider>
|
<PlusIcon className="size-5" />
|
||||||
</button>
|
</div>
|
||||||
))}
|
<p className="text-lg font-medium leading-tight">Add</p>
|
||||||
<Link to="/landing">
|
</div>
|
||||||
<div className="flex h-36 w-32 flex-col items-center justify-center gap-4 rounded-2xl p-2 text-white hover:bg-white/10 dark:hover:bg-black/10">
|
</Link>
|
||||||
<div className="flex size-20 items-center justify-center rounded-full bg-white/20 dark:bg-black/20">
|
</>
|
||||||
<PlusIcon className="size-5" />
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-medium leading-tight">Add</p>
|
</div>
|
||||||
</div>
|
<div className="absolute z-10 h-full w-full bg-white/10 backdrop-blur-lg dark:bg-black/10" />
|
||||||
</Link>
|
<div className="absolute inset-0 h-full w-full">
|
||||||
</>
|
<img
|
||||||
)}
|
src="/lock-screen.jpg"
|
||||||
</div>
|
srcSet="/lock-screen@2x.jpg 2x"
|
||||||
</div>
|
alt="Lock Screen Background"
|
||||||
<div className="absolute z-10 h-full w-full bg-white/10 backdrop-blur-lg dark:bg-black/10" />
|
className="h-full w-full object-cover"
|
||||||
<div className="absolute inset-0 h-full w-full">
|
/>
|
||||||
<img
|
<a
|
||||||
src="/lock-screen.jpg"
|
href="https://njump.me/nprofile1qqs9tuz9jpn57djg7nxunhyvuvk69g5zqaxdpvpqt9hwqv7395u9rpg6zq5uw"
|
||||||
srcSet="/lock-screen@2x.jpg 2x"
|
target="_blank"
|
||||||
alt="Lock Screen Background"
|
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="h-full w-full object-cover"
|
rel="noreferrer"
|
||||||
/>
|
>
|
||||||
<a
|
Design by NoGood
|
||||||
href="https://njump.me/nprofile1qqs9tuz9jpn57djg7nxunhyvuvk69g5zqaxdpvpqt9hwqv7395u9rpg6zq5uw"
|
</a>
|
||||||
target="_blank"
|
</div>
|
||||||
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"
|
</div>
|
||||||
>
|
);
|
||||||
Design by NoGood
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,116 +6,114 @@ 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,
|
label: search.label,
|
||||||
label: search.label,
|
name: search.name,
|
||||||
name: search.name,
|
};
|
||||||
};
|
},
|
||||||
},
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { label, name, redirect } = Route.useSearch();
|
const { label, name, redirect } = Route.useSearch();
|
||||||
const { ark } = Route.useRouteContext();
|
const { ark } = Route.useRouteContext();
|
||||||
|
|
||||||
const [hashtags, setHashtags] = useState<string[]>([]);
|
const [hashtags, setHashtags] = useState<string[]>([]);
|
||||||
const [isDone, setIsDone] = useState(false);
|
const [isDone, setIsDone] = useState(false);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const toggleHashtag = (item: string) => {
|
const toggleHashtag = (item: string) => {
|
||||||
const arr = hashtags.includes(item)
|
const arr = hashtags.includes(item)
|
||||||
? hashtags.filter((i) => i !== item)
|
? hashtags.filter((i) => i !== item)
|
||||||
: [...hashtags, item];
|
: [...hashtags, item];
|
||||||
setHashtags(arr);
|
setHashtags(arr);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleAll = (item: string[]) => {
|
const toggleAll = (item: string[]) => {
|
||||||
const sets = new Set([...hashtags, ...item]);
|
const sets = new Set([...hashtags, ...item]);
|
||||||
setHashtags([...sets]);
|
setHashtags([...sets]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
try {
|
try {
|
||||||
if (isDone) {
|
if (isDone) {
|
||||||
return router.history.push(redirect);
|
return router.history.push(redirect);
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventId = await ark.set_interest(undefined, undefined, hashtags);
|
const eventId = await ark.set_interest(undefined, undefined, hashtags);
|
||||||
if (eventId) {
|
if (eventId) {
|
||||||
setIsDone(true);
|
setIsDone(true);
|
||||||
toast.success("Interest has been updated successfully.");
|
toast.success("Interest has been updated successfully.");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(String(e));
|
toast.error(String(e));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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="flex flex-1 flex-col">
|
||||||
<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">
|
<h3 className="font-semibold">Interests</h3>
|
||||||
<div className="flex flex-1 flex-col">
|
<p className="text-sm leading-tight text-neutral-700 dark:text-neutral-300">
|
||||||
<h3 className="font-semibold">Interests</h3>
|
Pick things you'd like to see.
|
||||||
<p className="text-sm leading-tight text-neutral-700 dark:text-neutral-300">
|
</p>
|
||||||
Pick things you'd like to see.
|
</div>
|
||||||
</p>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
<button
|
onClick={submit}
|
||||||
type="button"
|
className="inline-flex h-8 w-20 items-center justify-center rounded-full bg-blue-500 px-2 text-sm font-medium text-white hover:bg-blue-600 disabled:opacity-50"
|
||||||
onClick={submit}
|
>
|
||||||
className="inline-flex h-8 w-20 items-center justify-center rounded-full bg-blue-500 px-2 text-sm font-medium text-white hover:bg-blue-600 disabled:opacity-50"
|
{isDone ? t("global.back") : t("global.update")}
|
||||||
>
|
</button>
|
||||||
{isDone ? t("global.back") : t("global.update")}
|
</div>
|
||||||
</button>
|
<div className="flex-1 flex flex-col gap-3 pb-2 scrollbar-none overflow-y-auto">
|
||||||
</div>
|
{TOPICS.map((topic) => (
|
||||||
<div className="flex w-full flex-col p-3">
|
<div
|
||||||
<div className="flex flex-col gap-8">
|
key={topic.title}
|
||||||
{TOPICS.map((topic) => (
|
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 key={topic.title} className="flex flex-col gap-4">
|
>
|
||||||
<div className="flex w-full items-center justify-between">
|
<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}
|
||||||
alt={topic.title}
|
alt={topic.title}
|
||||||
className="size-8 rounded-lg object-cover"
|
className="size-8 rounded-lg object-cover"
|
||||||
/>
|
/>
|
||||||
<h3 className="text-lg font-semibold">{topic.title}</h3>
|
<h3 className="text-lg font-semibold">{topic.title}</h3>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleAll(topic.content)}
|
onClick={() => toggleAll(topic.content)}
|
||||||
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||||
>
|
>
|
||||||
{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}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleHashtag(hashtag)}
|
onClick={() => toggleHashtag(hashtag)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center rounded-full border border-transparent bg-neutral-100 px-2 py-1 text-sm font-medium dark:bg-neutral-900",
|
"inline-flex items-center rounded-full border border-transparent bg-neutral-100 px-2 py-1 text-sm font-medium dark:bg-neutral-900",
|
||||||
hashtags.includes(hashtag)
|
hashtags.includes(hashtag)
|
||||||
? "border-blue-500 text-blue-500"
|
? "border-blue-500 text-blue-500"
|
||||||
: "",
|
: "",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{hashtag}
|
{hashtag}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Column.Content>
|
);
|
||||||
</Column.Root>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,82 +3,83 @@ import { Link, createFileRoute } from "@tanstack/react-router";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export const Route = createFileRoute("/landing/")({
|
export const Route = createFileRoute("/landing/")({
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-screen w-screen bg-black">
|
<div className="relative flex h-screen w-screen bg-black">
|
||||||
<div
|
<div
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
className="absolute left-0 top-0 z-50 h-16 w-full"
|
className="absolute left-0 top-0 z-50 h-16 w-full"
|
||||||
/>
|
/>
|
||||||
<div className="z-20 flex h-full w-full flex-col items-center justify-between">
|
<div className="z-20 flex h-full w-full flex-col items-center justify-between">
|
||||||
<div />
|
<div />
|
||||||
<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"
|
||||||
/>
|
/>
|
||||||
<p className="mt-4 whitespace-pre-line text-lg font-medium leading-snug text-white/70">
|
<p className="mt-4 whitespace-pre-line text-lg font-medium leading-snug text-white/70">
|
||||||
{t("welcome.title")}
|
{t("welcome.title")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-auto flex w-full max-w-sm flex-col gap-4">
|
<div className="mx-auto flex w-full max-w-sm flex-col gap-4">
|
||||||
<Link
|
<Link
|
||||||
to="/auth/new/profile"
|
to="/auth/new/profile"
|
||||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-white font-medium text-blue-500 backdrop-blur-lg hover:bg-white/90"
|
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-white font-medium text-blue-500 backdrop-blur-lg hover:bg-white/90"
|
||||||
>
|
>
|
||||||
{t("welcome.signup")}
|
{t("welcome.signup")}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="h-px flex-1 bg-white/20" />
|
<div className="h-px flex-1 bg-white/20" />
|
||||||
<div className="text-white/70">{t("login.or")}</div>
|
<div className="text-white/70">{t("login.or")}</div>
|
||||||
<div className="h-px flex-1 bg-white/20" />
|
<div className="h-px flex-1 bg-white/20" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Link
|
<Link
|
||||||
to="/auth/remote"
|
to="/auth/remote"
|
||||||
className="group inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-white/20 px-3 font-medium text-white backdrop-blur-md hover:bg-white/40"
|
className="group inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-white/20 px-3 font-medium text-white backdrop-blur-md hover:bg-white/40"
|
||||||
>
|
>
|
||||||
<RemoteIcon className="size-5 text-neutral-600 dark:text-neutral-400 group-hover:text-neutral-400 dark:group-hover:text-neutral-600" />
|
<RemoteIcon className="size-5 text-neutral-600 dark:text-neutral-400 group-hover:text-neutral-400 dark:group-hover:text-neutral-600" />
|
||||||
Nostr Connect
|
Nostr Connect
|
||||||
<div className="size-5" />
|
<div className="size-5" />
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/auth/privkey"
|
to="/auth/privkey"
|
||||||
className="group inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-white/20 px-3 font-medium text-white backdrop-blur-md hover:bg-white/40"
|
className="group inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-white/20 px-3 font-medium text-white backdrop-blur-md hover:bg-white/40"
|
||||||
>
|
>
|
||||||
<KeyIcon className="size-5 text-neutral-600 dark:text-neutral-400 group-hover:text-neutral-400 dark:group-hover:text-neutral-600" />
|
<KeyIcon className="size-5 text-neutral-600 dark:text-neutral-400 group-hover:text-neutral-400 dark:group-hover:text-neutral-600" />
|
||||||
Private Key
|
Private Key
|
||||||
<div className="size-5" />
|
<div className="size-5" />
|
||||||
</Link>
|
</Link>
|
||||||
</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">
|
||||||
<img
|
<img
|
||||||
src="/lock-screen.jpg"
|
src="/lock-screen.jpg"
|
||||||
srcSet="/lock-screen@2x.jpg 2x"
|
srcSet="/lock-screen@2x.jpg 2x"
|
||||||
alt="Lock Screen Background"
|
alt="Lock Screen Background"
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
<a
|
<a
|
||||||
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
|
>
|
||||||
</a>
|
Design by NoGood
|
||||||
</div>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,134 +1,154 @@
|
|||||||
|
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")({
|
||||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||||
return {
|
return {
|
||||||
account: search.account,
|
account: search.account,
|
||||||
label: search.label,
|
label: search.label,
|
||||||
name: search.name,
|
name: search.name,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: async ({ context }) => {
|
||||||
const ark = context.ark;
|
const ark = context.ark;
|
||||||
const settings = await ark.get_settings();
|
const settings = await ark.get_settings();
|
||||||
|
|
||||||
return { settings };
|
return { settings };
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
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,
|
||||||
queryKey: [label, account],
|
isLoading,
|
||||||
initialPageParam: 0,
|
isFetching,
|
||||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
isFetchingNextPage,
|
||||||
const events = await ark.get_events(20, pageParam);
|
hasNextPage,
|
||||||
return events;
|
fetchNextPage,
|
||||||
},
|
} = useInfiniteQuery({
|
||||||
getNextPageParam: (lastPage) => {
|
queryKey: [label, account],
|
||||||
const lastEvent = lastPage?.at(-1);
|
initialPageParam: 0,
|
||||||
return lastEvent ? lastEvent.created_at - 1 : null;
|
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||||
},
|
const events = await ark.get_events(20, pageParam);
|
||||||
select: (data) => data?.pages.flatMap((page) => page),
|
return events;
|
||||||
refetchOnWindowFocus: false,
|
},
|
||||||
});
|
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) => {
|
const renderItem = (event: Event) => {
|
||||||
if (!event) return;
|
if (!event) return;
|
||||||
switch (event.kind) {
|
switch (event.kind) {
|
||||||
case Kind.Repost:
|
case Kind.Repost:
|
||||||
return <RepostNote key={event.id} event={event} />;
|
return <RepostNote key={event.id} event={event} />;
|
||||||
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;
|
||||||
|
|
||||||
return (
|
if (isConversation) {
|
||||||
<Column.Root>
|
return <Conversation key={event.id} event={event} className="mb-3" />;
|
||||||
<Column.Header label={label} name={name} />
|
}
|
||||||
<Column.Content>
|
|
||||||
{isLoading ? (
|
if (isQuote) {
|
||||||
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
return <Quote key={event.id} event={event} className="mb-3" />;
|
||||||
<button type="button" className="size-5" disabled>
|
}
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
</button>
|
return <TextNote key={event.id} event={event} className="mb-3" />;
|
||||||
</div>
|
}
|
||||||
) : !data.length ? (
|
}
|
||||||
<Empty />
|
};
|
||||||
) : (
|
|
||||||
<Virtualizer overscan={3}>
|
return (
|
||||||
{data.map((item) => renderItem(item))}
|
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
|
||||||
</Virtualizer>
|
{isFetching && !isLoading && !isFetchingNextPage ? (
|
||||||
)}
|
<div className="w-full h-11 flex items-center justify-center">
|
||||||
{data?.length && hasNextPage ? (
|
<div className="flex items-center justify-center gap-2">
|
||||||
<div className="flex h-20 items-center justify-center">
|
<Spinner className="size-5" />
|
||||||
<button
|
<span className="text-sm font-medium">Fetching new notes...</span>
|
||||||
type="button"
|
</div>
|
||||||
onClick={() => fetchNextPage()}
|
</div>
|
||||||
disabled={isFetchingNextPage || isLoading}
|
) : null}
|
||||||
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"
|
{isLoading ? (
|
||||||
>
|
<div className="flex h-16 w-full items-center justify-center gap-2">
|
||||||
{isFetchingNextPage ? (
|
<Spinner className="size-5" />
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
<span className="text-sm font-medium">Loading...</span>
|
||||||
) : (
|
</div>
|
||||||
<>
|
) : !data.length ? (
|
||||||
<ArrowRightCircleIcon className="size-5" />
|
<Empty />
|
||||||
Load more
|
) : (
|
||||||
</>
|
<Virtualizer overscan={3}>
|
||||||
)}
|
{data.map((item) => renderItem(item))}
|
||||||
</button>
|
</Virtualizer>
|
||||||
</div>
|
)}
|
||||||
) : null}
|
{data?.length && hasNextPage ? (
|
||||||
</Column.Content>
|
<div>
|
||||||
</Column.Root>
|
<button
|
||||||
);
|
type="button"
|
||||||
|
onClick={() => fetchNextPage()}
|
||||||
|
disabled={isFetchingNextPage || isLoading}
|
||||||
|
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-black/5 px-3 font-medium hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||||
|
>
|
||||||
|
{isFetchingNextPage ? (
|
||||||
|
<Spinner className="size-5" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ArrowRightCircleIcon className="size-5" />
|
||||||
|
Load more
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Empty() {
|
function Empty() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col py-10 gap-10">
|
<div className="flex flex-col py-10 gap-10">
|
||||||
<div className="text-center flex flex-col items-center justify-center">
|
<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="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 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>
|
||||||
<p className="text-lg font-medium">Your newsfeed is empty</p>
|
<p className="text-lg font-medium">Your newsfeed is empty</p>
|
||||||
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
||||||
Here are few suggestions to get started.
|
Here are few suggestions to get started.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col px-3 gap-2">
|
<div className="flex flex-col px-3 gap-2">
|
||||||
<Link
|
<Link
|
||||||
to="/global"
|
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"
|
||||||
>
|
>
|
||||||
<ArrowRightIcon className="size-5" />
|
<ArrowRightIcon className="size-5" />
|
||||||
Show global newsfeed
|
Show trending notes
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/trending/notes"
|
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"
|
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" />
|
<ArrowRightIcon className="size-5" />
|
||||||
Show trending notes
|
Discover trending users
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
</div>
|
||||||
to="/trending/users"
|
</div>
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,59 +4,59 @@ import { createLazyFileRoute } from "@tanstack/react-router";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/nwc")({
|
export const Route = createLazyFileRoute("/nwc")({
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const { ark } = Route.useRouteContext();
|
const { ark } = Route.useRouteContext();
|
||||||
|
|
||||||
const [uri, setUri] = useState("");
|
const [uri, setUri] = useState("");
|
||||||
const [isDone, setIsDone] = useState(false);
|
const [isDone, setIsDone] = useState(false);
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
const nwc = await ark.set_nwc(uri);
|
const nwc = await ark.set_nwc(uri);
|
||||||
setIsDone(nwc);
|
setIsDone(nwc);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container withDrag withNavigate={false}>
|
<Container withDrag withNavigate={false}>
|
||||||
<div className="h-full w-full flex-1 px-5">
|
<div className="h-full w-full flex-1 px-5">
|
||||||
{!isDone ? (
|
{!isDone ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="inline-flex size-14 items-center justify-center rounded-xl bg-black text-white shadow-md">
|
<div className="inline-flex size-14 items-center justify-center rounded-xl bg-black text-white shadow-md">
|
||||||
<ZapIcon className="size-5" />
|
<ZapIcon className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-2xl font-light">
|
<h3 className="text-2xl font-light">
|
||||||
Connect <span className="font-semibold">bitcoin wallet</span>{" "}
|
Connect <span className="font-semibold">bitcoin wallet</span>{" "}
|
||||||
to start zapping to your favorite content and creator.
|
to start zapping to your favorite content and creator.
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-10 flex flex-col gap-2">
|
<div className="mt-10 flex flex-col gap-2">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label>Paste a Nostr Wallet Connect connection string</label>
|
<label>Paste a Nostr Wallet Connect connection string</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={uri}
|
value={uri}
|
||||||
onChange={(e) => setUri(e.target.value)}
|
onChange={(e) => setUri(e.target.value)}
|
||||||
placeholder="nostrconnect://"
|
placeholder="nostrconnect://"
|
||||||
className="h-24 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
className="h-24 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={save}
|
onClick={save}
|
||||||
className="inline-flex h-11 w-full items-center justify-center gap-1.5 rounded-lg bg-blue-500 px-5 font-medium text-white hover:bg-blue-600"
|
className="inline-flex h-11 w-full items-center justify-center gap-1.5 rounded-lg bg-blue-500 px-5 font-medium text-white hover:bg-blue-600"
|
||||||
>
|
>
|
||||||
Save & Connect
|
Save & Connect
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div>Done</div>
|
<div>Done</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,446 @@
|
|||||||
|
import { ArrowRightIcon, CancelIcon } from "@lume/icons";
|
||||||
|
import type { ColumnRouteSearch, LumeColumn } from "@lume/types";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { User } from "@/components/user";
|
||||||
|
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,51 +1,48 @@
|
|||||||
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";
|
||||||
|
|
||||||
export const Route = createLazyRoute("/open")({
|
export const Route = createLazyRoute("/open")({
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const install = async (column: LumeColumn) => {
|
const install = async (column: LumeColumn) => {
|
||||||
const mainWindow = getCurrent();
|
const mainWindow = getCurrent();
|
||||||
await mainWindow.emit("columns", { type: "add", column });
|
await mainWindow.emit("columns", { type: "add", column });
|
||||||
};
|
};
|
||||||
|
|
||||||
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"
|
onClick={() =>
|
||||||
onClick={() =>
|
install({
|
||||||
install({
|
label: "store",
|
||||||
label: "store",
|
name: "Store",
|
||||||
name: "Store",
|
content: "/store/official",
|
||||||
content: "/store/official",
|
})
|
||||||
})
|
}
|
||||||
}
|
className="flex h-full w-full items-center justify-center rounded-xl bg-transparent transition-colors duration-100 ease-in-out group-hover:bg-black/5 dark:group-hover:bg-white/5"
|
||||||
className="flex h-full w-full items-center justify-center rounded-xl bg-transparent transition-colors duration-100 ease-in-out group-hover:bg-black/5 dark:group-hover:bg-white/5"
|
>
|
||||||
>
|
<PlusIcon className="size-6 scale-0 transform transition-transform duration-150 ease-in-out will-change-transform group-hover:scale-100" />
|
||||||
<PlusIcon className="size-6 scale-0 transform transition-transform duration-150 ease-in-out will-change-transform group-hover:scale-100" />
|
</button>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={() =>
|
||||||
onClick={() =>
|
install({
|
||||||
install({
|
label: "store",
|
||||||
label: "store",
|
name: "Store",
|
||||||
name: "Store",
|
content: "/store/official",
|
||||||
content: "/store/official",
|
})
|
||||||
})
|
}
|
||||||
}
|
className="inline-flex size-14 items-center justify-center rounded-full bg-black/10 backdrop-blur-lg hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
||||||
className="inline-flex size-14 items-center justify-center rounded-full bg-black/10 backdrop-blur-lg hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
>
|
||||||
>
|
<PlusIcon className="size-8" />
|
||||||
<PlusIcon className="size-8" />
|
</button>
|
||||||
</button>
|
</div>
|
||||||
</Column.Content>
|
);
|
||||||
</Column.Root>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { SearchIcon } from "@lume/icons";
|
||||||
|
import { type Event, Kind } from "@lume/types";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { Note } from "@/components/note";
|
||||||
|
import { User } from "@/components/user";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useDebounce } from "use-debounce";
|
||||||
|
|
||||||
|
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,106 +1,127 @@
|
|||||||
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";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export const Route = createFileRoute("/settings")({
|
export const Route = createFileRoute("/settings")({
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const { t } = useTranslation();
|
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">
|
||||||
{({ isActive }) => {
|
{({ isActive }) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
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" />
|
||||||
<p className="text-sm font-medium">
|
<p className="text-sm font-medium">
|
||||||
{t("settings.general.title")}
|
{t("settings.general.title")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/settings/user">
|
<Link to="/settings/user">
|
||||||
{({ isActive }) => {
|
{({ isActive }) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
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" />
|
||||||
<p className="text-sm font-medium">
|
<p className="text-sm font-medium">
|
||||||
{t("settings.user.title")}
|
{t("settings.user.title")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/settings/zap">
|
<Link to="/settings/relay">
|
||||||
{({ isActive }) => {
|
{({ isActive }) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
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" />
|
<RelayIcon className="size-5 shrink-0" />
|
||||||
<p className="text-sm font-medium">
|
<p className="text-sm font-medium">Relay</p>
|
||||||
{t("settings.zap.title")}
|
</div>
|
||||||
</p>
|
);
|
||||||
</div>
|
}}
|
||||||
);
|
</Link>
|
||||||
}}
|
<Link to="/settings/zap">
|
||||||
</Link>
|
{({ isActive }) => {
|
||||||
<Link to="/settings/backup">
|
return (
|
||||||
{({ isActive }) => {
|
<div
|
||||||
return (
|
className={cn(
|
||||||
<div
|
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
|
||||||
className={cn(
|
isActive
|
||||||
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
|
? "bg-black/10 hover:bg-black/20 dark:bg-white/10 text-neutral-900 dark:text-neutral-100 dark:hover:bg-bg-white/20"
|
||||||
isActive
|
: "text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
|
||||||
? "bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700"
|
)}
|
||||||
: "text-neutral-700 hover:bg-neutral-200 dark:text-neutral-300 dark:hover:bg-neutral-800",
|
>
|
||||||
)}
|
<ZapIcon className="size-5 shrink-0" />
|
||||||
>
|
<p className="text-sm font-medium">
|
||||||
<SecureIcon className="size-5 shrink-0" />
|
{t("settings.zap.title")}
|
||||||
<p className="text-sm font-medium">
|
</p>
|
||||||
{t("settings.backup.title")}
|
</div>
|
||||||
</p>
|
);
|
||||||
</div>
|
}}
|
||||||
);
|
</Link>
|
||||||
}}
|
<Link to="/settings/backup">
|
||||||
</Link>
|
{({ isActive }) => {
|
||||||
</div>
|
return (
|
||||||
</div>
|
<div
|
||||||
<div className="w-full flex-1 overflow-y-auto px-5 py-4">
|
className={cn(
|
||||||
<div className="mx-auto w-full max-w-xl">
|
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
|
||||||
<Outlet />
|
isActive
|
||||||
</div>
|
? "bg-black/10 hover:bg-black/20 dark:bg-white/10 text-neutral-900 dark:text-neutral-100 dark:hover:bg-bg-white/20"
|
||||||
</div>
|
: "text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
|
||||||
</div>
|
)}
|
||||||
);
|
>
|
||||||
|
<SecureIcon className="size-5 shrink-0" />
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{t("settings.backup.title")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex-1 overflow-y-auto scrollbar-none px-5 py-4">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { type Account } from "@lume/types";
|
import { User } from "@/components/user";
|
||||||
import { User } from "@lume/ui";
|
import type { Account } from "@lume/types";
|
||||||
import { displayNsec } from "@lume/utils";
|
import { displayNsec } from "@lume/utils";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
@@ -8,115 +8,121 @@ import { useState } from "react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export const Route = createFileRoute("/settings/backup")({
|
export const Route = createFileRoute("/settings/backup")({
|
||||||
component: Screen,
|
component: Screen,
|
||||||
loader: async ({ context }) => {
|
loader: async ({ context }) => {
|
||||||
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", {
|
||||||
npub: account.npub,
|
npub: account.npub,
|
||||||
});
|
});
|
||||||
accounts.push({ ...account, nsec });
|
accounts.push({ ...account, nsec });
|
||||||
}
|
}
|
||||||
|
|
||||||
return accounts;
|
return accounts;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const accounts = Route.useLoaderData();
|
const accounts = Route.useLoaderData();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700">
|
<div className="mx-auto w-full max-w-xl">
|
||||||
{accounts.map((account, index) => (
|
<div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700">
|
||||||
<div key={account.npub} className="flex items-start gap-6 py-3">
|
{accounts.map((account) => (
|
||||||
<div className="w-36 shrink-0 text-end font-medium">
|
<NostrAccount key={account.npub} account={account} />
|
||||||
Account {index}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Account account={account} />
|
</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("");
|
||||||
|
|
||||||
const encrypt = async () => {
|
const encrypt = async () => {
|
||||||
const encrypted: string = await invoke("get_encrypted_key", {
|
const encrypted: string = await invoke("get_encrypted_key", {
|
||||||
npub: account.npub,
|
npub: account.npub,
|
||||||
password: passphase,
|
password: passphase,
|
||||||
});
|
});
|
||||||
setKey(encrypted);
|
setKey(encrypted);
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyKey = async () => {
|
const copyKey = async () => {
|
||||||
try {
|
try {
|
||||||
await writeText(key);
|
await writeText(key);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e);
|
toast.error(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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 items-center gap-2">
|
<div className="flex w-full flex-col gap-1">
|
||||||
<input
|
<label
|
||||||
readOnly
|
htmlFor="nsec"
|
||||||
type="text"
|
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||||
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"
|
Private Key
|
||||||
/>
|
</label>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
type="button"
|
<input
|
||||||
onClick={copyKey}
|
readOnly
|
||||||
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"
|
name="nsec"
|
||||||
>
|
type="text"
|
||||||
{copied ? "Copied" : "Copy"}
|
value={displayNsec(key, 36)}
|
||||||
</button>
|
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">
|
<button
|
||||||
<label
|
type="button"
|
||||||
htmlFor="passphase"
|
onClick={() => copyKey()}
|
||||||
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
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"
|
||||||
>
|
>
|
||||||
Set a passphase to secure your key
|
{copied ? "Copied" : "Copy"}
|
||||||
</label>
|
</button>
|
||||||
<div className="flex items-center gap-2">
|
</div>
|
||||||
<input
|
</div>
|
||||||
name="passphase"
|
<div className="flex w-full flex-col gap-1">
|
||||||
type="password"
|
<label
|
||||||
value={passphase}
|
htmlFor="passphase"
|
||||||
onChange={(e) => setPassphase(e.target.value)}
|
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||||
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"
|
>
|
||||||
/>
|
Set a passphase to secure your key
|
||||||
<button
|
</label>
|
||||||
type="button"
|
<div className="flex items-center gap-2">
|
||||||
onClick={encrypt}
|
<input
|
||||||
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"
|
name="passphase"
|
||||||
>
|
type="password"
|
||||||
Update
|
value={passphase}
|
||||||
</button>
|
onChange={(e) => setPassphase(e.target.value)}
|
||||||
</div>
|
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>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
);
|
onClick={() => encrypt()}
|
||||||
|
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,93 +4,99 @@ import { useState } from "react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/settings/zap")({
|
export const Route = createLazyFileRoute("/settings/zap")({
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700">
|
<div className="mx-auto w-full max-w-xl">
|
||||||
<div className="flex flex-col gap-6 py-3">
|
<div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700">
|
||||||
<Connection />
|
<div className="flex flex-col gap-6 py-3">
|
||||||
<DefaultAmount />
|
<Connection />
|
||||||
</div>
|
<DefaultAmount />
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Connection() {
|
function Connection() {
|
||||||
const [uri, setUri] = useState("");
|
const [uri, setUri] = useState("");
|
||||||
|
|
||||||
const connect = async () => {
|
const connect = async () => {
|
||||||
try {
|
try {
|
||||||
await invoke("set_nwc", { uri });
|
await invoke("set_nwc", { uri });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(String(e));
|
toast.error(String(e));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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">
|
||||||
<div className="flex-1">
|
Connection
|
||||||
<div className="flex w-full flex-col gap-1">
|
</div>
|
||||||
<label
|
<div className="flex-1">
|
||||||
htmlFor="nwc"
|
<div className="flex w-full flex-col gap-1">
|
||||||
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
<label
|
||||||
>
|
htmlFor="nwc"
|
||||||
Nostr Wallet Connect
|
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||||
</label>
|
>
|
||||||
<div className="flex items-center gap-2">
|
Nostr Wallet Connect
|
||||||
<input
|
</label>
|
||||||
name="nwc"
|
<div className="flex items-center gap-2">
|
||||||
type="text"
|
<input
|
||||||
value={uri}
|
name="nwc"
|
||||||
onChange={(e) => setUri(e.target.value)}
|
type="text"
|
||||||
placeholder="nostrconnect://"
|
value={uri}
|
||||||
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"
|
onChange={(e) => setUri(e.target.value)}
|
||||||
/>
|
placeholder="nostrconnect://"
|
||||||
<button
|
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"
|
||||||
type="button"
|
/>
|
||||||
onClick={connect}
|
<button
|
||||||
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
|
type="button"
|
||||||
>
|
onClick={() => connect()}
|
||||||
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"
|
||||||
</button>
|
>
|
||||||
</div>
|
Connect
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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">
|
||||||
<div className="flex-1">
|
Default amount
|
||||||
<div className="flex w-full flex-col gap-1">
|
</div>
|
||||||
<label
|
<div className="flex-1">
|
||||||
htmlFor="amount"
|
<div className="flex w-full flex-col gap-1">
|
||||||
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
<label
|
||||||
>
|
htmlFor="amount"
|
||||||
Set default amount for quick zapping
|
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||||
</label>
|
>
|
||||||
<div className="flex items-center gap-2">
|
Set default amount for quick zapping
|
||||||
<input
|
</label>
|
||||||
name="amount"
|
<div className="flex items-center gap-2">
|
||||||
type="number"
|
<input
|
||||||
value={21}
|
name="amount"
|
||||||
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"
|
type="number"
|
||||||
/>
|
value={21}
|
||||||
<button
|
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"
|
||||||
type="button"
|
/>
|
||||||
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
|
<button
|
||||||
>
|
type="button"
|
||||||
Update
|
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"
|
||||||
</button>
|
>
|
||||||
</div>
|
Update
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
export const Route = createFileRoute("/store/community")({
|
export const Route = createFileRoute("/store/community")({
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
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>
|
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" />
|
||||||
);
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="font-semibold text-lg">Coming Soon</h1>
|
||||||
|
<p className="text-sm text-neutral-700 dark:text-neutral-300 leading-tight">
|
||||||
|
Enhance your experience <br /> by adding column shared by community.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +1,69 @@
|
|||||||
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",
|
);
|
||||||
);
|
const officialColumns: LumeColumn[] = JSON.parse(
|
||||||
const officialColumns: LumeColumn[] = JSON.parse(
|
await readTextFile(resourcePath),
|
||||||
await readTextFile(resourcePath),
|
);
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
officialColumns,
|
officialColumns,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const { officialColumns } = Route.useRouteContext();
|
const { officialColumns } = Route.useRouteContext();
|
||||||
|
|
||||||
const install = async (column: LumeColumn) => {
|
const install = async (column: LumeColumn) => {
|
||||||
const mainWindow = getCurrent();
|
const mainWindow = getCurrent();
|
||||||
await mainWindow.emit("columns", { type: "add", column });
|
await mainWindow.emit("columns", { type: "add", column });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 p-3">
|
<div className="flex flex-col gap-3 p-3">
|
||||||
{officialColumns.map((column) => (
|
{officialColumns.map((column) => (
|
||||||
<div
|
<div
|
||||||
key={column.label}
|
key={column.label}
|
||||||
className="relative h-[200px] w-full overflow-hidden rounded-xl bg-gradient-to-tr from-orange-100 to-blue-200 px-3 pt-3"
|
className="relative h-[200px] w-full overflow-hidden rounded-xl bg-gradient-to-tr from-orange-100 to-blue-200 px-3 pt-3"
|
||||||
>
|
>
|
||||||
{column.cover ? (
|
{column.cover ? (
|
||||||
<img
|
<img
|
||||||
src={column.cover}
|
src={column.cover}
|
||||||
srcSet={column.coverRetina}
|
srcSet={column.coverRetina}
|
||||||
alt={column.name}
|
alt={column.name}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
className="absolute left-0 top-0 z-10 h-full w-full object-cover"
|
className="absolute left-0 top-0 z-10 h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="absolute bottom-0 left-0 z-20 h-16 w-full bg-black/40 px-3 backdrop-blur-xl">
|
<div className="absolute bottom-0 left-0 z-20 h-16 w-full bg-black/40 px-3 backdrop-blur-xl">
|
||||||
<div className="flex h-full items-center justify-between">
|
<div className="flex h-full items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-semibold text-white">{column.name}</h1>
|
<h1 className="font-semibold text-white">{column.name}</h1>
|
||||||
<p className="max-w-[24rem] truncate text-sm text-white/80">
|
<p className="max-w-[24rem] truncate text-sm text-white/80">
|
||||||
{column.description}
|
{column.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => install(column)}
|
onClick={() => install(column)}
|
||||||
className="inline-flex h-8 w-16 shrink-0 items-center justify-center rounded-full bg-white/20 text-sm font-medium text-white hover:bg-white hover:text-blue-500"
|
className="inline-flex h-8 w-16 shrink-0 items-center justify-center rounded-full bg-white/20 text-sm font-medium text-white hover:bg-white hover:text-blue-500"
|
||||||
>
|
>
|
||||||
Add
|
Add
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +1,56 @@
|
|||||||
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";
|
||||||
|
|
||||||
export const Route = createFileRoute("/store")({
|
export const Route = createFileRoute("/store")({
|
||||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||||
return {
|
return {
|
||||||
account: search.account,
|
account: search.account,
|
||||||
label: search.label,
|
label: search.label,
|
||||||
name: search.name,
|
name: search.name,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const { label, name } = Route.useSearch();
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
return (
|
<div className="px-3 mt-2 mb-1">
|
||||||
<Column.Root>
|
<div className="p-1 shrink-0 inline-flex w-full rounded-lg items-center gap-1 bg-black/5 dark:bg-white/5">
|
||||||
<Column.Header label={label} name={name}>
|
<Link to="/store/official" className="flex-1">
|
||||||
<div className="inline-flex h-full w-full items-center gap-1">
|
{({ isActive }) => (
|
||||||
<Link to="/store/official">
|
<div
|
||||||
{({ isActive }) => (
|
className={cn(
|
||||||
<div
|
"inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-md text-sm font-medium leading-tight",
|
||||||
className={cn(
|
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
|
||||||
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
|
)}
|
||||||
isActive
|
>
|
||||||
? "bg-neutral-100 dark:bg-neutral-900"
|
<LaurelIcon className="size-4" />
|
||||||
: "opacity-50",
|
Official
|
||||||
)}
|
</div>
|
||||||
>
|
)}
|
||||||
<LaurelIcon className="size-4" />
|
</Link>
|
||||||
Official
|
<Link to="/store/community" className="flex-1">
|
||||||
</div>
|
{({ isActive }) => (
|
||||||
)}
|
<div
|
||||||
</Link>
|
className={cn(
|
||||||
<Link to="/store/community">
|
"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",
|
||||||
<div
|
)}
|
||||||
className={cn(
|
>
|
||||||
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
|
<GlobalIcon className="size-4" />
|
||||||
isActive
|
Community
|
||||||
? "bg-neutral-100 dark:bg-neutral-900"
|
</div>
|
||||||
: "opacity-50",
|
)}
|
||||||
)}
|
</Link>
|
||||||
>
|
</div>
|
||||||
<GlobalIcon className="size-4" />
|
</div>
|
||||||
Community
|
<div className="flex-1 overflow-y-auto scrollbar-none">
|
||||||
</div>
|
<Outlet />
|
||||||
)}
|
</div>
|
||||||
</Link>
|
</div>
|
||||||
</div>
|
);
|
||||||
</Column.Header>
|
|
||||||
<Column.Content>
|
|
||||||
<Outlet />
|
|
||||||
</Column.Content>
|
|
||||||
</Column.Root>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,66 +1,59 @@
|
|||||||
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 }) => {
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
data: defer(
|
data: defer(
|
||||||
fetch("https://api.nostr.band/v0/trending/notes", {
|
fetch("https://api.nostr.band/v0/trending/notes", {
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
})
|
})
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((res) => res.notes.map((item) => item.event) as Event[]),
|
.then((res) => res.notes.map((item) => item.event) as Event[]),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(String(e));
|
throw new Error(String(e));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function Screen() {
|
export function Screen() {
|
||||||
const { data } = Route.useLoaderData();
|
const { data } = Route.useLoaderData();
|
||||||
|
|
||||||
const renderItem = (event: Event) => {
|
return (
|
||||||
if (!event) return;
|
<div className="w-full h-full">
|
||||||
switch (event.kind) {
|
<Virtualizer overscan={3}>
|
||||||
case Kind.Repost:
|
<Suspense
|
||||||
return <RepostNote key={event.id} event={event} />;
|
fallback={
|
||||||
default:
|
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
||||||
return <TextNote key={event.id} event={event} />;
|
<button
|
||||||
}
|
type="button"
|
||||||
};
|
className="inline-flex items-center gap-2 text-sm font-medium"
|
||||||
|
disabled
|
||||||
return (
|
>
|
||||||
<div className="w-full h-full">
|
<Spinner className="size-5" />
|
||||||
<Virtualizer overscan={3}>
|
Loading...
|
||||||
<Suspense
|
</button>
|
||||||
fallback={
|
</div>
|
||||||
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
}
|
||||||
<button
|
>
|
||||||
type="button"
|
<Await promise={data}>
|
||||||
className="inline-flex items-center gap-2 text-sm font-medium"
|
{(notes) =>
|
||||||
disabled
|
notes.map((event) => (
|
||||||
>
|
<TextNote key={event.id} event={event} className="mb-3" />
|
||||||
<LoaderIcon className="animate-spin size-5" />
|
))
|
||||||
Loading...
|
}
|
||||||
</button>
|
</Await>
|
||||||
</div>
|
</Suspense>
|
||||||
}
|
</Virtualizer>
|
||||||
>
|
</div>
|
||||||
<Await promise={data}>
|
);
|
||||||
{(notes) => notes.map((event) => renderItem(event))}
|
|
||||||
</Await>
|
|
||||||
</Suspense>
|
|
||||||
</Virtualizer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +1,62 @@
|
|||||||
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";
|
||||||
|
|
||||||
export const Route = createFileRoute("/trending")({
|
export const Route = createFileRoute("/trending")({
|
||||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||||
return {
|
return {
|
||||||
account: search.account,
|
account: search.account,
|
||||||
label: search.label,
|
label: search.label,
|
||||||
name: search.name,
|
name: search.name,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: async ({ context }) => {
|
||||||
const ark = context.ark;
|
const ark = context.ark;
|
||||||
const settings = await ark.get_settings();
|
const settings = await ark.get_settings();
|
||||||
|
|
||||||
return { settings };
|
return { settings };
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function Screen() {
|
function Screen() {
|
||||||
const { label, name } = Route.useSearch();
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
return (
|
<div className="h-11 shrink-0 inline-flex w-full items-center gap-1 px-3">
|
||||||
<Column.Root>
|
<div className="inline-flex h-full w-full items-center gap-1">
|
||||||
<Column.Header label={label} name={name}>
|
<Link to="/trending/notes">
|
||||||
<div className="inline-flex h-full w-full items-center gap-1">
|
{({ isActive }) => (
|
||||||
<Link to="/trending/notes">
|
<div
|
||||||
{({ isActive }) => (
|
className={cn(
|
||||||
<div
|
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
|
||||||
className={cn(
|
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
|
||||||
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
|
)}
|
||||||
isActive
|
>
|
||||||
? "bg-neutral-100 dark:bg-neutral-900"
|
<ArticleIcon className="size-4" />
|
||||||
: "opacity-50",
|
Notes
|
||||||
)}
|
</div>
|
||||||
>
|
)}
|
||||||
<ArticleIcon className="size-4" />
|
</Link>
|
||||||
Notes
|
<Link to="/trending/users">
|
||||||
</div>
|
{({ isActive }) => (
|
||||||
)}
|
<div
|
||||||
</Link>
|
className={cn(
|
||||||
<Link to="/trending/users">
|
"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",
|
||||||
<div
|
)}
|
||||||
className={cn(
|
>
|
||||||
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
|
<GroupFeedsIcon className="size-4" />
|
||||||
isActive
|
Users
|
||||||
? "bg-neutral-100 dark:bg-neutral-900"
|
</div>
|
||||||
: "opacity-50",
|
)}
|
||||||
)}
|
</Link>
|
||||||
>
|
</div>
|
||||||
<GroupFeedsIcon className="size-4" />
|
</div>
|
||||||
Users
|
<div className="p-2 flex-1 overflow-y-auto w-full h-full scrollbar-none">
|
||||||
</div>
|
<Outlet />
|
||||||
)}
|
</div>
|
||||||
</Link>
|
</div>
|
||||||
</div>
|
);
|
||||||
</Column.Header>
|
|
||||||
<Column.Content>
|
|
||||||
<Outlet />
|
|
||||||
</Column.Content>
|
|
||||||
</Column.Root>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,71 +1,71 @@
|
|||||||
import { LoaderIcon } from "@lume/icons";
|
import { Spinner } from "@lume/ui";
|
||||||
import { User } from "@lume/ui";
|
import { User } from "@/components/user";
|
||||||
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";
|
||||||
|
|
||||||
export const Route = createFileRoute("/trending/users")({
|
export const Route = createFileRoute("/trending/users")({
|
||||||
loader: async ({ abortController }) => {
|
loader: async ({ abortController }) => {
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
data: defer(
|
data: defer(
|
||||||
fetch("https://api.nostr.band/v0/trending/profiles", {
|
fetch("https://api.nostr.band/v0/trending/profiles", {
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
}).then((res) => res.json()),
|
}).then((res) => res.json()),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(String(e));
|
throw new Error(String(e));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function Screen() {
|
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">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex items-center gap-2 text-sm font-medium"
|
className="inline-flex items-center gap-2 text-sm font-medium"
|
||||||
disabled
|
disabled
|
||||||
>
|
>
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
<Spinner className="size-5" />
|
||||||
Loading...
|
Loading...
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Await promise={data}>
|
<Await promise={data}>
|
||||||
{(users) =>
|
{(users) =>
|
||||||
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>
|
||||||
<div className="flex h-full w-full flex-col gap-2">
|
<div className="flex h-full w-full flex-col gap-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<User.Avatar className="size-10 shrink-0 rounded-full object-cover" />
|
<User.Avatar className="size-10 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>
|
||||||
</User.Root>
|
</User.Root>
|
||||||
</User.Provider>
|
</User.Provider>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
</Await>
|
</Await>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user