Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
595bcc9b3c | ||
|
|
15991d07ab | ||
|
|
99fc1f0b10 | ||
|
|
1041e1ccd4 | ||
|
|
2eeb2c896d | ||
|
|
caf8fb584a | ||
|
|
58205713ab | ||
|
|
33802d32f3 |
@@ -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
|
||||
49
.eslintrc.js
Normal 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'],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
72
.github/workflows/flatpak-bundle.yml
vendored
@@ -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 }}
|
||||
90
.github/workflows/main.yml
vendored
@@ -1,73 +1,71 @@
|
||||
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: "macos-latest" # for Intel based macs.
|
||||
args: "--target universal-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: 'ubuntu-20.04'
|
||||
args: ''
|
||||
- platform: 'windows-latest'
|
||||
args: '--target x86_64-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
@@ -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
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
pnpm exec lint-staged
|
||||
5
.idea/.gitignore
generated
vendored
@@ -1,5 +0,0 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
@@ -1,5 +0,0 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
||||
</state>
|
||||
</component>
|
||||
46
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -1,46 +0,0 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="CheckEmptyScriptTag" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="CheckValidXmlInScriptTagBody" enabled="false" level="ERROR" enabled_by_default="false" />
|
||||
<inspection_tool class="GithubFunctionSignatureValidation" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="GrazieInspection" enabled="false" level="GRAMMAR_ERROR" enabled_by_default="false" />
|
||||
<inspection_tool class="HtmlExtraClosingTag" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="HtmlMissingClosingTag" enabled="false" level="INFORMATION" enabled_by_default="false" />
|
||||
<inspection_tool class="HtmlUnknownAnchorTarget" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="HtmlUnknownAttribute" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="HtmlUnknownBooleanAttribute" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="HtmlUnknownTag" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="HtmlUnknownTarget" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="HtmlWrongAttributeValue" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="HttpClientInappropriateProtocolUsageInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="HttpClientUnresolvedAuthId" enabled="false" level="ERROR" enabled_by_default="false" />
|
||||
<inspection_tool class="HttpClientUnresolvedVariable" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="HttpRequestContentLengthIsIgnored" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="HttpRequestEnvironmentAuthConfigurationValidationInspection" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="HttpRequestPlaceholder" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="HttpRequestRequestSeparatorJsonBodyInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="HttpRequestRequestSeparatorXmlBodyInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="HttpRequestRequestSeparatorYamlBodyInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="HttpRequestWhitespaceInsideRequestTargetPath" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="IncorrectHttpHeaderInspection" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="LanguageDetectionInspection" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="MandatoryParamsAbsent" enabled="false" level="ERROR" enabled_by_default="false" />
|
||||
<inspection_tool class="MarkdownIncorrectTableFormatting" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="MarkdownIncorrectlyNumberedListItem" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="MarkdownLinkDestinationWithSpaces" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="MarkdownNoTableBorders" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="MarkdownOutdatedTableOfContents" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="MarkdownUnresolvedFileReference" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="MarkdownUnresolvedHeaderReference" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="MarkdownUnresolvedLinkLabel" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="RequiredAttributes" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="SpellCheckingInspection" enabled="false" level="TYPO" enabled_by_default="false">
|
||||
<option name="processCode" value="true" />
|
||||
<option name="processLiterals" value="true" />
|
||||
<option name="processComments" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="UndefinedAction" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="UndefinedParamsPresent" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
</profile>
|
||||
</component>
|
||||
17
.idea/lume.iml
generated
@@ -1,17 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="EMPTY_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/src-tauri/src" isTestSource="false" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.github" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.turbo" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/apps" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/flatpak" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/node_modules" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/src-tauri/target" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
8
.idea/modules.xml
generated
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/lume.iml" filepath="$PROJECT_DIR$/.idea/lume.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
9
.prettierignore
Normal file
@@ -0,0 +1,9 @@
|
||||
.tmp
|
||||
.cache/
|
||||
coverage/
|
||||
.nyc_output/
|
||||
**/.yarn/**
|
||||
**/.pnp.*
|
||||
/dist*/
|
||||
node_modules/
|
||||
src-tauri/
|
||||
22
.prettierrc
Normal 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
|
||||
}
|
||||
51
README.md
@@ -1,31 +1,19 @@
|
||||
## Introduction
|
||||
### Introduction
|
||||
|
||||
Lume is a Nostr client for desktop include Linux, Windows and macOS. It is free and open source, you can look at source code on Github. Lume is actively improving the app and adding new features, you can expect new update every month.
|
||||
Lume is a nostr client
|
||||
|
||||
## Usage
|
||||
### Usage
|
||||
|
||||
Download Lume v4 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 are coming soon.
|
||||
Supported platform: macOS, Windows and Linux
|
||||
|
||||
Windows and Linux are availabel on v3 and below.
|
||||
|
||||
## 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
|
||||
@@ -34,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>
|
||||
```
|
||||
|
||||
26
apps/desktop2/.gitignore
vendored
@@ -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
|
||||
@@ -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>
|
||||
@@ -1,65 +0,0 @@
|
||||
{
|
||||
"name": "@lume/desktop2",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lume/icons": "workspace:^",
|
||||
"@lume/system": "workspace:^",
|
||||
"@lume/ui": "workspace:^",
|
||||
"@lume/utils": "workspace:^",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-hover-card": "^1.0.7",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@tanstack/query-persist-client-core": "^5.45.0",
|
||||
"@tanstack/react-query": "^5.45.0",
|
||||
"@tanstack/react-router": "^1.38.1",
|
||||
"embla-carousel-react": "^8.1.5",
|
||||
"i18next": "^23.11.5",
|
||||
"i18next-resources-to-backend": "^1.2.1",
|
||||
"minidenticons": "^4.2.1",
|
||||
"nanoid": "^5.0.7",
|
||||
"nostr-tools": "^2.7.0",
|
||||
"react": "^18.3.1",
|
||||
"react-currency-input-field": "^3.8.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.52.0",
|
||||
"react-hotkeys-hook": "^4.5.0",
|
||||
"react-i18next": "^14.1.2",
|
||||
"react-string-replace": "^1.1.1",
|
||||
"slate": "^0.103.0",
|
||||
"slate-react": "^0.105.0",
|
||||
"sonner": "^1.5.0",
|
||||
"use-debounce": "^10.0.1",
|
||||
"virtua": "^0.31.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lume/tailwindcss": "workspace:^",
|
||||
"@lume/tsconfig": "workspace:^",
|
||||
"@lume/types": "workspace:^",
|
||||
"@tanstack/router-devtools": "^1.38.1",
|
||||
"@tanstack/router-vite-plugin": "^1.38.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.3.1",
|
||||
"vite-plugin-top-level-await": "^1.4.1",
|
||||
"vite-tsconfig-paths": "^4.3.2"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 249 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 211 KiB |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||
import React, { StrictMode } from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import "./app.css";
|
||||
import { type } from "@tauri-apps/plugin-os";
|
||||
import i18n from "./locale";
|
||||
import { routeTree } from "./router.gen"; // auto generated file
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
const os = await type();
|
||||
|
||||
// Set up a Router instance
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
context: {
|
||||
queryClient,
|
||||
platform: os,
|
||||
},
|
||||
Wrap: ({ children }) => {
|
||||
return (
|
||||
<I18nextProvider i18n={i18n} defaultNS={"translation"}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// 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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { cn } from "@lume/utils";
|
||||
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 [loading, setLoading] = useState(false);
|
||||
|
||||
const uploadAvatar = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const image = await NostrQuery.upload();
|
||||
setPicture(image);
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => uploadAvatar()}
|
||||
className={cn("size-4", className)}
|
||||
>
|
||||
{loading ? <Spinner className="size-4" /> : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { User } from "@/components/user";
|
||||
import { NostrAccount } from "@lume/system";
|
||||
import { getBitcoinDisplayValues } from "@lume/utils";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
export function Balance({ account }: { account: string }) {
|
||||
const [balance, setBalance] = useState(0);
|
||||
const value = useMemo(() => getBitcoinDisplayValues(balance), [balance]);
|
||||
|
||||
useEffect(() => {
|
||||
async function getBalance() {
|
||||
const val = await NostrAccount.getBalance();
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,159 +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 { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrent } from "@tauri-apps/api/webviewWindow";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
type WindowEvent = {
|
||||
scroll: boolean;
|
||||
resize: boolean;
|
||||
};
|
||||
|
||||
export function Column({
|
||||
column,
|
||||
account,
|
||||
}: {
|
||||
column: LumeColumn;
|
||||
account: string;
|
||||
}) {
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const webviewLabel = `column-${account}_${column.label}`;
|
||||
|
||||
const [isCreated, setIsCreated] = useState(false);
|
||||
|
||||
const repositionWebview = useCallback(async () => {
|
||||
const newRect = container.current.getBoundingClientRect();
|
||||
await invoke("reposition_column", {
|
||||
label: webviewLabel,
|
||||
x: newRect.x,
|
||||
y: newRect.y,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const resizeWebview = useCallback(async () => {
|
||||
const newRect = container.current.getBoundingClientRect();
|
||||
await invoke("resize_column", {
|
||||
label: webviewLabel,
|
||||
width: newRect.width,
|
||||
height: newRect.height,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCreated) return;
|
||||
|
||||
const unlisten = listen<WindowEvent>("child-webview", (data) => {
|
||||
if (data.payload.scroll) repositionWebview();
|
||||
if (data.payload.resize) repositionWebview().then(() => resizeWebview());
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten.then((f) => f());
|
||||
};
|
||||
}, [isCreated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!container?.current) return;
|
||||
|
||||
const rect = container.current.getBoundingClientRect();
|
||||
const url = `${column.content}?account=${account}&label=${column.label}&name=${column.name}`;
|
||||
|
||||
// create new webview
|
||||
invoke("create_column", {
|
||||
label: webviewLabel,
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
url,
|
||||
}).then(() => {
|
||||
console.log("created: ", webviewLabel);
|
||||
setIsCreated(true);
|
||||
});
|
||||
|
||||
// close webview when unmounted
|
||||
return () => {
|
||||
invoke("close_column", { label: webviewLabel }).then(() => {
|
||||
console.log("closed: ", webviewLabel);
|
||||
});
|
||||
};
|
||||
}, [account]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-[500px] 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"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<Header label={column.label} name={column.name} />
|
||||
<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="flex items-center justify-between w-full px-1 h-9 shrink-0">
|
||||
<div className="size-7" />
|
||||
<div className="flex items-center justify-center shrink-0 h-9">
|
||||
<div className="relative flex items-center gap-2">
|
||||
<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="inline-flex items-center justify-center rounded-lg size-7 hover:bg-black/10 dark:hover:bg-white/10 text-neutral-600 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200"
|
||||
>
|
||||
<CancelIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { ThreadIcon } from "@lume/icons";
|
||||
import { Note } from "@/components/note";
|
||||
import { cn } from "@lume/utils";
|
||||
import type { LumeEvent } from "@lume/system";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function Conversation({
|
||||
event,
|
||||
className,
|
||||
}: {
|
||||
event: LumeEvent;
|
||||
className?: string;
|
||||
}) {
|
||||
const thread = useMemo(() => event.thread, [event]);
|
||||
|
||||
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?.id ? <Note.Child event={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?.id ? <Note.Child event={thread?.reply} /> : null}
|
||||
<div>
|
||||
<div className="flex items-center justify-between px-3 h-14">
|
||||
<Note.User />
|
||||
</div>
|
||||
<Note.Content className="px-3" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center px-3 h-14">
|
||||
<Note.Open />
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { VisitIcon } from "@lume/icons";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { useNoteContext } from "../provider";
|
||||
import { LumeWindow } from "@lume/system";
|
||||
|
||||
export function NoteOpenThread() {
|
||||
const event = useNoteContext();
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openEvent(event)}
|
||||
className="group inline-flex h-7 w-14 bg-neutral-100 dark:bg-white/10 rounded-full items-center justify-center text-sm font-medium text-neutral-800 dark:text-neutral-200 hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
|
||||
>
|
||||
<VisitIcon className="shrink-0 size-4" />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade">
|
||||
Open
|
||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { ReplyIcon } from "@lume/icons";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { useNoteContext } from "../provider";
|
||||
import { cn } from "@lume/utils";
|
||||
import { LumeWindow } from "@lume/system";
|
||||
|
||||
export function NoteReply({ large = false }: { large?: boolean }) {
|
||||
const event = useNoteContext();
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openEditor(event.id)}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
||||
large
|
||||
? "rounded-full bg-neutral-100 dark:bg-white/10 h-7 gap-1.5 w-24 text-sm font-medium hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
|
||||
: "size-7",
|
||||
)}
|
||||
>
|
||||
<ReplyIcon className="shrink-0 size-4" />
|
||||
{large ? "Reply" : null}
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
||||
Reply
|
||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { RepostIcon } from "@lume/icons";
|
||||
import { LumeWindow } from "@lume/system";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { cn } from "@lume/utils";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { Menu, MenuItem } from "@tauri-apps/api/menu";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useNoteContext } from "../provider";
|
||||
|
||||
export function NoteRepost({ large = false }: { large?: boolean }) {
|
||||
const event = useNoteContext();
|
||||
const { settings } = useRouteContext({ strict: false });
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isRepost, setIsRepost] = useState(false);
|
||||
|
||||
const repost = async () => {
|
||||
if (isRepost) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// repost
|
||||
await event.repost();
|
||||
|
||||
// update state
|
||||
setLoading(false);
|
||||
setIsRepost(true);
|
||||
|
||||
// notify
|
||||
toast.success("You've reposted this post successfully");
|
||||
} catch {
|
||||
setLoading(false);
|
||||
toast.error("Repost failed, try again later");
|
||||
}
|
||||
};
|
||||
|
||||
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const menuItems = await Promise.all([
|
||||
MenuItem.new({
|
||||
text: "Quote",
|
||||
action: async () => repost(),
|
||||
}),
|
||||
MenuItem.new({
|
||||
text: "Repost",
|
||||
action: () => LumeWindow.openEditor(null, event.id),
|
||||
}),
|
||||
]);
|
||||
|
||||
const menu = await Menu.new({
|
||||
items: menuItems,
|
||||
});
|
||||
|
||||
await menu.popup().catch((e) => console.error(e));
|
||||
}, []);
|
||||
|
||||
if (!settings.display_repost_button) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => showContextMenu(e)}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200 rounded-full",
|
||||
large
|
||||
? "bg-neutral-100 dark:bg-white/10 h-7 gap-1.5 w-24 text-sm font-medium hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
|
||||
: "size-7",
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<RepostIcon className={cn("size-4", isRepost ? "text-blue-500" : "")} />
|
||||
)}
|
||||
{large ? "Repost" : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { ZapIcon } from "@lume/icons";
|
||||
import { LumeWindow } from "@lume/system";
|
||||
import { cn } from "@lume/utils";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useNoteContext } from "../provider";
|
||||
|
||||
export function NoteZap({ large = false }: { large?: boolean }) {
|
||||
const event = useNoteContext();
|
||||
const { settings } = useRouteContext({ strict: false });
|
||||
|
||||
if (!settings.display_zap_button) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openZap(event.id, event.pubkey)}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
||||
large
|
||||
? "rounded-full bg-neutral-100 dark:bg-white/10 h-7 gap-1.5 w-24 text-sm font-medium hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
|
||||
: "size-7",
|
||||
)}
|
||||
>
|
||||
<ZapIcon className="size-4" />
|
||||
{large ? "Zap" : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useEvent } from "@lume/system";
|
||||
import { cn } from "@lume/utils";
|
||||
import { Note } from ".";
|
||||
import { InfoIcon } from "@lume/icons";
|
||||
import type { EventTag } from "@lume/types";
|
||||
|
||||
export function NoteChild({
|
||||
event,
|
||||
isRoot,
|
||||
}: {
|
||||
event: EventTag;
|
||||
isRoot?: boolean;
|
||||
}) {
|
||||
const { isLoading, isError, data } = useEvent(event.id, event.relayHint);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 pt-3">
|
||||
<div className="rounded-full size-8 shrink-0 bg-neutral-200 dark:bg-neutral-800 animate-pulse" />
|
||||
<div className="w-2/3 h-4 rounded-md animate-pulse bg-neutral-200 dark:bg-neutral-800" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 pt-3">
|
||||
<div className="inline-flex items-center justify-center text-white bg-red-500 rounded-full size-8 shrink-0">
|
||||
<InfoIcon className="size-5" />
|
||||
</div>
|
||||
<p className="text-sm text-red-500">
|
||||
Event not found with your current relay set
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Note.Provider event={data}>
|
||||
<Note.Root className={cn(isRoot ? "mb-3" : "")}>
|
||||
<div className="flex items-center justify-between px-3 h-14">
|
||||
<Note.User />
|
||||
</div>
|
||||
<Note.Content className="px-3" />
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { nanoid } from "nanoid";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
import { Hashtag } from "./mentions/hashtag";
|
||||
import { MentionNote } from "./mentions/note";
|
||||
import { MentionUser } from "./mentions/user";
|
||||
import { Images } from "./preview/images";
|
||||
import { Videos } from "./preview/videos";
|
||||
import { useNoteContext } from "./provider";
|
||||
|
||||
export function NoteContent({
|
||||
quote = true,
|
||||
mention = true,
|
||||
clean,
|
||||
className,
|
||||
}: {
|
||||
quote?: boolean;
|
||||
mention?: boolean;
|
||||
clean?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { settings } = useRouteContext({ strict: false });
|
||||
const event = useNoteContext();
|
||||
const content = useMemo(() => {
|
||||
try {
|
||||
// Get parsed meta
|
||||
const { content, hashtags, events, mentions } = event.meta;
|
||||
|
||||
// Define rich content
|
||||
let richContent: ReactNode[] | string = settings.display_media
|
||||
? content
|
||||
: event.content;
|
||||
|
||||
for (const hashtag of hashtags) {
|
||||
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
|
||||
richContent = reactStringReplace(richContent, regex, (_, index) => {
|
||||
return <Hashtag key={hashtag + index} tag={hashtag} />;
|
||||
});
|
||||
}
|
||||
|
||||
for (const event of events) {
|
||||
if (quote) {
|
||||
richContent = reactStringReplace(richContent, event, (_, index) => (
|
||||
<MentionNote key={event + index} eventId={event} />
|
||||
));
|
||||
}
|
||||
|
||||
if (!quote && clean) {
|
||||
richContent = reactStringReplace(richContent, event, () => null);
|
||||
}
|
||||
}
|
||||
|
||||
for (const user of mentions) {
|
||||
if (mention) {
|
||||
richContent = reactStringReplace(richContent, user, (_, index) => (
|
||||
<MentionUser key={user + index} pubkey={user} />
|
||||
));
|
||||
}
|
||||
|
||||
if (!mention && clean) {
|
||||
richContent = reactStringReplace(richContent, user, () => null);
|
||||
}
|
||||
}
|
||||
|
||||
richContent = reactStringReplace(
|
||||
richContent,
|
||||
/(https?:\/\/\S+)/gi,
|
||||
(match, index) => (
|
||||
<a
|
||||
key={match + index}
|
||||
href={match}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
{match}
|
||||
</a>
|
||||
),
|
||||
);
|
||||
|
||||
richContent = reactStringReplace(richContent, /(\r\n|\r|\n)+/g, () => (
|
||||
<div key={nanoid()} className="h-3" />
|
||||
));
|
||||
|
||||
return richContent;
|
||||
} catch (e) {
|
||||
console.log("[parser]: ", e);
|
||||
return event.content;
|
||||
}
|
||||
}, [event.content]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"select-text text-pretty content-break overflow-hidden",
|
||||
event.content.length > 620 ? "max-h-[250px] gradient-mask-b-0" : "",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
{settings.display_media && event.meta?.images.length ? (
|
||||
<Images urls={event.meta.images} />
|
||||
) : null}
|
||||
{settings.display_media && event.meta?.videos.length ? (
|
||||
<Videos urls={event.meta.videos} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import { nanoid } from "nanoid";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
import { Hashtag } from "./mentions/hashtag";
|
||||
import { MentionNote } from "./mentions/note";
|
||||
import { MentionUser } from "./mentions/user";
|
||||
import { ImagePreview } from "./preview/image";
|
||||
import { VideoPreview } from "./preview/video";
|
||||
import { useNoteContext } from "./provider";
|
||||
|
||||
export function NoteContentLarge({
|
||||
className,
|
||||
}: {
|
||||
className?: string;
|
||||
}) {
|
||||
const event = useNoteContext();
|
||||
const content = useMemo(() => {
|
||||
try {
|
||||
// Get parsed meta
|
||||
const { images, videos, hashtags, events, mentions } = event.meta;
|
||||
|
||||
// Define rich content
|
||||
let richContent: ReactNode[] | string = event.content;
|
||||
|
||||
for (const hashtag of hashtags) {
|
||||
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
|
||||
richContent = reactStringReplace(richContent, regex, () => (
|
||||
<Hashtag key={nanoid()} tag={hashtag} />
|
||||
));
|
||||
}
|
||||
|
||||
for (const event of events) {
|
||||
richContent = reactStringReplace(richContent, event, (match, i) => (
|
||||
<MentionNote key={match + i} eventId={event} />
|
||||
));
|
||||
}
|
||||
|
||||
for (const mention of mentions) {
|
||||
richContent = reactStringReplace(richContent, mention, (match, i) => (
|
||||
<MentionUser key={match + i} pubkey={mention} />
|
||||
));
|
||||
}
|
||||
|
||||
for (const image of images) {
|
||||
richContent = reactStringReplace(richContent, image, (match, i) => (
|
||||
<ImagePreview key={match + i} url={match} />
|
||||
));
|
||||
}
|
||||
|
||||
for (const video of videos) {
|
||||
richContent = reactStringReplace(richContent, video, (match, i) => (
|
||||
<VideoPreview key={match + i} url={match} />
|
||||
));
|
||||
}
|
||||
|
||||
richContent = reactStringReplace(
|
||||
richContent,
|
||||
/(https?:\/\/\S+)/gi,
|
||||
(match, i) => (
|
||||
<a
|
||||
key={match + i}
|
||||
href={match}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 line-clamp-1 hover:text-blue-600"
|
||||
>
|
||||
{match}
|
||||
</a>
|
||||
),
|
||||
);
|
||||
|
||||
richContent = reactStringReplace(richContent, /(\r\n|\r|\n)+/g, () => (
|
||||
<div key={nanoid()} className="h-3" />
|
||||
));
|
||||
|
||||
return richContent;
|
||||
} catch (e) {
|
||||
console.log("[parser]: ", e);
|
||||
return event.content;
|
||||
}
|
||||
}, [event.content]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"select-text leading-normal text-pretty content-break",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { NoteOpenThread } from "./buttons/open";
|
||||
import { NoteReply } from "./buttons/reply";
|
||||
import { NoteRepost } from "./buttons/repost";
|
||||
import { NoteZap } from "./buttons/zap";
|
||||
import { NoteChild } from "./child";
|
||||
import { NoteContent } from "./content";
|
||||
import { NoteContentLarge } from "./contentLarge";
|
||||
import { NoteMenu } from "./menu";
|
||||
import { NoteProvider } from "./provider";
|
||||
import { NoteRoot } from "./root";
|
||||
import { NoteUser } from "./user";
|
||||
|
||||
export const Note = {
|
||||
Provider: NoteProvider,
|
||||
Root: NoteRoot,
|
||||
User: NoteUser,
|
||||
Menu: NoteMenu,
|
||||
Reply: NoteReply,
|
||||
Repost: NoteRepost,
|
||||
Content: NoteContent,
|
||||
ContentLarge: NoteContentLarge,
|
||||
Zap: NoteZap,
|
||||
Open: NoteOpenThread,
|
||||
Child: NoteChild,
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
export function Hashtag({ tag }: { tag: string }) {
|
||||
return (
|
||||
<span className="leading-normal break-all cursor-default group text-start">
|
||||
<span className="text-blue-500">#</span>
|
||||
<span className="underline underline-offset-1 decoration-2 decoration-blue-200 dark:decoration-blue-800 group-hover:decoration-blue-500">
|
||||
{tag.replace("#", "")}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { LumeWindow, useEvent } from "@lume/system";
|
||||
import { LinkIcon } from "@lume/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@lume/utils";
|
||||
import { User } from "@/components/user";
|
||||
import { Spinner } from "@lume/ui";
|
||||
|
||||
export function MentionNote({
|
||||
eventId,
|
||||
openable = true,
|
||||
}: {
|
||||
eventId: string;
|
||||
openable?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { isLoading, isError, data } = useEvent(eventId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-20 mt-2 border rounded-xl border-black/10 dark:border-white/10">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="w-full p-3 mt-2 border rounded-xl border-black/10 dark:border-white/10">
|
||||
{t("note.error")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full border rounded-lg cursor-default border-black/10 dark:border-white/10">
|
||||
<User.Provider pubkey={data.pubkey}>
|
||||
<User.Root className="flex items-center gap-2 px-3 h-11">
|
||||
<User.Avatar className="object-cover rounded-full size-6 shrink-0" />
|
||||
<div className="inline-flex items-center flex-1 gap-2">
|
||||
<User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" />
|
||||
<span className="text-neutral-600 dark:text-neutral-400">·</span>
|
||||
<User.Time
|
||||
time={data.created_at}
|
||||
className="text-neutral-600 dark:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<div
|
||||
className={cn(
|
||||
"px-3 select-text whitespace-normal text-pretty content-break leading-normal",
|
||||
data.content.length > 400 ? "max-h-[150px] gradient-mask-b-0" : "",
|
||||
)}
|
||||
>
|
||||
{data.content}
|
||||
</div>
|
||||
{openable ? (
|
||||
<div className="flex items-center justify-end px-2 h-11">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
LumeWindow.openEvent(data);
|
||||
}}
|
||||
className="z-10 inline-flex items-center justify-center gap-1 text-sm rounded-full h-7 w-28 bg-black/10 dark:bg-white/10 text-neutral-600 hover:text-blue-500 dark:text-neutral-400"
|
||||
>
|
||||
View post
|
||||
<LinkIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-3" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { LumeWindow, useProfile } from "@lume/system";
|
||||
import { displayNpub } from "@lume/utils";
|
||||
|
||||
export function MentionUser({ pubkey }: { pubkey: string }) {
|
||||
const { isLoading, isError, profile } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openProfile(pubkey)}
|
||||
className="break-words text-start text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
{isLoading
|
||||
? "@anon"
|
||||
: isError
|
||||
? displayNpub(pubkey, 16)
|
||||
: `@${profile?.name || profile?.display_name || "anon"}`}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { HorizontalDotsIcon } from "@lume/icons";
|
||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { useNoteContext } from "./provider";
|
||||
import { useCallback } from "react";
|
||||
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
|
||||
|
||||
export function NoteMenu() {
|
||||
const event = useNoteContext();
|
||||
|
||||
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const menuItems = await Promise.all([
|
||||
MenuItem.new({
|
||||
text: "Copy Sharable Link",
|
||||
action: async () => {
|
||||
const eventId = await event.idAsBech32();
|
||||
await writeText(`https://njump.me/${eventId}`);
|
||||
},
|
||||
}),
|
||||
MenuItem.new({
|
||||
text: "Copy Event ID",
|
||||
action: async () => {
|
||||
const eventId = await event.idAsBech32();
|
||||
await writeText(eventId);
|
||||
},
|
||||
}),
|
||||
MenuItem.new({
|
||||
text: "Copy Public Key",
|
||||
action: async () => {
|
||||
const pubkey = await event.pubkeyAsBech32();
|
||||
await writeText(pubkey);
|
||||
},
|
||||
}),
|
||||
PredefinedMenuItem.new({ item: "Separator" }),
|
||||
MenuItem.new({
|
||||
text: "Copy Raw Event",
|
||||
action: async () => {
|
||||
event.meta = undefined;
|
||||
const raw = JSON.stringify(event);
|
||||
await writeText(raw);
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const menu = await Menu.new({
|
||||
items: menuItems,
|
||||
});
|
||||
|
||||
await menu.popup().catch((e) => console.error(e));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => showContextMenu(e)}
|
||||
className="inline-flex items-center justify-center group size-7 text-neutral-600 dark:text-neutral-400"
|
||||
>
|
||||
<HorizontalDotsIcon className="size-5" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { open } from "@tauri-apps/plugin-shell";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function ImagePreview({ url }: { url: string }) {
|
||||
const { settings } = useRouteContext({ strict: false });
|
||||
|
||||
const imageUrl = useMemo(() => {
|
||||
if (settings.image_resize_service.length) {
|
||||
const newUrl = `${settings.image_resize_service}?url=${url}&ll&af&default=1&n=-1`;
|
||||
return newUrl;
|
||||
} else {
|
||||
return url;
|
||||
}
|
||||
}, [settings.image_resize_service]);
|
||||
|
||||
if (!settings.display_media) {
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-1">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={url}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: "auto" }}
|
||||
className="max-h-[600px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||
onClick={() => open(url)}
|
||||
onKeyDown={() => open(url)}
|
||||
onError={({ currentTarget }) => {
|
||||
currentTarget.onerror = null;
|
||||
currentTarget.src = "/404.jpg";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { Carousel, CarouselItem } from "@lume/ui";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { open } from "@tauri-apps/plugin-shell";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function Images({ urls }: { urls: string[] }) {
|
||||
const { settings } = useRouteContext({ strict: false });
|
||||
|
||||
const imageUrls = useMemo(() => {
|
||||
if (settings.image_resize_service.length) {
|
||||
const newUrls = urls.map(
|
||||
(url) =>
|
||||
`${settings.image_resize_service}?url=${url}&ll&af&default=1&n=-1`,
|
||||
);
|
||||
return newUrls;
|
||||
} else {
|
||||
return urls;
|
||||
}
|
||||
}, [settings.image_resize_service]);
|
||||
|
||||
if (urls.length === 1) {
|
||||
return (
|
||||
<div className="px-3 group">
|
||||
<img
|
||||
src={imageUrls[0]}
|
||||
alt={urls[0]}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: "auto" }}
|
||||
className="max-h-[400px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||
onClick={() => urls[0]}
|
||||
onKeyDown={() => urls[0]}
|
||||
onError={({ currentTarget }) => {
|
||||
currentTarget.onerror = null;
|
||||
currentTarget.src = "/404.jpg";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Carousel
|
||||
items={imageUrls}
|
||||
renderItem={({ item, isSnapPoint }) => (
|
||||
<CarouselItem key={item} isSnapPoint={isSnapPoint}>
|
||||
<img
|
||||
src={item}
|
||||
alt={item}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: "auto" }}
|
||||
className="object-cover w-full h-full rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||
onClick={() => open(item)}
|
||||
onKeyDown={() => open(item)}
|
||||
onError={({ currentTarget }) => {
|
||||
currentTarget.onerror = null;
|
||||
currentTarget.src = "/404.jpg";
|
||||
}}
|
||||
/>
|
||||
</CarouselItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
|
||||
export function VideoPreview({ url }: { url: string }) {
|
||||
const { settings } = useRouteContext({ strict: false });
|
||||
|
||||
if (settings.display_media) {
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-1">
|
||||
<video
|
||||
className="max-h-[600px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||
controls
|
||||
muted
|
||||
>
|
||||
<source src={url} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Carousel, CarouselItem } from "@lume/ui";
|
||||
|
||||
export function Videos({ urls }: { urls: string[] }) {
|
||||
if (urls.length === 1) {
|
||||
return (
|
||||
<div className="group px-3">
|
||||
<video
|
||||
className="w-full h-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||
controls
|
||||
muted
|
||||
>
|
||||
<source src={urls[0]} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Carousel
|
||||
items={urls}
|
||||
renderItem={({ item, isSnapPoint }) => (
|
||||
<CarouselItem key={item} isSnapPoint={isSnapPoint}>
|
||||
<video
|
||||
className="w-full h-full object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||
controls={false}
|
||||
muted
|
||||
>
|
||||
<source src={item} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</CarouselItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { LumeEvent } from "@lume/system";
|
||||
import { type ReactNode, createContext, useContext } from "react";
|
||||
|
||||
const NoteContext = createContext<LumeEvent>(null);
|
||||
|
||||
export function NoteProvider({
|
||||
event,
|
||||
children,
|
||||
}: {
|
||||
event: LumeEvent;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <NoteContext.Provider value={event}>{children}</NoteContext.Provider>;
|
||||
}
|
||||
|
||||
export function useNoteContext() {
|
||||
const context = useContext(NoteContext);
|
||||
if (!context) {
|
||||
throw new Error("Please import Note Provider to use useNoteContext() hook");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function NoteRoot({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("h-min w-full", className)} contentEditable={false}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import * as HoverCard from "@radix-ui/react-hover-card";
|
||||
import { User } from "../user";
|
||||
import { useNoteContext } from "./provider";
|
||||
import { LumeWindow } from "@lume/system";
|
||||
|
||||
export function NoteUser({ className }: { className?: string }) {
|
||||
const event = useNoteContext();
|
||||
|
||||
return (
|
||||
<User.Provider pubkey={event.pubkey}>
|
||||
<HoverCard.Root>
|
||||
<User.Root
|
||||
className={cn("flex items-start justify-between", className)}
|
||||
>
|
||||
<div className="flex w-full gap-2">
|
||||
<HoverCard.Trigger className="shrink-0">
|
||||
<User.Avatar className="object-cover rounded-full size-8 outline outline-1 -outline-offset-1 outline-black/15" />
|
||||
</HoverCard.Trigger>
|
||||
<div className="flex items-center w-full gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<User.Name className="font-semibold text-neutral-950 dark:text-neutral-50" />
|
||||
<User.NIP05 />
|
||||
</div>
|
||||
<div className="text-neutral-600 dark:text-neutral-400">·</div>
|
||||
<User.Time
|
||||
time={event.created_at}
|
||||
className="text-neutral-600 dark:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</User.Root>
|
||||
<HoverCard.Portal>
|
||||
<HoverCard.Content
|
||||
className="w-[300px] rounded-xl bg-black p-3 data-[side=bottom]:animate-slideUpAndFade data-[state=open]:transition-all dark:bg-white dark:shadow-none"
|
||||
sideOffset={5}
|
||||
side="right"
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<User.Avatar className="object-cover rounded-lg size-11" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<User.Name className="font-semibold leading-tight text-white dark:text-neutral-900" />
|
||||
<User.NIP05 />
|
||||
</div>
|
||||
<User.About className="text-sm text-white line-clamp-3 dark:text-neutral-900" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openProfile(event.pubkey)}
|
||||
className="inline-flex items-center justify-center w-full mt-2 text-sm font-medium bg-white rounded-lg h-9 hover:bg-neutral-200 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-200"
|
||||
>
|
||||
View profile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<HoverCard.Arrow className="fill-black dark:fill-white" />
|
||||
</HoverCard.Content>
|
||||
</HoverCard.Portal>
|
||||
</HoverCard.Root>
|
||||
</User.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { QuoteIcon } from "@lume/icons";
|
||||
import { Note } from "@/components/note";
|
||||
import { cn } from "@lume/utils";
|
||||
import type { LumeEvent } from "@lume/system";
|
||||
|
||||
export function Quote({
|
||||
event,
|
||||
className,
|
||||
}: {
|
||||
event: LumeEvent;
|
||||
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 className="flex flex-col gap-3">
|
||||
<Note.Child event={event.quote} 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="flex items-center justify-between px-3 h-14">
|
||||
<Note.User />
|
||||
</div>
|
||||
<Note.Content className="px-3" quote={false} clean />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center px-3 h-14">
|
||||
<Note.Open />
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { Note } from "@/components/note";
|
||||
import { User } from "@/components/user";
|
||||
import { cn } from "@lume/utils";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { type LumeEvent, NostrQuery } from "@lume/system";
|
||||
|
||||
export function RepostNote({
|
||||
event,
|
||||
className,
|
||||
}: {
|
||||
event: LumeEvent;
|
||||
className?: string;
|
||||
}) {
|
||||
const { isLoading, isError, data } = useQuery({
|
||||
queryKey: ["event", event.repostId],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const data = await NostrQuery.getRepostEvent(event);
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(e);
|
||||
}
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
return (
|
||||
<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,
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-20 gap-2">
|
||||
<Spinner />
|
||||
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
Loading event...
|
||||
</span>
|
||||
</div>
|
||||
) : isError || !data ? (
|
||||
<div className="flex items-center justify-center h-20">
|
||||
Event not found within your current relay set
|
||||
</div>
|
||||
) : (
|
||||
<Note.Provider event={data}>
|
||||
<Note.Root>
|
||||
<div className="flex items-center justify-between px-3 h-14">
|
||||
<Note.User />
|
||||
<Note.Menu />
|
||||
</div>
|
||||
<Note.Content className="px-3" />
|
||||
<div className="flex items-center justify-between px-3 mt-3 h-14">
|
||||
<div className="inline-flex items-center gap-3">
|
||||
<Note.Open />
|
||||
<Note.Reply />
|
||||
<Note.Repost />
|
||||
<Note.Zap />
|
||||
</div>
|
||||
<div>
|
||||
<User.Provider pubkey={event.pubkey}>
|
||||
<User.Root className="flex items-center gap-2">
|
||||
<div className="text-sm font-medium text-neutral-800 dark:text-neutral-200">
|
||||
Reposted by
|
||||
</div>
|
||||
<User.Avatar className="object-cover rounded-full size-6 shrink-0 ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</div>
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
)}
|
||||
</Note.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import { Note } from "@/components/note";
|
||||
import type { LumeEvent } from "@lume/system";
|
||||
|
||||
export function TextNote({
|
||||
event,
|
||||
className,
|
||||
}: {
|
||||
event: LumeEvent;
|
||||
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="flex items-center justify-between px-3 h-14">
|
||||
<Note.User />
|
||||
<Note.Menu />
|
||||
</div>
|
||||
<Note.Content className="px-3" />
|
||||
<div className="flex items-center gap-4 px-3 mt-3 h-14">
|
||||
<Note.Open />
|
||||
<Note.Reply />
|
||||
<Note.Repost />
|
||||
<Note.Zap />
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import { useUserContext } from "./provider";
|
||||
|
||||
export function UserAbout({ className }: { className?: string }) {
|
||||
const user = useUserContext();
|
||||
|
||||
return (
|
||||
<div className={cn("content-break select-text", className)}>
|
||||
{user.profile?.about?.trim() || "No bio"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import * as Avatar from "@radix-ui/react-avatar";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { minidenticon } from "minidenticons";
|
||||
import { nanoid } from "nanoid";
|
||||
import { useMemo } from "react";
|
||||
import { useUserContext } from "./provider";
|
||||
|
||||
export function UserAvatar({ className }: { className?: string }) {
|
||||
const user = useUserContext();
|
||||
const { settings } = useRouteContext({ strict: false });
|
||||
|
||||
const picture = useMemo(() => {
|
||||
if (
|
||||
settings?.image_resize_service?.length &&
|
||||
user.profile?.picture?.length
|
||||
) {
|
||||
const url = `${settings.image_resize_service}?url=${user.profile?.picture}&w=100&h=100&default=1&n=-1`;
|
||||
return url;
|
||||
} else {
|
||||
return user.profile?.picture;
|
||||
}
|
||||
}, [user.profile?.picture]);
|
||||
|
||||
const fallbackAvatar = useMemo(
|
||||
() =>
|
||||
`data:image/svg+xml;utf8,${encodeURIComponent(
|
||||
minidenticon(user.pubkey || nanoid(), 90, 50),
|
||||
)}`,
|
||||
[user.pubkey],
|
||||
);
|
||||
|
||||
if (settings && !settings.display_avatar) {
|
||||
return (
|
||||
<Avatar.Root className="shrink-0">
|
||||
<Avatar.Fallback delayMs={120}>
|
||||
<img
|
||||
src={fallbackAvatar}
|
||||
alt={user.pubkey}
|
||||
className={cn("bg-black dark:bg-white", className)}
|
||||
/>
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Avatar.Root className="shrink-0">
|
||||
<Avatar.Image
|
||||
src={picture}
|
||||
alt={user.pubkey}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
className={cn("outline-[.5px] outline-black/5 object-cover", className)}
|
||||
/>
|
||||
<Avatar.Fallback delayMs={120}>
|
||||
<img
|
||||
src={fallbackAvatar}
|
||||
alt={user.pubkey}
|
||||
className={cn("bg-black dark:bg-white", className)}
|
||||
/>
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import { useUserContext } from "./provider";
|
||||
|
||||
export function UserCover({ className }: { className?: string }) {
|
||||
const user = useUserContext();
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"animate-pulse bg-neutral-300 dark:bg-neutral-700",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (user && !user.profile?.banner) {
|
||||
return (
|
||||
<div
|
||||
className={cn("bg-gradient-to-b from-blue-400 to-teal-200", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={user?.profile?.banner}
|
||||
alt="banner"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: "auto" }}
|
||||
className={cn("object-cover", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { useUserContext } from "./provider";
|
||||
import { NostrAccount } from "@lume/system";
|
||||
|
||||
export function UserFollowButton({
|
||||
simple = false,
|
||||
className,
|
||||
}: {
|
||||
simple?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const user = useUserContext();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [followed, setFollowed] = useState(false);
|
||||
|
||||
const toggleFollow = async () => {
|
||||
setLoading(true);
|
||||
|
||||
const toggle = await NostrAccount.toggleContact(user.pubkey);
|
||||
|
||||
if (toggle) {
|
||||
setFollowed((prev) => !prev);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
NostrAccount.checkContact(user.pubkey).then((status) => {
|
||||
if (mounted) setFollowed(status);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => toggleFollow()}
|
||||
className={cn("w-max", className)}
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : followed ? (
|
||||
!simple ? (
|
||||
"Unfollow"
|
||||
) : null
|
||||
) : (
|
||||
"Follow"
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { UserAbout } from "./about";
|
||||
import { UserAvatar } from "./avatar";
|
||||
import { UserCover } from "./cover";
|
||||
import { UserFollowButton } from "./followButton";
|
||||
import { UserName } from "./name";
|
||||
import { UserNip05 } from "./nip05";
|
||||
import { UserProvider } from "./provider";
|
||||
import { UserRoot } from "./root";
|
||||
import { UserTime } from "./time";
|
||||
|
||||
export const User = {
|
||||
Provider: UserProvider,
|
||||
Root: UserRoot,
|
||||
Avatar: UserAvatar,
|
||||
Cover: UserCover,
|
||||
Name: UserName,
|
||||
NIP05: UserNip05,
|
||||
Time: UserTime,
|
||||
About: UserAbout,
|
||||
Button: UserFollowButton,
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
import { cn, displayNpub } from "@lume/utils";
|
||||
import { useUserContext } from "./provider";
|
||||
|
||||
export function UserName({
|
||||
className,
|
||||
prefix,
|
||||
}: {
|
||||
className?: string;
|
||||
prefix?: string;
|
||||
}) {
|
||||
const user = useUserContext();
|
||||
|
||||
return (
|
||||
<div className={cn("max-w-[12rem] truncate", className)}>
|
||||
{prefix}
|
||||
{user.profile?.display_name ||
|
||||
user.profile?.name ||
|
||||
displayNpub(user.pubkey, 16)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { VerifiedIcon } from "@lume/icons";
|
||||
import { displayLongHandle, displayNpub } from "@lume/utils";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useUserContext } from "./provider";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import { experimental_createPersister } from "@tanstack/query-persist-client-core";
|
||||
|
||||
export function UserNip05() {
|
||||
const user = useUserContext();
|
||||
const { isLoading, data: verified } = useQuery({
|
||||
queryKey: ["nip05", user?.pubkey],
|
||||
queryFn: async () => {
|
||||
if (!user.profile?.nip05?.length) return false;
|
||||
|
||||
const verify = await NostrQuery.verifyNip05(
|
||||
user.pubkey,
|
||||
user.profile?.nip05,
|
||||
);
|
||||
|
||||
return verify;
|
||||
},
|
||||
enabled: !!user.profile?.nip05,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
retry: false,
|
||||
persister: experimental_createPersister({
|
||||
storage: localStorage,
|
||||
maxAge: 1000 * 60 * 60 * 72, // 72 hours
|
||||
}),
|
||||
});
|
||||
|
||||
if (!user.profile?.nip05?.length) return;
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger>
|
||||
{!isLoading && verified ? (
|
||||
<VerifiedIcon className="text-teal-500 size-4" />
|
||||
) : null}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm font-medium text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
||||
{!user.profile?.nip05
|
||||
? displayNpub(user.pubkey, 16)
|
||||
: user.profile?.nip05.length > 50
|
||||
? displayLongHandle(user.profile?.nip05)
|
||||
: user.profile.nip05?.replace("_@", "")}
|
||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { useProfile } from "@lume/system";
|
||||
import type { Metadata } from "@lume/types";
|
||||
import { type ReactNode, createContext, useContext } from "react";
|
||||
|
||||
const UserContext = createContext<{
|
||||
pubkey: string;
|
||||
profile: Metadata;
|
||||
isError: boolean;
|
||||
isLoading: boolean;
|
||||
}>(null);
|
||||
|
||||
export function UserProvider({
|
||||
pubkey,
|
||||
children,
|
||||
embedProfile,
|
||||
}: {
|
||||
pubkey: string;
|
||||
children: ReactNode;
|
||||
embedProfile?: string;
|
||||
}) {
|
||||
const { isLoading, isError, profile } = useProfile(pubkey, embedProfile);
|
||||
|
||||
return (
|
||||
<UserContext.Provider value={{ pubkey, profile, isError, isLoading }}>
|
||||
{children}
|
||||
</UserContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useUserContext() {
|
||||
const context = useContext(UserContext);
|
||||
return context;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function UserRoot({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={cn(className)}>{children}</div>;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { cn, formatCreatedAt } from "@lume/utils";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function UserTime({
|
||||
time,
|
||||
className,
|
||||
}: {
|
||||
time: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const createdAt = useMemo(() => formatCreatedAt(time), [time]);
|
||||
|
||||
return (
|
||||
<div className={cn("text-neutral-600 dark:text-neutral-400", className)}>
|
||||
{createdAt}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,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;
|
||||
@@ -1,187 +0,0 @@
|
||||
import { Column } from "@/components/column";
|
||||
import { Toolbar } from "@/components/toolbar";
|
||||
import { ArrowLeftIcon, ArrowRightIcon, PlusSquareIcon } from "@lume/icons";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import type { ColumnEvent, LumeColumn } from "@lume/types";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrent } from "@tauri-apps/api/window";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { nanoid } from "nanoid";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
export const Route = createFileRoute("/$account/home")({
|
||||
loader: async () => {
|
||||
const columns = await NostrQuery.getColumns();
|
||||
return columns;
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { account } = Route.useParams();
|
||||
const initialColumnList = Route.useLoaderData();
|
||||
|
||||
const [columns, setColumns] = useState<LumeColumn[]>([]);
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||
watchDrag: false,
|
||||
loop: false,
|
||||
});
|
||||
|
||||
const scrollPrev = useCallback(() => {
|
||||
if (emblaApi) emblaApi.scrollPrev(true);
|
||||
}, [emblaApi]);
|
||||
|
||||
const scrollNext = useCallback(() => {
|
||||
if (emblaApi) emblaApi.scrollNext(true);
|
||||
}, [emblaApi]);
|
||||
|
||||
const emitScrollEvent = useCallback(() => {
|
||||
getCurrent().emit("child-webview", { scroll: true });
|
||||
}, []);
|
||||
|
||||
const emitResizeEvent = useCallback(() => {
|
||||
getCurrent().emit("child-webview", { resize: true, direction: "x" });
|
||||
}, []);
|
||||
|
||||
const openLumeStore = useDebouncedCallback(async () => {
|
||||
await getCurrent().emit("columns", {
|
||||
type: "add",
|
||||
column: {
|
||||
label: "store",
|
||||
name: "Store",
|
||||
content: "/store/official",
|
||||
},
|
||||
});
|
||||
}, 150);
|
||||
|
||||
const add = useDebouncedCallback((column: LumeColumn) => {
|
||||
column.label = `${column.label}-${nanoid()}`; // update col label
|
||||
setColumns((prev) => [column, ...prev]);
|
||||
}, 150);
|
||||
|
||||
const remove = useDebouncedCallback((label: string) => {
|
||||
setColumns((prev) => prev.filter((t) => t.label !== label));
|
||||
}, 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 reset = useDebouncedCallback(() => setColumns([]), 150);
|
||||
|
||||
const handleKeyDown = useDebouncedCallback((event) => {
|
||||
if (event.defaultPrevented) return;
|
||||
|
||||
switch (event.code) {
|
||||
case "ArrowLeft":
|
||||
if (emblaApi) emblaApi.scrollPrev(true);
|
||||
break;
|
||||
case "ArrowRight":
|
||||
if (emblaApi) emblaApi.scrollNext(true);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
}, 150);
|
||||
|
||||
useEffect(() => {
|
||||
if (emblaApi) {
|
||||
emblaApi.on("scroll", emitScrollEvent);
|
||||
emblaApi.on("resize", emitResizeEvent);
|
||||
emblaApi.on("slidesChanged", emitScrollEvent);
|
||||
}
|
||||
|
||||
return () => {
|
||||
emblaApi?.off("scroll", emitScrollEvent);
|
||||
emblaApi?.off("resize", emitResizeEvent);
|
||||
emblaApi?.off("slidesChanged", emitScrollEvent);
|
||||
};
|
||||
}, [emblaApi, emitScrollEvent, emitResizeEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (columns?.length) {
|
||||
NostrQuery.setColumns(columns).then(() => console.log("saved"));
|
||||
}
|
||||
}, [columns]);
|
||||
|
||||
useEffect(() => {
|
||||
setColumns(initialColumnList);
|
||||
}, [initialColumnList]);
|
||||
|
||||
// Listen for keyboard event
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [handleKeyDown]);
|
||||
|
||||
// Listen for columns event
|
||||
useEffect(() => {
|
||||
const unlisten = listen<ColumnEvent>("columns", (data) => {
|
||||
if (data.payload.type === "reset") reset();
|
||||
if (data.payload.type === "add") add(data.payload.column);
|
||||
if (data.payload.type === "remove") remove(data.payload.label);
|
||||
if (data.payload.type === "set_title")
|
||||
updateName(data.payload.label, data.payload.title);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten.then((f) => f());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="size-full">
|
||||
<div ref={emblaRef} className="overflow-hidden size-full">
|
||||
<div className="flex size-full">
|
||||
{columns?.map((column) => (
|
||||
<Column
|
||||
key={account + column.label}
|
||||
column={column}
|
||||
account={account}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Toolbar>
|
||||
<div className="flex items-center h-8 gap-1 p-[2px] rounded-full bg-black/5 dark:bg-white/5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollPrev()}
|
||||
className="inline-flex items-center justify-center rounded-full size-7 text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openLumeStore()}
|
||||
className="inline-flex items-center justify-center rounded-full size-7 text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
||||
>
|
||||
<PlusSquareIcon className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollNext()}
|
||||
className="inline-flex items-center justify-center rounded-full size-7 text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
||||
>
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Toolbar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
import { User } from "@/components/user";
|
||||
import {
|
||||
ComposeFilledIcon,
|
||||
HorizontalDotsIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
} from "@lume/icons";
|
||||
import { LumeWindow, NostrAccount } from "@lume/system";
|
||||
import { cn } from "@lume/utils";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { getCurrent } from "@tauri-apps/api/window";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/$account")({
|
||||
beforeLoad: async () => {
|
||||
const accounts = await NostrAccount.getAccounts();
|
||||
return { accounts };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { platform } = Route.useRouteContext();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-screen h-screen">
|
||||
<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 />
|
||||
<Link
|
||||
to="/landing"
|
||||
className="inline-flex items-center justify-center rounded-full size-8 shrink-0 bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20"
|
||||
>
|
||||
<PlusIcon className="size-5" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openEditor()}
|
||||
className="inline-flex items-center justify-center h-8 gap-1 px-3 text-sm font-medium text-white bg-blue-500 rounded-full w-max hover:bg-blue-600"
|
||||
>
|
||||
<ComposeFilledIcon className="size-4" />
|
||||
New Post
|
||||
</button>
|
||||
<div id="toolbar" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Accounts() {
|
||||
const { accounts } = Route.useRouteContext();
|
||||
const { account } = Route.useParams();
|
||||
|
||||
const [windowWidth, setWindowWidth] = useState<number>(null);
|
||||
|
||||
const navigate = Route.useNavigate();
|
||||
const sortedList = useMemo(() => {
|
||||
const list = accounts;
|
||||
|
||||
for (const [i, item] of list.entries()) {
|
||||
if (item === account) {
|
||||
list.splice(i, 1);
|
||||
list.unshift(item);
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [accounts]);
|
||||
|
||||
const changeAccount = async (npub: string) => {
|
||||
if (npub === account) {
|
||||
return await LumeWindow.openProfile(account);
|
||||
}
|
||||
|
||||
// Change current account and update signer
|
||||
const select = await NostrAccount.loadAccount(npub);
|
||||
|
||||
if (select) {
|
||||
// Reset current columns
|
||||
await getCurrent().emit("columns", { type: "reset" });
|
||||
|
||||
// Redirect to new account
|
||||
return navigate({
|
||||
to: "/$account/home",
|
||||
params: { account: npub },
|
||||
resetScroll: true,
|
||||
replace: true,
|
||||
});
|
||||
} else {
|
||||
toast.warning("Something wrong.");
|
||||
}
|
||||
};
|
||||
|
||||
const getWindowDimensions = () => {
|
||||
const { innerWidth: width, innerHeight: height } = window;
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
function handleResize() {
|
||||
setWindowWidth(getWindowDimensions().width);
|
||||
}
|
||||
|
||||
if (!windowWidth) {
|
||||
setWindowWidth(getWindowDimensions().width);
|
||||
}
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div data-tauri-drag-region className="flex items-center gap-3">
|
||||
{sortedList
|
||||
.slice(0, windowWidth > 500 ? account.length : 2)
|
||||
.map((user) => (
|
||||
<button key={user} type="button" onClick={() => changeAccount(user)}>
|
||||
<User.Provider pubkey={user}>
|
||||
<User.Root
|
||||
className={cn(
|
||||
"shrink-0 rounded-full transition-all ease-in-out duration-150 will-change-auto",
|
||||
user === account
|
||||
? "ring-1 ring-teal-500 ring-offset-2 ring-offset-neutral-200 dark:ring-offset-neutral-950"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<User.Avatar
|
||||
className={cn(
|
||||
"aspect-square h-auto rounded-full object-cover transition-all ease-in-out duration-150 will-change-auto",
|
||||
user === account ? "w-7" : "w-8",
|
||||
)}
|
||||
/>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</button>
|
||||
))}
|
||||
{accounts.length >= 3 && windowWidth <= 700 ? (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger className="inline-flex items-center justify-center rounded-full size-8 shrink-0 bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20">
|
||||
<HorizontalDotsIcon className="size-5" />
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content className="flex h-11 select-none items-center justify-center rounded-md bg-neutral-950 p-1 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
||||
{sortedList.slice(2).map((user) => (
|
||||
<button
|
||||
key={user}
|
||||
type="button"
|
||||
onClick={() => changeAccount(user)}
|
||||
className="inline-flex items-center justify-center rounded-md size-9 hover:bg-white/10"
|
||||
>
|
||||
<User.Provider pubkey={user}>
|
||||
<User.Root className="rounded-full ring-1 ring-white/10">
|
||||
<User.Avatar className="object-cover h-auto rounded-full size-7 aspect-square" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</button>
|
||||
))}
|
||||
<Popover.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { CancelCircleIcon, CheckCircleIcon, InfoCircleIcon } from "@lume/icons";
|
||||
import type { Settings } from "@lume/system";
|
||||
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 { Toaster } from "sonner";
|
||||
|
||||
interface RouterContext {
|
||||
// System
|
||||
queryClient: QueryClient;
|
||||
// App info
|
||||
platform?: Platform;
|
||||
locale?: string;
|
||||
// Settings
|
||||
settings?: Settings;
|
||||
// Accounts
|
||||
accounts?: string[];
|
||||
}
|
||||
|
||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
component: () => (
|
||||
<>
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
icons={{
|
||||
success: <CheckCircleIcon className="size-5" />,
|
||||
info: <InfoCircleIcon className="size-5" />,
|
||||
error: <CancelCircleIcon className="size-5" />,
|
||||
}}
|
||||
closeButton
|
||||
theme="system"
|
||||
/>
|
||||
<Outlet />
|
||||
</>
|
||||
),
|
||||
pendingComponent: Pending,
|
||||
wrapInSuspense: true,
|
||||
});
|
||||
|
||||
function Pending() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-screen h-screen">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import { CheckIcon } from "@lume/icons";
|
||||
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/$account/backup")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { account } = Route.useParams();
|
||||
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");
|
||||
}
|
||||
|
||||
navigate({ to: "/", replace: true });
|
||||
}
|
||||
|
||||
// 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="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
|
||||
<div className="flex flex-col text-center">
|
||||
<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 flex-col w-full 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 px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{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 px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyKey()}
|
||||
className="inline-flex items-center justify-center w-24 rounded-lg h-11 bg-neutral-200 hover:bg-neutral-300 dark:bg-white/20 dark:hover:bg-white/30"
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</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 items-center justify-center rounded-md outline-none appearance-none size-6 bg-neutral-100 dark:bg-white/10 dark:hover:bg-white/20"
|
||||
id="confirm1"
|
||||
>
|
||||
<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 items-center justify-center rounded-md outline-none appearance-none size-6 bg-neutral-100 dark:bg-white/10 dark:hover:bg-white/20"
|
||||
id="confirm2"
|
||||
>
|
||||
<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 items-center justify-center rounded-md outline-none appearance-none size-6 bg-neutral-100 dark:bg-white/10 dark:hover:bg-white/20"
|
||||
id="confirm3"
|
||||
>
|
||||
<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 items-center justify-center w-full font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading ? <Spinner /> : t("global.continue")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import { AvatarUploader } from "@/components/avatarUploader";
|
||||
import { PlusIcon } from "@lume/icons";
|
||||
import { NostrAccount } from "@lume/system";
|
||||
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/create-profile")({
|
||||
component: Screen,
|
||||
loader: async () => {
|
||||
const account = await NostrAccount.createAccount();
|
||||
return account;
|
||||
},
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const account = Route.useLoaderData();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { t } = useTranslation();
|
||||
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 NostrAccount.saveAccount(account.nsec);
|
||||
|
||||
// Then create profile
|
||||
if (save) {
|
||||
const profile: Metadata = { ...data, picture };
|
||||
const eventId = await NostrAccount.createProfile(profile);
|
||||
|
||||
if (eventId) {
|
||||
navigate({
|
||||
to: "/auth/$account/backup",
|
||||
params: { account: account.npub },
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
|
||||
<div className="text-center">
|
||||
<h3 className="text-xl font-semibold">Let's set up your profile.</h3>
|
||||
</div>
|
||||
<div>
|
||||
<div className="relative rounded-full size-24 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 object-cover w-full h-full rounded-full"
|
||||
/>
|
||||
) : null}
|
||||
<AvatarUploader
|
||||
setPicture={setPicture}
|
||||
className="absolute inset-0 z-20 flex items-center justify-center w-full h-full text-white rounded-full dark:text-black bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
<PlusIcon className="size-8" />
|
||||
</AvatarUploader>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex flex-col w-full 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="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<div 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="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<div 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="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-500 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex items-center justify-center w-full mt-3 font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading ? <Spinner /> : t("global.continue")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { NostrAccount } from "@lume/system";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createLazyFileRoute("/auth/import")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
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 NostrAccount.saveAccount(key, password);
|
||||
|
||||
if (npub) {
|
||||
navigate({ to: "/", replace: true });
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
|
||||
<div className="text-center">
|
||||
<h3 className="text-xl font-semibold">Continue with Private Key</h3>
|
||||
</div>
|
||||
<div className="flex flex-col w-full 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="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<div 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="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center justify-center w-full mt-3 font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading ? <Spinner /> : "Login"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { NostrAccount } from "@lume/system";
|
||||
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 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 remoteAccount = await NostrAccount.connectRemoteAccount(uri);
|
||||
|
||||
if (remoteAccount?.length) {
|
||||
navigate({ to: "/", replace: true });
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
|
||||
<div className="text-center">
|
||||
<h3 className="text-xl font-semibold">Continue with Nostr Connect</h3>
|
||||
</div>
|
||||
<div className="flex flex-col w-full 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="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center justify-center w-full mt-3 font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading ? <Spinner /> : "Login"}
|
||||
</button>
|
||||
{loading ? (
|
||||
<p className="text-sm text-center text-neutral-600 dark:text-neutral-400">
|
||||
Waiting confirmation...
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import { CancelIcon, PlusIcon } from "@lume/icons";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import type { Relay } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/bootstrap-relays")({
|
||||
loader: async () => {
|
||||
const bootstrapRelays = await NostrQuery.getBootstrapRelays();
|
||||
return bootstrapRelays;
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const bootstrapRelays = Route.useLoaderData();
|
||||
const { register, reset, handleSubmit } = useForm();
|
||||
|
||||
const [relays, setRelays] = useState<Relay[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const removeRelay = (url: string) => {
|
||||
setRelays((prev) => prev.filter((relay) => relay.url !== url));
|
||||
};
|
||||
|
||||
const onSubmit = async (data: { url: string; purpose: string }) => {
|
||||
try {
|
||||
const relay: Relay = { url: data.url, purpose: data.purpose };
|
||||
setRelays((prev) => [...prev, relay]);
|
||||
reset();
|
||||
} catch (e) {
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await NostrQuery.saveBootstrapRelays(relays);
|
||||
} catch (e) {
|
||||
setIsLoading(false);
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setRelays(bootstrapRelays);
|
||||
}, [bootstrapRelays]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-screen h-screen">
|
||||
<div className="w-full max-w-sm mx-auto lg:max-w-lg">
|
||||
<div className="text-center h-11">
|
||||
<h1 className="font-semibold">Customize Bootstrap Relays</h1>
|
||||
</div>
|
||||
<div className="flex flex-col w-full px-2 bg-white rounded-xl shadow-primary backdrop-blur-lg dark:bg-white/20 dark:ring-1 ring-neutral-800/50">
|
||||
{relays.map((relay) => (
|
||||
<div
|
||||
key={relay.url}
|
||||
className="flex items-center justify-between h-11"
|
||||
>
|
||||
<div className="inline-flex items-center gap-2 text-sm font-medium">
|
||||
{relay.url}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{relay.purpose?.length ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center px-2 text-xs font-medium uppercase rounded-md h-7 w-max hover:bg-black/10 dark:hover:bg-white/10"
|
||||
>
|
||||
{relay.purpose}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRelay(relay.url)}
|
||||
className="inline-flex items-center justify-center rounded-md size-7 text-neutral-700 dark:text-white/20 hover:bg-black/10 dark:hover:bg-white/10"
|
||||
>
|
||||
<CancelIcon className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center border-t h-14 border-neutral-100 dark:border-white/5">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex items-center w-full gap-2 mb-0"
|
||||
>
|
||||
<div className="flex items-center flex-1 gap-2 border rounded-lg border-neutral-300 dark:border-white/20">
|
||||
<input
|
||||
{...register("url", {
|
||||
required: true,
|
||||
minLength: 1,
|
||||
})}
|
||||
name="url"
|
||||
placeholder="wss://..."
|
||||
spellCheck={false}
|
||||
className="flex-1 px-3 bg-transparent border-none rounded-l-lg h-9 placeholder:text-neutral-500 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
<select
|
||||
{...register("purpose")}
|
||||
className="flex-1 p-0 m-0 text-sm bg-transparent border-none outline-none h-9 ring-0 focus:outline-none focus:ring-0"
|
||||
>
|
||||
<option value="read">Read</option>
|
||||
<option value="write">Write</option>
|
||||
<option value="">Both</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex items-center justify-center px-2 text-sm font-medium text-white rounded-lg shrink-0 h-9 w-14 bg-black/20 dark:bg-white/20 hover:bg-blue-500 disabled:opacity-50"
|
||||
>
|
||||
<PlusIcon className="size-7" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => save()}
|
||||
disabled={isLoading}
|
||||
className="inline-flex items-center justify-center w-full h-10 mt-4 text-sm font-semibold text-white bg-blue-500 rounded-lg shrink-0 hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? <Spinner /> : "Save & Relaunch"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
import { CancelIcon, PlusIcon } from "@lume/icons";
|
||||
import type { ColumnRouteSearch } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { User } from "@/components/user";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { NostrAccount, NostrQuery } from "@lume/system";
|
||||
|
||||
export const Route = createFileRoute("/create-group")({
|
||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||
return {
|
||||
account: search.account,
|
||||
label: search.label,
|
||||
name: search.name,
|
||||
};
|
||||
},
|
||||
loader: async () => {
|
||||
const contacts = await NostrAccount.getContactList();
|
||||
return contacts;
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const [title, setTitle] = useState("");
|
||||
const [npub, setNpub] = useState("");
|
||||
const [users, setUsers] = useState<string[]>([
|
||||
"npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445", // reya
|
||||
]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const contacts = Route.useLoaderData();
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
const toggleUser = (pubkey: string) => {
|
||||
setUsers((prev) =>
|
||||
prev.includes(pubkey)
|
||||
? prev.filter((i) => i !== pubkey)
|
||||
: [...prev, pubkey],
|
||||
);
|
||||
};
|
||||
|
||||
const addUser = () => {
|
||||
if (!npub.startsWith("npub1")) return;
|
||||
if (users.includes(npub)) return;
|
||||
|
||||
setUsers((prev) => [...prev, npub]);
|
||||
setNpub("");
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const key = `lume_group_${search.label}`;
|
||||
const createGroup = await NostrQuery.setNstore(
|
||||
key,
|
||||
JSON.stringify(users),
|
||||
);
|
||||
|
||||
if (createGroup) {
|
||||
return navigate({ to: search.redirect, search: { ...search } });
|
||||
}
|
||||
} catch (e) {
|
||||
setIsLoading(false);
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
|
||||
<div className="text-center flex flex-col items-center justify-center">
|
||||
<h1 className="text-2xl font-serif font-medium">
|
||||
Focus feeds for people you like
|
||||
</h1>
|
||||
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
||||
Add some people for custom feeds.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-4/5 max-w-full flex flex-col gap-3">
|
||||
<div className="w-full h-9 shrink-0 flex items-center bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="w-16 border-r border-black/10 dark:border-white/10 shrink-0 text-center text-sm font-semibold"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
name="name"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Enter a name for this group"
|
||||
className="h-full bg-transparent border-none text-sm px-3 placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full flex flex-col items-center gap-3">
|
||||
<div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] flex flex-col gap-3 bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
name="npub"
|
||||
value={npub}
|
||||
onChange={(e) => setNpub(e.target.value)}
|
||||
placeholder="npub1..."
|
||||
className="h-9 w-full rounded-lg bg-black/10 dark:bg-white/10 border-none text-sm px-3 placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addUser()}
|
||||
className="inline-flex size-9 rounded-lg items-center justify-center bg-black/20 dark:bg-white/20 shrink-0 text-white hover:bg-blue-500"
|
||||
>
|
||||
<PlusIcon className="size-6" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-semibold">Added</span>
|
||||
<div className="flex flex-col gap-2">
|
||||
{users.length ? (
|
||||
users.map((item: string) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
onClick={() => toggleUser(item)}
|
||||
className="inline-flex items-center justify-between px-3 py-2 rounded-lg bg-white dark:bg-black/20 backdrop-blur-lg shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||
>
|
||||
<User.Provider pubkey={item}>
|
||||
<User.Root className="flex items-center gap-2.5">
|
||||
<User.Avatar className="size-8 rounded-full object-cover" />
|
||||
<div className="flex items-center gap-1">
|
||||
<User.Name className="text-sm font-medium" />
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<div>
|
||||
<CancelIcon className="size-4" />
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="bg-black/5 dark:bg-white/5 text-sm flex items-center justify-center h-14 rounded-lg">
|
||||
Empty.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-semibold">Contacts</span>
|
||||
<div className="flex flex-col gap-2">
|
||||
{contacts.length ? (
|
||||
contacts.map((item: string) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
onClick={() => toggleUser(item)}
|
||||
className="inline-flex items-center justify-between px-3 py-2 rounded-lg bg-white dark:bg-black/20 backdrop-blur-lg shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||
>
|
||||
<User.Provider pubkey={item}>
|
||||
<User.Root className="flex items-center gap-2.5">
|
||||
<User.Avatar className="size-8 rounded-full object-cover" />
|
||||
<div className="flex items-center gap-1">
|
||||
<User.Name className="text-sm font-medium" />
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="bg-black/5 dark:bg-white/5 text-sm flex items-center justify-center h-14 rounded-lg">
|
||||
<p>
|
||||
Find more user at{" "}
|
||||
<a
|
||||
href="https://www.nostr.directory/"
|
||||
target="_blank"
|
||||
className="text-blue-600 after:content-['_↗']"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Nostr Directory
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={isLoading || users.length < 1}
|
||||
className="inline-flex items-center justify-center w-36 rounded-full h-9 bg-blue-500 text-white text-sm font-medium hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? <Spinner /> : "Confirm"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { NostrAccount } from "@lume/system";
|
||||
import type { ColumnRouteSearch } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/create-newsfeed/f2f")({
|
||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||
return {
|
||||
account: search.account,
|
||||
label: search.label,
|
||||
name: search.name,
|
||||
};
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const navigate = Route.useNavigate();
|
||||
const { redirect } = Route.useSearch();
|
||||
|
||||
const [npub, setNpub] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
if (!npub.startsWith("npub1"))
|
||||
return toast.warning("You must enter a valid npub.");
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const sync = await NostrAccount.f2f(npub);
|
||||
|
||||
if (sync) {
|
||||
return navigate({ to: redirect });
|
||||
}
|
||||
} catch (e) {
|
||||
setIsLoading(false);
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto scrollbar-none p-2 shrink-0 h-[450px] bg-white dark:bg-white/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
|
||||
<div className="h-full flex flex-col justify-between">
|
||||
<div className="flex-1 flex flex-col gap-1.5 justify-center px-5">
|
||||
<p className="font-semibold text-neutral-500">
|
||||
You already have a friend on Nostr?
|
||||
</p>
|
||||
<p>Instead of building the timeline by yourself.</p>
|
||||
<p className="font-semibold text-neutral-500">
|
||||
Just enter your friend's{" "}
|
||||
<span className="text-blue-500">npub.</span>
|
||||
</p>
|
||||
<p>
|
||||
You will have the same experience as your friend. Of course, you
|
||||
always can edit your network later.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="npub" className="font-medium text-sm">
|
||||
NPUB
|
||||
</label>
|
||||
<input
|
||||
name="npub"
|
||||
placeholder="npub1..."
|
||||
value={npub}
|
||||
onChange={(e) => setNpub(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="h-11 rounded-lg bg-transparent border border-neutral-200 dark:border-neutral-800 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
className="inline-flex items-center justify-center w-full rounded-lg h-9 bg-blue-500 text-white text-sm font-medium hover:bg-blue-600"
|
||||
>
|
||||
{isLoading ? <Spinner /> : "Confirm"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import type { ColumnRouteSearch } from "@lume/types";
|
||||
import { cn } from "@lume/utils";
|
||||
import { Link, Outlet } from "@tanstack/react-router";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/create-newsfeed")({
|
||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||
return {
|
||||
account: search.account,
|
||||
label: search.label,
|
||||
name: search.name,
|
||||
};
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const search = Route.useSearch();
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-4">
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<h1 className="font-serif text-2xl font-medium">
|
||||
Build up your timeline.
|
||||
</h1>
|
||||
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
||||
Follow some people to keep up to date with them.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col w-4/5 max-w-full gap-3">
|
||||
<div className="w-full h-9 shrink-0 flex items-center justify-between bg-black/5 dark:bg-white/5 rounded-lg px-0.5">
|
||||
<Link
|
||||
to="/create-newsfeed/users"
|
||||
search={search}
|
||||
className="flex-1 h-8"
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm font-medium rounded-md h-full flex items-center justify-center",
|
||||
isActive
|
||||
? "bg-white dark:bg-white/20 shadow"
|
||||
: "bg-transparent",
|
||||
)}
|
||||
>
|
||||
Users
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
<Link
|
||||
to="/create-newsfeed/f2f"
|
||||
search={search}
|
||||
className="flex-1 h-8"
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md h-full flex items-center justify-center",
|
||||
isActive ? "bg-white dark:bg-white/20" : "bg-transparent",
|
||||
)}
|
||||
>
|
||||
Friend to Friend
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Suspense, useState } from "react";
|
||||
import { Await, defer } from "@tanstack/react-router";
|
||||
import { User } from "@/components/user";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { toast } from "sonner";
|
||||
import type { ColumnRouteSearch } from "@lume/types";
|
||||
import { NostrAccount } from "@lume/system";
|
||||
|
||||
export const Route = createFileRoute("/create-newsfeed/users")({
|
||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||
return {
|
||||
account: search.account,
|
||||
label: search.label,
|
||||
name: search.name,
|
||||
};
|
||||
},
|
||||
loader: async ({ abortController }) => {
|
||||
try {
|
||||
return {
|
||||
data: defer(
|
||||
fetch("https://api.nostr.band/v0/trending/profiles", {
|
||||
signal: abortController.signal,
|
||||
}).then((res) => res.json()),
|
||||
),
|
||||
};
|
||||
} catch (e) {
|
||||
throw new Error(String(e));
|
||||
}
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { data } = Route.useLoaderData();
|
||||
const { redirect } = Route.useSearch();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [follows, setFollows] = useState<string[]>([]);
|
||||
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
const toggleFollow = (pubkey: string) => {
|
||||
setFollows((prev) =>
|
||||
prev.includes(pubkey)
|
||||
? prev.filter((i) => i !== pubkey)
|
||||
: [...prev, pubkey],
|
||||
);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const newContactList = await NostrAccount.setContactList(follows);
|
||||
|
||||
if (newContactList) {
|
||||
return navigate({ to: redirect });
|
||||
}
|
||||
} catch (e) {
|
||||
setIsLoading(false);
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col items-center gap-3">
|
||||
<div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium"
|
||||
disabled
|
||||
>
|
||||
<Spinner className="size-5" />
|
||||
Loading...
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Await promise={data}>
|
||||
{(users) =>
|
||||
users.profiles.map((item: { pubkey: string }) => (
|
||||
<div
|
||||
key={item.pubkey}
|
||||
className="h-max w-full overflow-hidden mb-2 p-2 bg-white dark:bg-black/20 backdrop-blur-lg rounded-lg shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||
>
|
||||
<User.Provider pubkey={item.pubkey}>
|
||||
<User.Root>
|
||||
<div className="flex h-full w-full flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<User.Avatar className="size-7 shrink-0 rounded-full object-cover" />
|
||||
<User.Name className="text-sm leadning-tight max-w-[15rem] truncate font-semibold" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleFollow(item.pubkey)}
|
||||
className="inline-flex h-7 w-20 items-center justify-center rounded-lg bg-black/10 text-sm font-medium hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
{follows.includes(item.pubkey)
|
||||
? "Unfollow"
|
||||
: "Follow"}
|
||||
</button>
|
||||
</div>
|
||||
<User.About className="line-clamp-3 max-w-none select-text text-neutral-800 dark:text-neutral-400" />
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={isLoading || follows.length < 1}
|
||||
className="inline-flex items-center justify-center w-36 rounded-full h-9 bg-blue-500 text-white text-sm font-medium hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? <Spinner /> : "Confirm"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { CheckCircleIcon } from "@lume/icons";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import type { ColumnRouteSearch } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { TOPICS } from "@lume/utils";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type Topic = {
|
||||
title: string;
|
||||
content: string[];
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/create-topic")({
|
||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||
return {
|
||||
account: search.account,
|
||||
label: search.label,
|
||||
name: search.name,
|
||||
};
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const [topics, setTopics] = useState<Topic[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
const toggleTopic = (topic: Topic) => {
|
||||
setTopics((prev) =>
|
||||
prev.find((item) => item.title === topic.title)
|
||||
? prev.filter((i) => i.title !== topic.title)
|
||||
: [...prev, topic],
|
||||
);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const key = `lume_topic_${search.label}`;
|
||||
const createTopic = await NostrQuery.setNstore(
|
||||
key,
|
||||
JSON.stringify(topics),
|
||||
);
|
||||
|
||||
if (createTopic) {
|
||||
return navigate({ to: search.redirect, search: { ...search } });
|
||||
}
|
||||
} catch (e) {
|
||||
setIsLoading(false);
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-4">
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<h1 className="font-serif text-2xl font-medium">
|
||||
What are your interests?
|
||||
</h1>
|
||||
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
||||
Add some topics you want to focus on.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col w-4/5 max-w-full gap-3">
|
||||
<div className="flex items-center justify-between w-full px-3 rounded-lg h-9 shrink-0 bg-black/5 dark:bg-white/5">
|
||||
<span className="text-sm font-medium">Added: {topics.length}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center w-full gap-3">
|
||||
<div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl">
|
||||
<div className="flex flex-col gap-3">
|
||||
{TOPICS.map((topic) => (
|
||||
<button
|
||||
key={topic.title}
|
||||
type="button"
|
||||
onClick={() => toggleTopic(topic)}
|
||||
className="flex items-center justify-between px-3 bg-white border border-transparent rounded-lg h-11 dark:bg-black/20 backdrop-blur-lg hover:border-blue-500 shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||
>
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<div>{topic.icon}</div>
|
||||
<div className="text-sm font-medium">
|
||||
<span>{topic.title}</span>
|
||||
<span className="ml-1 italic font-normal text-neutral-400 dark:text-neutral-600">
|
||||
{topic.content.length} hashtags
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{topics.find((item) => item.title === topic.title) ? (
|
||||
<CheckCircleIcon className="text-teal-500 size-4" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={isLoading || topics.length < 1}
|
||||
className="inline-flex items-center justify-center text-sm font-medium text-white bg-blue-500 rounded-full w-36 h-9 hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? <Spinner /> : "Confirm"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { AddMediaIcon } from "@lume/icons";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { insertImage, isImagePath } from "@lume/utils";
|
||||
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() {
|
||||
const editor = useSlateStatic();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const upload = async () => {
|
||||
try {
|
||||
// start loading
|
||||
setLoading(true);
|
||||
|
||||
const image = await NostrQuery.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 NostrQuery.upload(item);
|
||||
insertImage(editor, image);
|
||||
}
|
||||
}
|
||||
// stop loading
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
listenFileDrop();
|
||||
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => upload()}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center h-8 gap-2 px-2.5 text-sm rounded-lg text-black/70 dark:text-white/70 w-max hover:bg-black/10 dark:hover:bg-white/10"
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<AddMediaIcon className="size-4" />
|
||||
)}
|
||||
Add media
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { PowIcon } from "@lume/icons";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
export function PowButton({
|
||||
setDifficulty,
|
||||
}: {
|
||||
setDifficulty: Dispatch<SetStateAction<{ enable: boolean; num: number }>>;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setDifficulty((prev) => ({ ...prev, enable: !prev.enable }))
|
||||
}
|
||||
className="inline-flex items-center h-8 gap-2 px-2.5 text-sm rounded-lg text-black/70 dark:text-white/70 w-max hover:bg-black/10 dark:hover:bg-white/10"
|
||||
>
|
||||
<PowIcon className="size-4" />
|
||||
PoW
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { NsfwIcon } from "@lume/icons";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
export function WarningButton({
|
||||
setWarning,
|
||||
}: {
|
||||
setWarning: Dispatch<SetStateAction<{ enable: boolean; reason: string }>>;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWarning((prev) => ({ ...prev, enable: !prev.enable }))}
|
||||
className="inline-flex items-center h-8 gap-2 px-2.5 text-sm rounded-lg text-black/70 dark:text-white/70 w-max hover:bg-black/10 dark:hover:bg-white/10"
|
||||
>
|
||||
<NsfwIcon className="size-4" />
|
||||
Mark as sensitive
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,399 +0,0 @@
|
||||
import { ComposeFilledIcon } from "@lume/icons";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { cn, insertImage, insertNostrEvent, isImageUrl } from "@lume/utils";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
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 { LumeEvent, useEvent } from "@lume/system";
|
||||
import { WarningButton } from "./-components/warning";
|
||||
import { MentionNote } from "@/components/note/mentions/note";
|
||||
import { PowButton } from "./-components/pow";
|
||||
import { User } from "@/components/user";
|
||||
import { Note } from "@/components/note";
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
type EditorSearch = {
|
||||
reply_to: string;
|
||||
quote: string;
|
||||
};
|
||||
|
||||
type EditorElement = {
|
||||
type: string;
|
||||
children: Descendant[];
|
||||
eventId?: string;
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/editor/")({
|
||||
validateSearch: (search: Record<string, string>): EditorSearch => {
|
||||
return {
|
||||
reply_to: search.reply_to,
|
||||
quote: search.quote,
|
||||
};
|
||||
},
|
||||
beforeLoad: ({ search }) => {
|
||||
let initialValue: EditorElement[];
|
||||
|
||||
if (search?.quote?.length) {
|
||||
const eventId = nip19.noteEncode(search.quote);
|
||||
initialValue = [
|
||||
{
|
||||
type: "paragraph",
|
||||
children: [{ text: "" }],
|
||||
},
|
||||
{
|
||||
type: "event",
|
||||
eventId: `nostr:${eventId}`,
|
||||
children: [{ text: "" }],
|
||||
},
|
||||
];
|
||||
} else {
|
||||
initialValue = [
|
||||
{
|
||||
type: "paragraph",
|
||||
children: [{ text: "" }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return { initialValue };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { reply_to } = Route.useSearch();
|
||||
const { initialValue } = Route.useRouteContext();
|
||||
|
||||
const [editorValue, setEditorValue] = useState<EditorElement[]>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [warning, setWarning] = useState({ enable: false, reason: "" });
|
||||
const [difficulty, setDifficulty] = useState({ enable: false, num: 21 });
|
||||
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 LumeEvent.publish(
|
||||
content,
|
||||
warning.enable && warning.reason.length ? warning.reason : null,
|
||||
difficulty.enable && difficulty.num > 0 ? difficulty.num : null,
|
||||
reply_to,
|
||||
);
|
||||
|
||||
if (eventId) {
|
||||
// stop loading
|
||||
setLoading(false);
|
||||
// reset form
|
||||
reset();
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setEditorValue(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
if (!editorValue) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full">
|
||||
<Slate editor={editor} initialValue={editorValue}>
|
||||
<div data-tauri-drag-region className="h-9 shrink-0" />
|
||||
<div className="flex flex-col flex-1 overflow-y-auto">
|
||||
{reply_to?.length ? (
|
||||
<div className="flex items-center gap-3 px-2.5 pb-3 border-b border-black/5 dark:border-white/5">
|
||||
<div className="text-sm font-semibold shrink-0">Reply to:</div>
|
||||
<ChildNote id={reply_to} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="px-4 py-4 overflow-y-auto">
|
||||
<Editable
|
||||
key={JSON.stringify(editorValue)}
|
||||
autoFocus={true}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="none"
|
||||
spellCheck={false}
|
||||
renderElement={(props) => <Element {...props} />}
|
||||
placeholder={
|
||||
reply_to ? "Type your reply..." : "What're you up to?"
|
||||
}
|
||||
className="focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{warning.enable ? (
|
||||
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5">
|
||||
<span className="text-sm shrink-0 text-black/50 dark:text-white/50">
|
||||
Reason:
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="NSFW..."
|
||||
value={warning.reason}
|
||||
onChange={(e) =>
|
||||
setWarning((prev) => ({ ...prev, reason: e.target.value }))
|
||||
}
|
||||
className="flex-1 text-sm bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-black/50 dark:placeholder:text-white/50"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{difficulty.enable ? (
|
||||
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5">
|
||||
<span className="text-sm shrink-0 text-black/50 dark:text-white/50">
|
||||
Difficulty:
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]"
|
||||
onKeyDown={(event) => {
|
||||
if (!/[0-9]/.test(event.key)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
placeholder="21"
|
||||
defaultValue={difficulty.num}
|
||||
onChange={(e) =>
|
||||
setWarning((prev) => ({ ...prev, num: Number(e.target.value) }))
|
||||
}
|
||||
className="flex-1 text-sm bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-black/50 dark:placeholder:text-white/50"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex items-center w-full h-16 gap-4 px-4 border-t divide-x divide-black/5 dark:divide-white/5 shrink-0 border-black/5 dark:border-white/5"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => publish()}
|
||||
className="inline-flex items-center justify-center h-8 gap-1 px-2.5 text-sm font-medium rounded-lg bg-black/10 w-max hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<ComposeFilledIcon className="size-4" />
|
||||
)}
|
||||
Publish
|
||||
</button>
|
||||
<div className="inline-flex items-center flex-1 gap-2 pl-4">
|
||||
<MediaButton />
|
||||
<WarningButton setWarning={setWarning} />
|
||||
<PowButton setDifficulty={setDifficulty} />
|
||||
</div>
|
||||
</div>
|
||||
</Slate>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChildNote({ id }: { id: string }) {
|
||||
const { isLoading, isError, data } = useEvent(id);
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner className="size-5" />;
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return <div>Event not found with your current relay set.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Note.Provider event={data}>
|
||||
<Note.Root className="flex items-center gap-2">
|
||||
<User.Provider pubkey={data.pubkey}>
|
||||
<User.Root className="shrink-0">
|
||||
<User.Avatar className="rounded-full size-8 shrink-0" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<div className="content-break line-clamp-1">{data.content}</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const withNostrEvent = (editor: ReactEditor) => {
|
||||
const { 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("nevent") || text.startsWith("note")) {
|
||||
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, element, children }) => {
|
||||
const editor = useSlateStatic();
|
||||
const selected = useSelected();
|
||||
const focused = useFocused();
|
||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
||||
|
||||
return (
|
||||
<div {...attributes}>
|
||||
{children}
|
||||
<img
|
||||
src={element.url}
|
||||
alt={element.url}
|
||||
className={cn(
|
||||
"my-2 h-auto w-1/2 rounded-lg object-cover ring-2 outline outline-1 -outline-offset-1 outline-black/15",
|
||||
selected && focused ? "ring-blue-500" : "ring-transparent",
|
||||
)}
|
||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||
onKeyDown={() => Transforms.removeNodes(editor, { at: path })}
|
||||
/>
|
||||
</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 text-blue-500 align-baseline 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}
|
||||
<div
|
||||
contentEditable={false}
|
||||
className="relative my-2 user-select-none"
|
||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||
onKeyDown={() => Transforms.removeNodes(editor, { at: path })}
|
||||
>
|
||||
<MentionNote eventId={element.eventId} 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>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,69 +0,0 @@
|
||||
import { Note } from "@/components/note";
|
||||
import { type LumeEvent, NostrQuery, useEvent } from "@lume/system";
|
||||
import { Box, Container, 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 () => {
|
||||
const settings = await NostrQuery.getUserSettings();
|
||||
return { settings };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { eventId } = Route.useParams();
|
||||
const { isLoading, isError, data } = useEvent(eventId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<p>Not found.</p>
|
||||
</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container withDrag>
|
||||
<Box className="scrollbar-none">
|
||||
<WindowVirtualizer>
|
||||
<MainNote data={data} />
|
||||
{data ? (
|
||||
<ReplyList eventId={eventId} />
|
||||
) : (
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
)}
|
||||
</WindowVirtualizer>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function MainNote({ data }: { data: LumeEvent }) {
|
||||
return (
|
||||
<Note.Provider event={data}>
|
||||
<Note.Root>
|
||||
<div className="flex items-center justify-between px-3 h-14">
|
||||
<Note.User />
|
||||
<Note.Menu />
|
||||
</div>
|
||||
<Note.ContentLarge className="px-3" />
|
||||
<div className="flex items-center justify-end gap-2 px-3 mt-4 h-11">
|
||||
<Note.Reply large />
|
||||
<Note.Repost large />
|
||||
<Note.Zap large />
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { EventWithReplies } from "@lume/types";
|
||||
import { cn } from "@lume/utils";
|
||||
import { SubReply } from "./subReply";
|
||||
import { Note } from "@/components/note";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { EventWithReplies } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { cn } from "@lume/utils";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Reply } from "./reply";
|
||||
import { LumeEvent } from "@lume/system";
|
||||
|
||||
export function ReplyList({
|
||||
eventId,
|
||||
className,
|
||||
}: {
|
||||
eventId: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [t] = useTranslation();
|
||||
const [data, setData] = useState<null | EventWithReplies[]>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function getReplies() {
|
||||
const events = await LumeEvent.getReplies(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>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { NostrEvent } from "@lume/types";
|
||||
import { Note } from "@/components/note";
|
||||
|
||||
export function SubReply({
|
||||
event,
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
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>
|
||||
);
|
||||
}
|
||||