Compare commits
102 Commits
v4.0.0-bet
...
v4.0.10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1424b851c | ||
|
|
d14e609579 | ||
|
|
8c0627ff27 | ||
|
|
18c133d096 | ||
|
|
0061ecea78 | ||
|
|
d01cf8319d | ||
|
|
843895d876 | ||
|
|
7c99ed39e4 | ||
|
|
71be59b2e9 | ||
|
|
1c20512ecc | ||
|
|
90342c552f | ||
|
|
b396c8a695 | ||
|
|
6996e30889 | ||
|
|
7ba793fad8 | ||
|
|
f11f836518 | ||
|
|
04fe0fcec8 | ||
|
|
799835a629 | ||
|
|
4e7da4108b | ||
|
|
7c7b082b3a | ||
|
|
38d6c51921 | ||
|
|
1738cbdd97 | ||
|
|
2e885b76a1 | ||
|
|
f94680e487 | ||
|
|
c682a58842 | ||
|
|
921cf871ee | ||
|
|
d5b1593aca | ||
|
|
6676b4e2a4 | ||
|
|
5f30ddcfca | ||
|
|
41d0de539d | ||
|
|
e254ee3203 | ||
|
|
6d42360549 | ||
|
|
70c5143445 | ||
|
|
41b66b18f5 | ||
|
|
dda0720ed4 | ||
|
|
4b60b39119 | ||
|
|
d2e5122d5a | ||
|
|
32f3315344 | ||
|
|
5ca9444358 | ||
|
|
4dc13385a5 | ||
|
|
b90ad1421f | ||
|
|
bba324ea53 | ||
|
|
7449000f5f | ||
|
|
dc7762ca11 | ||
|
|
3a3f960dde | ||
|
|
12e066ff2e | ||
|
|
fe4f965ed5 | ||
|
|
5d3f2264e9 | ||
|
|
407fe40b67 | ||
|
|
1f38eba2cc | ||
|
|
9b5867f80c | ||
|
|
cac774a0c1 | ||
|
|
82689bf3c3 | ||
|
|
f60e438a64 | ||
|
|
ca06f2b6ed | ||
|
|
99d9c70826 | ||
|
|
60afbf090b | ||
|
|
10ca4e6ff4 | ||
|
|
b0f387d029 | ||
|
|
1a8f750640 | ||
|
|
25523229a2 | ||
|
|
47835ed857 | ||
|
|
d84647bc6b | ||
|
|
7724eccd72 | ||
|
|
8ea2335225 | ||
|
|
b60d4db0df | ||
|
|
f1e17ff3c4 | ||
|
|
32954f17b6 | ||
|
|
cf70b0f882 | ||
|
|
135d0918b3 | ||
|
|
e1fbcf0460 | ||
|
|
99aaf3da82 | ||
|
|
3ef13e43f1 | ||
|
|
8939196ae4 | ||
|
|
571d4b4004 | ||
|
|
73f80f27fb | ||
|
|
b46a5cf68f | ||
|
|
8c0d03aed0 | ||
|
|
777eb15b4f | ||
|
|
c8e1b8b8bd | ||
|
|
437cd71f7e | ||
|
|
afb7c87fa3 | ||
|
|
c843626bca | ||
|
|
28337e5915 | ||
|
|
a4aef25adb | ||
|
|
61d1f095d4 | ||
|
|
f027eae52d | ||
|
|
174a3cc74e | ||
|
|
c00a7749b4 | ||
|
|
c755b8d137 | ||
| 17766d29d6 | |||
| 3b13dfeed8 | |||
| 17ba79e01b | |||
| bafad544e9 | |||
| 89c36423ae | |||
| cd31b99559 | |||
| f3c52237fa | |||
| 413d8d82df | |||
| 2eb2010d43 | |||
| 94d400cab2 | |||
| 09b143cb08 | |||
| e3ede34108 | |||
| ed6aca41ea |
73
.github/workflows/main.yml
vendored
@@ -1,53 +1,56 @@
|
|||||||
name: 'Publish'
|
name: "Publish"
|
||||||
on: workflow_dispatch
|
on: workflow_dispatch
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_INCREMENTAL: 0
|
CARGO_INCREMENTAL: 0
|
||||||
RUST_BACKTRACE: short
|
RUST_BACKTRACE: short
|
||||||
RUSTFLAGS: '-W unreachable-pub -W rust-2021-compatibility'
|
RUSTFLAGS: "-W unreachable-pub -W rust-2021-compatibility"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish-tauri:
|
publish-tauri:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
settings:
|
include:
|
||||||
- platform: 'macos-latest'
|
- platform: "macos-latest" # for Arm based macs (M1 and above).
|
||||||
args: '--target universal-apple-darwin'
|
args: "--target aarch64-apple-darwin"
|
||||||
- platform: 'ubuntu-22.04'
|
- platform: "macos-latest" # for Intel based macs.
|
||||||
args: ''
|
args: "--target x86_64-apple-darwin"
|
||||||
- platform: 'windows-latest'
|
- platform: "macos-latest" # for Intel based macs.
|
||||||
args: '--target x86_64-pc-windows-msvc'
|
args: "--target universal-apple-darwin"
|
||||||
runs-on: ${{ matrix.settings.platform }}
|
#- platform: 'ubuntu-22.04'
|
||||||
|
# args: ''
|
||||||
|
#- platform: 'windows-latest'
|
||||||
|
# args: '--target x86_64-pc-windows-msvc'
|
||||||
|
runs-on: ${{ matrix.platform }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- name: setup node
|
|
||||||
uses: actions/setup-node@v3
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: "lts/*"
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
- name: Install PNPM
|
||||||
targets: aarch64-apple-darwin
|
|
||||||
- name: install dependencies (ubuntu only)
|
|
||||||
if: matrix.settings.platform == 'ubuntu-22.04'
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y build-essential libssl-dev javascriptcoregtk-4.1 libayatana-appindicator3-dev libsoup-3.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev webkit2gtk-4.1 librsvg2-dev patchelf
|
|
||||||
- name: Install pnpm
|
|
||||||
uses: pnpm/action-setup@v2
|
uses: pnpm/action-setup@v2
|
||||||
with:
|
with:
|
||||||
version: 8.x.x
|
version: 8.x.x
|
||||||
run_install: false
|
run_install: false
|
||||||
- name: Setup node and cache for package data
|
|
||||||
uses: actions/setup-node@v3
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
node-version: 'lts/*'
|
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||||
cache: 'pnpm'
|
|
||||||
cache-dependency-path: pnpm-lock.yaml
|
- name: Install dependencies (ubuntu only)
|
||||||
- uses: Swatinem/rust-cache@v2
|
if: matrix.platform == 'ubuntu-22.04'
|
||||||
with:
|
run: |
|
||||||
cache-on-failure: true
|
sudo apt-get update
|
||||||
- run: pnpm install
|
sudo apt-get install -y build-essential libssl-dev javascriptcoregtk-4.1 libayatana-appindicator3-dev libsoup-3.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev webkit2gtk-4.1 librsvg2-dev patchelf
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
- uses: tauri-apps/tauri-action@dev
|
- uses: tauri-apps/tauri-action@dev
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -62,9 +65,9 @@ jobs:
|
|||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
with:
|
with:
|
||||||
tagName: v__VERSION__
|
tagName: v__VERSION__
|
||||||
releaseName: 'v__VERSION__'
|
releaseName: "v__VERSION__"
|
||||||
releaseBody: 'See the assets to download this version and install.'
|
releaseBody: "See the assets to download this version and install."
|
||||||
releaseDraft: true
|
releaseDraft: true
|
||||||
prerelease: false
|
prerelease: false
|
||||||
args: ${{ matrix.settings.args }}
|
args: ${{ matrix.args }}
|
||||||
includeDebug: false
|
includeDebug: false
|
||||||
|
|||||||
5
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<state>
|
||||||
|
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
||||||
|
</state>
|
||||||
|
</component>
|
||||||
46
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<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
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?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
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?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
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
12
README.md
@@ -1,18 +1,14 @@
|
|||||||
_Note_: Lume is under rewrite to using Rust Nostr as back-end and more lightweight front-end. If you need stable version, you can download v3 and below.
|
|
||||||
|
|
||||||
Source code for v3 is stored here: https://github.com/lumehq/lume/tree/old
|
|
||||||
|
|
||||||
--
|
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
Lume is a Nostr client for desktop include Linux, Windows and macOS. It is free and open source, you can look at source code on Github. Lume is actively improving the app and adding new features, you can expect new update every month.
|
Lume is a Nostr client for desktop include Linux, Windows and macOS. It is free and open source, you can look at source code on Github. Lume is actively improving the app and adding new features, you can expect new update every month.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Download Lume v3 (v3.0.1-stable) for your platform here: [https://github.com/lumehq/lume/releases](https://github.com/lumehq/lume/releases)
|
Download Lume v4 for your platform here: [https://github.com/lumehq/lume/releases](https://github.com/lumehq/lume/releases)
|
||||||
|
|
||||||
Supported platform: macOS, Windows and Linux
|
Supported platform: macOS. Windows and Linux are coming soon.
|
||||||
|
|
||||||
|
Windows and Linux are availabel on v3 and below.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
|
|||||||
@@ -9,51 +9,56 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lume/ark": "workspace:^",
|
|
||||||
"@lume/icons": "workspace:^",
|
"@lume/icons": "workspace:^",
|
||||||
|
"@lume/system": "workspace:^",
|
||||||
"@lume/ui": "workspace:^",
|
"@lume/ui": "workspace:^",
|
||||||
"@lume/utils": "workspace:^",
|
"@lume/utils": "workspace:^",
|
||||||
|
"@radix-ui/react-avatar": "^1.0.4",
|
||||||
"@radix-ui/react-checkbox": "^1.0.4",
|
"@radix-ui/react-checkbox": "^1.0.4",
|
||||||
"@radix-ui/react-collapsible": "^1.0.3",
|
"@radix-ui/react-collapsible": "^1.0.3",
|
||||||
"@radix-ui/react-dialog": "^1.0.5",
|
"@radix-ui/react-dialog": "^1.0.5",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
"@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-popover": "^1.0.7",
|
||||||
"@radix-ui/react-switch": "^1.0.3",
|
"@radix-ui/react-switch": "^1.0.3",
|
||||||
"@tanstack/query-sync-storage-persister": "^5.29.0",
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
"@tanstack/react-query": "^5.29.0",
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
"@tanstack/react-query-persist-client": "^5.29.0",
|
"@tanstack/query-persist-client-core": "^5.45.0",
|
||||||
"@tanstack/react-router": "^1.26.19",
|
"@tanstack/react-query": "^5.45.0",
|
||||||
"i18next": "^23.11.1",
|
"@tanstack/react-router": "^1.38.1",
|
||||||
|
"embla-carousel-react": "^8.1.5",
|
||||||
|
"i18next": "^23.11.5",
|
||||||
"i18next-resources-to-backend": "^1.2.1",
|
"i18next-resources-to-backend": "^1.2.1",
|
||||||
"minidenticons": "^4.2.1",
|
"minidenticons": "^4.2.1",
|
||||||
"nanoid": "^5.0.7",
|
"nanoid": "^5.0.7",
|
||||||
"nostr-tools": "^2.4.0",
|
"nostr-tools": "^2.7.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.3.1",
|
||||||
"react-currency-input-field": "^3.8.0",
|
"react-currency-input-field": "^3.8.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.51.2",
|
"react-hook-form": "^7.52.0",
|
||||||
"react-hotkeys-hook": "^4.5.0",
|
"react-hotkeys-hook": "^4.5.0",
|
||||||
"react-i18next": "^14.1.0",
|
"react-i18next": "^14.1.2",
|
||||||
"slate": "^0.102.0",
|
"react-string-replace": "^1.1.1",
|
||||||
"slate-react": "^0.102.0",
|
"slate": "^0.103.0",
|
||||||
"sonner": "^1.4.41",
|
"slate-react": "^0.105.0",
|
||||||
"use-debounce": "^10.0.0",
|
"sonner": "^1.5.0",
|
||||||
"virtua": "^0.29.2"
|
"use-debounce": "^10.0.1",
|
||||||
|
"virtua": "^0.31.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lume/tailwindcss": "workspace:^",
|
"@lume/tailwindcss": "workspace:^",
|
||||||
"@lume/tsconfig": "workspace:^",
|
"@lume/tsconfig": "workspace:^",
|
||||||
"@lume/types": "workspace:^",
|
"@lume/types": "workspace:^",
|
||||||
"@tanstack/router-devtools": "^1.26.19",
|
"@tanstack/router-devtools": "^1.38.1",
|
||||||
"@tanstack/router-vite-plugin": "^1.26.16",
|
"@tanstack/router-vite-plugin": "^1.38.0",
|
||||||
"@types/react": "^18.2.75",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.2.24",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"tailwindcss": "^3.4.3",
|
"tailwindcss": "^3.4.4",
|
||||||
"typescript": "^5.4.5",
|
"typescript": "^5.4.5",
|
||||||
"vite": "^5.2.8",
|
"vite": "^5.3.1",
|
||||||
"vite-plugin-top-level-await": "^1.4.1",
|
"vite-plugin-top-level-await": "^1.4.1",
|
||||||
"vite-tsconfig-paths": "^4.3.2"
|
"vite-tsconfig-paths": "^4.3.2"
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
apps/desktop2/public/404.jpg
Normal file
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 186 KiB |
BIN
apps/desktop2/public/icon.jpeg
Normal file
|
After Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 310 KiB |
|
Before Width: | Height: | Size: 951 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 30 KiB |
BIN
apps/desktop2/public/newsfeed.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
apps/desktop2/public/newsfeed@2x.png
Normal file
|
After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 22 KiB |
BIN
apps/desktop2/public/poster_1.jpeg
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
apps/desktop2/public/poster_2.jpeg
Normal file
|
After Width: | Height: | Size: 201 KiB |
BIN
apps/desktop2/public/poster_3.jpeg
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
apps/desktop2/public/poster_4.jpeg
Normal file
|
After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 257 KiB |
@@ -60,3 +60,63 @@ input::-ms-clear {
|
|||||||
::-webkit-input-placeholder {
|
::-webkit-input-placeholder {
|
||||||
line-height: normal;
|
line-height: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.spinner-leaf {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: calc(50% - 12.5% / 2);
|
||||||
|
width: 12.5%;
|
||||||
|
height: 100%;
|
||||||
|
animation: spinner-leaf-fade 800ms linear infinite;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 30%;
|
||||||
|
background-color: currentColor;
|
||||||
|
@apply rounded;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:where(:nth-child(1)) {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
animation-delay: -800ms;
|
||||||
|
}
|
||||||
|
&:where(:nth-child(2)) {
|
||||||
|
transform: rotate(45deg);
|
||||||
|
animation-delay: -700ms;
|
||||||
|
}
|
||||||
|
&:where(:nth-child(3)) {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
animation-delay: -600ms;
|
||||||
|
}
|
||||||
|
&:where(:nth-child(4)) {
|
||||||
|
transform: rotate(135deg);
|
||||||
|
animation-delay: -500ms;
|
||||||
|
}
|
||||||
|
&:where(:nth-child(5)) {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
animation-delay: -400ms;
|
||||||
|
}
|
||||||
|
&:where(:nth-child(6)) {
|
||||||
|
transform: rotate(225deg);
|
||||||
|
animation-delay: -300ms;
|
||||||
|
}
|
||||||
|
&:where(:nth-child(7)) {
|
||||||
|
transform: rotate(270deg);
|
||||||
|
animation-delay: -200ms;
|
||||||
|
}
|
||||||
|
&:where(:nth-child(8)) {
|
||||||
|
transform: rotate(315deg);
|
||||||
|
animation-delay: -100ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spinner-leaf-fade {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,38 +1,31 @@
|
|||||||
import { QueryClient } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||||
import React, { StrictMode } from "react";
|
import React, { StrictMode } from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import { I18nextProvider } from "react-i18next";
|
import { I18nextProvider } from "react-i18next";
|
||||||
import "./app.css";
|
import "./app.css";
|
||||||
|
import { type } from "@tauri-apps/plugin-os";
|
||||||
import i18n from "./locale";
|
import i18n from "./locale";
|
||||||
import { Toaster } from "sonner";
|
|
||||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
|
||||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
|
||||||
import { routeTree } from "./router.gen"; // auto generated file
|
import { routeTree } from "./router.gen"; // auto generated file
|
||||||
import { CancelCircleIcon, CheckCircleIcon, InfoCircleIcon } from "@lume/icons";
|
|
||||||
import { Ark } from "@lume/ark";
|
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient();
|
||||||
defaultOptions: {
|
const os = await type();
|
||||||
queries: {
|
|
||||||
gcTime: 1000 * 60 * 60 * 24, // 24 hours
|
|
||||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const persister = createSyncStoragePersister({
|
|
||||||
storage: window.localStorage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const ark = new Ark();
|
|
||||||
|
|
||||||
// Set up a Router instance
|
// Set up a Router instance
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
routeTree,
|
routeTree,
|
||||||
context: {
|
context: {
|
||||||
ark,
|
|
||||||
queryClient,
|
queryClient,
|
||||||
|
platform: os,
|
||||||
|
},
|
||||||
|
Wrap: ({ children }) => {
|
||||||
|
return (
|
||||||
|
<I18nextProvider i18n={i18n} defaultNS={"translation"}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
</I18nextProvider>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,25 +46,8 @@ const rootElement = document.getElementById("root")!;
|
|||||||
if (!rootElement.innerHTML) {
|
if (!rootElement.innerHTML) {
|
||||||
const root = ReactDOM.createRoot(rootElement);
|
const root = ReactDOM.createRoot(rootElement);
|
||||||
root.render(
|
root.render(
|
||||||
<I18nextProvider i18n={i18n} defaultNS={"translation"}>
|
|
||||||
<PersistQueryClientProvider
|
|
||||||
client={queryClient}
|
|
||||||
persistOptions={{ persister }}
|
|
||||||
>
|
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<Toaster
|
|
||||||
position="bottom-right"
|
|
||||||
icons={{
|
|
||||||
success: <CheckCircleIcon className="size-5" />,
|
|
||||||
info: <InfoCircleIcon className="size-5" />,
|
|
||||||
error: <CancelCircleIcon className="size-5" />,
|
|
||||||
}}
|
|
||||||
closeButton
|
|
||||||
theme="system"
|
|
||||||
/>
|
|
||||||
<App />
|
<App />
|
||||||
</StrictMode>
|
</StrictMode>,
|
||||||
</PersistQueryClientProvider>
|
|
||||||
</I18nextProvider>,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
import { Account } from "@lume/types";
|
|
||||||
import { User } from "@lume/ui";
|
|
||||||
import {
|
|
||||||
useNavigate,
|
|
||||||
useParams,
|
|
||||||
useRouteContext,
|
|
||||||
} from "@tanstack/react-router";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
export function Accounts() {
|
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const params = useParams({ strict: false });
|
|
||||||
|
|
||||||
const [accounts, setAccounts] = useState<Account[]>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function getAllAccounts() {
|
|
||||||
const data = await ark.get_all_accounts();
|
|
||||||
if (data) setAccounts(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
getAllAccounts();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div data-tauri-drag-region className="flex items-center gap-3">
|
|
||||||
{accounts
|
|
||||||
? accounts.map((account) =>
|
|
||||||
// @ts-ignore, useless
|
|
||||||
account.npub === params.account ? (
|
|
||||||
<Active key={account.npub} pubkey={account.npub} />
|
|
||||||
) : (
|
|
||||||
<Inactive key={account.npub} pubkey={account.npub} />
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Inactive({ pubkey }: { pubkey: string }) {
|
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const changeAccount = async (npub: string) => {
|
|
||||||
const select = await ark.load_selected_account(npub);
|
|
||||||
if (select) navigate({ to: "/$account/home", params: { account: npub } });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button type="button" onClick={() => changeAccount(pubkey)}>
|
|
||||||
<User.Provider pubkey={pubkey}>
|
|
||||||
<User.Root className="rounded-full">
|
|
||||||
<User.Avatar className="aspect-square h-auto w-8 rounded-full object-cover" />
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Active({ pubkey }: { pubkey: string }) {
|
|
||||||
return (
|
|
||||||
<User.Provider pubkey={pubkey}>
|
|
||||||
<User.Root className="rounded-full ring-1 ring-teal-500 ring-offset-2 ring-offset-neutral-200 dark:ring-offset-neutral-950">
|
|
||||||
<User.Avatar className="aspect-square h-auto w-7 rounded-full object-cover" />
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
import { LoaderIcon } from "@lume/icons";
|
import { NostrQuery } from "@lume/system";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
import {
|
||||||
import { Dispatch, ReactNode, SetStateAction, useState } from "react";
|
type Dispatch,
|
||||||
|
type ReactNode,
|
||||||
|
type SetStateAction,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export function AvatarUploader({
|
export function AvatarUploader({
|
||||||
@@ -13,21 +18,17 @@ export function AvatarUploader({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const uploadAvatar = async () => {
|
const uploadAvatar = async () => {
|
||||||
// start loading
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const image = await ark.upload();
|
setLoading(true);
|
||||||
|
const image = await NostrQuery.upload();
|
||||||
setPicture(image);
|
setPicture(image);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
setLoading(false);
|
||||||
toast.error(String(e));
|
toast.error(String(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
// stop loading
|
|
||||||
setLoading(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -36,7 +37,7 @@ export function AvatarUploader({
|
|||||||
onClick={() => uploadAvatar()}
|
onClick={() => uploadAvatar()}
|
||||||
className={cn("size-4", className)}
|
className={cn("size-4", className)}
|
||||||
>
|
>
|
||||||
{loading ? <LoaderIcon className="size-4 animate-spin" /> : children}
|
{loading ? <Spinner className="size-4" /> : children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import { User } from "@lume/ui";
|
import { User } from "@/components/user";
|
||||||
|
import { NostrAccount } from "@lume/system";
|
||||||
import { getBitcoinDisplayValues } from "@lume/utils";
|
import { getBitcoinDisplayValues } from "@lume/utils";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
export function Balance({ account }: { account: string }) {
|
export function Balance({ account }: { account: string }) {
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const [balance, setBalance] = useState(0);
|
const [balance, setBalance] = useState(0);
|
||||||
const value = useMemo(() => getBitcoinDisplayValues(balance), [balance]);
|
const value = useMemo(() => getBitcoinDisplayValues(balance), [balance]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function getBalance() {
|
async function getBalance() {
|
||||||
const val = await ark.get_balance();
|
const val = await NostrAccount.getBalance();
|
||||||
setBalance(val);
|
setBalance(val);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { LumeColumn } from "@lume/types";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { LoaderIcon } from "@lume/icons";
|
|
||||||
|
|
||||||
export function Col({
|
|
||||||
column,
|
|
||||||
account,
|
|
||||||
isScroll,
|
|
||||||
}: {
|
|
||||||
column: LumeColumn;
|
|
||||||
account: string;
|
|
||||||
isScroll: boolean;
|
|
||||||
}) {
|
|
||||||
const webview = useRef<string | undefined>(undefined);
|
|
||||||
const container = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const repositionWebview = async () => {
|
|
||||||
if (webview.current && webview.current.length > 1) {
|
|
||||||
const newRect = container.current.getBoundingClientRect();
|
|
||||||
await invoke("reposition_column", {
|
|
||||||
label: webview.current,
|
|
||||||
x: newRect.x,
|
|
||||||
y: newRect.y,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isScroll) {
|
|
||||||
repositionWebview();
|
|
||||||
}
|
|
||||||
}, [isScroll]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
const rect = container.current.getBoundingClientRect();
|
|
||||||
const windowLabel = `column-${column.label}`;
|
|
||||||
const url =
|
|
||||||
column.content +
|
|
||||||
`?account=${account}&label=${column.label}&name=${column.name}`;
|
|
||||||
|
|
||||||
// create new webview
|
|
||||||
webview.current = await invoke("create_column", {
|
|
||||||
label: windowLabel,
|
|
||||||
x: rect.x,
|
|
||||||
y: rect.y,
|
|
||||||
width: rect.width,
|
|
||||||
height: rect.height,
|
|
||||||
url,
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
// close webview when unmounted
|
|
||||||
return () => {
|
|
||||||
if (webview.current && webview.current.length > 1) {
|
|
||||||
invoke("close_column", {
|
|
||||||
label: webview.current,
|
|
||||||
}).then(() => {
|
|
||||||
webview.current = undefined;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={container} className="h-full w-[440px] shrink-0 p-2">
|
|
||||||
{column.label !== "open" ? (
|
|
||||||
<div className="w-full h-full flex items-center justify-center rounded-xl flex-col bg-black/5 dark:bg-white/5 backdrop-blur-lg">
|
|
||||||
<button type="button" className="size-5" disabled>
|
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
159
apps/desktop2/src/components/column.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
apps/desktop2/src/components/conversation.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
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,11 +1,10 @@
|
|||||||
import { PinIcon } from "@lume/icons";
|
import { VisitIcon } from "@lume/icons";
|
||||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useNoteContext } from "../provider";
|
import { useNoteContext } from "../provider";
|
||||||
|
import { LumeWindow } from "@lume/system";
|
||||||
|
|
||||||
export function NotePin() {
|
export function NoteOpenThread() {
|
||||||
const event = useNoteContext();
|
const event = useNoteContext();
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip.Provider>
|
<Tooltip.Provider>
|
||||||
@@ -13,15 +12,15 @@ export function NotePin() {
|
|||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Trigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex items-center justify-center gap-2 pl-2 pr-3 text-sm font-medium rounded-full h-7 w-max bg-neutral-100 hover:bg-neutral-200 dark:hover:bg-neutral-800 dark:bg-neutral-900"
|
onClick={() => 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"
|
||||||
>
|
>
|
||||||
<PinIcon className="size-4" />
|
<VisitIcon className="shrink-0 size-4" />
|
||||||
{t("note.buttons.pin")}
|
|
||||||
</button>
|
</button>
|
||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
<Tooltip.Portal>
|
<Tooltip.Portal>
|
||||||
<Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade">
|
<Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade">
|
||||||
{t("note.buttons.pinTooltip")}
|
Open
|
||||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||||
</Tooltip.Content>
|
</Tooltip.Content>
|
||||||
</Tooltip.Portal>
|
</Tooltip.Portal>
|
||||||
37
apps/desktop2/src/components/note/buttons/reply.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
apps/desktop2/src/components/note/buttons/repost.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
apps/desktop2/src/components/note/buttons/zap.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
apps/desktop2/src/components/note/child.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
apps/desktop2/src/components/note/content.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
apps/desktop2/src/components/note/contentLarge.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
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,14 +1,13 @@
|
|||||||
import { NotePin } from "./buttons/pin";
|
import { NoteOpenThread } from "./buttons/open";
|
||||||
import { NoteReaction } from "./buttons/reaction";
|
|
||||||
import { NoteReply } from "./buttons/reply";
|
import { NoteReply } from "./buttons/reply";
|
||||||
import { NoteRepost } from "./buttons/repost";
|
import { NoteRepost } from "./buttons/repost";
|
||||||
import { NoteZap } from "./buttons/zap";
|
import { NoteZap } from "./buttons/zap";
|
||||||
import { NoteChild } from "./child";
|
import { NoteChild } from "./child";
|
||||||
import { NoteContent } from "./content";
|
import { NoteContent } from "./content";
|
||||||
|
import { NoteContentLarge } from "./contentLarge";
|
||||||
import { NoteMenu } from "./menu";
|
import { NoteMenu } from "./menu";
|
||||||
import { NoteProvider } from "./provider";
|
import { NoteProvider } from "./provider";
|
||||||
import { NoteRoot } from "./root";
|
import { NoteRoot } from "./root";
|
||||||
import { NoteThread } from "./thread";
|
|
||||||
import { NoteUser } from "./user";
|
import { NoteUser } from "./user";
|
||||||
|
|
||||||
export const Note = {
|
export const Note = {
|
||||||
@@ -18,10 +17,9 @@ export const Note = {
|
|||||||
Menu: NoteMenu,
|
Menu: NoteMenu,
|
||||||
Reply: NoteReply,
|
Reply: NoteReply,
|
||||||
Repost: NoteRepost,
|
Repost: NoteRepost,
|
||||||
Reaction: NoteReaction,
|
|
||||||
Content: NoteContent,
|
Content: NoteContent,
|
||||||
|
ContentLarge: NoteContentLarge,
|
||||||
Zap: NoteZap,
|
Zap: NoteZap,
|
||||||
Pin: NotePin,
|
Open: NoteOpenThread,
|
||||||
Child: NoteChild,
|
Child: NoteChild,
|
||||||
Thread: NoteThread,
|
|
||||||
};
|
};
|
||||||
10
apps/desktop2/src/components/note/mentions/hashtag.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
apps/desktop2/src/components/note/mentions/note.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/desktop2/src/components/note/mentions/user.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
apps/desktop2/src/components/note/menu.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
apps/desktop2/src/components/note/preview/image.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
apps/desktop2/src/components/note/preview/images.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
apps/desktop2/src/components/note/preview/video.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
apps/desktop2/src/components/note/preview/videos.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Carousel, CarouselItem } from "@lume/ui";
|
||||||
|
|
||||||
|
export function Videos({ urls }: { urls: string[] }) {
|
||||||
|
if (urls.length === 1) {
|
||||||
|
return (
|
||||||
|
<div className="group px-3">
|
||||||
|
<video
|
||||||
|
className="w-full h-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||||
|
controls
|
||||||
|
muted
|
||||||
|
>
|
||||||
|
<source src={urls[0]} type="video/mp4" />
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Carousel
|
||||||
|
items={urls}
|
||||||
|
renderItem={({ item, isSnapPoint }) => (
|
||||||
|
<CarouselItem key={item} isSnapPoint={isSnapPoint}>
|
||||||
|
<video
|
||||||
|
className="w-full h-full object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||||
|
controls={false}
|
||||||
|
muted
|
||||||
|
>
|
||||||
|
<source src={item} type="video/mp4" />
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
</CarouselItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
apps/desktop2/src/components/note/provider.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
16
apps/desktop2/src/components/note/root.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export function NoteRoot({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn("h-min w-full", className)} contentEditable={false}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
apps/desktop2/src/components/note/user.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
apps/desktop2/src/components/quote.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
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,117 +1,81 @@
|
|||||||
import { RepostIcon } from "@lume/icons";
|
import { Spinner } from "@lume/ui";
|
||||||
import { Event } from "@lume/types";
|
import { Note } from "@/components/note";
|
||||||
|
import { User } from "@/components/user";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { type LumeEvent, NostrQuery } from "@lume/system";
|
||||||
import { Note, User } from "@lume/ui";
|
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export function RepostNote({
|
export function RepostNote({
|
||||||
event,
|
event,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
event: Event;
|
event: LumeEvent;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { ark, settings } = useRouteContext({ strict: false });
|
const { isLoading, isError, data } = useQuery({
|
||||||
const { t } = useTranslation();
|
queryKey: ["event", event.repostId],
|
||||||
const {
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
data: repostEvent,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["repost", event.id],
|
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
if (event.content.length > 50) {
|
const data = await NostrQuery.getRepostEvent(event);
|
||||||
const embed: Event = JSON.parse(event.content);
|
return data;
|
||||||
return embed;
|
} catch (e) {
|
||||||
}
|
throw new Error(e);
|
||||||
const id = event.tags.find((el) => el[0] === "e")[1];
|
|
||||||
return await ark.get_event(id);
|
|
||||||
} catch {
|
|
||||||
throw new Error("Failed to get repost event");
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
staleTime: Number.POSITIVE_INFINITY,
|
||||||
|
retry: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <div className="w-full px-3 pb-3">Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError || !repostEvent) {
|
|
||||||
return (
|
return (
|
||||||
<Note.Root
|
<Note.Root
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col gap-2 border-b border-neutral-100 px-3 py-5 dark:border-neutral-900",
|
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<User.Provider pubkey={event.pubkey}>
|
{isLoading ? (
|
||||||
<User.Root className="flex h-14 gap-2 px-3">
|
<div className="flex items-center justify-center h-20 gap-2">
|
||||||
<div className="inline-flex w-10 shrink-0 items-center justify-center">
|
<Spinner />
|
||||||
<RepostIcon className="h-5 w-5 text-blue-500" />
|
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||||
|
Loading event...
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-flex items-center gap-2">
|
) : isError || !data ? (
|
||||||
<User.Avatar className="size-6 shrink-0 rounded object-cover" />
|
<div className="flex items-center justify-center h-20">
|
||||||
<div className="inline-flex items-baseline gap-1">
|
Event not found within your current relay set
|
||||||
<User.Name className="font-medium text-neutral-900 dark:text-neutral-100" />
|
|
||||||
<span className="text-blue-500">{t("note.reposted")}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
</User.Root>
|
<Note.Provider event={data}>
|
||||||
</User.Provider>
|
<Note.Root>
|
||||||
<div className="mb-3 select-text px-3">
|
<div className="flex items-center justify-between px-3 h-14">
|
||||||
<div className="flex flex-col items-start justify-start rounded-lg bg-red-100 px-3 py-3 dark:bg-red-900">
|
|
||||||
<p className="text-red-500">Failed to get event</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Note.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Note.Root
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col gap-2 border-b border-neutral-100 px-3 py-5 dark:border-neutral-900",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<User.Provider pubkey={event.pubkey}>
|
|
||||||
<User.Root className="flex gap-3">
|
|
||||||
<div className="inline-flex w-11 shrink-0 items-center justify-center">
|
|
||||||
<RepostIcon className="h-5 w-5 text-blue-500" />
|
|
||||||
</div>
|
|
||||||
<div className="inline-flex items-center gap-2">
|
|
||||||
<User.Avatar className="size-6 shrink-0 rounded-full object-cover" />
|
|
||||||
<div className="inline-flex items-baseline gap-1">
|
|
||||||
<User.Name className="font-medium text-neutral-900 dark:text-neutral-100" />
|
|
||||||
<span className="text-blue-500">{t("note.reposted")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
<Note.Provider event={repostEvent}>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Note.User />
|
<Note.User />
|
||||||
<div className="flex gap-3">
|
|
||||||
<div className="size-11 shrink-0" />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<Note.Content />
|
|
||||||
<div className="mt-4 flex items-center justify-between">
|
|
||||||
<div className="-ml-1 inline-flex items-center gap-4">
|
|
||||||
<Note.Reply />
|
|
||||||
<Note.Repost />
|
|
||||||
{settings.zap ? <Note.Zap /> : null}
|
|
||||||
</div>
|
|
||||||
<Note.Menu />
|
<Note.Menu />
|
||||||
</div>
|
</div>
|
||||||
|
<Note.Content className="px-3" />
|
||||||
|
<div className="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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Note.Root>
|
||||||
</Note.Provider>
|
</Note.Provider>
|
||||||
|
)}
|
||||||
</Note.Root>
|
</Note.Root>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,32 @@
|
|||||||
import { Event } from "@lume/types";
|
|
||||||
import { Note } from "@lume/ui";
|
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
import { Note } from "@/components/note";
|
||||||
|
import type { LumeEvent } from "@lume/system";
|
||||||
|
|
||||||
export function TextNote({
|
export function TextNote({
|
||||||
event,
|
event,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
event: Event;
|
event: LumeEvent;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { settings } = useRouteContext({ strict: false });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Note.Provider event={event}>
|
<Note.Provider event={event}>
|
||||||
<Note.Root
|
<Note.Root
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col gap-2 border-b border-neutral-100 px-3 py-5 dark:border-neutral-900",
|
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<div className="flex items-center justify-between px-3 h-14">
|
||||||
<Note.User />
|
<Note.User />
|
||||||
<div className="flex gap-3">
|
|
||||||
<div className="size-11 shrink-0" />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<Note.Content className="mb-2" />
|
|
||||||
<Note.Thread />
|
|
||||||
<div className="mt-4 flex items-center justify-between">
|
|
||||||
<div className="-ml-1 inline-flex items-center gap-4">
|
|
||||||
<Note.Reply />
|
|
||||||
<Note.Repost />
|
|
||||||
{settings.zap ? <Note.Zap /> : null}
|
|
||||||
</div>
|
|
||||||
<Note.Menu />
|
<Note.Menu />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Note.Content className="px-3" />
|
||||||
|
<div className="flex items-center gap-4 px-3 mt-3 h-14">
|
||||||
|
<Note.Open />
|
||||||
|
<Note.Reply />
|
||||||
|
<Note.Repost />
|
||||||
|
<Note.Zap />
|
||||||
</div>
|
</div>
|
||||||
</Note.Root>
|
</Note.Root>
|
||||||
</Note.Provider>
|
</Note.Provider>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ReactNode } from "@tanstack/react-router";
|
import type { ReactNode } from "@tanstack/react-router";
|
||||||
import { useLayoutEffect, useState } from "react";
|
import { useLayoutEffect, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
|||||||
12
apps/desktop2/src/components/user/about.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { useUserContext } from "./provider";
|
||||||
|
|
||||||
|
export function UserAbout({ className }: { className?: string }) {
|
||||||
|
const user = useUserContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("content-break select-text", className)}>
|
||||||
|
{user.profile?.about?.trim() || "No bio"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
apps/desktop2/src/components/user/avatar.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ export function UserCover({ className }: { className?: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user && !user.profile.banner) {
|
if (user && !user.profile?.banner) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn("bg-gradient-to-b from-blue-400 to-teal-200", className)}
|
className={cn("bg-gradient-to-b from-blue-400 to-teal-200", className)}
|
||||||
@@ -25,7 +25,7 @@ export function UserCover({ className }: { className?: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={user.profile.banner}
|
src={user?.profile?.banner}
|
||||||
alt="banner"
|
alt="banner"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
60
apps/desktop2/src/components/user/followButton.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
apps/desktop2/src/components/user/name.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { cn, displayNpub } from "@lume/utils";
|
||||||
|
import { useUserContext } from "./provider";
|
||||||
|
|
||||||
|
export function UserName({
|
||||||
|
className,
|
||||||
|
prefix,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
prefix?: string;
|
||||||
|
}) {
|
||||||
|
const user = useUserContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("max-w-[12rem] truncate", className)}>
|
||||||
|
{prefix}
|
||||||
|
{user.profile?.display_name ||
|
||||||
|
user.profile?.name ||
|
||||||
|
displayNpub(user.pubkey, 16)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
apps/desktop2/src/components/user/nip05.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
apps/desktop2/src/components/user/provider.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
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,5 +1,5 @@
|
|||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
export function UserRoot({
|
export function UserRoot({
|
||||||
children,
|
children,
|
||||||
18
apps/desktop2/src/components/user/time.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { cn, formatCreatedAt } from "@lume/utils";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
export function UserTime({
|
||||||
|
time,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
time: number;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const createdAt = useMemo(() => formatCreatedAt(time), [time]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("text-neutral-600 dark:text-neutral-400", className)}>
|
||||||
|
{createdAt}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,155 +1,187 @@
|
|||||||
import { Col } from "@/components/col";
|
import { Column } from "@/components/column";
|
||||||
import { Toolbar } from "@/components/toolbar";
|
import { Toolbar } from "@/components/toolbar";
|
||||||
import { ArrowLeftIcon, ArrowRightIcon, LoaderIcon } from "@lume/icons";
|
import { ArrowLeftIcon, ArrowRightIcon, PlusSquareIcon } from "@lume/icons";
|
||||||
import { EventColumns, LumeColumn } from "@lume/types";
|
import { NostrQuery } from "@lume/system";
|
||||||
|
import type { ColumnEvent, LumeColumn } from "@lume/types";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { resolveResource } from "@tauri-apps/api/path";
|
import { getCurrent } from "@tauri-apps/api/window";
|
||||||
import { readTextFile } from "@tauri-apps/plugin-fs";
|
import useEmblaCarousel from "embla-carousel-react";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
import { VList, VListHandle } from "virtua";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/$account/home")({
|
export const Route = createFileRoute("/$account/home")({
|
||||||
component: Screen,
|
loader: async () => {
|
||||||
pendingComponent: Pending,
|
const columns = await NostrQuery.getColumns();
|
||||||
beforeLoad: async ({ context }) => {
|
return columns;
|
||||||
const ark = context.ark;
|
|
||||||
const resourcePath = await resolveResource("resources/system_columns.json");
|
|
||||||
const systemColumns: LumeColumn[] = JSON.parse(
|
|
||||||
await readTextFile(resourcePath),
|
|
||||||
);
|
|
||||||
const userColumns = await ark.get_columns();
|
|
||||||
|
|
||||||
return {
|
|
||||||
storedColumns: !userColumns.length ? systemColumns : userColumns,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const { account } = Route.useParams();
|
const { account } = Route.useParams();
|
||||||
const { ark, storedColumns } = Route.useRouteContext();
|
const initialColumnList = Route.useLoaderData();
|
||||||
|
|
||||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
const [columns, setColumns] = useState<LumeColumn[]>([]);
|
||||||
const [isScroll, setIsScroll] = useState(false);
|
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||||
const [columns, setColumns] = useState(storedColumns);
|
watchDrag: false,
|
||||||
|
loop: false,
|
||||||
const vlistRef = useRef<VListHandle>(null);
|
|
||||||
|
|
||||||
const goLeft = () => {
|
|
||||||
const prevIndex = Math.max(selectedIndex - 1, 0);
|
|
||||||
setSelectedIndex(prevIndex);
|
|
||||||
vlistRef.current.scrollToIndex(prevIndex, {
|
|
||||||
align: "center",
|
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const goRight = () => {
|
const scrollPrev = useCallback(() => {
|
||||||
const nextIndex = Math.min(selectedIndex + 1, columns.length - 1);
|
if (emblaApi) emblaApi.scrollPrev(true);
|
||||||
setSelectedIndex(nextIndex);
|
}, [emblaApi]);
|
||||||
vlistRef.current.scrollToIndex(nextIndex, {
|
|
||||||
align: "center",
|
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) => {
|
const add = useDebouncedCallback((column: LumeColumn) => {
|
||||||
column["label"] = column.label + "-" + nanoid();
|
column.label = `${column.label}-${nanoid()}`; // update col label
|
||||||
|
setColumns((prev) => [column, ...prev]);
|
||||||
setColumns((state) => [...state, column]);
|
|
||||||
setSelectedIndex(columns.length + 1);
|
|
||||||
|
|
||||||
// scroll to the last column
|
|
||||||
vlistRef.current.scrollToIndex(columns.length + 1, {
|
|
||||||
align: "end",
|
|
||||||
});
|
|
||||||
}, 150);
|
}, 150);
|
||||||
|
|
||||||
const remove = useDebouncedCallback((label: string) => {
|
const remove = useDebouncedCallback((label: string) => {
|
||||||
setColumns((state) => state.filter((t) => t.label !== label));
|
setColumns((prev) => prev.filter((t) => t.label !== label));
|
||||||
setSelectedIndex(columns.length - 1);
|
}, 150);
|
||||||
|
|
||||||
// scroll to the first column
|
const updateName = useDebouncedCallback((label: string, title: string) => {
|
||||||
vlistRef.current.scrollToIndex(0, {
|
const currentColIndex = columns.findIndex((col) => col.label === label);
|
||||||
align: "start",
|
|
||||||
});
|
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);
|
}, 150);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// save state
|
if (emblaApi) {
|
||||||
ark.set_columns(columns);
|
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]);
|
}, [columns]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unlisten: Awaited<ReturnType<typeof listen>> | undefined = undefined;
|
setColumns(initialColumnList);
|
||||||
|
}, [initialColumnList]);
|
||||||
|
|
||||||
(async () => {
|
// Listen for keyboard event
|
||||||
if (unlisten) return;
|
useEffect(() => {
|
||||||
unlisten = await listen<EventColumns>("columns", (data) => {
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
if (data.payload.type === "add") add(data.payload.column);
|
|
||||||
if (data.payload.type === "remove") remove(data.payload.label);
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (unlisten) unlisten();
|
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 (
|
return (
|
||||||
<div className="h-full w-full">
|
<div className="size-full">
|
||||||
<VList
|
<div ref={emblaRef} className="overflow-hidden size-full">
|
||||||
ref={vlistRef}
|
<div className="flex size-full">
|
||||||
horizontal
|
{columns?.map((column) => (
|
||||||
tabIndex={-1}
|
<Column
|
||||||
itemSize={440}
|
key={account + column.label}
|
||||||
overscan={3}
|
|
||||||
onScroll={() => {
|
|
||||||
setIsScroll(true);
|
|
||||||
}}
|
|
||||||
onScrollEnd={() => {
|
|
||||||
setIsScroll(false);
|
|
||||||
}}
|
|
||||||
className="scrollbar-none h-full w-full overflow-x-auto focus:outline-none"
|
|
||||||
>
|
|
||||||
{columns.map((column, index) => (
|
|
||||||
<Col
|
|
||||||
key={column.label + index}
|
|
||||||
column={column}
|
column={column}
|
||||||
account={account}
|
account={account}
|
||||||
isScroll={isScroll}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</VList>
|
</div>
|
||||||
|
</div>
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center h-8 gap-1 p-[2px] rounded-full bg-black/5 dark:bg-white/5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => goLeft()}
|
onClick={() => scrollPrev()}
|
||||||
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-neutral-200 dark:text-neutral-200 dark:hover:bg-neutral-800"
|
className="inline-flex 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-5" />
|
<ArrowLeftIcon className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => goRight()}
|
onClick={() => openLumeStore()}
|
||||||
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-neutral-200 dark:text-neutral-200 dark:hover:bg-neutral-800"
|
className="inline-flex 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-5" />
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Pending() {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<button type="button" className="size-5" disabled>
|
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,23 +1,32 @@
|
|||||||
import { ComposeFilledIcon, PlusIcon } from "@lume/icons";
|
import { User } from "@/components/user";
|
||||||
import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router";
|
import {
|
||||||
|
ComposeFilledIcon,
|
||||||
|
HorizontalDotsIcon,
|
||||||
|
PlusIcon,
|
||||||
|
SearchIcon,
|
||||||
|
} from "@lume/icons";
|
||||||
|
import { LumeWindow, NostrAccount } from "@lume/system";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { Accounts } from "@/components/accounts";
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
import { platform } from "@tauri-apps/plugin-os";
|
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")({
|
export const Route = createFileRoute("/$account")({
|
||||||
component: App,
|
|
||||||
beforeLoad: async () => {
|
beforeLoad: async () => {
|
||||||
const platformName = await platform();
|
const accounts = await NostrAccount.getAccounts();
|
||||||
return { platform: platformName };
|
return { accounts };
|
||||||
},
|
},
|
||||||
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function App() {
|
function Screen() {
|
||||||
const navigate = useNavigate();
|
const { platform } = Route.useRouteContext();
|
||||||
const { ark, platform } = Route.useRouteContext();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-screen flex-col">
|
<div className="flex flex-col w-screen h-screen">
|
||||||
<div
|
<div
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -27,19 +36,18 @@ function App() {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Accounts />
|
<Accounts />
|
||||||
<button
|
<Link
|
||||||
type="button"
|
to="/landing"
|
||||||
onClick={() => navigate({ 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"
|
||||||
className="inline-flex size-8 items-center justify-center rounded-full bg-neutral-200 text-neutral-800 hover:bg-neutral-400 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:bg-neutral-600"
|
|
||||||
>
|
>
|
||||||
<PlusIcon className="size-5" />
|
<PlusIcon className="size-5" />
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => ark.open_editor()}
|
onClick={() => LumeWindow.openEditor()}
|
||||||
className="inline-flex h-8 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600"
|
className="inline-flex 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" />
|
<ComposeFilledIcon className="size-4" />
|
||||||
New Post
|
New Post
|
||||||
@@ -53,3 +61,126 @@ function App() {
|
|||||||
</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,32 +1,47 @@
|
|||||||
import { LoaderIcon } from "@lume/icons";
|
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 { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
|
||||||
import { type Ark } from "@lume/ark";
|
import type { Platform } from "@tauri-apps/plugin-os";
|
||||||
import { type QueryClient } from "@tanstack/react-query";
|
import { Toaster } from "sonner";
|
||||||
import { type Platform } from "@tauri-apps/plugin-os";
|
|
||||||
import { Account, Interests, Settings } from "@lume/types";
|
|
||||||
|
|
||||||
interface RouterContext {
|
interface RouterContext {
|
||||||
ark: Ark;
|
// System
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
|
// App info
|
||||||
platform?: Platform;
|
platform?: Platform;
|
||||||
locale?: string;
|
locale?: string;
|
||||||
|
// Settings
|
||||||
settings?: Settings;
|
settings?: Settings;
|
||||||
interests?: Interests;
|
// Accounts
|
||||||
accounts?: Account[];
|
accounts?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||||
component: () => <Outlet />,
|
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,
|
pendingComponent: Pending,
|
||||||
wrapInSuspense: true,
|
wrapInSuspense: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Pending() {
|
function Pending() {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-screen flex-col items-center justify-center">
|
<div className="flex flex-col items-center justify-center w-screen h-screen">
|
||||||
<button type="button" className="size-5" disabled>
|
<Spinner className="size-5" />
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
import { RepostNote } from "@/components/repost";
|
|
||||||
import { TextNote } from "@/components/text";
|
|
||||||
import { LoaderIcon, ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
|
||||||
import { ColumnRouteSearch, Event, Kind } from "@lume/types";
|
|
||||||
import { Column } from "@lume/ui";
|
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
||||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { Virtualizer } from "virtua";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/antenas")({
|
|
||||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
|
||||||
return {
|
|
||||||
account: search.account,
|
|
||||||
label: search.label,
|
|
||||||
name: search.name,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function Screen() {
|
|
||||||
const { label, name, account } = Route.useSearch();
|
|
||||||
const { ark } = Route.useRouteContext();
|
|
||||||
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
|
||||||
useInfiniteQuery({
|
|
||||||
queryKey: [name, account],
|
|
||||||
initialPageParam: 0,
|
|
||||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
|
||||||
const events = await ark.get_events(20, pageParam);
|
|
||||||
return events;
|
|
||||||
},
|
|
||||||
getNextPageParam: (lastPage) => {
|
|
||||||
const lastEvent = lastPage?.at(-1);
|
|
||||||
return lastEvent ? lastEvent.created_at - 1 : null;
|
|
||||||
},
|
|
||||||
select: (data) => data?.pages.flatMap((page) => page),
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderItem = (event: Event) => {
|
|
||||||
if (!event) return;
|
|
||||||
switch (event.kind) {
|
|
||||||
case Kind.Repost:
|
|
||||||
return <RepostNote key={event.id} event={event} />;
|
|
||||||
default:
|
|
||||||
return <TextNote key={event.id} event={event} />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Column.Root>
|
|
||||||
<Column.Header label={label} name={name} />
|
|
||||||
<Column.Content>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : !data.length ? (
|
|
||||||
<Empty />
|
|
||||||
) : (
|
|
||||||
<Virtualizer overscan={3}>
|
|
||||||
{data.map((item) => renderItem(item))}
|
|
||||||
</Virtualizer>
|
|
||||||
)}
|
|
||||||
<div className="flex h-20 items-center justify-center">
|
|
||||||
{hasNextPage ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => fetchNextPage()}
|
|
||||||
disabled={!hasNextPage || isFetchingNextPage}
|
|
||||||
className="inline-flex h-12 w-36 items-center justify-center gap-2 rounded-full bg-neutral-100 px-3 font-medium hover:bg-neutral-200 focus:outline-none dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
|
||||||
>
|
|
||||||
{isFetchingNextPage ? (
|
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ArrowRightCircleIcon className="size-5" />
|
|
||||||
Load more
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</Column.Content>
|
|
||||||
</Column.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Empty() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col py-10 gap-10">
|
|
||||||
<div className="text-center flex flex-col items-center justify-center">
|
|
||||||
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8">
|
|
||||||
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" />
|
|
||||||
</div>
|
|
||||||
<p className="text-lg font-medium">Your newsfeed is empty</p>
|
|
||||||
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
|
||||||
Here are few suggestions to get started.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col px-3 gap-2">
|
|
||||||
<Link
|
|
||||||
to="/trending/notes"
|
|
||||||
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
|
|
||||||
>
|
|
||||||
<ArrowRightIcon className="size-5" />
|
|
||||||
Show trending notes
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/trending/users"
|
|
||||||
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
|
|
||||||
>
|
|
||||||
<ArrowRightIcon className="size-5" />
|
|
||||||
Discover trending users
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
191
apps/desktop2/src/routes/auth/$account.backup.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
apps/desktop2/src/routes/auth/create-profile.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
apps/desktop2/src/routes/auth/import.lazy.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
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,191 +0,0 @@
|
|||||||
import { displayNsec } from "@lume/utils";
|
|
||||||
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";
|
|
||||||
import * as Checkbox from "@radix-ui/react-checkbox";
|
|
||||||
import { CheckIcon } from "@lume/icons";
|
|
||||||
import { AppRouteSearch } from "@lume/types";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/auth/new/backup")({
|
|
||||||
validateSearch: (search: Record<string, string>): AppRouteSearch => {
|
|
||||||
return {
|
|
||||||
account: search.account,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const { account } = Route.useSearch();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const [key, setKey] = useState(null);
|
|
||||||
const [passphase, setPassphase] = useState("");
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const [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");
|
|
||||||
} else {
|
|
||||||
return navigate({
|
|
||||||
to: "/auth/settings",
|
|
||||||
search: { account },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const encrypted: string = await invoke("get_encrypted_key", {
|
|
||||||
npub: account,
|
|
||||||
password: passphase,
|
|
||||||
});
|
|
||||||
|
|
||||||
setKey(encrypted);
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(String(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyKey = async () => {
|
|
||||||
try {
|
|
||||||
await writeText(key);
|
|
||||||
setCopied(true);
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
|
|
||||||
<div className="flex flex-col text-center">
|
|
||||||
<h3 className="text-xl font-semibold">Backup your sign in keys</h3>
|
|
||||||
<p className="text-neutral-700 dark:text-neutral-300">
|
|
||||||
It's use for login to Lume or other Nostr clients. You will lost
|
|
||||||
access to your account if you lose this key.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full flex-col gap-5">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<label htmlFor="passphase" className="font-medium">
|
|
||||||
Set a passphase to secure your key
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
name="passphase"
|
|
||||||
type="password"
|
|
||||||
value={passphase}
|
|
||||||
onChange={(e) => setPassphase(e.target.value)}
|
|
||||||
className="h-11 w-full resize-none rounded-lg border-transparent bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900"
|
|
||||||
/>
|
|
||||||
</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="h-11 w-full resize-none rounded-lg border-transparent bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={copyKey}
|
|
||||||
className="inline-flex h-11 w-24 items-center justify-center rounded-lg bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
|
|
||||||
>
|
|
||||||
{copied ? "Copied" : "Copy"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="font-medium">Before you continue:</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox.Root
|
|
||||||
checked={confirm.c1}
|
|
||||||
onCheckedChange={() =>
|
|
||||||
setConfirm((state) => ({ ...state, c1: !state.c1 }))
|
|
||||||
}
|
|
||||||
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-neutral-900"
|
|
||||||
id="confirm1"
|
|
||||||
>
|
|
||||||
<Checkbox.Indicator className="text-blue-500">
|
|
||||||
<CheckIcon className="size-4" />
|
|
||||||
</Checkbox.Indicator>
|
|
||||||
</Checkbox.Root>
|
|
||||||
<label
|
|
||||||
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
|
|
||||||
htmlFor="confirm1"
|
|
||||||
>
|
|
||||||
{t("backup.confirm1")}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox.Root
|
|
||||||
checked={confirm.c2}
|
|
||||||
onCheckedChange={() =>
|
|
||||||
setConfirm((state) => ({ ...state, c2: !state.c2 }))
|
|
||||||
}
|
|
||||||
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-neutral-900"
|
|
||||||
id="confirm2"
|
|
||||||
>
|
|
||||||
<Checkbox.Indicator className="text-blue-500">
|
|
||||||
<CheckIcon className="size-4" />
|
|
||||||
</Checkbox.Indicator>
|
|
||||||
</Checkbox.Root>
|
|
||||||
<label
|
|
||||||
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
|
|
||||||
htmlFor="confirm2"
|
|
||||||
>
|
|
||||||
{t("backup.confirm2")}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox.Root
|
|
||||||
checked={confirm.c3}
|
|
||||||
onCheckedChange={() =>
|
|
||||||
setConfirm((state) => ({ ...state, c3: !state.c3 }))
|
|
||||||
}
|
|
||||||
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-neutral-900"
|
|
||||||
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}
|
|
||||||
className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{t("global.continue")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
import { AvatarUploader } from "@/components/avatarUploader";
|
|
||||||
import { LoaderIcon, PlusIcon } from "@lume/icons";
|
|
||||||
import { Metadata } from "@lume/types";
|
|
||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/auth/new/profile")({
|
|
||||||
component: Screen,
|
|
||||||
loader: ({ context }) => {
|
|
||||||
return context.ark.create_keys();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const keys = Route.useLoaderData();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { ark } = Route.useRouteContext();
|
|
||||||
const { register, handleSubmit } = useForm();
|
|
||||||
|
|
||||||
const [picture, setPicture] = useState<string>("");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const onSubmit = async (data: {
|
|
||||||
name: string;
|
|
||||||
about: string;
|
|
||||||
website: string;
|
|
||||||
}) => {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Save account keys
|
|
||||||
const save = await ark.save_account(keys.nsec);
|
|
||||||
|
|
||||||
// Then create profile
|
|
||||||
if (save) {
|
|
||||||
const profile: Metadata = { ...data, picture };
|
|
||||||
const eventId = await ark.create_profile(profile);
|
|
||||||
|
|
||||||
if (eventId) {
|
|
||||||
navigate({
|
|
||||||
to: "/auth/new/backup",
|
|
||||||
search: { account: keys.npub },
|
|
||||||
replace: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setLoading(false);
|
|
||||||
toast.error(String(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-xl font-semibold">Let's set up your profile.</h3>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="relative size-24 rounded-full bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200">
|
|
||||||
{picture ? (
|
|
||||||
<img
|
|
||||||
src={picture}
|
|
||||||
alt="avatar"
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
className="absolute inset-0 z-10 h-full w-full rounded-full object-cover"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<AvatarUploader
|
|
||||||
setPicture={setPicture}
|
|
||||||
className="absolute inset-0 z-20 flex h-full w-full items-center justify-center rounded-full bg-black/10 text-white hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
|
||||||
>
|
|
||||||
<PlusIcon className="size-8" />
|
|
||||||
</AvatarUploader>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
|
||||||
className="flex w-full flex-col gap-3"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label htmlFor="display_name" className="font-medium">
|
|
||||||
{t("user.displayName")} *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type={"text"}
|
|
||||||
{...register("display_name", { required: true, minLength: 1 })}
|
|
||||||
placeholder="e.g. Alice in Nostrland"
|
|
||||||
spellCheck={false}
|
|
||||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label htmlFor="name" className="font-medium">
|
|
||||||
{t("user.name")}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type={"text"}
|
|
||||||
{...register("name")}
|
|
||||||
placeholder="e.g. alice"
|
|
||||||
spellCheck={false}
|
|
||||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
|
||||||
/>
|
|
||||||
</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 focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label htmlFor="website" className="font-medium">
|
|
||||||
{t("user.website")}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
{...register("website")}
|
|
||||||
placeholder="e.g. https://alice.me"
|
|
||||||
spellCheck={false}
|
|
||||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<LoaderIcon className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
t("global.continue")
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { LoaderIcon } from "@lume/icons";
|
|
||||||
import { createLazyFileRoute, useNavigate } from "@tanstack/react-router";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/auth/privkey")({
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const { ark } = Route.useRouteContext();
|
|
||||||
const navigate = 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",
|
|
||||||
);
|
|
||||||
if (key.length < 30)
|
|
||||||
return toast.warning("You need to enter a valid private key");
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const npub = await ark.save_account(key, password);
|
|
||||||
navigate({
|
|
||||||
to: "/auth/settings",
|
|
||||||
search: { account: npub, new: false },
|
|
||||||
replace: true,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-xl font-semibold">Continue with Private Key</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full flex-col gap-3">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label
|
|
||||||
htmlFor="key"
|
|
||||||
className="font-medium text-neutral-900 dark:text-neutral-100"
|
|
||||||
>
|
|
||||||
Private Key
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
name="key"
|
|
||||||
type="text"
|
|
||||||
placeholder="nsec or ncryptsec..."
|
|
||||||
value={key}
|
|
||||||
onChange={(e) => setKey(e.target.value)}
|
|
||||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label
|
|
||||||
htmlFor="password"
|
|
||||||
className="font-medium text-neutral-900 dark:text-neutral-100"
|
|
||||||
>
|
|
||||||
Password (Optional)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={submit}
|
|
||||||
disabled={loading}
|
|
||||||
className="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? <LoaderIcon className="size-4 animate-spin" /> : "Login"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,77 @@
|
|||||||
|
import { NostrAccount } from "@lume/system";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/auth/remote")({
|
export const Route = createLazyFileRoute("/auth/remote")({
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
return <div>#todo</div>;
|
const 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,188 +0,0 @@
|
|||||||
import { LaurelIcon, LoaderIcon } from "@lume/icons";
|
|
||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import * as Switch from "@radix-ui/react-switch";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { AppRouteSearch, Settings } from "@lume/types";
|
|
||||||
import {
|
|
||||||
isPermissionGranted,
|
|
||||||
requestPermission,
|
|
||||||
} from "@tauri-apps/plugin-notification";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/auth/settings")({
|
|
||||||
validateSearch: (search: Record<string, string>): AppRouteSearch => {
|
|
||||||
return {
|
|
||||||
account: search.account,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
beforeLoad: async ({ context }) => {
|
|
||||||
const permissionGranted = await isPermissionGranted(); // get notification permission
|
|
||||||
const ark = context.ark;
|
|
||||||
const settings = await ark.get_settings();
|
|
||||||
|
|
||||||
return {
|
|
||||||
settings: { ...settings, notification: permissionGranted },
|
|
||||||
};
|
|
||||||
},
|
|
||||||
component: Screen,
|
|
||||||
pendingComponent: Pending,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const { account } = Route.useSearch();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { ark, settings } = Route.useRouteContext();
|
|
||||||
|
|
||||||
const [newSettings, setNewSettings] = useState<Settings>(settings);
|
|
||||||
|
|
||||||
const toggleNofitication = async () => {
|
|
||||||
await requestPermission();
|
|
||||||
setNewSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
notification: !newSettings.notification,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleAutoUpdate = () => {
|
|
||||||
setNewSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
autoUpdate: !newSettings.autoUpdate,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleEnhancedPrivacy = () => {
|
|
||||||
setNewSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
enhancedPrivacy: !newSettings.enhancedPrivacy,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleZap = () => {
|
|
||||||
setNewSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
zap: !newSettings.zap,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
try {
|
|
||||||
const eventId = await ark.set_settings(settings);
|
|
||||||
if (eventId) {
|
|
||||||
navigate({ to: "/$account/home", params: { account }, replace: true });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
|
|
||||||
<div className="flex flex-col items-center gap-5 text-center">
|
|
||||||
<div className="flex size-20 items-center justify-center rounded-full bg-teal-100 text-teal-500">
|
|
||||||
<LaurelIcon className="size-8" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-semibold">
|
|
||||||
{t("onboardingSettings.title")}
|
|
||||||
</h1>
|
|
||||||
<p className="leading-snug text-neutral-600 dark:text-neutral-400">
|
|
||||||
{t("onboardingSettings.subtitle")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-5">
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900">
|
|
||||||
<Switch.Root
|
|
||||||
checked={newSettings.notification}
|
|
||||||
onClick={() => toggleNofitication()}
|
|
||||||
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
|
|
||||||
>
|
|
||||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
|
||||||
</Switch.Root>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-semibold">Push Notification</h3>
|
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
||||||
Enabling push notifications will allow you to receive
|
|
||||||
notifications from Lume.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900">
|
|
||||||
<Switch.Root
|
|
||||||
checked={newSettings.enhancedPrivacy}
|
|
||||||
onClick={() => toggleEnhancedPrivacy()}
|
|
||||||
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
|
|
||||||
>
|
|
||||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
|
||||||
</Switch.Root>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-semibold">Enhanced Privacy</h3>
|
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
||||||
Lume will display external resources like image, video or link
|
|
||||||
preview as plain text.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900">
|
|
||||||
<Switch.Root
|
|
||||||
checked={newSettings.autoUpdate}
|
|
||||||
onClick={() => toggleAutoUpdate()}
|
|
||||||
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
|
|
||||||
>
|
|
||||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
|
||||||
</Switch.Root>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-semibold">Auto Update</h3>
|
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
||||||
Automatically download and install new version.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900">
|
|
||||||
<Switch.Root
|
|
||||||
checked={newSettings.zap}
|
|
||||||
onClick={() => toggleZap()}
|
|
||||||
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
|
|
||||||
>
|
|
||||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
|
||||||
</Switch.Root>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-semibold">Zap</h3>
|
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
||||||
Show the Zap button in each note and user's profile screen, use
|
|
||||||
for send Bitcoin tip to other users.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-50 px-5 py-4 dark:bg-neutral-950">
|
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
||||||
There are many more settings you can configure from the 'Settings'
|
|
||||||
Screen. Be sure to visit it later.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={submit}
|
|
||||||
className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{t("global.continue")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Pending() {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<button type="button" className="size-5" disabled>
|
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
132
apps/desktop2/src/routes/bootstrap-relays.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
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,9 +1,11 @@
|
|||||||
import { CheckCircleIcon } from "@lume/icons";
|
import { CancelIcon, PlusIcon } from "@lume/icons";
|
||||||
import { ColumnRouteSearch } from "@lume/types";
|
import type { ColumnRouteSearch } from "@lume/types";
|
||||||
import { Column, User } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
import { User } from "@/components/user";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { NostrAccount, NostrQuery } from "@lume/system";
|
||||||
|
|
||||||
export const Route = createFileRoute("/create-group")({
|
export const Route = createFileRoute("/create-group")({
|
||||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||||
@@ -13,105 +15,184 @@ export const Route = createFileRoute("/create-group")({
|
|||||||
name: search.name,
|
name: search.name,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
loader: async ({ context }) => {
|
loader: async () => {
|
||||||
const ark = context.ark;
|
const contacts = await NostrAccount.getContactList();
|
||||||
const contacts = await ark.get_contact_list();
|
|
||||||
return contacts;
|
return contacts;
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function 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 contacts = Route.useLoaderData();
|
||||||
const router = useRouter();
|
const search = Route.useSearch();
|
||||||
|
const navigate = Route.useNavigate();
|
||||||
const { ark } = Route.useRouteContext();
|
|
||||||
const { label, name, redirect } = Route.useSearch();
|
|
||||||
|
|
||||||
const [title, setTitle] = useState<string>("Just a new group");
|
|
||||||
const [users, setUsers] = useState<Array<string>>([]);
|
|
||||||
const [isDone, setIsDone] = useState(false);
|
|
||||||
|
|
||||||
const toggleUser = (pubkey: string) => {
|
const toggleUser = (pubkey: string) => {
|
||||||
const arr = users.includes(pubkey)
|
setUsers((prev) =>
|
||||||
? users.filter((i) => i !== pubkey)
|
prev.includes(pubkey)
|
||||||
: [...users, pubkey];
|
? prev.filter((i) => i !== pubkey)
|
||||||
setUsers(arr);
|
: [...prev, pubkey],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addUser = () => {
|
||||||
|
if (!npub.startsWith("npub1")) return;
|
||||||
|
if (users.includes(npub)) return;
|
||||||
|
|
||||||
|
setUsers((prev) => [...prev, npub]);
|
||||||
|
setNpub("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
try {
|
try {
|
||||||
if (isDone) return router.history.push(redirect);
|
setIsLoading(true);
|
||||||
|
|
||||||
const groups = await ark.set_nstore(
|
const key = `lume_group_${search.label}`;
|
||||||
`lume_group_${label}`,
|
const createGroup = await NostrQuery.setNstore(
|
||||||
|
key,
|
||||||
JSON.stringify(users),
|
JSON.stringify(users),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (groups) setIsDone(true);
|
if (createGroup) {
|
||||||
|
return navigate({ to: search.redirect, search: { ...search } });
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
setIsLoading(false);
|
||||||
toast.error(e);
|
toast.error(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column.Root>
|
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
|
||||||
<Column.Header label={label} name={name} />
|
<div className="text-center flex flex-col items-center justify-center">
|
||||||
<Column.Content>
|
<h1 className="text-2xl font-serif font-medium">
|
||||||
<div className="flex flex-col gap-5 p-3">
|
Focus feeds for people you like
|
||||||
<div className="flex flex-col gap-1">
|
</h1>
|
||||||
<label htmlFor="name" className="font-medium">
|
<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
|
Name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
name="name"
|
name="name"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="Nostrichs..."
|
placeholder="Enter a name for this group"
|
||||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
className="h-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>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="w-full flex flex-col items-center gap-3">
|
||||||
<div className="inline-flex items-center justify-between">
|
<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">
|
||||||
<span className="font-medium">Pick user</span>
|
<div className="flex gap-2">
|
||||||
<span className="text-xs text-neutral-600 dark:text-neutral-400">{`${users.length} / ∞`}</span>
|
<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>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{contacts.map((item: string) => (
|
<span className="text-sm font-semibold">Added</span>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{users.length ? (
|
||||||
|
users.map((item: string) => (
|
||||||
<button
|
<button
|
||||||
key={item}
|
key={item}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleUser(item)}
|
onClick={() => toggleUser(item)}
|
||||||
className="inline-flex items-center justify-between px-3 py-2 rounded-xl bg-neutral-50 dark:bg-neutral-950 hover:bg-neutral-100 dark:hover:bg-neutral-900"
|
className="inline-flex items-center justify-between px-3 py-2 rounded-lg bg-white dark:bg-black/20 backdrop-blur-lg shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||||
>
|
>
|
||||||
<User.Provider pubkey={item}>
|
<User.Provider pubkey={item}>
|
||||||
<User.Root className="flex items-center gap-2.5">
|
<User.Root className="flex items-center gap-2.5">
|
||||||
<User.Avatar className="size-10 rounded-full object-cover" />
|
<User.Avatar className="size-8 rounded-full object-cover" />
|
||||||
<div className="flex flex-col items-start">
|
<div className="flex items-center gap-1">
|
||||||
<User.Name className="font-medium" />
|
<User.Name className="text-sm font-medium" />
|
||||||
<User.NIP05 className="text-neutral-700 dark:text-neutral-300" />
|
|
||||||
</div>
|
</div>
|
||||||
</User.Root>
|
</User.Root>
|
||||||
</User.Provider>
|
</User.Provider>
|
||||||
{users.includes(item) ? (
|
<div>
|
||||||
<CheckCircleIcon className="size-5 text-teal-500" />
|
<CancelIcon className="size-4" />
|
||||||
) : null}
|
</div>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="fixed z-10 flex items-center justify-center w-full bottom-3">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={submit}
|
onClick={() => submit()}
|
||||||
disabled={users.length < 1}
|
disabled={isLoading || users.length < 1}
|
||||||
className="inline-flex items-center justify-center px-4 font-medium text-white transform bg-blue-500 rounded-full active:translate-y-1 w-36 h-11 hover:bg-blue-600 focus:outline-none disabled:cursor-not-allowed"
|
className="inline-flex items-center justify-center w-36 rounded-full h-9 bg-blue-500 text-white text-sm font-medium hover:bg-blue-600 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isDone ? "Back" : "Update"}
|
{isLoading ? <Spinner /> : "Confirm"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Column.Content>
|
</div>
|
||||||
</Column.Root>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
86
apps/desktop2/src/routes/create-newsfeed.f2f.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
apps/desktop2/src/routes/create-newsfeed.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
apps/desktop2/src/routes/create-newsfeed.users.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { Suspense, useState } from "react";
|
||||||
|
import { Await, defer } from "@tanstack/react-router";
|
||||||
|
import { User } from "@/components/user";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type { ColumnRouteSearch } from "@lume/types";
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
apps/desktop2/src/routes/create-topic.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
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,28 +1,26 @@
|
|||||||
import { AddMediaIcon, LoaderIcon } from "@lume/icons";
|
import { AddMediaIcon } from "@lume/icons";
|
||||||
import { cn, insertImage, isImagePath } from "@lume/utils";
|
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 { useEffect, useState } from "react";
|
||||||
import { useSlateStatic } from "slate-react";
|
import { useSlateStatic } from "slate-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getCurrent } from "@tauri-apps/api/window";
|
|
||||||
import { UnlistenFn } from "@tauri-apps/api/event";
|
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export function MediaButton({ className }: { className?: string }) {
|
export function MediaButton() {
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const editor = useSlateStatic();
|
const editor = useSlateStatic();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const uploadToNostrBuild = async () => {
|
const upload = async () => {
|
||||||
try {
|
try {
|
||||||
|
// start loading
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const image = await ark.upload();
|
const image = await NostrQuery.upload();
|
||||||
|
|
||||||
if (image) {
|
|
||||||
insertImage(editor, image);
|
insertImage(editor, image);
|
||||||
}
|
|
||||||
|
|
||||||
|
// reset loading
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -44,7 +42,7 @@ export function MediaButton({ className }: { className?: string }) {
|
|||||||
// upload all images
|
// upload all images
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (isImagePath(item)) {
|
if (isImagePath(item)) {
|
||||||
const image = await ark.upload(item);
|
const image = await NostrQuery.upload(item);
|
||||||
insertImage(editor, image);
|
insertImage(editor, image);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,15 +62,16 @@ export function MediaButton({ className }: { className?: string }) {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => uploadToNostrBuild()}
|
onClick={() => upload()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className={cn("inline-flex items-center justify-center", className)}
|
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 ? (
|
{loading ? (
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
<Spinner className="size-4" />
|
||||||
) : (
|
) : (
|
||||||
<AddMediaIcon className="size-5" />
|
<AddMediaIcon className="size-4" />
|
||||||
)}
|
)}
|
||||||
|
Add media
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
21
apps/desktop2/src/routes/editor/-components/pow.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
apps/desktop2/src/routes/editor/-components/warning.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
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,40 +1,31 @@
|
|||||||
import { LoaderIcon, TrashIcon } from "@lume/icons";
|
import { ComposeFilledIcon } from "@lume/icons";
|
||||||
import {
|
import { Spinner } from "@lume/ui";
|
||||||
Portal,
|
import { cn, insertImage, insertNostrEvent, isImageUrl } from "@lume/utils";
|
||||||
cn,
|
|
||||||
insertImage,
|
|
||||||
insertMention,
|
|
||||||
insertNostrEvent,
|
|
||||||
isImageUrl,
|
|
||||||
sendNativeNotification,
|
|
||||||
} from "@lume/utils";
|
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { type Descendant, Node, Transforms, createEditor } from "slate";
|
||||||
import { MediaButton } from "./-components/media";
|
|
||||||
import { MentionNote } from "@lume/ui/src/note/mentions/note";
|
|
||||||
import {
|
import {
|
||||||
Descendant,
|
|
||||||
Editor,
|
|
||||||
Node,
|
|
||||||
Range,
|
|
||||||
Transforms,
|
|
||||||
createEditor,
|
|
||||||
} from "slate";
|
|
||||||
import {
|
|
||||||
ReactEditor,
|
|
||||||
useSlateStatic,
|
|
||||||
useSelected,
|
|
||||||
useFocused,
|
|
||||||
withReact,
|
|
||||||
Slate,
|
|
||||||
Editable,
|
Editable,
|
||||||
|
ReactEditor,
|
||||||
|
Slate,
|
||||||
|
useFocused,
|
||||||
|
useSelected,
|
||||||
|
useSlateStatic,
|
||||||
|
withReact,
|
||||||
} from "slate-react";
|
} from "slate-react";
|
||||||
import { Contact } from "@lume/types";
|
import { MediaButton } from "./-components/media";
|
||||||
import { User } from "@lume/ui";
|
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";
|
import { nip19 } from "nostr-tools";
|
||||||
import { queryOptions, useSuspenseQuery } from "@tanstack/react-query";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
type EditorSearch = {
|
||||||
|
reply_to: string;
|
||||||
|
quote: string;
|
||||||
|
};
|
||||||
|
|
||||||
type EditorElement = {
|
type EditorElement = {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -42,29 +33,18 @@ type EditorElement = {
|
|||||||
eventId?: string;
|
eventId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const contactQueryOptions = queryOptions({
|
|
||||||
queryKey: ["contacts"],
|
|
||||||
queryFn: () => invoke("get_contact_metadata"),
|
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnReconnect: false,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/editor/")({
|
export const Route = createFileRoute("/editor/")({
|
||||||
loader: ({ context }) =>
|
validateSearch: (search: Record<string, string>): EditorSearch => {
|
||||||
context.queryClient.ensureQueryData(contactQueryOptions),
|
return {
|
||||||
component: Screen,
|
reply_to: search.reply_to,
|
||||||
pendingComponent: Pending,
|
quote: search.quote,
|
||||||
});
|
};
|
||||||
|
},
|
||||||
function Screen() {
|
beforeLoad: ({ search }) => {
|
||||||
// @ts-ignore, useless
|
|
||||||
const { reply_to, quote } = Route.useSearch();
|
|
||||||
const { ark } = Route.useRouteContext();
|
|
||||||
|
|
||||||
let initialValue: EditorElement[];
|
let initialValue: EditorElement[];
|
||||||
|
|
||||||
if (quote) {
|
if (search?.quote?.length) {
|
||||||
|
const eventId = nip19.noteEncode(search.quote);
|
||||||
initialValue = [
|
initialValue = [
|
||||||
{
|
{
|
||||||
type: "paragraph",
|
type: "paragraph",
|
||||||
@@ -72,11 +52,7 @@ function Screen() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "event",
|
type: "event",
|
||||||
eventId: `nostr:${nip19.noteEncode(reply_to)}`,
|
eventId: `nostr:${eventId}`,
|
||||||
children: [{ text: "" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "paragraph",
|
|
||||||
children: [{ text: "" }],
|
children: [{ text: "" }],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -89,25 +65,23 @@ function Screen() {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ref = useRef<HTMLDivElement | null>();
|
return { initialValue };
|
||||||
const contacts = useSuspenseQuery(contactQueryOptions).data as Contact[];
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
const [t] = useTranslation();
|
function Screen() {
|
||||||
const [editorValue, setEditorValue] = useState(initialValue);
|
const { reply_to } = Route.useSearch();
|
||||||
const [target, setTarget] = useState<Range | undefined>();
|
const { initialValue } = Route.useRouteContext();
|
||||||
const [index, setIndex] = useState(0);
|
|
||||||
const [search, setSearch] = useState("");
|
const [editorValue, setEditorValue] = useState<EditorElement[]>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [warning, setWarning] = useState({ enable: false, reason: "" });
|
||||||
|
const [difficulty, setDifficulty] = useState({ enable: false, num: 21 });
|
||||||
const [editor] = useState(() =>
|
const [editor] = useState(() =>
|
||||||
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
|
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
|
||||||
);
|
);
|
||||||
|
|
||||||
const filters = contacts
|
|
||||||
?.filter((c) =>
|
|
||||||
c?.profile.name?.toLowerCase().startsWith(search.toLowerCase()),
|
|
||||||
)
|
|
||||||
?.slice(0, 5);
|
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
// @ts-expect-error, backlog
|
// @ts-expect-error, backlog
|
||||||
editor.children = [{ type: "paragraph", children: [{ text: "" }] }];
|
editor.children = [{ type: "paragraph", children: [{ text: "" }] }];
|
||||||
@@ -144,85 +118,42 @@ function Screen() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const content = serialize(editor.children);
|
const content = serialize(editor.children);
|
||||||
const eventId = await ark.publish(content, reply_to, quote);
|
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) {
|
if (eventId) {
|
||||||
await sendNativeNotification("You've publish new post successfully.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// stop loading
|
// stop loading
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
// reset form
|
// reset form
|
||||||
reset();
|
reset();
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
await sendNativeNotification(String(e));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (target && filters.length > 0) {
|
setEditorValue(initialValue);
|
||||||
const el = ref.current;
|
}, [initialValue]);
|
||||||
const domRange = ReactEditor.toDOMRange(editor, target);
|
|
||||||
const rect = domRange.getBoundingClientRect();
|
if (!editorValue) return null;
|
||||||
el.style.top = `${rect.top + window.scrollY + 24}px`;
|
|
||||||
el.style.left = `${rect.left + window.scrollX}px`;
|
|
||||||
}
|
|
||||||
}, [filters.length, editor, index, search, target]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-screen flex-col bg-gradient-to-tr from-neutral-200 to-neutral-100 dark:from-neutral-950 dark:to-neutral-900">
|
<div className="flex flex-col w-full h-full">
|
||||||
<Slate
|
<Slate editor={editor} initialValue={editorValue}>
|
||||||
editor={editor}
|
<div data-tauri-drag-region className="h-9 shrink-0" />
|
||||||
initialValue={editorValue}
|
<div className="flex flex-col flex-1 overflow-y-auto">
|
||||||
onChange={() => {
|
{reply_to?.length ? (
|
||||||
const { selection } = editor;
|
<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>
|
||||||
if (selection && Range.isCollapsed(selection)) {
|
<ChildNote id={reply_to} />
|
||||||
const [start] = Range.edges(selection);
|
|
||||||
const wordBefore = Editor.before(editor, start, { unit: "word" });
|
|
||||||
const before = wordBefore && Editor.before(editor, wordBefore);
|
|
||||||
const beforeRange = before && Editor.range(editor, before, start);
|
|
||||||
const beforeText =
|
|
||||||
beforeRange && Editor.string(editor, beforeRange);
|
|
||||||
const beforeMatch = beforeText?.match(/^@(\w+)$/);
|
|
||||||
const after = Editor.after(editor, start);
|
|
||||||
const afterRange = Editor.range(editor, start, after);
|
|
||||||
const afterText = Editor.string(editor, afterRange);
|
|
||||||
const afterMatch = afterText.match(/^(\s|$)/);
|
|
||||||
|
|
||||||
if (beforeMatch && afterMatch) {
|
|
||||||
setTarget(beforeRange);
|
|
||||||
setSearch(beforeMatch[1]);
|
|
||||||
setIndex(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setTarget(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
data-tauri-drag-region
|
|
||||||
className="flex h-16 w-full shrink-0 items-center justify-end gap-3 px-2"
|
|
||||||
>
|
|
||||||
<MediaButton className="size-9 rounded-full bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={publish}
|
|
||||||
className="inline-flex h-9 w-24 items-center justify-center rounded-full bg-blue-500 px-3 font-medium text-white hover:bg-blue-600"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
t("global.post")
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-full min-h-0 w-full">
|
) : null}
|
||||||
<div className="flex h-full w-full flex-1 flex-col gap-2 px-2 pb-2">
|
<div className="px-4 py-4 overflow-y-auto">
|
||||||
{reply_to && !quote ? <MentionNote eventId={reply_to} /> : null}
|
|
||||||
<div className="h-full w-full flex-1 overflow-hidden overflow-y-auto rounded-xl bg-white p-5 shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:bg-black dark:shadow-none dark:ring-1 dark:ring-white/5">
|
|
||||||
<Editable
|
<Editable
|
||||||
key={JSON.stringify(editorValue)}
|
key={JSON.stringify(editorValue)}
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
@@ -231,41 +162,71 @@ function Screen() {
|
|||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
renderElement={(props) => <Element {...props} />}
|
renderElement={(props) => <Element {...props} />}
|
||||||
placeholder={
|
placeholder={
|
||||||
reply_to ? "Type your reply..." : t("editor.placeholder")
|
reply_to ? "Type your reply..." : "What're you up to?"
|
||||||
}
|
}
|
||||||
className="focus:outline-none"
|
className="focus:outline-none"
|
||||||
/>
|
/>
|
||||||
{target && filters.length > 0 && (
|
</div>
|
||||||
<Portal>
|
</div>
|
||||||
<div
|
{warning.enable ? (
|
||||||
ref={ref}
|
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5">
|
||||||
className="absolute left-[-9999px] top-[-9999px] z-10 w-[250px] rounded-xl border border-neutral-50 bg-white p-2 shadow-lg dark:border-neutral-900 dark:bg-neutral-950"
|
<span className="text-sm shrink-0 text-black/50 dark:text-white/50">
|
||||||
>
|
Reason:
|
||||||
{filters.map((contact) => (
|
</span>
|
||||||
<button
|
<input
|
||||||
key={contact.pubkey}
|
type="text"
|
||||||
type="button"
|
placeholder="NSFW..."
|
||||||
onClick={() => {
|
value={warning.reason}
|
||||||
Transforms.select(editor, target);
|
onChange={(e) =>
|
||||||
insertMention(editor, contact);
|
setWarning((prev) => ({ ...prev, reason: e.target.value }))
|
||||||
setTarget(null);
|
}
|
||||||
|
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();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="flex w-full flex-col rounded-lg p-2 hover:bg-neutral-100 dark:hover:bg-neutral-900"
|
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"
|
||||||
>
|
>
|
||||||
<User.Provider pubkey={contact.pubkey}>
|
<button
|
||||||
<User.Root className="flex w-full items-center gap-2">
|
type="button"
|
||||||
<User.Avatar className="size-7 shrink-0 rounded-full object-cover" />
|
onClick={() => publish()}
|
||||||
<div className="flex w-full flex-col items-start">
|
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"
|
||||||
<User.Name className="max-w-[8rem] truncate text-sm font-medium" />
|
>
|
||||||
</div>
|
{loading ? (
|
||||||
</User.Root>
|
<Spinner className="size-4" />
|
||||||
</User.Provider>
|
) : (
|
||||||
</button>
|
<ComposeFilledIcon className="size-4" />
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Portal>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
Publish
|
||||||
|
</button>
|
||||||
|
<div className="inline-flex items-center flex-1 gap-2 pl-4">
|
||||||
|
<MediaButton />
|
||||||
|
<WarningButton setWarning={setWarning} />
|
||||||
|
<PowButton setDifficulty={setDifficulty} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Slate>
|
</Slate>
|
||||||
@@ -273,17 +234,28 @@ function Screen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Pending() {
|
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 (
|
return (
|
||||||
<div
|
<Note.Provider event={data}>
|
||||||
data-tauri-drag-region
|
<Note.Root className="flex items-center gap-2">
|
||||||
className="flex h-full w-full items-center justify-center gap-2.5"
|
<User.Provider pubkey={data.pubkey}>
|
||||||
>
|
<User.Root className="shrink-0">
|
||||||
<button type="button" disabled>
|
<User.Avatar className="rounded-full size-8 shrink-0" />
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
</User.Root>
|
||||||
</button>
|
</User.Provider>
|
||||||
<p>Loading cache...</p>
|
<div className="content-break line-clamp-1">{data.content}</div>
|
||||||
</div>
|
</Note.Root>
|
||||||
|
</Note.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,7 +270,7 @@ const withNostrEvent = (editor: ReactEditor) => {
|
|||||||
editor.insertData = (data) => {
|
editor.insertData = (data) => {
|
||||||
const text = data.getData("text/plain");
|
const text = data.getData("text/plain");
|
||||||
|
|
||||||
if (text.startsWith("nevent1") || text.startsWith("note1")) {
|
if (text.startsWith("nevent") || text.startsWith("note")) {
|
||||||
insertNostrEvent(editor, text);
|
insertNostrEvent(editor, text);
|
||||||
} else {
|
} else {
|
||||||
insertData(data);
|
insertData(data);
|
||||||
@@ -350,35 +322,25 @@ const withImages = (editor: ReactEditor) => {
|
|||||||
return editor;
|
return editor;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Image = ({ attributes, children, element }) => {
|
const Image = ({ attributes, element, children }) => {
|
||||||
const editor = useSlateStatic();
|
const editor = useSlateStatic();
|
||||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
|
||||||
|
|
||||||
const selected = useSelected();
|
const selected = useSelected();
|
||||||
const focused = useFocused();
|
const focused = useFocused();
|
||||||
|
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div {...attributes}>
|
<div {...attributes}>
|
||||||
{children}
|
{children}
|
||||||
<div contentEditable={false} className="relative my-2">
|
|
||||||
<img
|
<img
|
||||||
src={element.url}
|
src={element.url}
|
||||||
alt={element.url}
|
alt={element.url}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-auto w-full rounded-lg border border-neutral-100 object-cover ring-2 dark:border-neutral-900",
|
"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",
|
selected && focused ? "ring-blue-500" : "ring-transparent",
|
||||||
)}
|
)}
|
||||||
contentEditable={false}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
contentEditable={false}
|
|
||||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||||
className="absolute right-2 top-2 inline-flex size-8 items-center justify-center rounded-lg bg-red-500 text-white hover:bg-red-600"
|
onKeyDown={() => Transforms.removeNodes(editor, { at: path })}
|
||||||
>
|
/>
|
||||||
<TrashIcon className="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -393,7 +355,7 @@ const Mention = ({ attributes, element }) => {
|
|||||||
type="button"
|
type="button"
|
||||||
contentEditable={false}
|
contentEditable={false}
|
||||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||||
className="inline-block align-baseline text-blue-500 hover:text-blue-600"
|
className="inline-block text-blue-500 align-baseline hover:text-blue-600"
|
||||||
>{`@${element.name}`}</span>
|
>{`@${element.name}`}</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -405,16 +367,13 @@ const Event = ({ attributes, element, children }) => {
|
|||||||
return (
|
return (
|
||||||
<div {...attributes}>
|
<div {...attributes}>
|
||||||
{children}
|
{children}
|
||||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> */}
|
|
||||||
<div
|
<div
|
||||||
contentEditable={false}
|
contentEditable={false}
|
||||||
|
className="relative my-2 user-select-none"
|
||||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||||
className="user-select-none relative my-2"
|
onKeyDown={() => Transforms.removeNodes(editor, { at: path })}
|
||||||
>
|
>
|
||||||
<MentionNote
|
<MentionNote eventId={element.eventId} openable={false} />
|
||||||
eventId={element.eventId.replace("nostr:", "")}
|
|
||||||
openable={false}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -432,7 +391,7 @@ const Element = (props) => {
|
|||||||
return <Event {...props} />;
|
return <Event {...props} />;
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<p {...attributes} className="text-lg">
|
<p {...attributes} className="text-[15px]">
|
||||||
{children}
|
{children}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
import { useEvent } from "@lume/ark";
|
|
||||||
import { LoaderIcon } from "@lume/icons";
|
|
||||||
import { Box, Container, Note, User } from "@lume/ui";
|
|
||||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
|
||||||
import { ReplyList } from "./-components/replyList";
|
|
||||||
import { WindowVirtualizer } from "virtua";
|
|
||||||
import { type Event } from "@lume/types";
|
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/events/$eventId")({
|
|
||||||
component: Event,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Event() {
|
|
||||||
const { eventId } = Route.useParams();
|
|
||||||
const { isLoading, isError, data } = useEvent(eventId);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<p>Not found.</p>
|
|
||||||
</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container withDrag>
|
|
||||||
<Box className="px-3 pt-3 scrollbar-none">
|
|
||||||
<WindowVirtualizer>
|
|
||||||
<MainNote data={data} />
|
|
||||||
{data ? <ReplyList eventId={eventId} /> : null}
|
|
||||||
</WindowVirtualizer>
|
|
||||||
</Box>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MainNote({ data }: { data: Event }) {
|
|
||||||
return (
|
|
||||||
<Note.Provider event={data}>
|
|
||||||
<Note.Root className="flex flex-col pb-3">
|
|
||||||
<User.Provider pubkey={data.pubkey}>
|
|
||||||
<User.Root className="mb-3 flex flex-1 items-center gap-3">
|
|
||||||
<User.Avatar className="size-11 shrink-0 rounded-full object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
|
|
||||||
<div className="flex flex-1 flex-col">
|
|
||||||
<User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" />
|
|
||||||
<div className="inline-flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400">
|
|
||||||
<User.Time time={data.created_at} />
|
|
||||||
<span>·</span>
|
|
||||||
<User.NIP05 />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
<Note.Thread className="mb-2" />
|
|
||||||
<Note.Content className="min-w-0" compact={false} />
|
|
||||||
<div className="mt-4 flex items-center justify-between">
|
|
||||||
<div className="-ml-1 inline-flex items-center gap-4">
|
|
||||||
<Note.Repost />
|
|
||||||
<Note.Zap />
|
|
||||||
</div>
|
|
||||||
<Note.Menu />
|
|
||||||
</div>
|
|
||||||
</Note.Root>
|
|
||||||
</Note.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
69
apps/desktop2/src/routes/events/$eventId.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||