Compare commits

..
56 Commits
Author SHA1 Message Date
reya 5d3f2264e9 chore: bump version 2024-05-22 13:26:08 +07:00
雨宮蓮andGitHub 407fe40b67 refactor: account managements (#190)
* feat: add keyring-search

* feat: improve nostr connect
2024-05-22 13:24:58 +07:00
雨宮蓮andGitHub 1f38eba2cc feat: immersive onboarding (#189)
* feat: change the default onboarding column

* feat: add newsfeed onboarding

* feat: add topic onboarding

* feat: add group onboarding

* chore: polish and format

* feat: rename foryou to topic

* fix: array
2024-05-22 10:44:19 +07:00
reya 9b5867f80c feat: improve tauri commands 2024-05-20 07:05:30 +07:00
reya cac774a0c1 feat: small ui improve 2024-05-19 08:48:29 +07:00
reya 82689bf3c3 feat: add windows support for v4 2024-05-18 21:02:04 +07:00
reya f60e438a64 feat: add custom titlebar 2024-05-18 19:07:34 +07:00
reya ca06f2b6ed chore: clean up tauri commands 2024-05-18 14:59:34 +07:00
reya 99d9c70826 feat: improve event discovery 2024-05-18 08:16:05 +07:00
reya 60afbf090b chore: update to rust-nostr 0.31 2024-05-18 07:52:28 +07:00
reya 10ca4e6ff4 chore: bump version 2024-05-17 15:32:14 +07:00
reya b0f387d029 fix: wrong permissions for dialog 2024-05-17 15:31:35 +07:00
reya 1a8f750640 fix: profile is not show on edit screen 2024-05-17 14:45:17 +07:00
reya 25523229a2 fix: copy to clipboard is not working properly 2024-05-16 14:24:55 +07:00
雨宮蓮andGitHub 47835ed857 feat: improve ui for multi-account (#188) 2024-05-16 14:13:07 +07:00
reya d84647bc6b chore: bump version 2024-05-15 13:58:39 +07:00
reya 7724eccd72 feat: improve tauri commands 2024-05-15 13:58:03 +07:00
reya 8ea2335225 chore: update deps 2024-05-15 10:50:35 +07:00
reya b60d4db0df feat: improve multi-account switching 2024-05-15 10:14:21 +07:00
reya f1e17ff3c4 fix: allow app auto restart after update 2024-05-13 15:36:47 +07:00
reya 32954f17b6 chore: bump version and update readme 2024-05-13 15:21:05 +07:00
雨宮蓮andGitHub cf70b0f882 fix: context issue in production (#187) 2024-05-13 15:18:23 +07:00
reya 135d0918b3 feat: add check for updates 2024-05-12 15:07:53 +07:00
reya e1fbcf0460 chore: fix ci again 2024-05-12 08:57:57 +07:00
reya 99aaf3da82 fix: misconfigure in github actions 2024-05-12 08:54:24 +07:00
reya 3ef13e43f1 chore: update github ci 2024-05-12 08:38:23 +07:00
reya 8939196ae4 chore: bump version to 4.0 2024-05-12 08:20:52 +07:00
reya 571d4b4004 feat: improve search 2024-05-12 08:18:25 +07:00
reya 73f80f27fb feat: add basic relay management in rust 2024-05-11 12:28:07 +07:00
雨宮蓮andGitHub b46a5cf68f Merge pull request #186 from kasugamirai/chore
feat: Refactor code to improve error handling and readability
2024-05-10 07:13:32 +07:00
xy 8c0d03aed0 feat: Refactor code to improve error handling and readability 2024-05-09 19:09:55 +09:00
reya 777eb15b4f feat: onboarding 2024-05-09 15:06:42 +07:00
reya c8e1b8b8bd feat: add unread notification badge to dock icon 2024-05-07 14:38:00 +07:00
reya 437cd71f7e feat: improve editor 2024-05-07 14:14:21 +07:00
reya afb7c87fa3 feat: add bell 2024-05-07 08:29:58 +07:00
reya c843626bca feat: add notification screen 2024-05-06 15:17:34 +07:00
reya 28337e5915 feat: improve dedup events 2024-05-04 08:29:52 +07:00
雨宮蓮andGitHub a4aef25adb final design (#184)
* feat: redesign

* feat: update other columns to new design

* chore: small fixes

* fix: better manage external webview

* feat: redesign note

* feat: update ui

* chore: update

* chore: update

* chore: polish ui

* chore: update auth ui

* feat: finalize note design

* chore: small fixes

* feat: add window management in rust

* chore: format

* feat: update ui for event screen

* feat: update event screen

* feat: final
2024-05-03 15:15:48 +07:00
reya 61d1f095d4 chore: clean up 2024-04-24 15:21:13 +07:00
雨宮蓮andGitHub f027eae52d feat: use negentropy (#182)
* feat: use negentropy

* chore: polish
2024-04-24 10:18:51 +07:00
雨宮蓮andGitHub 174a3cc74e feat: add search window (NIP-50) (#181)
* feat: add search window
* chore: improve search ui
2024-04-23 15:34:08 +07:00
reya c00a7749b4 feat: use nip44 to encrypt key-value store 2024-04-23 08:04:42 +07:00
c755b8d137 feat: add ability change column name on the fly (#180)
Co-authored-by: reya <reya@lume.nu>
2024-04-22 14:33:14 +07:00
reya 17766d29d6 chore: update deps 2024-04-22 13:18:34 +07:00
reya 3b13dfeed8 chore: update github action 2024-04-20 18:13:36 +07:00
reya 17ba79e01b chore: update app icon 2024-04-20 08:58:00 +07:00
reya bafad544e9 chore: update deps and bump version 2024-04-19 14:21:12 +07:00
reya 89c36423ae feat: add nip46 2024-04-18 15:09:33 +07:00
reya cd31b99559 feat: improve official columns 2024-04-18 07:50:46 +07:00
reya f3c52237fa fix: privacy setting is not working 2024-04-17 13:50:50 +07:00
reya 413d8d82df feat: finish settings screen 2024-04-16 15:28:38 +07:00
reya 2eb2010d43 chore: upgrade deps 2024-04-16 08:55:36 +07:00
reya 94d400cab2 feat: support nip-36 2024-04-16 07:49:44 +07:00
reya 09b143cb08 feat: auto resize mini webview when main webview resized 2024-04-15 13:30:55 +07:00
reya e3ede34108 feat: revert to sqlite 2024-04-14 09:05:46 +07:00
reya ed6aca41ea feat: add new spinner component 2024-04-13 15:01:27 +07:00
376 changed files with 13872 additions and 12806 deletions
+36 -35
View File
@@ -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
+4 -8
View File
@@ -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
View File
@@ -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"
}
} }
+4 -4
View File
@@ -1,6 +1,6 @@
module.exports = { module.exports = {
plugins: { plugins: {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
}, },
}; };
Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

+60
View File
@@ -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
View File
@@ -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>,
);
} }
-69
View File
@@ -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>
);
}
+36 -31
View File
@@ -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>
); );
} }
+33 -33
View File
@@ -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>
); );
} }
-77
View File
@@ -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>
);
}
+147
View File
@@ -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.get_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>
);
}
@@ -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,47 @@
import { useEvent } from "@lume/ark";
import { cn } from "@lume/utils";
import { Note } from ".";
import { InfoIcon } from "@lume/icons";
export function NoteChild({
eventId,
isRoot,
}: {
eventId: string;
isRoot?: boolean;
}) {
const { isLoading, isError, data } = useEvent(eventId);
if (isLoading) {
return (
<div className="pt-3 px-3 flex items-center gap-2">
<div className="size-8 shrink-0 rounded-full bg-neutral-200 dark:bg-neutral-800 animate-pulse" />
<div className="animate-pulse rounded-md h-4 w-2/3 bg-neutral-200 dark:bg-neutral-800" />
</div>
);
}
if (isError || !data) {
return (
<div className="pt-3 px-3 flex items-center gap-2">
<div className="size-8 shrink-0 rounded-full bg-red-500 text-white inline-flex items-center justify-center">
<InfoIcon className="size-5" />
</div>
<p className="text-red-500 text-sm">
Event not found with your current relay set
</p>
</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,78 @@
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";
import { Spinner } from "@lume/ui";
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="mt-2 w-full flex h-20 items-center justify-center rounded-xl border border-black/10 dark:border-white/10">
<Spinner className="size-5" />
</div>
);
}
if (isError || !data) {
return (
<div className="mt-2 w-full rounded-xl border border-black/10 p-3 dark:border-white/10">
{t("note.error")}
</div>
);
}
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>
);
}
+108
View File
@@ -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>
);
}
+47
View File
@@ -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>
);
}
+74 -106
View File
@@ -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>
);
} }
+28 -36
View File
@@ -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>
);
} }
+8 -8
View File
@@ -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>
);
}
+158 -126
View File
@@ -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>
);
} }
+247 -49
View File
@@ -1,55 +1,253 @@
import { ComposeFilledIcon, PlusIcon } from "@lume/icons"; import {
import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router"; BellIcon,
import { cn } from "@lume/utils"; ComposeFilledIcon,
import { Accounts } from "@/components/accounts"; HorizontalDotsIcon,
import { platform } from "@tauri-apps/plugin-os"; PlusIcon,
SearchIcon,
} from "@lume/icons";
import { type Event, Kind } from "@lume/types";
import { User } from "@/components/user";
import {
cn,
decodeZapInvoice,
displayNpub,
sendNativeNotification,
} from "@lume/utils";
import { Outlet, createFileRoute } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { getCurrent } from "@tauri-apps/api/window";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import * as Popover from "@radix-ui/react-popover";
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_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 shrink-0 items-center justify-center rounded-full bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20"
> >
<PlusIcon className="size-5" /> <PlusIcon className="size-5" />
</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 { ark, accounts } = Route.useRouteContext();
const { account } = Route.useParams();
const [windowWidth, setWindowWidth] = useState<number>(null);
const navigate = Route.useNavigate();
const sortedList = useMemo(() => {
const list = accounts;
for (const [i, item] of list.entries()) {
if (item === account) {
list.splice(i, 1);
list.unshift(item);
}
}
return list;
}, [accounts]);
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_account(npub);
if (select) {
return navigate({ to: "/$account/home", params: { account: npub } });
} else {
toast.warning("Something wrong.");
}
};
const getWindowDimensions = () => {
const { innerWidth: width, innerHeight: height } = window;
return {
width,
height,
};
};
useEffect(() => {
function handleResize() {
setWindowWidth(getWindowDimensions().width);
}
if (!windowWidth) setWindowWidth(getWindowDimensions().width);
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return (
<div data-tauri-drag-region className="flex items-center gap-3">
{sortedList
.slice(0, windowWidth > 500 ? account.length : 2)
.map((user) => (
<button key={user} type="button" onClick={() => changeAccount(user)}>
<User.Provider pubkey={user}>
<User.Root
className={cn(
"shrink-0 rounded-full transition-all ease-in-out duration-150 will-change-auto",
user === account
? "ring-1 ring-teal-500 ring-offset-2 ring-offset-neutral-200 dark:ring-offset-neutral-950"
: "",
)}
>
<User.Avatar
className={cn(
"aspect-square h-auto rounded-full object-cover transition-all ease-in-out duration-150 will-change-auto",
user === account ? "w-7" : "w-8",
)}
/>
</User.Root>
</User.Provider>
</button>
))}
{accounts.length >= 3 && windowWidth <= 700 ? (
<Popover.Root>
<Popover.Trigger className="inline-flex size-8 shrink-0 items-center justify-center rounded-full bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20">
<HorizontalDotsIcon className="size-5" />
</Popover.Trigger>
<Popover.Portal>
<Popover.Content className="flex h-11 select-none items-center justify-center rounded-md bg-neutral-950 p-1 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
{sortedList.slice(2).map((user) => (
<button
key={user}
type="button"
onClick={() => changeAccount(user)}
className="size-9 inline-flex items-center justify-center hover:bg-white/10 rounded-md"
>
<User.Provider pubkey={user}>
<User.Root className="rounded-full ring-1 ring-white/10">
<User.Avatar className="size-7 aspect-square h-auto rounded-full object-cover" />
</User.Root>
</User.Provider>
</button>
))}
<Popover.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
) : null}
</div>
);
}
function Bell() {
const { 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>
);
} }
+51 -22
View File
@@ -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>
);
}
-119
View File
@@ -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>
);
}
+8 -8
View File
@@ -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>
); );
} }
+180 -171
View File
@@ -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>
);
} }
+124 -127
View File
@@ -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>
);
} }
+76 -76
View File
@@ -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>
); );
} }
+74 -2
View File
@@ -1,9 +1,81 @@
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>
<div className="flex flex-col gap-1 items-center">
<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>
{loading ? (
<p className="text-neutral-600 dark:text-neutral-400 text-sm text-center">
Waiting confirmation...
</p>
) : null}
</div>
</div>
</div>
);
} }
+196 -166
View File
@@ -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>
); );
} }
+182 -102
View File
@@ -1,117 +1,197 @@
import { CheckCircleIcon } from "@lume/icons"; import { CancelIcon, CheckCircleIcon, PlusIcon } 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 { createFileRoute, useRouter } from "@tanstack/react-router"; import { User } from "@/components/user";
import { createFileRoute } 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 navigate = Route.useNavigate();
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("");
const [users, setUsers] = useState<Array<string>>([]); const [npub, setNpub] = useState("");
const [isDone, setIsDone] = useState(false); const [users, setUsers] = useState<string[]>([
"npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445", // reya
]);
const [isLoading, setIsLoading] = useState(false);
const toggleUser = (pubkey: string) => { const toggleUser = (pubkey: string) => {
const arr = users.includes(pubkey) setUsers((prev) =>
? users.filter((i) => i !== pubkey) prev.includes(pubkey)
: [...users, pubkey]; ? prev.filter((i) => i !== pubkey)
setUsers(arr); : [...prev, pubkey],
}; );
};
const submit = async () => { const addUser = () => {
try { if (!npub.startsWith("npub1")) return;
if (isDone) return router.history.push(redirect); if (users.includes(npub)) return;
const groups = await ark.set_nstore( setUsers((prev) => [...prev, npub]);
`lume_group_${label}`, setNpub("");
JSON.stringify(users), };
);
if (groups) setIsDone(true); const submit = async () => {
} catch (e) { try {
toast.error(e); setIsLoading(true);
}
};
return ( const key = `lume_group_${label}`;
<Column.Root> const createGroup = await ark.set_nstore(key, JSON.stringify(users));
<Column.Header label={label} name={name} />
<Column.Content> if (createGroup) {
<div className="flex flex-col gap-5 p-3"> return navigate({ to: redirect });
<div className="flex flex-col gap-1"> }
<label htmlFor="name" className="font-medium"> } catch (e) {
Name setIsLoading(false);
</label> toast.error(e);
<input }
name="name" };
value={title}
onChange={(e) => setTitle(e.target.value)} return (
placeholder="Nostrichs..." <div className="w-full h-full flex flex-col items-center justify-center gap-4">
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="text-center flex flex-col items-center justify-center">
/> <h1 className="text-2xl font-serif font-medium">
</div> Focus feeds for people you like
<div className="flex flex-col gap-1"> </h1>
<div className="inline-flex items-center justify-between"> <p className="leading-tight text-neutral-700 dark:text-neutral-300">
<span className="font-medium">Pick user</span> Add some people for custom feeds.
<span className="text-xs text-neutral-600 dark:text-neutral-400">{`${users.length} / ∞`}</span> </p>
</div> </div>
<div className="flex flex-col gap-2"> <div className="w-4/5 max-w-full flex flex-col gap-3">
{contacts.map((item: string) => ( <div className="w-full h-9 shrink-0 flex items-center bg-black/5 dark:bg-white/5 rounded-lg">
<button <label
key={item} htmlFor="name"
type="button" className="w-16 border-r border-black/10 dark:border-white/10 shrink-0 text-center text-sm font-semibold"
onClick={() => toggleUser(item)} >
className="inline-flex items-center justify-between px-3 py-2 rounded-xl bg-neutral-50 dark:bg-neutral-950 hover:bg-neutral-100 dark:hover:bg-neutral-900" Name
> </label>
<User.Provider pubkey={item}> <input
<User.Root className="flex items-center gap-2.5"> name="name"
<User.Avatar className="size-10 rounded-full object-cover" /> value={title}
<div className="flex flex-col items-start"> onChange={(e) => setTitle(e.target.value)}
<User.Name className="font-medium" /> placeholder="Enter a name for this group"
<User.NIP05 className="text-neutral-700 dark:text-neutral-300" /> className="h-full bg-transparent border-none text-sm px-3 placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
</div> />
</User.Root> </div>
</User.Provider> <div className="w-full flex flex-col items-center gap-3">
{users.includes(item) ? ( <div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] flex flex-col gap-3 bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl">
<CheckCircleIcon className="size-5 text-teal-500" /> <div className="flex gap-2">
) : null} <input
</button> name="npub"
))} value={npub}
</div> onChange={(e) => setNpub(e.target.value)}
</div> placeholder="npub1..."
</div> className="h-9 w-full rounded-lg bg-black/10 dark:bg-white/10 border-none text-sm px-3 placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
<div className="fixed z-10 flex items-center justify-center w-full bottom-3"> />
<button <button
type="button" type="button"
onClick={submit} onClick={() => addUser()}
disabled={users.length < 1} className="inline-flex size-9 rounded-lg items-center justify-center bg-black/20 dark:bg-white/20 shrink-0 text-white hover:bg-blue-500"
className="inline-flex items-center justify-center 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" >
> <PlusIcon className="size-6" />
{isDone ? "Back" : "Update"} </button>
</button> </div>
</div> <div className="flex flex-col gap-2">
</Column.Content> <span className="text-sm font-semibold">Added</span>
</Column.Root> <div className="flex flex-col gap-2">
); {users.length ? (
users.map((item: string) => (
<button
key={item}
type="button"
onClick={() => toggleUser(item)}
className="inline-flex items-center justify-between px-3 py-2 rounded-lg bg-white dark:bg-black/20 backdrop-blur-lg shadow-primary dark:ring-1 ring-neutral-800/50"
>
<User.Provider pubkey={item}>
<User.Root className="flex items-center gap-2.5">
<User.Avatar className="size-8 rounded-full object-cover" />
<div className="flex items-center gap-1">
<User.Name className="text-sm font-medium" />
</div>
</User.Root>
</User.Provider>
<div>
<CancelIcon className="size-4" />
</div>
</button>
))
) : (
<div className="bg-black/5 dark:bg-white/5 text-sm flex items-center justify-center h-14 rounded-lg">
Empty.
</div>
)}
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-semibold">Contacts</span>
<div className="flex flex-col gap-2">
{contacts.length ? (
contacts.map((item: string) => (
<button
key={item}
type="button"
onClick={() => toggleUser(item)}
className="inline-flex items-center justify-between px-3 py-2 rounded-lg bg-white dark:bg-black/20 backdrop-blur-lg shadow-primary dark:ring-1 ring-neutral-800/50"
>
<User.Provider pubkey={item}>
<User.Root className="flex items-center gap-2.5">
<User.Avatar className="size-8 rounded-full object-cover" />
<div className="flex items-center gap-1">
<User.Name className="text-sm font-medium" />
</div>
</User.Root>
</User.Provider>
</button>
))
) : (
<div className="bg-black/5 dark:bg-white/5 text-sm flex items-center justify-center h-14 rounded-lg">
<p>
Find more user at{" "}
<a
href="https://www.nostr.directory/"
target="_blank"
className="text-blue-600 after:content-['_↗']"
rel="noreferrer"
>
Nostr Directory
</a>
</p>
</div>
)}
</div>
</div>
</div>
<button
type="button"
onClick={() => submit()}
disabled={isLoading || users.length < 1}
className="inline-flex items-center justify-center w-36 rounded-full h-9 bg-blue-500 text-white text-sm font-medium hover:bg-blue-600 disabled:opacity-50"
>
{isLoading ? <Spinner /> : "Confirm"}
</button>
</div>
</div>
</div>
);
} }
@@ -0,0 +1,86 @@
import type { ColumnRouteSearch } from "@lume/types";
import { Spinner } from "@lume/ui";
import { createFileRoute } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { useState } from "react";
import { toast } from "sonner";
export const Route = createFileRoute("/create-newsfeed/f2f")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
component: Screen,
});
function Screen() {
const navigate = Route.useNavigate();
const { redirect } = Route.useSearch();
const [npub, setNpub] = useState("");
const [isLoading, setIsLoading] = useState(false);
const submit = async () => {
if (!npub.startsWith("npub1"))
return toast.warning("You must enter a valid npub.");
try {
setIsLoading(true);
const sync: boolean = await invoke("friend_to_friend", { npub });
if (sync) {
return navigate({ to: redirect });
}
} catch (e) {
setIsLoading(false);
toast.error(String(e));
}
};
return (
<div className="overflow-y-auto scrollbar-none p-2 shrink-0 h-[450px] bg-white dark:bg-white/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
<div className="h-full flex flex-col justify-between">
<div className="flex-1 flex flex-col gap-1.5 justify-center px-5">
<p className="font-semibold text-neutral-500">
You already have a friend on Nostr?
</p>
<p>Instead of building the timeline by yourself.</p>
<p className="font-semibold text-neutral-500">
Just enter your friend's{" "}
<span className="text-blue-500">npub.</span>
</p>
<p>
You will have the same experience as your friend. Of course, you
always can edit your network later.
</p>
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1">
<label htmlFor="npub" className="font-medium text-sm">
NPUB
</label>
<input
name="npub"
placeholder="npub1..."
value={npub}
onChange={(e) => setNpub(e.target.value)}
spellCheck={false}
className="h-11 rounded-lg bg-transparent border border-neutral-200 dark:border-neutral-800 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:placeholder:text-neutral-400"
/>
</div>
<button
type="button"
onClick={() => submit()}
className="inline-flex items-center justify-center w-full rounded-lg h-9 bg-blue-500 text-white text-sm font-medium hover:bg-blue-600"
>
{isLoading ? <Spinner /> : "Confirm"}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,53 @@
import { cn } from "@lume/utils";
import { Link, Outlet } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/create-newsfeed")({
component: Screen,
});
function Screen() {
return (
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
<div className="text-center flex flex-col items-center justify-center">
<h1 className="text-2xl font-serif font-medium">
Build up your timeline.
</h1>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
Follow some people to keep up to date with them.
</p>
</div>
<div className="w-4/5 max-w-full flex flex-col gap-3">
<div className="w-full h-9 shrink-0 flex items-center justify-between bg-black/5 dark:bg-white/5 rounded-lg px-0.5">
<Link to="/create-newsfeed/users" className="flex-1 h-8">
{({ isActive }) => (
<div
className={cn(
"text-sm font-medium rounded-md h-full flex items-center justify-center",
isActive
? "bg-white dark:bg-white/20 shadow"
: "bg-transparent",
)}
>
Users
</div>
)}
</Link>
<Link to="/create-newsfeed/f2f" className="flex-1 h-8">
{({ isActive }) => (
<div
className={cn(
"rounded-md h-full flex items-center justify-center",
isActive ? "bg-white dark:bg-white/20" : "bg-transparent",
)}
>
Friend to Friend
</div>
)}
</Link>
</div>
<Outlet />
</div>
</div>
);
}
@@ -0,0 +1,128 @@
import { createFileRoute } from "@tanstack/react-router";
import { Suspense, useState } from "react";
import { Await, defer } from "@tanstack/react-router";
import { User } from "@/components/user";
import { Spinner } from "@lume/ui";
import { toast } from "sonner";
import type { ColumnRouteSearch } from "@lume/types";
export const Route = createFileRoute("/create-newsfeed/users")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
loader: async ({ abortController }) => {
try {
return {
data: defer(
fetch("https://api.nostr.band/v0/trending/profiles", {
signal: abortController.signal,
}).then((res) => res.json()),
),
};
} catch (e) {
throw new Error(String(e));
}
},
component: Screen,
});
function Screen() {
const { ark } = Route.useRouteContext();
const { data } = Route.useLoaderData();
const { redirect } = Route.useSearch();
const [isLoading, setIsLoading] = useState(false);
const [follows, setFollows] = useState<string[]>([]);
const navigate = Route.useNavigate();
const toggleFollow = (pubkey: string) => {
setFollows((prev) =>
prev.includes(pubkey)
? prev.filter((i) => i !== pubkey)
: [...prev, pubkey],
);
};
const submit = async () => {
try {
setIsLoading(true);
const newContactList = await ark.set_contact_list(follows);
if (newContactList) {
return navigate({ to: redirect });
}
} catch (e) {
setIsLoading(false);
toast.error(String(e));
}
};
return (
<div className="w-full flex flex-col items-center gap-3">
<div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl">
<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}>
{(users) =>
users.profiles.map((item: { pubkey: string }) => (
<div
key={item.pubkey}
className="h-max w-full overflow-hidden mb-2 p-2 bg-white dark:bg-black/20 backdrop-blur-lg rounded-lg shadow-primary dark:ring-1 ring-neutral-800/50"
>
<User.Provider pubkey={item.pubkey}>
<User.Root>
<div className="flex h-full w-full flex-col gap-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<User.Avatar className="size-7 shrink-0 rounded-full object-cover" />
<User.Name className="text-sm leadning-tight max-w-[15rem] truncate font-semibold" />
</div>
<button
type="button"
onClick={() => toggleFollow(item.pubkey)}
className="inline-flex h-7 w-20 items-center justify-center rounded-lg bg-black/10 text-sm font-medium hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
>
{follows.includes(item.pubkey)
? "Unfollow"
: "Follow"}
</button>
</div>
<User.About className="line-clamp-3 max-w-none select-text text-neutral-800 dark:text-neutral-400" />
</div>
</User.Root>
</User.Provider>
</div>
))
}
</Await>
</Suspense>
</div>
<button
type="button"
onClick={() => submit()}
disabled={isLoading || follows.length < 1}
className="inline-flex items-center justify-center w-36 rounded-full h-9 bg-blue-500 text-white text-sm font-medium hover:bg-blue-600 disabled:opacity-50"
>
{isLoading ? <Spinner /> : "Confirm"}
</button>
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
import { CheckCircleIcon } from "@lume/icons";
import type { ColumnRouteSearch, Topic } from "@lume/types";
import { Spinner } from "@lume/ui";
import { TOPICS } from "@lume/utils";
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { toast } from "sonner";
export const Route = createFileRoute("/create-topic")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
component: Screen,
});
function Screen() {
const { label, redirect } = Route.useSearch();
const { ark } = Route.useRouteContext();
const [topics, setTopics] = useState<Topic[]>([]);
const [isLoading, setIsLoading] = useState(false);
const navigate = Route.useNavigate();
const toggleTopic = (topic: Topic) => {
setTopics((prev) =>
prev.find((item) => item.title === topic.title)
? prev.filter((i) => i.title !== topic.title)
: [...prev, topic],
);
};
const submit = async () => {
try {
setIsLoading(true);
const key = `lume_topic_${label}`;
const createTopic = await ark.set_nstore(key, JSON.stringify(topics));
if (createTopic) {
return navigate({ to: redirect });
}
} catch (e) {
setIsLoading(false);
toast.error(String(e));
}
};
return (
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
<div className="text-center flex flex-col items-center justify-center">
<h1 className="text-2xl font-serif font-medium">
What are your interests?
</h1>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
Add some topics you want to focus on.
</p>
</div>
<div className="w-4/5 max-w-full flex flex-col gap-3">
<div className="w-full h-9 shrink-0 flex items-center justify-between bg-black/5 dark:bg-white/5 rounded-lg px-3">
<span className="text-sm font-medium">Added: {topics.length}</span>
</div>
<div className="w-full flex flex-col items-center gap-3">
<div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl">
<div className="flex flex-col gap-3">
{TOPICS.map((topic) => (
<button
key={topic.title}
onClick={() => toggleTopic(topic)}
className="h-11 px-3 flex items-center justify-between bg-white dark:bg-black/20 backdrop-blur-lg border border-transparent hover:border-blue-500 rounded-lg shadow-primary dark:ring-1 ring-neutral-800/50"
>
<div className="inline-flex items-center gap-1">
<div>{topic.icon}</div>
<div className="text-sm font-medium">
<span>{topic.title}</span>
<span className="ml-1 italic text-neutral-400 dark:text-neutral-600 font-normal">
{topic.content.length} hashtags
</span>
</div>
</div>
{topics.find((item) => item.title === topic.title) ? (
<CheckCircleIcon className="text-teal-500 size-4" />
) : null}
</button>
))}
</div>
</div>
<button
type="button"
onClick={() => submit()}
disabled={isLoading || topics.length < 1}
className="inline-flex items-center justify-center w-36 rounded-full h-9 bg-blue-500 text-white text-sm font-medium hover:bg-blue-600 disabled:opacity-50"
>
{isLoading ? <Spinner /> : "Confirm"}
</button>
</div>
</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>
);
}
+281 -379
View File
@@ -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>
);
} }
-145
View File
@@ -1,145 +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, redirect } from "@tanstack/react-router";
import { Virtualizer } from "virtua";
export const Route = createFileRoute("/foryou")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
beforeLoad: async ({ search, context }) => {
const ark = context.ark;
const interests = await ark.get_interest();
if (!interests) {
throw redirect({
to: "/interests",
search: {
...search,
redirect: "/foryou",
},
});
}
return {
interests,
};
},
component: Screen,
});
export function Screen() {
const { label, name, account } = Route.useSearch();
const { ark, interests } = 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_from_interests(
interests.hashtags,
20,
pageParam,
true,
);
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">
<button type="button" className="size-5" disabled>
<LoaderIcon className="size-5 animate-spin" />
</button>
</div>
) : !data.length ? (
<Empty />
) : (
<Virtualizer overscan={3}>
{data.map((item) => renderItem(item))}
</Virtualizer>
)}
{data?.length && hasNextPage ? (
<div className="flex h-20 items-center justify-center">
<button
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || 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>
</div>
) : null}
</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>
);
}
+133 -110
View File
@@ -1,127 +1,150 @@
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 { label, 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: [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_global_events(20, pageParam);
select: (data) => data?.pages.flatMap((page) => page), return events;
refetchOnWindowFocus: false, },
}); getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
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>
); );
} }
+148 -119
View File
@@ -1,137 +1,166 @@
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, 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 key = `lume_group_${search.label}`;
const groups = (await ark.get_nstore(key)) as string[];
const settings = await ark.get_settings();
if (!groups) { if (!groups?.length) {
throw redirect({ throw redirect({
to: "/create-group", to: "/create-group",
search: { search: {
...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 { label, 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: [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_local_events(groups, 20, pageParam);
select: (data) => data?.pages.flatMap((page) => page), return events;
refetchOnWindowFocus: false, },
}); getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
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} />; 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" />;
<LoaderIcon className="size-5 animate-spin" /> }
</div>
) : !data.length ? ( return <TextNote key={event.id} event={event} className="mb-3" />;
<Empty /> }
) : ( }
<Virtualizer overscan={3}> };
{data.map((item) => renderItem(item))}
</Virtualizer> return (
)} <div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
<div className="flex h-20 items-center justify-center"> {isFetching && !isLoading && !isFetchingNextPage ? (
{hasNextPage ? ( <div className="mb-3 w-full h-11 flex items-center justify-center bg-black/10 dark:bg-white/10 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
<button <div className="flex items-center justify-center gap-2">
type="button" <Spinner className="size-5" />
onClick={() => fetchNextPage()} <span className="text-sm font-medium">Fetching new notes...</span>
disabled={!hasNextPage || 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" </div>
> ) : null}
{isFetchingNextPage ? ( {isLoading ? (
<LoaderIcon className="size-5 animate-spin" /> <div className="flex h-16 w-full items-center justify-center gap-2">
) : ( <Spinner className="size-5" />
<> <span className="text-sm font-medium">Loading...</span>
<ArrowRightCircleIcon className="size-5" /> </div>
Load more ) : !data.length ? (
</> <Empty />
)} ) : (
</button> <Virtualizer overscan={3}>
) : null} {data.map((item) => renderItem(item))}
</div> </Virtualizer>
</Column.Content> )}
</Column.Root> {data?.length && hasNextPage ? (
); <div>
<button
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-neutral-100 px-3 font-medium hover:bg-neutral-50 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
>
{isFetchingNextPage ? (
<Spinner className="size-5" />
) : (
<>
<ArrowRightCircleIcon className="size-5" />
Load more
</>
)}
</button>
</div>
) : null}
</div>
);
} }
function Empty() { 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
View File
@@ -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_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_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 flex-wrap px-3 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-8" />
</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>
);
} }
-122
View File
@@ -1,122 +0,0 @@
import { ColumnRouteSearch } from "@lume/types";
import { Column } from "@lume/ui";
import { TOPICS, cn } from "@lume/utils";
import { createFileRoute, useRouter } from "@tanstack/react-router";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export const Route = createFileRoute("/interests")({
component: Screen,
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
});
function Screen() {
const { t } = useTranslation();
const { label, name, redirect } = Route.useSearch();
const { ark } = Route.useRouteContext();
const [hashtags, setHashtags] = useState<string[]>([]);
const [isDone, setIsDone] = useState(false);
const router = useRouter();
const toggleHashtag = (item: string) => {
const arr = hashtags.includes(item)
? hashtags.filter((i) => i !== item)
: [...hashtags, item];
setHashtags(arr);
};
const toggleAll = (item: string[]) => {
const sets = new Set([...hashtags, ...item]);
setHashtags([...sets]);
};
const submit = async () => {
try {
if (isDone) {
return router.history.push(redirect);
}
const eventId = await ark.set_interest(undefined, undefined, hashtags);
if (eventId) {
setIsDone(true);
toast.success("Interest has been updated successfully.");
}
} catch (e) {
toast.error(String(e));
}
};
return (
<Column.Root>
<Column.Header label={label} name={name} />
<Column.Content>
<div className="sticky left-0 top-0 flex h-16 w-full items-center justify-between border-b border-neutral-100 bg-white px-3 dark:border-neutral-900 dark:bg-black">
<div className="flex flex-1 flex-col">
<h3 className="font-semibold">Interests</h3>
<p className="text-sm leading-tight text-neutral-700 dark:text-neutral-300">
Pick things you'd like to see.
</p>
</div>
<button
type="button"
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>
</div>
<div className="flex w-full flex-col p-3">
<div className="flex flex-col gap-8">
{TOPICS.map((topic) => (
<div key={topic.title} className="flex flex-col gap-4">
<div className="flex w-full items-center justify-between">
<div className="inline-flex items-center gap-2.5">
<img
src={topic.icon}
alt={topic.title}
className="size-8 rounded-lg object-cover"
/>
<h3 className="text-lg font-semibold">{topic.title}</h3>
</div>
<button
type="button"
onClick={() => toggleAll(topic.content)}
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
{t("interests.followAll")}
</button>
</div>
<div className="flex flex-wrap items-center gap-3">
{topic.content.map((hashtag) => (
<button
key={hashtag}
type="button"
onClick={() => toggleHashtag(hashtag)}
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",
hashtags.includes(hashtag)
? "border-blue-500 text-blue-500"
: "",
)}
>
{hashtag}
</button>
))}
</div>
</div>
))}
</div>
</div>
</Column.Content>
</Column.Root>
);
}
+75 -74
View File
@@ -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>
);
} }
+118 -120
View File
@@ -1,134 +1,132 @@
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, redirect } 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 ({ search, context }) => {
const ark = context.ark; const ark = context.ark;
const settings = await ark.get_settings(); const settings = await ark.get_settings();
const contacts = await ark.get_contact_list();
return { settings }; if (!contacts.length) {
}, throw redirect({
component: Screen, to: "/create-newsfeed/users",
search: {
...search,
redirect: "/newsfeed",
},
});
}
return { settings, contacts };
},
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, contacts } = 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_local_events(contacts, 20, pageParam);
select: (data) => data?.pages.flatMap((page) => page), return events;
refetchOnWindowFocus: false, },
}); getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
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 ? (
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
<button type="button" className="size-5" disabled>
<LoaderIcon className="size-5 animate-spin" />
</button>
</div>
) : !data.length ? (
<Empty />
) : (
<Virtualizer overscan={3}>
{data.map((item) => renderItem(item))}
</Virtualizer>
)}
{data?.length && hasNextPage ? (
<div className="flex h-20 items-center justify-center">
<button
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex h-12 w-36 items-center justify-center gap-2 rounded-full bg-neutral-100 px-3 font-medium hover:bg-neutral-200 focus:outline-none dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
{isFetchingNextPage ? (
<LoaderIcon className="size-5 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="size-5" />
Load more
</>
)}
</button>
</div>
) : null}
</Column.Content>
</Column.Root>
);
}
function Empty() { if (isQuote) {
return ( return <Quote key={event.id} event={event} className="mb-3" />;
<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"> return <TextNote key={event.id} event={event} className="mb-3" />;
<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. return (
</p> <div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
</div> {isFetching && !isLoading && !isFetchingNextPage ? (
<div className="flex flex-col px-3 gap-2"> <div className="mb-3 w-full h-11 flex items-center justify-center bg-black/10 dark:bg-white/10 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
<Link <div className="flex items-center justify-center gap-2">
to="/global" <Spinner className="size-5" />
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" <span className="text-sm font-medium">Fetching new notes...</span>
> </div>
<ArrowRightIcon className="size-5" /> </div>
Show global newsfeed ) : null}
</Link> {isLoading ? (
<Link <div className="flex h-16 w-full items-center justify-center gap-2">
to="/trending/notes" <Spinner className="size-5" />
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" <span className="text-sm font-medium">Loading...</span>
> </div>
<ArrowRightIcon className="size-5" /> ) : !data.length ? (
Show trending notes <div className="flex items-center justify-center">
</Link> Yo. You're catching up on all the things happening around you.
<Link </div>
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" <Virtualizer overscan={3}>
> {data.map((item) => renderItem(item))}
<ArrowRightIcon className="size-5" /> </Virtualizer>
Discover trending users )}
</Link> {data?.length && hasNextPage ? (
</div> <div>
</div> <button
); type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-black/5 px-3 font-medium hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
>
{isFetchingNextPage ? (
<Spinner className="size-5" />
) : (
<>
<ArrowRightCircleIcon className="size-5" />
Load more
</>
)}
</button>
</div>
) : null}
</div>
);
} }
+49 -49
View File
@@ -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>
); );
} }
+112
View File
@@ -0,0 +1,112 @@
import type { ColumnRouteSearch } from "@lume/types";
import { createFileRoute } from "@tanstack/react-router";
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() {
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 flex flex-col gap-3">
<div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10 backdrop-blur-lg">
<div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400">
01.
</div>
<div className="h-16 flex items-center justify-center shrink-0 px-3 text-lg select-text">
Navigate between columns.
</div>
<div className="flex-1 w-3/4 h-full pb-10">
<video
className="h-auto w-full aspect-square rounded-lg shadow-md transform"
controls
muted
>
<source
src="https://video.nostr.build/692f71e2be47ecfc29edcbdaa198cc5979bfb9c900f05d78682895dd546d8d4f.mp4"
type="video/mp4"
/>
Your browser does not support the video tag.
</video>
</div>
</div>
<div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10 backdrop-blur-lg">
<div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400">
02.
</div>
<div className="h-16 flex items-center justify-center shrink-0 px-3 text-lg select-text">
Switch between accounts.
</div>
<div className="flex-1 w-3/4 h-full pb-10">
<video
className="h-auto w-full aspect-square rounded-lg shadow-md transform"
controls
muted
>
<source
src="https://video.nostr.build/d33962520506d86acfb4b55a7b265821e10ae637f5ec830a173b7e6092b16ec8.mp4"
type="video/mp4"
/>
Your browser does not support the video tag.
</video>
</div>
</div>
<div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10 backdrop-blur-lg">
<div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400">
03.
</div>
<div className="h-16 flex items-center justify-center shrink-0 px-3 text-lg select-text">
Open Lume Store.
</div>
<div className="flex-1 w-3/4 h-full pb-10">
<video
className="h-auto w-full aspect-square rounded-lg shadow-md transform"
controls
muted
>
<source
src="https://video.nostr.build/927abbfde2097e470ac751181b1db456b7e4b9149550408efff1a966a7ffb9a8.mp4"
type="video/mp4"
/>
Your browser does not support the video tag.
</video>
</div>
</div>
<div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10 backdrop-blur-lg">
<div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400">
04.
</div>
<div className="h-16 flex items-center justify-center shrink-0 px-3 text-lg select-text">
Use the Tray Menu.
</div>
<div className="flex-1 w-3/4 h-full pb-10">
<video
className="h-auto w-full rounded-lg shadow-md transform"
controls
muted
>
<source
src="https://video.nostr.build/513de4824b6abaf7e9698c1dad2f68096574356848c0c200bc8cb8074df29410.mp4"
type="video/mp4"
/>
Your browser does not support the video tag.
</video>
</div>
</div>
</div>
</div>
);
}
+38 -41
View File
@@ -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>
);
} }
+145
View File
@@ -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>
);
}
+116 -95
View File
@@ -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>
);
} }
+107 -101
View File
@@ -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_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>
})

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