Compare commits

..

5 Commits

Author SHA1 Message Date
Ren Amamiya
1041e1ccd4 Merge pull request #50 from luminous-devs/main
v1.0.1
2023-07-10 14:40:17 +07:00
Ren Amamiya
2eeb2c896d Merge pull request #45 from luminous-devs/main
rebuild with code signing
2023-07-07 12:24:08 +07:00
Ren Amamiya
caf8fb584a Merge pull request #33 from luminous-devs/main
v1.0.0
2023-07-06 08:18:01 +07:00
Ren Amamiya
58205713ab Merge pull request #32 from luminous-devs/main
test github action again
2023-07-05 17:40:35 +07:00
Ren Amamiya
33802d32f3 Merge pull request #31 from luminous-devs/main
test github action
2023-07-05 09:50:57 +07:00
632 changed files with 18860 additions and 47639 deletions

View File

@@ -1,44 +0,0 @@
# Dependencies
**/node_modules
.pnp
.pnp.js
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Testing
coverage
# Turbo
.turbo
# Vercel
.vercel
# Build Outputs
**/.next/
**/out/
**/build
**/dist
**/target
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Misc
.DS_Store
*.pem
# Unnecessary files
**/.git/
.github/
flatpak/*.xml
flatpak/*.desktop
flatpak/*.yml

1
.envrc
View File

@@ -1 +0,0 @@
use flake

49
.eslintrc.js Normal file
View File

@@ -0,0 +1,49 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
settings: {
react: {
version: 'detect',
},
'import/resolver': {
node: {
paths: ['src'],
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
},
},
env: {
browser: true,
amd: true,
node: true,
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react/recommended',
'plugin:jsx-a11y/recommended',
'prettier'
],
plugins: [],
rules: {
'react/react-in-jsx-scope': 'off',
'jsx-a11y/accessible-emoji': 'off',
'react/prop-types': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'jsx-a11y/anchor-is-valid': [
'error',
{
components: ['Link'],
specialLink: ['hrefLeft', 'hrefRight'],
aspects: ['invalidHref', 'preferButton'],
},
],
},
};

View File

@@ -1,72 +0,0 @@
name: Flatpak
on: workflow_dispatch
jobs:
prepare-repo:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: 'recursive'
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: cache of container
id: cache-container
uses: actions/cache@v3
with:
path: prepare-dist
key: ${{ runner.os }}-container-${{ hashFiles('prepare-dist') }}
- name: Run latest-tag
id: latest-tag
uses: oprypin/find-latest-tag@v1
with:
repository:
lumehq/lume
#FIXME: lumehq after merged fix, now it just won't find tags
# repository: ${{ github.repository }}
- name: Build container
# if: steps.cache-container.outputs.cache-hit != 'true'
run: |
docker buildx build -t flatpak-prepare-lume --build-arg=${{steps.latest-tag.outputs.tag}} --rm --output=. --target=final -f flatpak/Containerfile .
- name: Copy flatpak files content
run: |
cp -r flatpak/*.xml flatpak/*.desktop flatpak/*.yml prepare-dist
- uses: actions/upload-artifact@v4
with:
name: repo-dist
path: prepare-dist
flatpak:
name: flatpak-bundle
needs: prepare-repo
runs-on: ubuntu-latest
container:
image: bilelmoussaoui/flatpak-github-actions:gnome-45
options: --privileged
steps:
- uses: actions/download-artifact@v4
with:
name: repo-dist
- uses: actions/checkout@v4
with:
repository: flathub/shared-modules
path: shared-modules
- uses: flatpak/flatpak-github-actions/flatpak-builder@v6
with:
bundle: lume.flatpak
manifest-path: nu.lume.Lume.yml
restore-cache: false
# cache-key: flatpak-builder-${{ github.sha }}
- name: Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
append_body: true
files: lume.flatpak
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: geekyeggo/delete-artifact@v4
with:
name: repo-dist
token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,71 +1,77 @@
name: "Publish"
on: workflow_dispatch
name: 'publish'
on:
push:
branches:
- release
env:
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: short
RUSTFLAGS: "-W unreachable-pub -W rust-2021-compatibility"
RUSTFLAGS: '-W unreachable-pub -W rust-2021-compatibility'
jobs:
publish-tauri:
strategy:
fail-fast: false
matrix:
include:
- platform: "macos-latest" # for Arm based macs (M1 and above).
args: "--target aarch64-apple-darwin"
- platform: "macos-latest" # for Intel based macs.
args: "--target x86_64-apple-darwin"
#- platform: 'ubuntu-22.04'
# args: ''
#- platform: 'windows-latest'
# args: '--target x86_64-pc-windows-msvc'
runs-on: ${{ matrix.platform }}
settings:
- platform: 'macos-latest'
args: '--target universal-apple-darwin'
- platform: 'macos-latest'
args: '--target x86_64-apple-darwin'
- platform: 'macos-latest'
args: '--target aarch64-apple-darwin'
- platform: 'ubuntu-20.04'
args: ''
- platform: 'windows-latest'
args: '--target x86_64-pc-windows-msvc'
- platform: 'windows-latest'
args: '--target i686-pc-windows-msvc'
runs-on: ${{ matrix.settings.platform }}
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
- uses: actions/checkout@v3
- name: setup node
uses: actions/setup-node@v3
with:
node-version: "lts/*"
- name: Install PNPM
uses: pnpm/action-setup@v2
node-version: 18
- uses: dtolnay/rust-toolchain@stable
with:
version: 8.x.x
run_install: false
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04'
targets: aarch64-apple-darwin
- name: install dependencies (ubuntu only)
if: matrix.settings.platform == 'ubuntu-20.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 frontend dependencies
run: pnpm install
sudo apt-get install -y libgtk-3-dev webkit2gtk-4.0 libappindicator3-dev librsvg2-dev patchelf
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 7.x.x
run_install: false
- name: Setup node and cache for package data
uses: actions/setup-node@v3
with:
node-version: 'lts/*'
cache: 'pnpm'
cache-dependency-path: pnpm-lock.yaml
- uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
- run: pnpm install
- uses: tauri-apps/tauri-action@dev
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with:
tagName: v__VERSION__
releaseName: "v__VERSION__"
releaseBody: "See the assets to download this version and install."
tagName: v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version
releaseName: 'App v__VERSION__'
releaseBody: 'See the assets to download this version and install.'
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
includeDebug: false
args: ${{ matrix.settings.args }}

61
.gitignore vendored
View File

@@ -1,38 +1,31 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Dependencies
node_modules
.pnp
.pnp.js
dist
dist-ssr
out
*.local
.next
.vscode
pnpm-lock.yaml
*.db
*.db-journal
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Testing
coverage/
# Turbo
.turbo/
# Vercel
.vercel/
# Build Outputs
.next/
out/
build/
dist/
# Debug
*.log.*
# Misc
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.pem
.vscode/
ndb/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
/.gtm/

4
.husky/pre-commit Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm exec lint-staged

9
.prettierignore Normal file
View File

@@ -0,0 +1,9 @@
.tmp
.cache/
coverage/
.nyc_output/
**/.yarn/**
**/.pnp.*
/dist*/
node_modules/
src-tauri/

22
.prettierrc Normal file
View File

@@ -0,0 +1,22 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"tabWidth": 2,
"printWidth": 90,
"useTabs": false,
"bracketSpacing": true,
"bracketSameLine": false,
"importOrder": [
"^@app/(.*)$",
"^@libs/(.*)$",
"^@shared/(.*)$",
"^@stores/(.*)$",
"^@utils/(.*)$",
"^[./]"
],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true,
"plugins": ["@trivago/prettier-plugin-sort-imports", "prettier-plugin-tailwindcss"],
"pluginSearchDirs": false
}

View File

@@ -1,35 +1,19 @@
_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.
### Introduction
Source code for v3 is stored here: https://github.com/lumehq/lume/tree/old
Lume is a nostr client
--
### Usage
## 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.
## 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 for your platform here: [https://github.com/luminous-devs/lume/releases](https://github.com/luminous-devs/lume/releases)
Supported platform: macOS, Windows and Linux
## Prerequisites
- Node.js >= 18: https://nodejs.org/en
- Rust: https://rustup.rs/
- PNPM: https://pnpm.io
- Tauri v2: https://beta.tauri.app/guides/prerequisites/
## Develop
### Develop
Clone project
```
git clone https://github.com/lumehq/lume.git && cd lume
git clone https://github.com/luminous-devs/lume.git && cd lume
```
Install packages
@@ -38,33 +22,20 @@ Install packages
pnpm install
```
Run dev build
Run dev
```
pnpm tauri dev
```
Generate production build
Build
```
pnpm tauri build
```
## Nix
(Advance) - Generate SQLite migration
Requirements:
1. [Install Nix](https://zero-to-flakes.com/install)
1. [Setup `direnv`](https://zero-to-flakes.com/direnv)
`cd` into the root folder of the project to enter `nix develop` shell. Run `direnv allow` (only once). Then run `pnpm` or `bun` (experimental) commands as described above.
## License
Copyright (C) 2023-2024 Ren Amamiya & other Lume contributors (see AUTHORS.md)
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see https://www.gnu.org/licenses/.
```
pnpm add-migrate <migrate_name>
```

View File

@@ -1,26 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
src/router.gen.ts

View File

@@ -1,14 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lume Desktop</title>
</head>
<body
class="relative h-screen w-screen cursor-default select-none overflow-hidden font-sans text-black antialiased dark:text-white"
>
<div id="root" class="h-full w-full"></div>
<script type="module" src="/src/app.tsx"></script>
</body>
</html>

View File

@@ -1,61 +0,0 @@
{
"name": "@lume/desktop2",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@lume/ark": "workspace:^",
"@lume/icons": "workspace:^",
"@lume/ui": "workspace:^",
"@lume/utils": "workspace:^",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/query-sync-storage-persister": "^5.32.0",
"@tanstack/react-query": "^5.32.0",
"@tanstack/react-query-persist-client": "^5.32.0",
"@tanstack/react-router": "1.29.2",
"i18next": "^23.11.3",
"i18next-resources-to-backend": "^1.2.1",
"minidenticons": "^4.2.1",
"nanoid": "^5.0.7",
"nostr-tools": "^2.5.1",
"react": "^18.3.1",
"react-currency-input-field": "^3.8.0",
"react-dom": "^18.3.1",
"react-hook-form": "^7.51.3",
"react-hotkeys-hook": "^4.5.0",
"react-i18next": "^14.1.1",
"slate": "^0.102.0",
"slate-react": "^0.102.0",
"sonner": "^1.4.41",
"use-debounce": "^10.0.0",
"virtua": "^0.30.2"
},
"devDependencies": {
"@lume/tailwindcss": "workspace:^",
"@lume/tsconfig": "workspace:^",
"@lume/types": "workspace:^",
"@tanstack/router-devtools": "^1.31.3",
"@tanstack/router-vite-plugin": "^1.30.0",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react-swc": "^3.6.0",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5",
"vite": "^5.2.10",
"vite-plugin-top-level-await": "^1.4.1",
"vite-tsconfig-paths": "^4.3.2"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 951 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.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

View File

@@ -1,122 +0,0 @@
@tailwind base;
@tailwind utilities;
@tailwind components;
*::-webkit-scrollbar {
@apply w-[5px];
}
*::-webkit-scrollbar-track {
@apply bg-transparent;
}
*::-webkit-scrollbar-thumb {
@apply rounded bg-black dark:bg-white;
}
@layer utilities {
.content-break {
word-break: break-word;
word-wrap: break-word;
overflow-wrap: break-word;
}
.shadow-toolbar {
box-shadow:
0 0 #0000,
0 0 #0000,
0 8px 24px 0 rgba(0, 0, 0, 0.2),
0 2px 8px 0 rgba(0, 0, 0, 0.08),
inset 0 0 0 1px rgba(0, 0, 0, 0.2),
inset 0 0 0 2px hsla(0, 0%, 100%, 0.14);
}
.shadow-primary {
box-shadow: 0px 0px 4px rgba(66, 65, 73, 0.14);
}
}
/*
Overide some default styles
*/
html {
font-size: 14px;
}
a {
@apply cursor-default no-underline !important;
}
button {
@apply cursor-default focus:outline-none;
}
input::-ms-reveal,
input::-ms-clear {
display: none;
}
::-webkit-input-placeholder {
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;
}
}

View File

@@ -1,72 +0,0 @@
import { Ark } from "@lume/ark";
import { CancelCircleIcon, CheckCircleIcon, InfoCircleIcon } from "@lume/icons";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
import { QueryClient } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { platform } from "@tauri-apps/plugin-os";
import React, { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import { I18nextProvider } from "react-i18next";
import { Toaster } from "sonner";
import "./app.css";
import i18n from "./locale";
import { routeTree } from "./router.gen"; // auto generated file
const ark = new Ark();
const queryClient = new QueryClient();
const platformName = await platform();
const persister = createSyncStoragePersister({
storage: window.localStorage,
});
// Set up a Router instance
const router = createRouter({
routeTree,
context: {
ark,
queryClient,
platform: platformName,
},
});
// Register things for typesafety
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
function App() {
return <RouterProvider router={router} />;
}
// biome-ignore lint/style/noNonNullAssertion: idk
const rootElement = document.getElementById("root")!;
if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<I18nextProvider i18n={i18n} defaultNS={"translation"}>
<PersistQueryClientProvider
client={queryClient}
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>,
);
}

View File

@@ -1,47 +0,0 @@
import { Spinner } from "@lume/ui";
import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import {
type Dispatch,
type ReactNode,
type SetStateAction,
useState,
} from "react";
import { toast } from "sonner";
export function AvatarUploader({
setPicture,
children,
className,
}: {
setPicture: Dispatch<SetStateAction<string>>;
children: ReactNode;
className?: string;
}) {
const { ark } = useRouteContext({ strict: false });
const [loading, setLoading] = useState(false);
const uploadAvatar = async () => {
// start loading
setLoading(true);
try {
const image = await ark.upload();
setPicture(image);
} catch (e) {
toast.error(String(e));
}
// stop loading
setLoading(false);
};
return (
<button
type="button"
onClick={() => uploadAvatar()}
className={cn("size-4", className)}
>
{loading ? <Spinner className="size-4" /> : children}
</button>
);
}

View File

@@ -1,42 +0,0 @@
import { User } from "@lume/ui";
import { getBitcoinDisplayValues } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { useEffect, useMemo, useState } from "react";
export function Balance({ account }: { account: string }) {
const { ark } = useRouteContext({ strict: false });
const [balance, setBalance] = useState(0);
const value = useMemo(() => getBitcoinDisplayValues(balance), [balance]);
useEffect(() => {
async function getBalance() {
const val = await ark.get_balance();
setBalance(val);
}
getBalance();
}, []);
return (
<div
data-tauri-drag-region
className="flex h-16 items-center justify-end px-3"
>
<div className="flex items-center gap-2">
<div className="text-end">
<div className="text-sm leading-tight text-neutral-700 dark:text-neutral-300">
Your balance
</div>
<div className="font-medium leading-tight">
{value.bitcoinFormatted}
</div>
</div>
<User.Provider pubkey={account}>
<User.Root>
<User.Avatar className="size-9 rounded-full" />
</User.Root>
</User.Provider>
</div>
</div>
);
}

View File

@@ -1,160 +0,0 @@
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 Col({
column,
account,
isScroll,
isResize,
}: {
column: LumeColumn;
account: string;
isScroll: boolean;
isResize: boolean;
}) {
const container = useRef<HTMLDivElement>(null);
const [webview, setWebview] = useState<string | undefined>(undefined);
const repositionWebview = async () => {
if (webview && webview.length > 1) {
const newRect = container.current.getBoundingClientRect();
await invoke("reposition_column", {
label: webview,
x: newRect.x,
y: newRect.y,
});
}
};
const resizeWebview = async () => {
if (webview && webview.length > 1) {
const newRect = container.current.getBoundingClientRect();
await invoke("resize_column", {
label: webview,
width: newRect.width,
height: newRect.height,
});
}
};
useEffect(() => {
resizeWebview();
}, [isResize]);
useEffect(() => {
if (isScroll) repositionWebview();
}, [isScroll]);
useEffect(() => {
(async () => {
if (webview && webview.length > 1) return;
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
const label: string = await invoke("create_column", {
label: windowLabel,
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
url,
});
setWebview(label);
})();
// close webview when unmounted
return () => {
if (webview && webview.length > 1) {
invoke("close_column", {
label: webview,
});
}
};
}, [webview]);
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>
);
}

View File

@@ -1,48 +0,0 @@
import { ThreadIcon } from "@lume/icons";
import type { Event } from "@lume/types";
import { Note } from "@lume/ui";
import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
export function Conversation({
event,
className,
}: {
event: Event;
className?: string;
}) {
const { ark } = useRouteContext({ strict: false });
const thread = ark.parse_event_thread(event.tags);
return (
<Note.Provider event={event}>
<Note.Root
className={cn(
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
className,
)}
>
<div className="flex flex-col gap-3">
{thread?.root ? <Note.Child eventId={thread?.root} isRoot /> : null}
<div className="flex items-center gap-2 px-3">
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
<ThreadIcon className="size-4" />
Thread
</div>
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
</div>
{thread?.reply ? <Note.Child eventId={thread?.reply} /> : null}
<div>
<div className="px-3 h-14 flex items-center justify-between">
<Note.User />
</div>
<Note.Content className="px-3" />
</div>
</div>
<div className="flex items-center h-14 px-3">
<Note.Open />
</div>
</Note.Root>
</Note.Provider>
);
}

View File

@@ -1,32 +0,0 @@
import type { Event } from "@lume/types";
import { Note } from "@lume/ui";
import { cn } from "@lume/utils";
export function Notification({
event,
className,
}: {
event: Event;
className?: string;
}) {
return (
<Note.Provider event={event}>
<Note.Root
className={cn(
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
className,
)}
>
<div>
<div className="px-3 h-14 flex items-center justify-between">
<Note.User />
</div>
<Note.Content className="px-3" />
</div>
<div className="flex items-center h-14 px-3">
<Note.Open />
</div>
</Note.Root>
</Note.Provider>
);
}

View File

@@ -1,47 +0,0 @@
import { QuoteIcon } from "@lume/icons";
import type { Event } from "@lume/types";
import { Note } from "@lume/ui";
import { cn } from "@lume/utils";
export function Quote({
event,
className,
}: {
event: Event;
className?: string;
}) {
const quoteEventId = event.tags.find(
(tag) => tag[0] === "q" || tag[3] === "mention",
)?.[1];
return (
<Note.Provider event={event}>
<Note.Root
className={cn(
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
className,
)}
>
<div className="flex flex-col gap-3">
<Note.Child eventId={quoteEventId} isRoot />
<div className="flex items-center gap-2 px-3">
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
<QuoteIcon className="size-4" />
Quote
</div>
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
</div>
<div>
<div className="px-3 h-14 flex items-center justify-between">
<Note.User />
</div>
<Note.Content className="px-3" quote={false} clean />
</div>
</div>
<div className="flex items-center h-14 px-3">
<Note.Open />
</div>
</Note.Root>
</Note.Provider>
);
}

View File

@@ -1,83 +0,0 @@
import type { Event } from "@lume/types";
import { Note, Spinner, User } from "@lume/ui";
import { cn } from "@lume/utils";
import { useQuery } from "@tanstack/react-query";
import { useRouteContext } from "@tanstack/react-router";
export function RepostNote({
event,
className,
}: {
event: Event;
className?: string;
}) {
const { ark } = useRouteContext({ strict: false });
const {
isLoading,
isError,
data: repostEvent,
} = useQuery({
queryKey: ["repost", event.id],
queryFn: async () => {
try {
if (event.content.length > 50) {
const embed: Event = JSON.parse(event.content);
return embed;
}
const id = event.tags.find((el) => el[0] === "e")?.[1];
const repostEvent = await ark.get_event(id);
return repostEvent;
} catch (e) {
throw new Error(e);
}
},
refetchOnWindowFocus: false,
refetchOnMount: false,
});
return (
<Note.Root
className={cn(
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl mb-3 shadow-primary dark:ring-1 ring-neutral-800/50",
className,
)}
>
<User.Provider pubkey={event.pubkey}>
<User.Root className="flex items-center gap-2 px-3 py-3 border-b border-neutral-100 dark:border-neutral-800/50 rounded-t-xl">
<div className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
Reposted by
</div>
<User.Avatar className="size-6 shrink-0 rounded-full object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
</User.Root>
</User.Provider>
{isLoading ? (
<div className="flex h-20 items-center justify-center gap-2">
<Spinner />
Loading event...
</div>
) : isError || !repostEvent ? (
<div className="flex h-20 items-center justify-center">
Event not found within your current relay set
</div>
) : (
<Note.Provider event={repostEvent}>
<Note.Root>
<div className="px-3 h-14 flex items-center justify-between">
<Note.User />
<Note.Menu />
</div>
<Note.Content className="px-3" />
<div className="mt-3 flex items-center gap-4 h-14 px-3">
<Note.Open />
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
</Note.Root>
</Note.Provider>
)}
</Note.Root>
);
}

View File

@@ -1,34 +0,0 @@
import type { Event } from "@lume/types";
import { Note } from "@lume/ui";
import { cn } from "@lume/utils";
export function TextNote({
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 shadow-primary dark:ring-1 ring-neutral-800/50",
className,
)}
>
<div className="px-3 h-14 flex items-center justify-between">
<Note.User />
<Note.Menu />
</div>
<Note.Content className="px-3" />
<div className="mt-3 flex items-center gap-4 h-14 px-3">
<Note.Open />
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
</Note.Root>
</Note.Provider>
);
}

View File

@@ -1,15 +0,0 @@
import type { ReactNode } from "@tanstack/react-router";
import { useLayoutEffect, useState } from "react";
import { createPortal } from "react-dom";
export function Toolbar({ children }: { children: ReactNode }) {
const [domReady, setDomReady] = useState(false);
useLayoutEffect(() => {
setDomReady(true);
}, []);
return domReady
? createPortal(children, document.getElementById("toolbar"))
: null;
}

View File

@@ -1,23 +0,0 @@
import { resolveResource } from "@tauri-apps/api/path";
import { readTextFile } from "@tauri-apps/plugin-fs";
import i18n from "i18next";
import resourcesToBackend from "i18next-resources-to-backend";
import { initReactI18next } from "react-i18next";
i18n
.use(
resourcesToBackend(async (language: string) => {
const file_path = await resolveResource(`locales/${language}.json`);
return JSON.parse(await readTextFile(file_path));
}),
)
.use(initReactI18next)
.init({
lng: "en",
fallbackLng: "en",
interpolation: {
escapeValue: false,
},
});
export default i18n;

View File

@@ -1,182 +0,0 @@
import { Col } from "@/components/col";
import { Toolbar } from "@/components/toolbar";
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
import type { EventColumns, LumeColumn } from "@lume/types";
import { createFileRoute } from "@tanstack/react-router";
import { listen } from "@tauri-apps/api/event";
import { resolveResource } from "@tauri-apps/api/path";
import { getCurrent } from "@tauri-apps/api/window";
import { readTextFile } from "@tauri-apps/plugin-fs";
import { nanoid } from "nanoid";
import { useEffect, useRef, useState } from "react";
import { useDebouncedCallback } from "use-debounce";
import { VList, type VListHandle } from "virtua";
export const Route = createFileRoute("/$account/home")({
beforeLoad: async ({ context }) => {
try {
const ark = context.ark;
const resourcePath = await resolveResource(
"resources/system_columns.json",
);
const systemColumns: LumeColumn[] = JSON.parse(
await readTextFile(resourcePath),
);
const userColumns = await ark.get_columns();
return {
storedColumns: !userColumns.length ? systemColumns : userColumns,
};
} catch (e) {
console.error(String(e));
}
},
component: Screen,
});
function Screen() {
const vlistRef = useRef<VListHandle>(null);
const { account } = Route.useParams();
const { ark, storedColumns } = Route.useRouteContext();
const [selectedIndex, setSelectedIndex] = useState(-1);
const [columns, setColumns] = useState(storedColumns);
const [isScroll, setIsScroll] = useState(false);
const [isResize, setIsResize] = useState(false);
const goLeft = () => {
const prevIndex = Math.max(selectedIndex - 1, 0);
setSelectedIndex(prevIndex);
vlistRef.current.scrollToIndex(prevIndex, {
align: "center",
});
};
const goRight = () => {
const nextIndex = Math.min(selectedIndex + 1, columns.length - 1);
setSelectedIndex(nextIndex);
vlistRef.current.scrollToIndex(nextIndex, {
align: "center",
});
};
const add = useDebouncedCallback((column: LumeColumn) => {
// update col label
column.label = `${column.label}-${nanoid()}`;
// create new cols
const cols = [...columns];
const openColIndex = cols.findIndex((col) => col.label === "open");
const newCols = [
...cols.slice(0, openColIndex),
column,
...cols.slice(openColIndex),
];
setColumns(newCols);
setSelectedIndex(newCols.length);
setIsScroll(true);
// scroll to the newest column
vlistRef.current.scrollToIndex(newCols.length - 1, {
align: "end",
});
}, 150);
const remove = useDebouncedCallback((label: string) => {
const newCols = columns.filter((t) => t.label !== label);
setColumns(newCols);
setSelectedIndex(newCols.length);
setIsScroll(true);
// scroll to the first column
vlistRef.current.scrollToIndex(newCols.length - 1, {
align: "start",
});
}, 150);
const updateName = useDebouncedCallback((label: string, title: string) => {
const currentColIndex = columns.findIndex((col) => col.label === label);
const updatedCol = Object.assign({}, columns[currentColIndex]);
updatedCol.name = title;
const newCols = columns.slice();
newCols[currentColIndex] = updatedCol;
setColumns(newCols);
}, 150);
const startResize = useDebouncedCallback(
() => setIsResize((prev) => !prev),
150,
);
useEffect(() => {
// save state
ark.set_columns(columns);
}, [columns]);
useEffect(() => {
const unlistenColEvent = listen<EventColumns>("columns", (data) => {
if (data.payload.type === "add") add(data.payload.column);
if (data.payload.type === "remove") remove(data.payload.label);
if (data.payload.type === "set_title")
updateName(data.payload.label, data.payload.title);
});
const unlistenWindowResize = getCurrent().listen("tauri://resize", () => {
startResize();
});
return () => {
unlistenColEvent.then((f) => f());
unlistenWindowResize.then((f) => f());
};
}, []);
return (
<div className="h-full w-full">
<VList
ref={vlistRef}
horizontal
tabIndex={-1}
itemSize={440}
overscan={3}
onScroll={() => setIsScroll(true)}
onScrollEnd={() => setIsScroll(false)}
className="scrollbar-none h-full w-full overflow-x-auto focus:outline-none"
>
{columns.map((column) => (
<Col
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>
);
}

View File

@@ -1,178 +0,0 @@
import { BellIcon, ComposeFilledIcon, PlusIcon, SearchIcon } from "@lume/icons";
import { Event, Kind } from "@lume/types";
import { User } from "@lume/ui";
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, useState } from "react";
export const Route = createFileRoute("/$account")({
beforeLoad: async ({ context }) => {
const ark = context.ark;
const accounts = await ark.get_all_accounts();
return { accounts };
},
component: Screen,
});
function Screen() {
const { ark, platform } = Route.useRouteContext();
const navigate = Route.useNavigate();
return (
<div className="flex h-screen w-screen flex-col">
<div
data-tauri-drag-region
className={cn(
"flex h-11 shrink-0 items-center justify-between pr-2",
platform === "macos" ? "ml-2 pl-20" : "pl-4",
)}
>
<div className="flex items-center gap-3">
<Accounts />
<button
type="button"
onClick={() => navigate({ to: "/landing" })}
className="inline-flex size-8 items-center justify-center rounded-full bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20"
>
<PlusIcon className="size-5" />
</button>
</div>
<div className="flex items-center gap-2">
<button
type="button"
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"
>
<ComposeFilledIcon className="size-4" />
New Post
</button>
<Bell />
<button
type="button"
onClick={() => ark.open_search()}
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
>
<SearchIcon className="size-5" />
</button>
<div id="toolbar" />
</div>
</div>
<div className="flex-1">
<Outlet />
</div>
</div>
);
}
function Accounts() {
const navigate = Route.useNavigate();
const { ark, accounts } = Route.useRouteContext();
const { account } = Route.useParams();
const changeAccount = async (npub: string) => {
if (npub === account) return;
const select = await ark.load_selected_account(npub);
if (select) {
return navigate({ to: "/$account/home", params: { account: npub } });
}
};
return (
<div data-tauri-drag-region className="flex items-center gap-3">
{accounts.map((user) => (
<button key={user} type="button" onClick={() => changeAccount(user)}>
<User.Provider pubkey={user}>
<User.Root
className={cn(
"rounded-full",
user === account
? "ring-1 ring-teal-500 ring-offset-2 ring-offset-neutral-200 dark:ring-offset-neutral-950"
: "",
)}
>
<User.Avatar
className={cn(
"aspect-square h-auto rounded-full object-cover",
user === account ? "w-7" : "w-8",
)}
/>
</User.Root>
</User.Provider>
</button>
))}
</div>
);
}
function Bell() {
const { ark } = Route.useRouteContext();
const { account } = Route.useParams();
const [count, setCount] = useState(0);
useEffect(() => {
const unlisten = getCurrent().listen<string>(
"activity",
async (payload) => {
setCount((prevCount) => prevCount + 1);
await invoke("set_badge", { count });
const event: Event = JSON.parse(payload.payload);
const user = await ark.get_profile(event.pubkey);
const userName =
user.display_name || user.name || displayNpub(event.pubkey, 16);
switch (event.kind) {
case Kind.Text: {
sendNativeNotification("Mentioned you in a note", userName);
break;
}
case Kind.Repost: {
sendNativeNotification("Reposted your note", userName);
break;
}
case Kind.ZapReceipt: {
const amount = decodeZapInvoice(event.tags);
sendNativeNotification(
`Zapped ₿ ${amount.bitcoinFormatted}`,
userName,
);
break;
}
default:
break;
}
},
);
return () => {
unlisten.then((f) => f());
};
}, []);
return (
<button
type="button"
onClick={() => {
setCount(0);
ark.open_activity(account);
}}
className="relative inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
>
<BellIcon className="size-5" />
{count > 0 ? (
<span className="absolute right-0 top-0 block size-2 rounded-full bg-teal-500 ring-1 ring-black/5" />
) : null}
</button>
);
}

View File

@@ -1,45 +0,0 @@
import type { Ark } from "@lume/ark";
import type { Interests, Metadata, Settings } from "@lume/types";
import { Spinner } from "@lume/ui";
import type { QueryClient } from "@tanstack/react-query";
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
import type { Platform } from "@tauri-apps/plugin-os";
import type { Descendant } from "slate";
type EditorElement = {
type: string;
children: Descendant[];
eventId?: string;
};
interface RouterContext {
// System
ark: Ark;
queryClient: QueryClient;
// App info
platform?: Platform;
locale?: string;
// Settings
settings?: Settings;
interests?: Interests;
// Profile
accounts?: string[];
profile?: Metadata;
isNewUser?: boolean;
// Editor
initialValue?: EditorElement[];
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: () => <Outlet />,
pendingComponent: Pending,
wrapInSuspense: true,
});
function Pending() {
return (
<div className="flex h-screen w-screen flex-col items-center justify-center">
<Spinner className="size-5" />
</div>
);
}

View File

@@ -1,5 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/activity/$account/messages")({
component: () => <div>Hello /activity/$account/messages!</div>,
});

View File

@@ -1,60 +0,0 @@
import { Note, Spinner } from "@lume/ui";
import { Await, createFileRoute, defer } from "@tanstack/react-router";
import { Suspense } from "react";
import { Virtualizer } from "virtua";
export const Route = createFileRoute("/activity/$account/texts")({
loader: async ({ context, params }) => {
const ark = context.ark;
return { data: defer(ark.get_activities(params.account, "1")) };
},
component: Screen,
});
function Screen() {
const { data } = Route.useLoaderData();
return (
<Virtualizer overscan={3}>
<Suspense
fallback={
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
<button
type="button"
className="inline-flex items-center gap-2 text-sm font-medium"
disabled
>
<Spinner className="size-5" />
Loading...
</button>
</div>
}
>
<Await promise={data}>
{(events) =>
events.map((event) => (
<div
key={event.id}
className="flex flex-col gap-2 mb-3 bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"
>
<Note.Provider event={event}>
<Note.Root>
<div className="px-3 h-14 flex items-center justify-between">
<Note.User />
<Note.Menu />
</div>
<Note.Activity className="px-3" />
<Note.Content className="px-3" quote={false} clean />
<div className="mt-3 flex items-center gap-4 h-14 px-3">
<Note.Open />
</div>
</Note.Root>
</Note.Provider>
</div>
))
}
</Await>
</Suspense>
</Virtualizer>
);
}

View File

@@ -1,50 +0,0 @@
import { Box, Container } from "@lume/ui";
import { cn } from "@lume/utils";
import { Link, Outlet } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/activity/$account")({
component: Screen,
});
function Screen() {
const { account } = Route.useParams();
return (
<Container withDrag withNavigate={false}>
<Box className="scrollbar-none shadow-none bg-black/5 dark:bg-white/5 backdrop-blur-sm flex flex-col overflow-y-auto">
<div className="h-14 shrink-0 flex w-full items-center gap-1 px-3">
<div className="inline-flex h-full w-full items-center gap-1">
<Link to="/activity/$account/texts" params={{ account }}>
{({ isActive }) => (
<div
className={cn(
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
)}
>
Notes
</div>
)}
</Link>
<Link to="/activity/$account/zaps" params={{ account }}>
{({ isActive }) => (
<div
className={cn(
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
)}
>
Zaps
</div>
)}
</Link>
</div>
</div>
<div className="px-2 flex-1 overflow-y-auto w-full h-full scrollbar-none">
<Outlet />
</div>
</Box>
</Container>
);
}

View File

@@ -1,64 +0,0 @@
import { Note, Spinner, User } from "@lume/ui";
import { decodeZapInvoice } from "@lume/utils";
import { Await, createFileRoute, defer } from "@tanstack/react-router";
import { Suspense } from "react";
import { Virtualizer } from "virtua";
export const Route = createFileRoute("/activity/$account/zaps")({
loader: async ({ context, params }) => {
const ark = context.ark;
return { data: defer(ark.get_activities(params.account, "9735")) };
},
component: Screen,
});
function Screen() {
const { data } = Route.useLoaderData();
return (
<Virtualizer overscan={3}>
<Suspense
fallback={
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
<button
type="button"
className="inline-flex items-center gap-2 text-sm font-medium"
disabled
>
<Spinner className="size-5" />
Loading...
</button>
</div>
}
>
<Await promise={data}>
{(events) =>
events.map((event) => (
<div
key={event.id}
className="flex flex-col gap-2 mb-3 bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"
>
<User.Provider pubkey={event.pubkey}>
<User.Root className="flex flex-col">
<div className="text-lg h-20 font-medium leading-tight flex w-full items-center justify-center">
{decodeZapInvoice(event.tags).bitcoinFormatted}
</div>
<div className="h-11 border-t border-neutral-100 dark:border-neutral-900 flex items-center gap-1 px-2">
<div className="inline-flex items-center gap-2">
<User.Avatar className="size-7 rounded-full shrink-0" />
<User.Name className="text-sm font-medium" />
</div>
<div className="text-sm text-neutral-700 dark:text-neutral-300">
zapped you
</div>
</div>
</User.Root>
</User.Provider>
</div>
))
}
</Await>
</Suspense>
</Virtualizer>
);
}

View File

@@ -1,16 +0,0 @@
import { Box, Container } from "@lume/ui";
import { Outlet, createLazyFileRoute } from "@tanstack/react-router";
export const Route = createLazyFileRoute("/auth")({
component: Screen,
});
function Screen() {
return (
<Container withDrag>
<Box className="px-3 pt-3">
<Outlet />
</Box>
</Container>
);
}

View File

@@ -1,200 +0,0 @@
import { CheckIcon } from "@lume/icons";
import type { AppRouteSearch } from "@lume/types";
import { Spinner } from "@lume/ui";
import { displayNsec } from "@lume/utils";
import * as Checkbox from "@radix-ui/react-checkbox";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export const Route = createFileRoute("/auth/new/backup")({
validateSearch: (search: Record<string, string>): AppRouteSearch => {
return {
account: search.account,
};
},
component: Screen,
});
function Screen() {
const { account } = Route.useSearch();
const { t } = useTranslation();
const [key, setKey] = useState(null);
const [passphase, setPassphase] = useState("");
const [copied, setCopied] = useState(false);
const [loading, setLoading] = useState(false);
const [confirm, setConfirm] = useState({ c1: false, c2: false, c3: false });
const navigate = useNavigate();
const submit = async () => {
try {
if (key) {
if (!confirm.c1 || !confirm.c2 || !confirm.c3) {
return toast.warning("You need to confirm before continue");
}
return navigate({
to: "/auth/settings",
search: { account },
});
}
// start loading
setLoading(true);
invoke("get_encrypted_key", {
npub: account,
password: passphase,
}).then((encrypted: string) => {
// update state
setKey(encrypted);
setLoading(false);
});
} catch (e) {
setLoading(false);
toast.error(String(e));
}
};
const copyKey = async () => {
try {
await writeText(key);
setCopied(true);
} catch (e) {
toast.error(e);
}
};
return (
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
<div className="flex flex-col text-center">
<h3 className="text-xl font-semibold">Backup your sign in keys</h3>
<p className="text-neutral-700 dark:text-neutral-300">
It's use for login to Lume or other Nostr clients. You will lost
access to your account if you lose this key.
</p>
</div>
<div className="flex w-full flex-col gap-5">
<div className="flex flex-col gap-2">
<label htmlFor="passphase" className="font-medium">
Set a passphase to secure your key
</label>
<div className="relative">
<input
name="passphase"
type="password"
value={passphase}
onChange={(e) => setPassphase(e.target.value)}
className="w-full h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
</div>
</div>
{key ? (
<>
<div className="flex flex-col gap-2">
<label htmlFor="nsec" className="font-medium">
Copy this key and keep it in safe place
</label>
<div className="flex items-center gap-2">
<input
name="nsec"
type="text"
value={displayNsec(key, 36)}
readOnly
className="w-full h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
<button
type="button"
onClick={() => copyKey()}
className="inline-flex h-11 w-24 items-center justify-center rounded-lg bg-neutral-200 hover:bg-neutral-300 dark:bg-white/20 dark:hover:bg-white/30"
>
{copied ? "Copied" : "Copy"}
</button>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="font-medium">Before you continue:</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Checkbox.Root
checked={confirm.c1}
onCheckedChange={() =>
setConfirm((state) => ({ ...state, c1: !state.c1 }))
}
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-white/10 dark:hover:bg-white/20"
id="confirm1"
>
<Checkbox.Indicator className="text-blue-500">
<CheckIcon className="size-4" />
</Checkbox.Indicator>
</Checkbox.Root>
<label
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
htmlFor="confirm1"
>
{t("backup.confirm1")}
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox.Root
checked={confirm.c2}
onCheckedChange={() =>
setConfirm((state) => ({ ...state, c2: !state.c2 }))
}
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-white/10 dark:hover:bg-white/20"
id="confirm2"
>
<Checkbox.Indicator className="text-blue-500">
<CheckIcon className="size-4" />
</Checkbox.Indicator>
</Checkbox.Root>
<label
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
htmlFor="confirm2"
>
{t("backup.confirm2")}
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox.Root
checked={confirm.c3}
onCheckedChange={() =>
setConfirm((state) => ({ ...state, c3: !state.c3 }))
}
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-white/10 dark:hover:bg-white/20"
id="confirm3"
>
<Checkbox.Indicator className="text-blue-500">
<CheckIcon className="size-4" />
</Checkbox.Indicator>
</Checkbox.Root>
<label
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
htmlFor="confirm3"
>
{t("backup.confirm3")}
</label>
</div>
</div>
</div>
</>
) : null}
<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>
);
}

View File

@@ -1,143 +0,0 @@
import { AvatarUploader } from "@/components/avatarUploader";
import { PlusIcon } from "@lume/icons";
import type { Metadata } from "@lume/types";
import { Spinner } from "@lume/ui";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export const Route = createFileRoute("/auth/new/profile")({
component: Screen,
loader: ({ context }) => {
return context.ark.create_keys();
},
});
function Screen() {
const keys = Route.useLoaderData();
const navigate = useNavigate();
const { t } = useTranslation();
const { ark } = Route.useRouteContext();
const { register, handleSubmit } = useForm();
const [picture, setPicture] = useState<string>("");
const [loading, setLoading] = useState(false);
const onSubmit = async (data: {
name: string;
about: string;
website: string;
}) => {
setLoading(true);
try {
// Save account keys
const save = await ark.save_account(keys.nsec);
// Then create profile
if (save) {
const profile: Metadata = { ...data, picture };
const eventId = await ark.create_profile(profile);
if (eventId) {
navigate({
to: "/auth/new/backup",
search: { account: keys.npub },
replace: true,
});
}
}
} catch (e) {
setLoading(false);
toast.error(String(e));
}
};
return (
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
<div className="text-center">
<h3 className="text-xl font-semibold">Let's set up your profile.</h3>
</div>
<div>
<div className="relative size-24 rounded-full bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200">
{picture ? (
<img
src={picture}
alt="avatar"
loading="lazy"
decoding="async"
className="absolute inset-0 z-10 h-full w-full rounded-full object-cover"
/>
) : null}
<AvatarUploader
setPicture={setPicture}
className="absolute inset-0 z-20 flex h-full w-full items-center justify-center rounded-full dark:text-black bg-black/10 text-white hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
>
<PlusIcon className="size-8" />
</AvatarUploader>
</div>
</div>
<form
onSubmit={handleSubmit(onSubmit)}
className="flex w-full flex-col gap-3"
>
<div className="flex flex-col gap-1">
<label htmlFor="display_name" className="font-medium">
{t("user.displayName")} *
</label>
<input
type={"text"}
{...register("display_name", { required: true, minLength: 1 })}
placeholder="e.g. Alice in Nostrland"
spellCheck={false}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="name" className="font-medium">
{t("user.name")}
</label>
<input
type={"text"}
{...register("name")}
placeholder="e.g. alice"
spellCheck={false}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="about" className="font-medium">
{t("user.bio")}
</label>
<textarea
{...register("about")}
placeholder="e.g. Artist, anime-lover, and k-pop fan"
spellCheck={false}
className="relative h-24 w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-2 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="website" className="font-medium">
{t("user.website")}
</label>
<input
type="url"
{...register("website")}
placeholder="e.g. https://alice.me"
spellCheck={false}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
</div>
<button
type="submit"
className="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
>
{loading ? <Spinner /> : t("global.continue")}
</button>
</form>
</div>
);
}

View File

@@ -1,90 +0,0 @@
import { Spinner } from "@lume/ui";
import { createLazyFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { toast } from "sonner";
export const Route = createLazyFileRoute("/auth/privkey")({
component: Screen,
});
function Screen() {
const { ark } = Route.useRouteContext();
const navigate = Route.useNavigate();
const [key, setKey] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const submit = async () => {
if (!key.startsWith("nsec1"))
return toast.warning(
"You need to enter a valid private key starts with nsec or ncryptsec",
);
try {
setLoading(true);
const npub = await ark.save_account(key, password);
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 Private Key</h3>
</div>
<div className="flex w-full flex-col gap-3">
<div className="flex flex-col gap-1">
<label
htmlFor="key"
className="font-medium text-neutral-900 dark:text-neutral-100"
>
Private Key
</label>
<input
name="key"
type="text"
placeholder="nsec or ncryptsec..."
value={key}
onChange={(e) => setKey(e.target.value)}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="password"
className="font-medium text-neutral-900 dark:text-neutral-100"
>
Password (Optional)
</label>
<input
name="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
</div>
<button
type="button"
onClick={() => submit()}
disabled={loading}
className="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
>
{loading ? <Spinner /> : "Login"}
</button>
</div>
</div>
);
}

View File

@@ -1,74 +0,0 @@
import { Spinner } from "@lume/ui";
import { createLazyFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { toast } from "sonner";
export const Route = createLazyFileRoute("/auth/remote")({
component: Screen,
});
function Screen() {
const { ark } = Route.useRouteContext();
const navigate = Route.useNavigate();
const [uri, setUri] = useState("");
const [loading, setLoading] = useState(false);
const submit = async () => {
if (!uri.startsWith("bunker://"))
return toast.warning(
"You need to enter a valid Connect URI starts with bunker://",
);
try {
setLoading(true);
const npub = await ark.nostr_connect(uri);
if (npub) {
navigate({
to: "/auth/settings",
search: { account: npub },
replace: true,
});
}
} catch (e) {
setLoading(false);
toast.error(e);
}
};
return (
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
<div className="text-center">
<h3 className="text-xl font-semibold">Continue with Nostr Connect</h3>
</div>
<div className="flex w-full flex-col gap-3">
<div className="flex flex-col gap-1">
<label
htmlFor="uri"
className="font-medium text-neutral-900 dark:text-neutral-100"
>
Connect URI
</label>
<input
name="uri"
type="text"
placeholder="bunker://..."
value={uri}
onChange={(e) => setUri(e.target.value)}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
</div>
<button
type="button"
onClick={() => submit()}
disabled={loading}
className="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
>
{loading ? <Spinner /> : "Login"}
</button>
</div>
</div>
);
}

View File

@@ -1,218 +0,0 @@
import { LaurelIcon } from "@lume/icons";
import type { AppRouteSearch, Settings } from "@lume/types";
import { Spinner } from "@lume/ui";
import * as Switch from "@radix-ui/react-switch";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import {
isPermissionGranted,
requestPermission,
} from "@tauri-apps/plugin-notification";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export const Route = createFileRoute("/auth/settings")({
validateSearch: (search: Record<string, string>): AppRouteSearch => {
return {
account: search.account,
};
},
beforeLoad: async ({ context }) => {
const permissionGranted = await isPermissionGranted(); // get notification permission
const ark = context.ark;
const settings = await ark.get_settings();
return {
settings: { ...settings, notification: permissionGranted },
};
},
component: Screen,
pendingComponent: Pending,
});
function Screen() {
const navigate = useNavigate();
const { account } = Route.useSearch();
const { t } = useTranslation();
const { ark, settings } = Route.useRouteContext();
const [newSettings, setNewSettings] = useState<Settings>(settings);
const [loading, setLoading] = useState(false);
const toggleNofitication = async () => {
await requestPermission();
setNewSettings((prev) => ({
...prev,
notification: !newSettings.notification,
}));
};
const toggleAutoUpdate = () => {
setNewSettings((prev) => ({
...prev,
autoUpdate: !newSettings.autoUpdate,
}));
};
const toggleEnhancedPrivacy = () => {
setNewSettings((prev) => ({
...prev,
enhancedPrivacy: !newSettings.enhancedPrivacy,
}));
};
const toggleZap = () => {
setNewSettings((prev) => ({
...prev,
zap: !newSettings.zap,
}));
};
const toggleNsfw = () => {
setNewSettings((prev) => ({
...prev,
nsfw: !newSettings.nsfw,
}));
};
const submit = async () => {
try {
// start loading
setLoading(true);
// publish settings
const eventId = await ark.set_settings(newSettings);
if (eventId) {
return navigate({
to: "/$account/home",
params: { account },
replace: true,
});
}
} catch (e) {
setLoading(false);
toast.error(e);
}
};
return (
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
<div className="flex flex-col items-center gap-5 text-center">
<div className="flex size-20 items-center justify-center rounded-full bg-teal-100 dark:bg-teal-950 text-teal-500">
<LaurelIcon className="size-8" />
</div>
<div>
<h1 className="text-xl font-semibold">
{t("onboardingSettings.title")}
</h1>
<p className="leading-snug text-neutral-600 dark:text-neutral-400">
{t("onboardingSettings.subtitle")}
</p>
</div>
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-3">
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
<div className="flex-1">
<h3 className="font-semibold">Push Notification</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Enabling push notifications will allow you to receive
notifications from Lume.
</p>
</div>
<Switch.Root
checked={newSettings.notification}
onClick={() => toggleNofitication()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
<div className="flex-1">
<h3 className="font-semibold">Enhanced Privacy</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Lume will display external resources like image, video or link
preview as plain text.
</p>
</div>
<Switch.Root
checked={newSettings.enhancedPrivacy}
onClick={() => toggleEnhancedPrivacy()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
<div className="flex-1">
<h3 className="font-semibold">Auto Update</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Automatically download and install new version.
</p>
</div>
<Switch.Root
checked={newSettings.autoUpdate}
onClick={() => toggleAutoUpdate()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
<div className="flex-1">
<h3 className="font-semibold">Zap</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Show the Zap button in each note and user's profile screen, use
for send Bitcoin tip to other users.
</p>
</div>
<Switch.Root
checked={newSettings.zap}
onClick={() => toggleZap()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
<div className="flex-1">
<h3 className="font-semibold">Filter sensitive content</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
By default, Lume will display all content which have Content
Warning tag, it's may include NSFW content.
</p>
</div>
<Switch.Root
checked={newSettings.nsfw}
onClick={() => toggleNsfw()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
</div>
<button
type="button"
onClick={() => submit()}
disabled={loading}
className="mb-1 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
>
{t("global.continue")}
</button>
</div>
</div>
);
}
function Pending() {
return (
<div className="flex h-full w-full items-center justify-center">
<button type="button" className="size-5" disabled>
<Spinner className="size-5" />
</button>
</div>
);
}

View File

@@ -1,126 +0,0 @@
import { CheckCircleIcon } from "@lume/icons";
import type { ColumnRouteSearch } from "@lume/types";
import { Spinner, User } from "@lume/ui";
import { createFileRoute, useRouter } from "@tanstack/react-router";
import { useState } from "react";
import { toast } from "sonner";
export const Route = createFileRoute("/create-group")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
loader: async ({ context }) => {
const ark = context.ark;
const contacts = await ark.get_contact_list();
return contacts;
},
component: Screen,
});
function Screen() {
const contacts = Route.useLoaderData();
const router = useRouter();
const { ark } = Route.useRouteContext();
const { label, redirect } = Route.useSearch();
const [title, setTitle] = useState<string>("Just a new group");
const [users, setUsers] = useState<Array<string>>([]);
const [loading, setLoading] = useState(false);
const [isDone, setIsDone] = useState(false);
const toggleUser = (pubkey: string) => {
const arr = users.includes(pubkey)
? users.filter((i) => i !== pubkey)
: [...users, pubkey];
setUsers(arr);
};
const submit = async () => {
try {
if (isDone) return router.history.push(redirect);
// start loading
setLoading(true);
const groups = await ark.set_nstore(
`lume_group_${label}`,
JSON.stringify(users),
);
if (groups) {
toast.success("Group has been created successfully.");
// start loading
setIsDone(true);
setLoading(false);
}
} catch (e) {
setLoading(false);
toast.error(e);
}
};
return (
<div className="h-full overflow-y-auto scrollbar-none">
<div className="flex flex-col gap-5 p-3">
<div className="flex flex-col gap-1">
<label htmlFor="name" className="font-medium">
Name
</label>
<input
name="name"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Nostrichs..."
className="h-10 rounded-lg bg-transparent border border-neutral-300 dark:border-neutral-700 px-3 placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
/>
</div>
<div className="flex flex-col gap-1">
<div className="inline-flex items-center justify-between">
<span className="font-medium">Pick user</span>
<span className="text-neutral-600 dark:text-neutral-400">{`${users.length} / ∞`}</span>
</div>
<div className="flex flex-col gap-2">
{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-black/10 dark:bg-white/10 hover:bg-black/20 dark:hover:bg-white/20"
>
<User.Provider pubkey={item}>
<User.Root className="flex items-center gap-2.5">
<User.Avatar className="size-10 rounded-full object-cover" />
<div className="flex items-center gap-1">
<User.Name className="font-medium" />
<User.NIP05 />
</div>
</User.Root>
</User.Provider>
{users.includes(item) ? (
<CheckCircleIcon className="size-5 text-teal-500" />
) : null}
</button>
))}
</div>
</div>
</div>
<div className="fixed z-10 flex items-center justify-center w-full bottom-6">
{users.length >= 1 ? (
<button
type="button"
onClick={() => submit()}
disabled={users.length < 1}
className="inline-flex items-center justify-center px-4 font-medium text-white transform bg-blue-500 rounded-full active:translate-y-1 w-32 h-10 hover:bg-blue-600 focus:outline-none"
>
{isDone ? "Back" : loading ? <Spinner /> : "Update"}
</button>
) : null}
</div>
</div>
);
}

View File

@@ -1,90 +0,0 @@
import { AddMediaIcon } from "@lume/icons";
import { Spinner } from "@lume/ui";
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 { useSlateStatic } from "slate-react";
import { toast } from "sonner";
export function MediaButton({ className }: { className?: string }) {
const editor = useSlateStatic();
const { ark } = useRouteContext({ strict: false });
const [loading, setLoading] = useState(false);
const uploadToNostrBuild = async () => {
try {
// start loading
setLoading(true);
const image = await ark.upload();
insertImage(editor, image);
// reset loading
setLoading(false);
} catch (e) {
setLoading(false);
toast.error(`Upload failed, error: ${e}`);
}
};
useEffect(() => {
let unlisten: UnlistenFn = undefined;
async function listenFileDrop() {
const window = getCurrent();
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 () => {
if (unlisten) unlisten();
};
}, []);
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => uploadToNostrBuild()}
disabled={loading}
className={cn("inline-flex items-center justify-center", className)}
>
{loading ? (
<Spinner className="size-4" />
) : (
<AddMediaIcon className="size-4" />
)}
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
Upload media
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -1,83 +0,0 @@
import { MentionIcon } from "@lume/icons";
import { cn, insertMention } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { useEffect, useState } from "react";
import { useRouteContext } from "@tanstack/react-router";
import { User } from "@lume/ui";
import { useSlateStatic } from "slate-react";
import type { Contact } from "@lume/types";
import { toast } from "sonner";
export function MentionButton({ className }: { className?: string }) {
const editor = useSlateStatic();
const { ark } = useRouteContext({ strict: false });
const [contacts, setContacts] = useState<string[]>([]);
const select = async (user: string) => {
try {
const metadata = await ark.get_profile(user);
const contact: Contact = { pubkey: user, profile: metadata };
insertMention(editor, contact);
} catch (e) {
toast.error(String(e));
}
};
useEffect(() => {
async function getContacts() {
const data = await ark.get_contact_list();
setContacts(data);
}
getContacts();
}, []);
return (
<DropdownMenu.Root>
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<DropdownMenu.Trigger asChild>
<Tooltip.Trigger asChild>
<button
type="button"
className={cn(
"inline-flex items-center justify-center",
className,
)}
>
<MentionIcon className="size-4" />
</button>
</Tooltip.Trigger>
</DropdownMenu.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
Mention
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
<DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[220px] h-[220px] scrollbar-none flex-col overflow-y-auto rounded-xl bg-black py-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
{contacts.map((contact) => (
<DropdownMenu.Item
key={contact}
onClick={() => select(contact)}
className="shrink-0 h-11 flex items-center hover:bg-white/10 px-2"
>
<User.Provider pubkey={contact}>
<User.Root className="flex items-center gap-2">
<User.Avatar className="shrink-0 size-8 rounded-full" />
<User.Name className="text-sm font-medium text-white dark:text-black" />
</User.Root>
</User.Provider>
</DropdownMenu.Item>
))}
<DropdownMenu.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}

View File

@@ -1,40 +0,0 @@
import { NsfwIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip";
import type { Dispatch, SetStateAction } from "react";
export function NsfwToggle({
nsfw,
setNsfw,
className,
}: {
nsfw: boolean;
setNsfw: Dispatch<SetStateAction<boolean>>;
className?: string;
}) {
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => setNsfw((prev) => !prev)}
className={cn(
"inline-flex items-center justify-center",
className,
nsfw ? "bg-blue-500 text-white" : "",
)}
>
<NsfwIcon className="size-4" />
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
Mark as sensitive content
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -1,40 +0,0 @@
import { NsfwIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip";
import type { Dispatch, SetStateAction } from "react";
export function 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>
);
}

View File

@@ -1,341 +0,0 @@
import { ComposeFilledIcon, TrashIcon } from "@lume/icons";
import { Spinner } from "@lume/ui";
import { MentionNote } from "@lume/ui/src/note/mentions/note";
import {
cn,
insertImage,
insertNostrEvent,
isImageUrl,
sendNativeNotification,
} from "@lume/utils";
import { createFileRoute } from "@tanstack/react-router";
import { nip19 } from "nostr-tools";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { type Descendant, Node, Transforms, createEditor } from "slate";
import {
Editable,
ReactEditor,
Slate,
useFocused,
useSelected,
useSlateStatic,
withReact,
} from "slate-react";
import { MediaButton } from "./-components/media";
import { NsfwToggle } from "./-components/nsfw";
import { MentionButton } from "./-components/mention";
type EditorSearch = {
reply_to: string;
quote: boolean;
};
export const Route = createFileRoute("/editor/")({
validateSearch: (search: Record<string, string>): EditorSearch => {
return {
reply_to: search.reply_to,
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() {
const { reply_to, quote } = Route.useSearch();
const { ark, initialValue } = Route.useRouteContext();
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())))),
);
const reset = () => {
// @ts-expect-error, backlog
editor.children = [{ type: "paragraph", children: [{ text: "" }] }];
setEditorValue([{ type: "paragraph", children: [{ text: "" }] }]);
};
const serialize = (nodes: Descendant[]) => {
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;
// @ts-expect-error, backlog
if (n.children.length) {
// @ts-expect-error, backlog
return n.children
.map((n) => {
if (n.type === "mention") return n.npub;
return Node.string(n).trim();
})
.join(" ");
}
return Node.string(n);
})
.join("\n");
};
const publish = async () => {
try {
// start loading
setLoading(true);
const content = serialize(editor.children);
const eventId = await ark.publish(content, reply_to, quote);
if (eventId) {
await sendNativeNotification(
"Your note has been published successfully.",
"Lume",
);
}
// stop loading
setLoading(false);
// reset form
reset();
} catch (e) {
setLoading(false);
await sendNativeNotification(String(e));
}
};
return (
<div className="w-full h-full">
<Slate editor={editor} initialValue={editorValue}>
<div
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"
>
<NsfwToggle
nsfw={nsfw}
setNsfw={setNsfw}
className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
/>
<MentionButton className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" />
<MediaButton className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" />
<button
type="button"
onClick={() => publish()}
className="inline-flex h-8 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600"
>
{loading ? (
<Spinner className="size-4" />
) : (
<ComposeFilledIcon className="size-4" />
)}
{t("global.post")}
</button>
</div>
<div className="flex h-full w-full flex-1 flex-col">
{reply_to && !quote ? (
<div className="px-4 py-2">
<MentionNote eventId={reply_to} />
</div>
) : null}
<div className="overflow-y-auto p-4">
<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"
/>
</div>
</div>
</Slate>
</div>
);
}
const withNostrEvent = (editor: ReactEditor) => {
const { insertData, isVoid } = editor;
editor.isVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "event" ? true : isVoid(element);
};
editor.insertData = (data) => {
const text = data.getData("text/plain");
if (text.startsWith("nevent1") || text.startsWith("note1")) {
insertNostrEvent(editor, text);
} else {
insertData(data);
}
};
return editor;
};
const withMentions = (editor: ReactEditor) => {
const { isInline, isVoid, markableVoid } = editor;
editor.isInline = (element) => {
// @ts-expect-error, wtf
return element.type === "mention" ? true : isInline(element);
};
editor.isVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "mention" ? true : isVoid(element);
};
editor.markableVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "mention" || markableVoid(element);
};
return editor;
};
const withImages = (editor: ReactEditor) => {
const { insertData, isVoid } = editor;
editor.isVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "image" ? true : isVoid(element);
};
editor.insertData = (data) => {
const text = data.getData("text/plain");
if (isImageUrl(text)) {
insertImage(editor, text);
} else {
insertData(data);
}
};
return editor;
};
const Image = ({ attributes, children, element }) => {
const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element);
const selected = useSelected();
const focused = useFocused();
return (
<div {...attributes}>
{children}
<div contentEditable={false} className="relative my-2">
<img
src={element.url}
alt={element.url}
className={cn(
"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",
)}
contentEditable={false}
/>
<button
type="button"
contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="absolute right-2 top-2 inline-flex size-8 items-center justify-center rounded-lg bg-red-500 text-white hover:bg-red-600"
>
<TrashIcon className="size-4" />
</button>
</div>
</div>
);
};
const Mention = ({ attributes, element }) => {
const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element);
return (
<span
{...attributes}
type="button"
contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="inline-block align-baseline text-blue-500 hover:text-blue-600"
>{`@${element.name}`}</span>
);
};
const Event = ({ attributes, element, children }) => {
const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element);
return (
<div {...attributes}>
{children}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> */}
<div
contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="user-select-none relative my-2"
>
<MentionNote
eventId={element.eventId.replace("nostr:", "")}
openable={false}
/>
</div>
</div>
);
};
const Element = (props) => {
const { attributes, children, element } = props;
switch (element.type) {
case "image":
return <Image {...props} />;
case "mention":
return <Mention {...props} />;
case "event":
return <Event {...props} />;
default:
return (
<p {...attributes} className="text-[15px]">
{children}
</p>
);
}
};

View File

@@ -1,71 +0,0 @@
import { useEvent } from "@lume/ark";
import type { Event } from "@lume/types";
import { Box, Container, Note, Spinner } from "@lume/ui";
import { createFileRoute } from "@tanstack/react-router";
import { WindowVirtualizer } from "virtua";
import { ReplyList } from "./-components/replyList";
export const Route = createFileRoute("/events/$eventId")({
beforeLoad: async ({ context }) => {
const ark = context.ark;
const settings = await ark.get_settings();
return { settings };
},
component: Screen,
});
function Screen() {
const { eventId } = Route.useParams();
const { isLoading, isError, data } = useEvent(eventId);
if (isLoading) {
return (
<div className="flex h-full w-full items-center justify-center">
<Spinner className="size-5" />
</div>
);
}
if (isError) {
<div className="flex h-full w-full items-center justify-center">
<p>Not found.</p>
</div>;
}
return (
<Container withDrag>
<Box className="scrollbar-none">
<WindowVirtualizer>
<MainNote data={data} />
{data ? (
<ReplyList eventId={eventId} />
) : (
<div className="flex h-full w-full items-center justify-center">
<Spinner className="size-5" />
</div>
)}
</WindowVirtualizer>
</Box>
</Container>
);
}
function MainNote({ data }: { data: Event }) {
return (
<Note.Provider event={data}>
<Note.Root>
<div className="px-3 h-14 flex items-center justify-between">
<Note.User />
<Note.Menu />
</div>
<Note.ContentLarge className="px-3" />
<div className="mt-4 h-11 gap-2 flex items-center justify-end px-3">
<Note.Reply large />
<Note.Repost large />
<Note.Zap large />
</div>
</Note.Root>
</Note.Provider>
);
}

View File

@@ -1,36 +0,0 @@
import type { EventWithReplies } from "@lume/types";
import { Note } from "@lume/ui";
import { cn } from "@lume/utils";
import { SubReply } from "./subReply";
export function Reply({ event }: { event: EventWithReplies }) {
return (
<Note.Provider event={event}>
<Note.Root className="border-t border-neutral-100 dark:border-neutral-900">
<div className="px-3 h-14 flex items-center justify-between">
<Note.User />
<Note.Menu />
</div>
<Note.ContentLarge className="px-3" />
<div className="mt-3 flex items-center gap-4 px-3 h-14">
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
<div
className={cn(
event.replies?.length > 0
? "py-2 pl-3 flex flex-col gap-3 divide-y divide-neutral-100 bg-neutral-50 dark:bg-white/5 border-l-2 border-blue-500 dark:divide-neutral-900"
: "",
)}
>
{event.replies?.length > 0
? event.replies?.map((childEvent) => (
<SubReply key={childEvent.id} event={childEvent} />
))
: null}
</div>
</Note.Root>
</Note.Provider>
);
}

View File

@@ -1,51 +0,0 @@
import type { EventWithReplies } from "@lume/types";
import { Spinner } from "@lume/ui";
import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Reply } from "./reply";
export function ReplyList({
eventId,
className,
}: {
eventId: string;
className?: string;
}) {
const { ark } = useRouteContext({ strict: false });
const [t] = useTranslation();
const [data, setData] = useState<null | EventWithReplies[]>(null);
useEffect(() => {
async function getReplies() {
const events = await ark.get_event_thread(eventId);
setData(events);
}
getReplies();
}, [eventId]);
return (
<div className={cn("flex flex-col", className)}>
<div className="h-11 flex px-3 items-center text-sm font-semibold text-neutral-700 dark:text-neutral-300 border-t border-neutral-100 dark:border-neutral-900">
Replies ({data?.length ?? 0})
</div>
{!data ? (
<div className="flex h-16 items-center justify-center p-3">
<Spinner className="size-5" />
</div>
) : data.length === 0 ? (
<div className="flex w-full items-center justify-center">
<div className="flex flex-col items-center justify-center gap-2 py-6">
<h3 className="text-3xl">👋</h3>
<p className="leading-none text-neutral-600 dark:text-neutral-400">
{t("note.reply.empty")}
</p>
</div>
</div>
) : (
data.map((event) => <Reply key={event.id} event={event} />)
)}
</div>
);
}

View File

@@ -1,21 +0,0 @@
import type { Event } from "@lume/types";
import { Note } from "@lume/ui";
export function SubReply({ event }: { event: Event; rootEventId?: string }) {
return (
<Note.Provider event={event}>
<Note.Root>
<div className="px-3 h-14 flex items-center justify-between">
<Note.User />
<Note.Menu />
</div>
<Note.ContentLarge className="px-3" />
<div className="mt-3 flex items-center gap-4 px-3">
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
</Note.Root>
</Note.Provider>
);
}

View File

@@ -1,155 +0,0 @@
import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text";
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types";
import { Spinner } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Link, createFileRoute, redirect } from "@tanstack/react-router";
import { Virtualizer } from "virtua";
export const Route = createFileRoute("/foryou")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
beforeLoad: async ({ search, context }) => {
const ark = context.ark;
const interests = await ark.get_interest();
const settings = await ark.get_settings();
if (!interests) {
throw redirect({
to: "/interests",
search: {
...search,
redirect: "/foryou",
},
});
}
return {
interests,
settings,
};
},
component: Screen,
});
export function Screen() {
const { name, account } = Route.useSearch();
const { ark, interests } = Route.useRouteContext();
const {
data,
isLoading,
isFetching,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useInfiniteQuery({
queryKey: [name, account],
initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await ark.get_events_from_interests(
interests.hashtags,
20,
pageParam,
);
return events;
},
getNextPageParam: (lastPage) => {
const lastEvent = lastPage?.at(-1);
return lastEvent ? lastEvent.created_at - 1 : null;
},
select: (data) => data?.pages.flatMap((page) => page),
refetchOnWindowFocus: false,
});
const renderItem = (event: Event) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} />;
default:
return <TextNote key={event.id} event={event} />;
}
};
return (
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
{isFetching && !isLoading && !isFetchingNextPage ? (
<div className="w-full h-11 flex items-center justify-center">
<div className="flex items-center justify-center gap-2">
<Spinner className="size-5" />
<span className="text-sm font-medium">Fetching new notes...</span>
</div>
</div>
) : null}
{isLoading ? (
<div className="flex h-16 w-full items-center justify-center gap-2">
<Spinner className="size-5" />
<span className="text-sm font-medium">Loading...</span>
</div>
) : !data.length ? (
<Empty />
) : (
<Virtualizer overscan={3}>
{data.map((item) => renderItem(item))}
</Virtualizer>
)}
{data?.length && hasNextPage ? (
<div>
<button
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-neutral-100 px-3 font-medium hover:bg-neutral-50 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
>
{isFetchingNextPage ? (
<Spinner className="size-5" />
) : (
<>
<ArrowRightCircleIcon className="size-5" />
Load more
</>
)}
</button>
</div>
) : null}
</div>
);
}
function Empty() {
return (
<div className="flex flex-col py-10 gap-10">
<div className="text-center flex flex-col items-center justify-center">
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8">
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" />
</div>
<p className="text-lg font-medium">Your newsfeed is empty</p>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
Here are few suggestions to get started.
</p>
</div>
<div className="flex flex-col px-3 gap-2">
<Link
to="/trending/notes"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Show trending notes
</Link>
<Link
to="/trending/users"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Discover trending users
</Link>
</div>
</div>
);
}

View File

@@ -1,153 +0,0 @@
import { Conversation } from "@/components/conversation";
import { Quote } from "@/components/quote";
import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text";
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types";
import { Spinner } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import { Virtualizer } from "virtua";
export const Route = createFileRoute("/global")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
beforeLoad: async ({ context }) => {
const ark = context.ark;
const settings = await ark.get_settings();
return { settings };
},
component: Screen,
});
export function Screen() {
const { account } = Route.useSearch();
const { ark } = Route.useRouteContext();
const {
data,
isLoading,
isFetching,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useInfiniteQuery({
queryKey: ["global", account],
initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await ark.get_events(20, pageParam, undefined, 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: {
const isConversation =
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
.length > 0;
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
if (isConversation) {
return <Conversation key={event.id} event={event} className="mb-3" />;
}
if (isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
}
return <TextNote key={event.id} event={event} className="mb-3" />;
}
}
};
return (
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
{isFetching && !isLoading && !isFetchingNextPage ? (
<div className="w-full h-11 flex items-center justify-center">
<div className="flex items-center justify-center gap-2">
<Spinner className="size-5" />
<span className="text-sm font-medium">Fetching new notes...</span>
</div>
</div>
) : null}
{isLoading ? (
<div className="flex h-16 w-full items-center justify-center gap-2">
<Spinner className="size-5" />
<span className="text-sm font-medium">Loading...</span>
</div>
) : !data.length ? (
<Empty />
) : (
<Virtualizer overscan={3}>
{data.map((item) => renderItem(item))}
</Virtualizer>
)}
{data?.length && hasNextPage ? (
<div>
<button
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-black/5 px-3 font-medium hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
>
{isFetchingNextPage ? (
<Spinner className="size-5" />
) : (
<>
<ArrowRightCircleIcon className="size-5" />
Load more
</>
)}
</button>
</div>
) : null}
</div>
);
}
function Empty() {
return (
<div className="flex flex-col py-10 gap-10">
<div className="text-center flex flex-col items-center justify-center">
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8">
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" />
</div>
<p className="text-lg font-medium">Your newsfeed is empty</p>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
Here are few suggestions to get started.
</p>
</div>
<div className="flex flex-col px-3 gap-2">
<Link
to="/trending/notes"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Show trending notes
</Link>
<Link
to="/trending/users"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Discover trending users
</Link>
</div>
</div>
);
}

View File

@@ -1,154 +0,0 @@
import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text";
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types";
import { Spinner } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Link, createFileRoute, redirect } from "@tanstack/react-router";
import { Virtualizer } from "virtua";
export const Route = createFileRoute("/group")({
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 groups = (await ark.get_nstore(
`lume_group_${search.label}`,
)) as string[];
const settings = await ark.get_settings();
if (!groups) {
throw redirect({
to: "/create-group",
search: {
...search,
redirect: "/group",
},
});
}
return {
groups,
settings,
};
},
component: Screen,
});
export function Screen() {
const { name, account } = Route.useSearch();
const { ark, groups } = Route.useRouteContext();
const {
data,
isLoading,
isFetching,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useInfiniteQuery({
queryKey: [name, account],
initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await ark.get_events(20, pageParam, groups);
return events;
},
getNextPageParam: (lastPage) => {
const lastEvent = lastPage?.at(-1);
return lastEvent ? lastEvent.created_at - 1 : null;
},
select: (data) =>
data?.pages.flatMap((page) => page.filter((ev) => ev.kind === Kind.Text)),
refetchOnWindowFocus: false,
});
const renderItem = (event: Event) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} />;
default:
return <TextNote key={event.id} event={event} />;
}
};
return (
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
{isFetching && !isLoading && !isFetchingNextPage ? (
<div className="w-full h-11 flex items-center justify-center">
<div className="flex items-center justify-center gap-2">
<Spinner className="size-5" />
<span className="text-sm font-medium">Fetching new notes...</span>
</div>
</div>
) : null}
{isLoading ? (
<div className="flex h-16 w-full items-center justify-center gap-2">
<Spinner className="size-5" />
<span className="text-sm font-medium">Loading...</span>
</div>
) : !data.length ? (
<Empty />
) : (
<Virtualizer overscan={3}>
{data.map((item) => renderItem(item))}
</Virtualizer>
)}
{data?.length && hasNextPage ? (
<div>
<button
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-neutral-100 px-3 font-medium hover:bg-neutral-50 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
>
{isFetchingNextPage ? (
<Spinner className="size-5" />
) : (
<>
<ArrowRightCircleIcon className="size-5" />
Load more
</>
)}
</button>
</div>
) : null}
</div>
);
}
function Empty() {
return (
<div className="flex flex-col py-10 gap-10">
<div className="text-center flex flex-col items-center justify-center">
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8">
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" />
</div>
<p className="text-lg font-medium">Your newsfeed is empty</p>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
Here are few suggestions to get started.
</p>
</div>
<div className="flex flex-col px-3 gap-2">
<Link
to="/trending/notes"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Show trending notes
</Link>
<Link
to="/trending/users"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Discover trending users
</Link>
</div>
</div>
);
}

View File

@@ -1,122 +0,0 @@
import { PlusIcon } from "@lume/icons";
import { Spinner, User } from "@lume/ui";
import { checkForAppUpdates } from "@lume/utils";
import { Link } from "@tanstack/react-router";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { useState } from "react";
import { toast } from "sonner";
export const Route = createFileRoute("/")({
beforeLoad: async ({ context }) => {
// check for app updates
await checkForAppUpdates(true);
const ark = context.ark;
const accounts = await ark.get_all_accounts();
if (!accounts.length) {
throw redirect({
to: "/landing",
replace: true,
});
}
// Run notification service
await invoke("run_notification", { accounts });
return { accounts };
},
component: Screen,
});
function Screen() {
const navigate = Route.useNavigate();
const { ark, accounts } = Route.useRouteContext();
const [loading, setLoading] = useState(false);
const select = async (npub: string) => {
try {
setLoading(true);
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));
}
};
const currentDate = new Date().toLocaleString("default", {
weekday: "long",
month: "long",
day: "numeric",
});
return (
<div className="relative flex h-full w-full items-center justify-center">
<div className="relative z-20 flex flex-col items-center gap-16">
<div className="text-center text-white">
<h2 className="mb-1 text-2xl">{currentDate}</h2>
<h2 className="text-2xl font-semibold">Welcome back!</h2>
</div>
<div className="flex items-center justify-center gap-6">
{loading ? (
<div className="inline-flex size-6 items-center justify-center">
<Spinner className="size-6 text-white" />
</div>
) : (
<>
{accounts.map((account) => (
<button
type="button"
key={account}
onClick={() => select(account)}
>
<User.Provider pubkey={account}>
<User.Root className="flex h-36 w-32 flex-col items-center justify-center gap-4 rounded-2xl p-2 hover:bg-white/10 dark:hover:bg-black/10">
<User.Avatar className="size-20 rounded-full object-cover" />
<User.Name className="max-w-[5rem] truncate text-lg font-medium leading-tight text-white" />
</User.Root>
</User.Provider>
</button>
))}
<Link to="/landing">
<div className="flex h-36 w-32 flex-col items-center justify-center gap-4 rounded-2xl p-2 text-white hover:bg-white/10 dark:hover:bg-black/10">
<div className="flex size-20 items-center justify-center rounded-full bg-white/20 dark:bg-black/20">
<PlusIcon className="size-5" />
</div>
<p className="text-lg font-medium leading-tight">Add</p>
</div>
</Link>
</>
)}
</div>
</div>
<div className="absolute z-10 h-full w-full bg-white/10 backdrop-blur-lg dark:bg-black/10" />
<div className="absolute inset-0 h-full w-full">
<img
src="/lock-screen.jpg"
srcSet="/lock-screen@2x.jpg 2x"
alt="Lock Screen Background"
className="h-full w-full object-cover"
/>
<a
href="https://njump.me/nprofile1qqs9tuz9jpn57djg7nxunhyvuvk69g5zqaxdpvpqt9hwqv7395u9rpg6zq5uw"
target="_blank"
className="absolute bottom-3 right-3 z-50 rounded-md bg-white/20 px-2 py-1 text-xs font-medium text-white dark:bg-black/20"
rel="noreferrer"
>
Design by NoGood
</a>
</div>
</div>
);
}

View File

@@ -1,119 +0,0 @@
import type { ColumnRouteSearch } from "@lume/types";
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")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
component: Screen,
});
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 (
<div className="h-full flex flex-col px-2">
<div className="shrink-0 flex h-16 items-center justify-between">
<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-1 flex flex-col gap-3 pb-2 scrollbar-none overflow-y-auto">
{TOPICS.map((topic) => (
<div
key={topic.title}
className="flex flex-col gap-4 bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"
>
<div className="px-3 flex w-full items-center justify-between h-14 shrink-0 border-b border-neutral-100 dark:border-neutral-900">
<div className="inline-flex items-center gap-2.5">
<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="px-3 pb-3 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>
);
}

View File

@@ -1,85 +0,0 @@
import { KeyIcon, RemoteIcon } from "@lume/icons";
import { Link, createFileRoute } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
export const Route = createFileRoute("/landing/")({
component: Screen,
});
function Screen() {
const { t } = useTranslation();
return (
<div className="relative flex h-screen w-screen bg-black">
<div
data-tauri-drag-region
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 />
<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">
<img
src="/heading-en.png"
srcSet="/heading-en@2x.png 2x"
alt="lume"
className="xl:w-2/3"
/>
<p className="mt-4 whitespace-pre-line text-lg font-medium leading-snug text-white/70">
{t("welcome.title")}
</p>
</div>
<div className="mx-auto flex w-full max-w-sm flex-col gap-4">
<Link
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"
>
{t("welcome.signup")}
</Link>
<div className="flex items-center gap-2">
<div className="h-px flex-1 bg-white/20" />
<div className="text-white/70">{t("login.or")}</div>
<div className="h-px flex-1 bg-white/20" />
</div>
<div className="flex flex-col gap-2">
<Link
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"
>
<RemoteIcon className="size-5 text-neutral-600 dark:text-neutral-400 group-hover:text-neutral-400 dark:group-hover:text-neutral-600" />
Nostr Connect
<div className="size-5" />
</Link>
<Link
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"
>
<KeyIcon className="size-5 text-neutral-600 dark:text-neutral-400 group-hover:text-neutral-400 dark:group-hover:text-neutral-600" />
Private Key
<div className="size-5" />
</Link>
</div>
</div>
</div>
<div className="flex h-11 items-center justify-center" />
</div>
<div className="absolute z-10 h-full w-full bg-black/5 backdrop-blur-sm" />
<div className="absolute inset-0 h-full w-full">
<img
src="/lock-screen.jpg"
srcSet="/lock-screen@2x.jpg 2x"
alt="Lock Screen Background"
className="h-full w-full object-cover"
/>
<a
href="https://njump.me/nprofile1qqs9tuz9jpn57djg7nxunhyvuvk69g5zqaxdpvpqt9hwqv7395u9rpg6zq5uw"
target="_blank"
className="absolute bottom-3 right-3 z-50 rounded-md bg-white/20 px-2 py-1 text-xs font-medium text-white backdrop-blur-md dark:bg-black/20"
rel="noreferrer"
>
Design by NoGood
</a>
</div>
</div>
);
}

View File

@@ -1,154 +0,0 @@
import { Conversation } from "@/components/conversation";
import { Quote } from "@/components/quote";
import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text";
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types";
import { Spinner } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
import { Virtualizer } from "virtua";
export const Route = createFileRoute("/newsfeed")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
beforeLoad: async ({ context }) => {
const ark = context.ark;
const settings = await ark.get_settings();
return { settings };
},
component: Screen,
});
export function Screen() {
const { label, account } = Route.useSearch();
const { ark } = Route.useRouteContext();
const {
data,
isLoading,
isFetching,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useInfiniteQuery({
queryKey: [label, 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: {
const isConversation =
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
.length > 0;
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
if (isConversation) {
return <Conversation key={event.id} event={event} className="mb-3" />;
}
if (isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
}
return <TextNote key={event.id} event={event} className="mb-3" />;
}
}
};
return (
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
{isFetching && !isLoading && !isFetchingNextPage ? (
<div className="w-full h-11 flex items-center justify-center">
<div className="flex items-center justify-center gap-2">
<Spinner className="size-5" />
<span className="text-sm font-medium">Fetching new notes...</span>
</div>
</div>
) : null}
{isLoading ? (
<div className="flex h-16 w-full items-center justify-center gap-2">
<Spinner className="size-5" />
<span className="text-sm font-medium">Loading...</span>
</div>
) : !data.length ? (
<Empty />
) : (
<Virtualizer overscan={3}>
{data.map((item) => renderItem(item))}
</Virtualizer>
)}
{data?.length && hasNextPage ? (
<div>
<button
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-black/5 px-3 font-medium hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
>
{isFetchingNextPage ? (
<Spinner className="size-5" />
) : (
<>
<ArrowRightCircleIcon className="size-5" />
Load more
</>
)}
</button>
</div>
) : null}
</div>
);
}
function Empty() {
return (
<div className="flex flex-col py-10 gap-10">
<div className="text-center flex flex-col items-center justify-center">
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8">
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" />
</div>
<p className="text-lg font-medium">Your newsfeed is empty</p>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
Here are few suggestions to get started.
</p>
</div>
<div className="flex flex-col px-3 gap-2">
<Link
to="/trending/notes"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Show trending notes
</Link>
<Link
to="/trending/users"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Discover trending users
</Link>
</div>
</div>
);
}

View File

@@ -1,62 +0,0 @@
import { ZapIcon } from "@lume/icons";
import { Container } from "@lume/ui";
import { createLazyFileRoute } from "@tanstack/react-router";
import { useState } from "react";
export const Route = createLazyFileRoute("/nwc")({
component: Screen,
});
function Screen() {
const { ark } = Route.useRouteContext();
const [uri, setUri] = useState("");
const [isDone, setIsDone] = useState(false);
const save = async () => {
const nwc = await ark.set_nwc(uri);
setIsDone(nwc);
};
return (
<Container withDrag withNavigate={false}>
<div className="h-full w-full flex-1 px-5">
{!isDone ? (
<>
<div className="flex flex-col gap-2">
<div className="inline-flex size-14 items-center justify-center rounded-xl bg-black text-white shadow-md">
<ZapIcon className="size-5" />
</div>
<div>
<h3 className="text-2xl font-light">
Connect <span className="font-semibold">bitcoin wallet</span>{" "}
to start zapping to your favorite content and creator.
</h3>
</div>
</div>
<div className="mt-10 flex flex-col gap-2">
<div className="flex flex-col gap-1.5">
<label>Paste a Nostr Wallet Connect connection string</label>
<textarea
value={uri}
onChange={(e) => setUri(e.target.value)}
placeholder="nostrconnect://"
className="h-24 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<button
type="button"
onClick={save}
className="inline-flex h-11 w-full items-center justify-center gap-1.5 rounded-lg bg-blue-500 px-5 font-medium text-white hover:bg-blue-600"
>
Save & Connect
</button>
</div>
</>
) : (
<div>Done</div>
)}
</div>
</Container>
);
}

View File

@@ -1,445 +0,0 @@
import { ArrowRightIcon, CancelIcon } from "@lume/icons";
import type { ColumnRouteSearch, LumeColumn } from "@lume/types";
import { Spinner, User } from "@lume/ui";
import { cn } from "@lume/utils";
import { createFileRoute } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { getCurrent } from "@tauri-apps/api/window";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
export const Route = createFileRoute("/onboarding")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
component: Screen,
});
function Screen() {
const { label } = Route.useSearch();
const {
register,
handleSubmit,
reset,
formState: { isValid, isSubmitting },
} = useForm();
const [userType, setUserType] = useState<"new" | "veteran">(null);
const install = async (column: LumeColumn) => {
const mainWindow = getCurrent();
await mainWindow.emit("columns", { type: "add", column });
};
const close = async () => {
const mainWindow = getCurrent();
await mainWindow.emit("columns", { type: "remove", label });
};
const friendToFriend = async (data: { npub: string }) => {
if (!data.npub.startsWith("npub1"))
return toast.warning(
"NPUB is invalid. NPUB must be starts with npub1...",
);
try {
const connect: boolean = await invoke("friend_to_friend", {
npub: data.npub,
});
if (connect) {
const column = {
label: "newsfeed",
name: "Newsfeed",
content: "/newsfeed",
};
const mainWindow = getCurrent();
await mainWindow.emit("columns", { type: "add", column });
// reset form
reset();
}
} catch (e) {
toast.error(String(e));
}
};
return (
<div className="h-full flex flex-col py-6 gap-6 overflow-y-auto scrollbar-none">
<div className="text-center flex flex-col items-center justify-center">
<h1 className="text-2xl font-serif font-medium">Welcome to Lume</h1>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
Here are a few suggestions to help you get started.
</p>
</div>
<div className="px-3">
<div className="mb-6 w-full h-44">
<img
src="/lock-screen.jpg"
srcSet="/lock-screen@2x.jpg 2x"
alt="background"
className="h-full w-full object-cover rounded-xl outline outline-1 -outline-offset-1 outline-black/15"
/>
</div>
<div className="flex flex-col gap-6">
<div className="flex items-start gap-2 text-[13px]">
<Mide />
<div className="flex flex-col gap-0.5">
<h3 className="font-semibold">Mide</h3>
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
👋 Yo! I'm Mide, and I'll be your friendly guide to Nostr and
beyond. Looking forward to our adventure together!
</div>
</div>
</div>
<div className="flex items-start flex-row-reverse gap-2 text-[13px]">
<CurrentUser />
<div className="flex flex-col gap-0.5">
<h3 className="font-semibold text-end">You</h3>
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
How can I get started?
</div>
<button
type="button"
onClick={() => setUserType("new")}
className={cn(
"mt-1 px-3 py-2 shadow-primary flex items-center justify-between gap-6 bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg",
userType === "new"
? "bg-blue-500 text-white hover:bg-blue-600"
: "",
)}
>
I'm completely new to Nostr.
<ArrowRightIcon className="size-4" />
</button>
<button
type="button"
onClick={() => setUserType("veteran")}
className={cn(
"mt-1 px-3 py-2 shadow-primary flex items-center justify-between gap-6 bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg",
userType === "veteran"
? "bg-blue-500 text-white hover:bg-blue-600"
: "",
)}
>
I've already been using another Nostr client.
<ArrowRightIcon className="size-4" />
</button>
</div>
</div>
{userType === "veteran" ? (
<div className="flex items-start gap-2 text-[13px]">
<Mide />
<div className="flex flex-col gap-0.5">
<h3 className="font-semibold">Mide</h3>
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
So, I'm excited to give you a quick intro to Lume and all the
awesome features it has to offer. Let's dive in!
</div>
</div>
</div>
) : null}
{userType === "veteran" ? (
<div className="flex items-start flex-row-reverse gap-2 text-[13px]">
<CurrentUser />
<div className="flex flex-col gap-0.5">
<h3 className="font-semibold text-end">You</h3>
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
Thanks! But I already know about Lume.
</div>
<button
type="button"
onClick={() =>
install({
label: "newsfeed",
name: "Newsfeed",
content: "/newsfeed",
})
}
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between gap-6 bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
>
Skip! Show my newsfeed
<ArrowRightIcon className="size-4" />
</button>
</div>
</div>
) : null}
{userType === "veteran" ? (
<div className="flex items-start gap-2 text-[13px]">
<Mide />
<div className="flex flex-col gap-0.5">
<h3 className="font-semibold">Mide</h3>
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
First off, Lume is a social media client for Nostr. It's a
place where you can follow friends, dive into chats, and post
what's on your mind.
</div>
</div>
</div>
) : null}
{userType === "veteran" ? (
<div className="flex items-start gap-2 text-[13px]">
<Mide />
<div className="flex flex-col gap-0.5">
<h3 className="font-semibold">Mide</h3>
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
That's not all! What makes Lume unique is the column system.
You can enhance your experience by adding new columns from the
Lume Store.
</div>
<div className="mt-1 p-2 bg-black/5 dark:bg-white/5 rounded-lg">
If you're confused about the term "Column," you can imagine it
as mini-apps, with each column providing its own experience.
</div>
<div className="mt-1 p-2 bg-black/5 dark:bg-white/5 rounded-lg">
Here is a quick guide for how to add a new column:
</div>
<div className="mt-1 rounded-lg">
<video
className="h-auto w-full rounded-lg object-cover aspect-video outline outline-1 -outline-offset-1 outline-black/15"
controls
muted
>
<source
src="https://samplelib.com/lib/preview/mp4/sample-5s.mp4"
type="video/mp4"
/>
Your browser does not support the video tag.
</video>
</div>
</div>
</div>
) : null}
{userType === "veteran" ? (
<div className="flex items-start flex-row-reverse gap-2 text-[13px]">
<CurrentUser />
<div className="flex flex-col gap-0.5">
<h3 className="font-semibold text-end">You</h3>
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
Can you introduce me to the UI? I am still confused.
</div>
</div>
</div>
) : null}
{userType === "veteran" ? (
<div className="flex items-start gap-2 text-[13px]">
<Mide />
<div className="flex flex-col gap-0.5">
<h3 className="font-semibold">Mide</h3>
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
Of course, here is a quick introduction video for Lume.
</div>
<div className="mt-1 rounded-lg">
<video
className="h-auto w-full rounded-lg object-cover aspect-video outline outline-1 -outline-offset-1 outline-black/15"
controls
muted
>
<source
src="https://samplelib.com/lib/preview/mp4/sample-5s.mp4"
type="video/mp4"
/>
Your browser does not support the video tag.
</video>
</div>
</div>
</div>
) : null}
{userType === "new" ? (
<div className="flex items-start gap-2 text-[13px]">
<Mide />
<div className="flex flex-col gap-0.5">
<h3 className="font-semibold">Mide</h3>
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
Diving into new social media platforms like Nostr can be a bit
overwhelming, but don't worry! Here are some handy tips to
help you navigate and discover what interests you.
</div>
<button
type="button"
onClick={() =>
install({
label: "foryou",
name: "For you",
content: "/foryou",
})
}
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
>
Add some topics that you're interested in.
<ArrowRightIcon className="size-4" />
</button>
<button
type="button"
onClick={() =>
install({
label: "trending_users",
name: "Trending",
content: "/trending/users",
})
}
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
>
Follow some users.
<ArrowRightIcon className="size-4" />
</button>
</div>
</div>
) : null}
{userType === "new" ? (
<div className="flex items-start flex-row-reverse gap-2 text-[13px]">
<CurrentUser />
<div className="flex flex-col gap-0.5">
<h3 className="font-semibold text-end">You</h3>
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
My girlfriend introduced Nostr to me, and I have her NPUB. Can
I get the same experiences as her?
</div>
</div>
</div>
) : null}
{userType === "new" ? (
<div className="flex items-start gap-2 text-[13px]">
<Mide />
<div className="flex flex-col gap-0.5">
<h3 className="font-semibold">Mide</h3>
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
Absolutely! Since your girlfriend shared her NPUB with you,
you can dive into Nostr and explore it just like she does.
It's a great way to share experiences and discover what Nostr
has to offer together!
</div>
<form
onSubmit={handleSubmit(friendToFriend)}
className="mt-1 flex flex-col items-end bg-white dark:bg-white/10 rounded-lg shadow-primary"
>
<input
{...register("npub", { required: true })}
name="npub"
placeholder="Enter npub here..."
className="w-full h-14 px-3 rounded-t-lg bg-transparent border-b border-x-0 border-t-0 border-neutral-100 dark:border-white/5 focus:border-neutral-200 dark:focus:border-white/20 focus:outline-none focus:ring-0 placeholder:text-neutral-600 dark:placeholder:text-neutral-400"
/>
<div className="h-10 flex items-center px-1">
<button
type="submit"
disabled={!isValid || isSubmitting}
className="px-2 h-8 w-20 inline-flex items-center justify-center bg-blue-500 text-white rounded-md text-sm font-medium hover:bg-blue-600"
>
{isSubmitting ? <Spinner className="size-4" /> : "Submit"}
</button>
</div>
</form>
</div>
</div>
) : null}
{userType ? (
<>
<div className="flex items-start flex-row-reverse gap-2 text-[13px]">
<CurrentUser />
<div className="flex flex-col gap-0.5">
<h3 className="font-semibold text-end">You</h3>
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
Thank you. I can use Lume and explore Nostr by myself from
now on.
</div>
</div>
</div>
<div className="flex items-start gap-2 text-[13px]">
<Mide />
<div className="flex flex-col gap-0.5">
<h3 className="font-semibold">Mide</h3>
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
I really hope you enjoy your time on Nostr! If you're keen
to dive deeper, here are some helpful resources to get you
started:
</div>
<a
href="https://nostr.org"
target="_blank"
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
rel="noreferrer"
>
[Website] nostr.org
<ArrowRightIcon className="size-4" />
</a>
<a
href="https://www.youtube.com/watch?v=5W-jtbbh3eA"
target="_blank"
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
rel="noreferrer"
>
[Video] What is Nostr?
<ArrowRightIcon className="size-4" />
</a>
<a
href="https://github.com/nostr-protocol/nostr"
target="_blank"
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
rel="noreferrer"
>
[Develop] Github
<ArrowRightIcon className="size-4" />
</a>
<a
href="https://www.nostrapps.com/"
target="_blank"
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
rel="noreferrer"
>
[Ecosystem] nostrapps.com
<ArrowRightIcon className="size-4" />
</a>
</div>
</div>
<div className="flex items-start gap-2 text-[13px]">
<Mide />
<div className="flex flex-col gap-0.5">
<h3 className="font-semibold">Mide</h3>
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
If you want to close this onboarding board, you can click
the button below.
</div>
<button
type="button"
onClick={() => close()}
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
>
Close
<CancelIcon className="size-4" />
</button>
</div>
</div>
</>
) : null}
</div>
</div>
</div>
);
}
function Mide() {
return (
<img
src="/ai.jpg"
alt="Ai-chan"
className="shrink-0 size-10 rounded-full outline outline-1 -outline-offset-1 outline-black/15"
/>
);
}
function CurrentUser() {
const { account } = Route.useSearch();
return (
<User.Provider pubkey={account}>
<User.Root className="shrink-0">
<User.Avatar className="size-10 rounded-full outline outline-1 -outline-offset-1 outline-black/15" />
</User.Root>
</User.Provider>
);
}

View File

@@ -1,48 +0,0 @@
import { PlusIcon } from "@lume/icons";
import type { LumeColumn } from "@lume/types";
import { createLazyRoute } from "@tanstack/react-router";
import { getCurrent } from "@tauri-apps/api/window";
export const Route = createLazyRoute("/open")({
component: Screen,
});
function Screen() {
const install = async (column: LumeColumn) => {
const mainWindow = getCurrent();
await mainWindow.emit("columns", { type: "add", column });
};
return (
<div className="relative flex h-full w-full items-center justify-center">
<div className="group absolute left-0 top-0 z-10 h-full w-12">
<button
type="button"
onClick={() =>
install({
label: "store",
name: "Store",
content: "/store/official",
})
}
className="flex h-full w-full items-center justify-center rounded-xl bg-transparent transition-colors duration-100 ease-in-out group-hover:bg-black/5 dark:group-hover:bg-white/5"
>
<PlusIcon className="size-6 scale-0 transform transition-transform duration-150 ease-in-out will-change-transform group-hover:scale-100" />
</button>
</div>
<button
type="button"
onClick={() =>
install({
label: "store",
name: "Store",
content: "/store/official",
})
}
className="inline-flex size-14 items-center justify-center rounded-full bg-black/10 backdrop-blur-lg hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
>
<PlusIcon className="size-8" />
</button>
</div>
);
}

View File

@@ -1,143 +0,0 @@
import { SearchIcon } from "@lume/icons";
import { type Event, Kind } from "@lume/types";
import { Note, Spinner, User } from "@lume/ui";
import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { useDebounce } from "use-debounce";
export const Route = createFileRoute("/search")({
component: Screen,
});
function Screen() {
const [loading, setLoading] = useState(false);
const [events, setEvents] = useState<Event[]>([]);
const [search, setSearch] = useState("");
const [searchValue] = useDebounce(search, 500);
const searchEvents = async () => {
try {
setLoading(true);
const query = `https://api.nostr.wine/search?query=${searchValue}&kind=0,1`;
const res = await fetch(query);
const content = await res.json();
const events = content.data as Event[];
const sorted = events.sort((a, b) => b.created_at - a.created_at);
setLoading(false);
setEvents(sorted);
} catch (e) {
setLoading(false);
toast.error(String(e));
}
};
useEffect(() => {
if (searchValue.length >= 3 && searchValue.length < 500) {
searchEvents();
}
}, [searchValue]);
return (
<div data-tauri-drag-region className="flex flex-col w-full h-full">
<div className="relative h-24 shrink-0 flex flex-col border-b border-black/5 dark:border-white/5">
<div data-tauri-drag-region className="w-full h-4 shrink-0" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") searchEvents();
}}
placeholder="Search anything..."
className="w-full h-20 pt-10 px-3 text-lg bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-neutral-500 dark:placeholder:text-neutral-600"
/>
</div>
<div className="flex-1 p-3 overflow-y-auto scrollbar-none">
{loading ? (
<div className="w-full h-full flex items-center justify-center">
<Spinner />
</div>
) : events.length ? (
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 shrink-0">
Users
</div>
<div className="flex-1 flex flex-col gap-1">
{events
.filter((ev) => ev.kind === Kind.Metadata)
.map((event, index) => (
<SearchUser key={event.pubkey + index} event={event} />
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 shrink-0">
Notes
</div>
<div className="flex-1 flex flex-col gap-3">
{events
.filter((ev) => ev.kind === Kind.Text)
.map((event) => (
<SearchNote key={event.id} event={event} />
))}
</div>
</div>
</div>
) : null}
{!loading && !events.length ? (
<div className="h-full flex items-center justify-center flex-col gap-3">
<div className="size-16 bg-black/10 dark:bg-white/10 rounded-full inline-flex items-center justify-center">
<SearchIcon className="size-6" />
</div>
Try searching for people, notes, or keywords
</div>
) : null}
</div>
</div>
);
}
function SearchUser({ event }: { event: Event }) {
const { ark } = Route.useRouteContext();
return (
<button
key={event.id}
type="button"
onClick={() => ark.open_profile(event.pubkey)}
className="col-span-1 p-2 hover:bg-black/10 dark:hover:bg-white/10 rounded-lg"
>
<User.Provider pubkey={event.pubkey} embedProfile={event.content}>
<User.Root className="flex items-center gap-2">
<User.Avatar className="size-9 rounded-full shrink-0" />
<div className="inline-flex items-center gap-1.5">
<User.Name className="font-semibold" />
<User.NIP05 />
</div>
</User.Root>
</User.Provider>
</button>
);
}
function SearchNote({ event }: { event: Event }) {
return (
<div className="bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
<Note.Provider event={event}>
<Note.Root>
<div className="px-3 h-14 flex items-center justify-between">
<Note.User />
<Note.Menu />
</div>
<Note.Content className="px-3" quote={false} mention={false} />
<div className="mt-3 flex items-center gap-4 h-14 px-3">
<Note.Open />
</div>
</Note.Root>
</Note.Provider>
</div>
);
}

View File

@@ -1,127 +0,0 @@
import {
RelayIcon,
SecureIcon,
SettingsIcon,
UserIcon,
ZapIcon,
} from "@lume/icons";
import { cn } from "@lume/utils";
import { Link } from "@tanstack/react-router";
import { Outlet, createFileRoute } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
export const Route = createFileRoute("/settings")({
component: Screen,
});
function Screen() {
const { t } = useTranslation();
return (
<div className="flex h-full w-full flex-col">
<div
data-tauri-drag-region
className="flex h-20 w-full shrink-0 items-center justify-center border-b border-black/10 dark:border-white/10"
>
<div className="flex items-center gap-1">
<Link to="/settings/general">
{({ isActive }) => {
return (
<div
className={cn(
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
isActive
? "bg-black/10 hover:bg-black/20 dark:bg-white/10 text-neutral-900 dark:text-neutral-100 dark:hover:bg-bg-white/20"
: "text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
)}
>
<SettingsIcon className="size-5 shrink-0" />
<p className="text-sm font-medium">
{t("settings.general.title")}
</p>
</div>
);
}}
</Link>
<Link to="/settings/user">
{({ isActive }) => {
return (
<div
className={cn(
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
isActive
? "bg-black/10 hover:bg-black/20 dark:bg-white/10 text-neutral-900 dark:text-neutral-100 dark:hover:bg-bg-white/20"
: "text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
)}
>
<UserIcon className="size-5 shrink-0" />
<p className="text-sm font-medium">
{t("settings.user.title")}
</p>
</div>
);
}}
</Link>
<Link to="/settings/relay">
{({ isActive }) => {
return (
<div
className={cn(
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
isActive
? "bg-black/10 hover:bg-black/20 dark:bg-white/10 text-neutral-900 dark:text-neutral-100 dark:hover:bg-bg-white/20"
: "text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
)}
>
<RelayIcon className="size-5 shrink-0" />
<p className="text-sm font-medium">Relay</p>
</div>
);
}}
</Link>
<Link to="/settings/zap">
{({ isActive }) => {
return (
<div
className={cn(
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
isActive
? "bg-black/10 hover:bg-black/20 dark:bg-white/10 text-neutral-900 dark:text-neutral-100 dark:hover:bg-bg-white/20"
: "text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
)}
>
<ZapIcon className="size-5 shrink-0" />
<p className="text-sm font-medium">
{t("settings.zap.title")}
</p>
</div>
);
}}
</Link>
<Link to="/settings/backup">
{({ isActive }) => {
return (
<div
className={cn(
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
isActive
? "bg-black/10 hover:bg-black/20 dark:bg-white/10 text-neutral-900 dark:text-neutral-100 dark:hover:bg-bg-white/20"
: "text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
)}
>
<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>
);
}

View File

@@ -1,128 +0,0 @@
import type { Account } from "@lume/types";
import { User } from "@lume/ui";
import { displayNsec } from "@lume/utils";
import { createFileRoute } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { useState } from "react";
import { toast } from "sonner";
export const Route = createFileRoute("/settings/backup")({
component: Screen,
loader: async ({ context }) => {
const ark = context.ark;
const npubs = await ark.get_all_accounts();
const accounts: Account[] = [];
for (const account of npubs) {
const nsec: string = await invoke("get_stored_nsec", {
npub: account.npub,
});
accounts.push({ ...account, nsec });
}
return accounts;
},
});
function Screen() {
const accounts = Route.useLoaderData();
return (
<div className="mx-auto w-full max-w-xl">
<div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700">
{accounts.map((account) => (
<NostrAccount key={account.npub} account={account} />
))}
</div>
</div>
);
}
function NostrAccount({ account }: { account: Account }) {
const [key, setKey] = useState(account.nsec);
const [copied, setCopied] = useState(false);
const [passphase, setPassphase] = useState("");
const encrypt = async () => {
const encrypted: string = await invoke("get_encrypted_key", {
npub: account.npub,
password: passphase,
});
setKey(encrypted);
};
const copyKey = async () => {
try {
await writeText(key);
setCopied(true);
} catch (e) {
toast.error(e);
}
};
return (
<div className="flex flex-1 flex-col gap-2 py-3">
<User.Provider pubkey={account.npub}>
<User.Root className="flex items-center gap-2">
<User.Avatar className="size-8 rounded-full object-cover" />
<div className="flex flex-col">
<User.Name className="text-sm leading-tight" />
<User.NIP05 />
</div>
</User.Root>
</User.Provider>
<div className="flex flex-col gap-2">
<div className="flex w-full flex-col gap-1">
<label
htmlFor="nsec"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Private Key
</label>
<div className="flex items-center gap-2">
<input
readOnly
name="nsec"
type="text"
value={displayNsec(key, 36)}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
<button
type="button"
onClick={() => copyKey()}
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
>
{copied ? "Copied" : "Copy"}
</button>
</div>
</div>
<div className="flex w-full flex-col gap-1">
<label
htmlFor="passphase"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Set a passphase to secure your key
</label>
<div className="flex items-center gap-2">
<input
name="passphase"
type="password"
value={passphase}
onChange={(e) => setPassphase(e.target.value)}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
<button
type="button"
onClick={() => encrypt()}
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
>
Update
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,183 +0,0 @@
import type { Settings } from "@lume/types";
import * as Switch from "@radix-ui/react-switch";
import { createFileRoute } from "@tanstack/react-router";
import {
isPermissionGranted,
requestPermission,
} from "@tauri-apps/plugin-notification";
import { useEffect, useState } from "react";
import { useDebouncedCallback } from "use-debounce";
export const Route = createFileRoute("/settings/general")({
beforeLoad: async ({ context }) => {
const permissionGranted = await isPermissionGranted(); // get notification permission
const ark = context.ark;
const settings = await ark.get_settings();
return {
settings: { ...settings, notification: permissionGranted },
};
},
component: Screen,
});
function Screen() {
const { ark, settings } = Route.useRouteContext();
const [newSettings, setNewSettings] = useState<Settings>(settings);
const toggleNofitication = async () => {
await requestPermission();
setNewSettings((prev) => ({
...prev,
notification: !newSettings.notification,
}));
};
const toggleAutoUpdate = () => {
setNewSettings((prev) => ({
...prev,
autoUpdate: !newSettings.autoUpdate,
}));
};
const toggleEnhancedPrivacy = () => {
setNewSettings((prev) => ({
...prev,
enhancedPrivacy: !newSettings.enhancedPrivacy,
}));
};
const toggleZap = () => {
setNewSettings((prev) => ({
...prev,
zap: !newSettings.zap,
}));
};
const toggleNsfw = () => {
setNewSettings((prev) => ({
...prev,
nsfw: !newSettings.nsfw,
}));
};
const updateSettings = useDebouncedCallback(() => {
ark.set_settings(newSettings);
}, 200);
useEffect(() => {
updateSettings();
}, [newSettings]);
return (
<div className="mx-auto w-full max-w-xl">
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<h2 className="font-semibold text-sm text-neutral-700 dark:text-neutral-300">
General
</h2>
<div className="flex flex-col divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl px-3">
<div className="flex w-full items-start justify-between gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Notification</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
By turning on push notifications, you'll start getting
notifications from Lume directly.
</p>
</div>
<div className="w-36 flex justify-end shrink-0">
<Switch.Root
checked={newSettings.notification}
onClick={() => toggleNofitication()}
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
</div>
<div className="flex w-full items-start justify-between gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Enhanced Privacy</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Lume presents external resources like images, videos, or link
previews in plain text.
</p>
</div>
<div className="w-36 flex justify-end shrink-0">
<Switch.Root
checked={newSettings.enhancedPrivacy}
onClick={() => toggleEnhancedPrivacy()}
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
</div>
<div className="flex w-full items-start justify-between gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Auto Update</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Automatically download and install new version.
</p>
</div>
<div className="w-36 flex justify-end shrink-0">
<Switch.Root
checked={newSettings.autoUpdate}
onClick={() => toggleAutoUpdate()}
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
</div>
<div className="flex w-full items-start justify-between gap-4 py-3">
<div className="flex-1">
<h3 className="font-semibold">Filter sensitive content</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
By default, Lume will display all content which have Content
Warning tag, it's may include NSFW content.
</p>
</div>
<div className="w-36 flex justify-end shrink-0">
<Switch.Root
checked={newSettings.nsfw}
onClick={() => toggleNsfw()}
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<h2 className="font-semibold text-sm text-neutral-700 dark:text-neutral-300">
Interface
</h2>
<div className="flex flex-col divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl px-3">
<div className="flex flex-col gap-4">
<div className="flex w-full items-start justify-between gap-4 py-3">
<div className="flex-1">
<h3 className="font-semibold">Zap</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Show the Zap button in each note and user's profile screen,
use for send bitcoin tip to other users.
</p>
</div>
<div className="w-36 flex justify-end shrink-0">
<Switch.Root
checked={newSettings.zap}
onClick={() => toggleZap()}
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,135 +0,0 @@
import { CancelIcon, PlusIcon } from "@lume/icons";
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
export const Route = createFileRoute("/settings/relay")({
loader: async ({ context }) => {
const ark = context.ark;
const relays = await ark.get_relays();
return relays;
},
component: Screen,
});
function Screen() {
const relayList = Route.useLoaderData();
const [relays, setRelays] = useState(relayList.connected);
const { ark } = Route.useRouteContext();
const { register, reset, handleSubmit } = useForm();
const onSubmit = async (data: { url: string }) => {
try {
const add = await ark.add_relay(data.url);
if (add) {
setRelays((prev) => [...prev, data.url]);
reset();
}
} catch (e) {
toast.error(String(e));
}
};
return (
<div className="mx-auto w-full max-w-xl">
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<h2 className="font-semibold text-sm text-neutral-700 dark:text-neutral-300">
Connected Relays
</h2>
<div className="flex flex-col divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl px-3">
{relays.map((relay) => (
<div
key={relay}
className="flex justify-between items-center h-11"
>
<div className="inline-flex items-center gap-2 text-sm font-medium">
<span className="relative flex size-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-teal-400 opacity-75"></span>
<span className="relative inline-flex rounded-full size-2 bg-teal-500"></span>
</span>
{relay}
</div>
<div>
<button
type="button"
className="inline-flex items-center justify-center size-7 rounded-md hover:bg-black/10 dark:hover:bg-white/10"
>
<CancelIcon className="size-4" />
</button>
</div>
</div>
))}
<div className="flex items-center h-14">
<form
onSubmit={handleSubmit(onSubmit)}
className="w-full flex items-center gap-2 mb-0"
>
<input
{...register("url", {
required: true,
minLength: 1,
})}
name="url"
placeholder="wss://..."
spellCheck={false}
className="h-9 flex-1 rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring-0 dark:border-neutral-700 dark:placeholder:text-neutral-400"
/>
<button
type="submit"
className="shrink-0 inline-flex h-9 w-16 px-2 items-center justify-center rounded-lg bg-black/20 dark:bg-white/20 font-medium text-sm text-white hover:bg-blue-500 disabled:opacity-50"
>
<PlusIcon className="size-7" />
</button>
</form>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<h2 className="font-semibold text-sm text-neutral-700 dark:text-neutral-300">
User Relays (NIP-65)
</h2>
<div className="flex flex-col py-2 bg-black/5 dark:bg-white/5 rounded-xl px-3">
<p className="text-sm text-yellow-500">
Lume will automatically connect to the user's relay list, but the
manager function (like adding, removing, changing relay purpose)
is not yet available.
</p>
</div>
<div className="flex flex-col divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl px-3">
{relayList.read?.map((relay) => (
<div
key={relay}
className="flex justify-between items-center h-11"
>
<div className="text-sm font-medium">{relay}</div>
<div className="text-xs font-semibold">READ</div>
</div>
))}
{relayList.write?.map((relay) => (
<div
key={relay}
className="flex justify-between items-center h-11"
>
<div className="text-sm font-medium">{relay}</div>
<div className="text-xs font-semibold">WRITE</div>
</div>
))}
{relayList.both?.map((relay) => (
<div
key={relay}
className="flex justify-between items-center h-11"
>
<div className="text-sm font-medium">{relay}</div>
<div className="text-xs font-semibold">READ + WRITE</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,180 +0,0 @@
import { AvatarUploader } from "@/components/avatarUploader";
import { PlusIcon } from "@lume/icons";
import type { Metadata } from "@lume/types";
import { Spinner } from "@lume/ui";
import { Link } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
export const Route = createFileRoute("/settings/user")({
beforeLoad: async ({ context }) => {
const ark = context.ark;
const profile = await ark.get_current_user_profile();
return { profile };
},
component: Screen,
});
function Screen() {
const { ark, profile } = Route.useRouteContext();
const { register, handleSubmit } = useForm();
const [loading, setLoading] = useState(false);
const [picture, setPicture] = useState<string>("");
const onSubmit = async (data: Metadata) => {
try {
setLoading(true);
const profile = { ...data, picture };
await ark.create_profile(profile);
setLoading(false);
} catch (e) {
setLoading(false);
toast.error(String(e));
}
};
return (
<div className="flex w-full h-full">
<div className="flex-1 h-full flex items-center flex-col justify-center gap-3">
<div className="relative size-24 rounded-full bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200">
{profile.picture ? (
<img
src={picture || profile.picture}
alt="avatar"
loading="lazy"
decoding="async"
className="absolute inset-0 z-10 h-full w-full rounded-full object-cover"
/>
) : null}
<AvatarUploader
setPicture={setPicture}
className="absolute inset-0 z-20 flex h-full w-full items-center justify-center rounded-full bg-black/10 text-white hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
>
<PlusIcon className="size-8" />
</AvatarUploader>
</div>
<div className="text-center flex flex-col items-center">
<div className="text-lg font-semibold">{profile.display_name}</div>
<div className="text-neutral-800 dark:text-neutral-200">
{profile.nip05}
</div>
<div className="mt-4">
<Link
to="/settings/backup"
className="px-5 h-9 border border-blue-300 text-sm font-medium hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800 rounded-full bg-blue-100 text-blue-500 inline-flex items-center justify-center"
>
Backup Account
</Link>
</div>
</div>
</div>
<div className="flex-1 h-full">
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-3 mb-0"
>
<div className="flex w-full flex-col gap-1">
<label
htmlFor="display_name"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Display Name
</label>
<input
name="display_name"
{...register("display_name", { required: true, minLength: 1 })}
spellCheck={false}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex w-full flex-col gap-1">
<label
htmlFor="name"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Name
</label>
<input
name="name"
{...register("name")}
spellCheck={false}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex w-full flex-col gap-1">
<label
htmlFor="website"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Website
</label>
<input
name="website"
type="url"
{...register("website")}
spellCheck={false}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex w-full flex-col gap-1">
<label
htmlFor="banner"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Cover
</label>
<input
name="banner"
type="url"
{...register("banner")}
spellCheck={false}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex w-full flex-col gap-1">
<label
htmlFor="nip05"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
NIP-05
</label>
<input
name="nip05"
type="email"
{...register("nip05")}
spellCheck={false}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex w-full flex-col gap-1">
<label
htmlFor="lnaddress"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Lightning Address
</label>
<input
name="lnaddress"
type="email"
{...register("lud16")}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex items-center justify-end">
<button
type="submit"
className="inline-flex h-9 w-32 px-2 items-center justify-center rounded-lg bg-blue-500 font-medium text-sm text-white hover:bg-blue-600 disabled:opacity-50"
>
{loading ? <Spinner className="size-4" /> : "Update Profile"}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -1,102 +0,0 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { useState } from "react";
import { toast } from "sonner";
export const Route = createLazyFileRoute("/settings/zap")({
component: Screen,
});
function Screen() {
return (
<div className="mx-auto w-full max-w-xl">
<div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700">
<div className="flex flex-col gap-6 py-3">
<Connection />
<DefaultAmount />
</div>
</div>
</div>
);
}
function Connection() {
const [uri, setUri] = useState("");
const connect = async () => {
try {
await invoke("set_nwc", { uri });
} catch (e) {
toast.error(String(e));
}
};
return (
<div className="flex items-start gap-6">
<div className="w-36 shrink-0 text-end font-medium text-sm">
Connection
</div>
<div className="flex-1">
<div className="flex w-full flex-col gap-1">
<label
htmlFor="nwc"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Nostr Wallet Connect
</label>
<div className="flex items-center gap-2">
<input
name="nwc"
type="text"
value={uri}
onChange={(e) => setUri(e.target.value)}
placeholder="nostrconnect://"
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
<button
type="button"
onClick={() => connect()}
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
>
Connect
</button>
</div>
</div>
</div>
</div>
);
}
function DefaultAmount() {
return (
<div className="flex items-start gap-6">
<div className="w-36 shrink-0 text-end font-medium text-sm">
Default amount
</div>
<div className="flex-1">
<div className="flex w-full flex-col gap-1">
<label
htmlFor="amount"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Set default amount for quick zapping
</label>
<div className="flex items-center gap-2">
<input
name="amount"
type="number"
value={21}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
<button
type="button"
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
>
Update
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,21 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/store/community")({
component: Screen,
});
function Screen() {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 p-3">
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full">
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" />
</div>
<div className="text-center">
<h1 className="font-semibold text-lg">Coming Soon</h1>
<p className="text-sm text-neutral-700 dark:text-neutral-300 leading-tight">
Enhance your experience <br /> by adding column shared by community.
</p>
</div>
</div>
);
}

View File

@@ -1,69 +0,0 @@
import type { LumeColumn } from "@lume/types";
import { createFileRoute } from "@tanstack/react-router";
import { resolveResource } from "@tauri-apps/api/path";
import { getCurrent } from "@tauri-apps/api/window";
import { readTextFile } from "@tauri-apps/plugin-fs";
export const Route = createFileRoute("/store/official")({
beforeLoad: async () => {
const resourcePath = await resolveResource(
"resources/official_columns.json",
);
const officialColumns: LumeColumn[] = JSON.parse(
await readTextFile(resourcePath),
);
return {
officialColumns,
};
},
component: Screen,
});
function Screen() {
const { officialColumns } = Route.useRouteContext();
const install = async (column: LumeColumn) => {
const mainWindow = getCurrent();
await mainWindow.emit("columns", { type: "add", column });
};
return (
<div className="flex flex-col gap-3 p-3">
{officialColumns.map((column) => (
<div
key={column.label}
className="relative h-[200px] w-full overflow-hidden rounded-xl bg-gradient-to-tr from-orange-100 to-blue-200 px-3 pt-3"
>
{column.cover ? (
<img
src={column.cover}
srcSet={column.coverRetina}
alt={column.name}
loading="lazy"
decoding="async"
className="absolute left-0 top-0 z-10 h-full w-full object-cover"
/>
) : null}
<div className="absolute bottom-0 left-0 z-20 h-16 w-full bg-black/40 px-3 backdrop-blur-xl">
<div className="flex h-full items-center justify-between">
<div>
<h1 className="font-semibold text-white">{column.name}</h1>
<p className="max-w-[24rem] truncate text-sm text-white/80">
{column.description}
</p>
</div>
<button
type="button"
onClick={() => install(column)}
className="inline-flex h-8 w-16 shrink-0 items-center justify-center rounded-full bg-white/20 text-sm font-medium text-white hover:bg-white hover:text-blue-500"
>
Add
</button>
</div>
</div>
</div>
))}
</div>
);
}

View File

@@ -1,56 +0,0 @@
import { GlobalIcon, LaurelIcon } from "@lume/icons";
import type { ColumnRouteSearch } from "@lume/types";
import { cn } from "@lume/utils";
import { Link } from "@tanstack/react-router";
import { Outlet, createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/store")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
component: Screen,
});
function Screen() {
return (
<div className="flex flex-col h-full">
<div className="px-3 mt-2 mb-1">
<div className="p-1 shrink-0 inline-flex w-full rounded-lg items-center gap-1 bg-black/5 dark:bg-white/5">
<Link to="/store/official" className="flex-1">
{({ isActive }) => (
<div
className={cn(
"inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-md text-sm font-medium leading-tight",
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
)}
>
<LaurelIcon className="size-4" />
Official
</div>
)}
</Link>
<Link to="/store/community" className="flex-1">
{({ isActive }) => (
<div
className={cn(
"inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-md text-sm font-medium leading-tight",
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
)}
>
<GlobalIcon className="size-4" />
Community
</div>
)}
</Link>
</div>
</div>
<div className="flex-1 overflow-y-auto scrollbar-none">
<Outlet />
</div>
</div>
);
}

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