Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0191180f31 | ||
|
|
60ed56b1b9 | ||
|
|
da722afed3 | ||
|
|
d8eb51e49c | ||
|
|
c700a45ab6 | ||
|
|
b806a34edb | ||
|
|
21989e6fa5 | ||
|
|
0539c5649d | ||
|
|
ad488ff72d | ||
|
|
02e0309a41 | ||
|
|
b7f4af7883 | ||
|
|
cc48a4f36b | ||
|
|
46ed3330fc | ||
|
|
1fa1872ca6 | ||
|
|
c389a23365 | ||
|
|
eaf9bda077 | ||
|
|
84a248a5a9 | ||
|
|
711c1d561a | ||
|
|
21210b4336 | ||
|
|
3bd480b75e | ||
|
|
2b19650e46 | ||
|
|
23482531c5 | ||
|
|
cfda9ba899 | ||
|
|
698bd78684 | ||
|
|
b97676dd3e | ||
|
|
25ae4f2201 | ||
|
|
59435ccd13 | ||
|
|
e81912c5e9 | ||
|
|
af1b4e60d3 | ||
|
|
648cbf6f80 | ||
|
|
7b06a82ee7 | ||
|
|
d18de93c60 | ||
|
|
df15eb7a03 | ||
|
|
06674df6cc | ||
|
|
8295625a44 | ||
|
|
b11e2a4291 | ||
|
|
353c18bb76 | ||
|
|
02b0c9e48a | ||
|
|
ff73c8ac88 | ||
|
|
bc48391a1a | ||
|
|
b0a443c002 | ||
|
|
bef1f136ad | ||
|
|
9ba584bf14 | ||
|
|
43509fc943 | ||
|
|
4a99eb94e2 | ||
|
|
74426e13c8 | ||
|
|
bd45c36072 | ||
|
|
c13aefcd15 | ||
|
|
167caee8bc | ||
|
|
d527078d5c | ||
|
|
763ace5ddf | ||
|
|
057c57b70f | ||
|
|
cb71786ac1 | ||
|
|
67afeac198 | ||
|
|
f4ee25de8e | ||
|
|
445a218a9e | ||
|
|
f09139ffbe | ||
|
|
446721729b | ||
|
|
e0250d7f5c | ||
|
|
9fcdac4edb | ||
|
|
b726ae3c7c | ||
|
|
a3460418f6 | ||
|
|
f65175f11e | ||
|
|
16efd495a0 | ||
|
|
ed6423e4aa | ||
|
|
0e9418949b | ||
|
|
240fe8bc7c | ||
|
|
c3482cddd8 | ||
|
|
d13e7b3ef6 | ||
|
|
47800bd2ff | ||
|
|
c0305db5fc | ||
|
|
0b745cb40e | ||
|
|
a20f5ca15d | ||
|
|
c29b4e173e | ||
|
|
33dd8b1d8a | ||
|
|
1503d90bd5 | ||
|
|
6581ffb92b | ||
|
|
939dfd9cc1 | ||
|
|
a98ffd4887 | ||
|
|
2e23b3ae06 |
@@ -0,0 +1,44 @@
|
||||
# Dependencies
|
||||
**/node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
|
||||
# Turbo
|
||||
.turbo
|
||||
|
||||
# Vercel
|
||||
.vercel
|
||||
|
||||
# Build Outputs
|
||||
**/.next/
|
||||
**/out/
|
||||
**/build
|
||||
**/dist
|
||||
**/target
|
||||
|
||||
|
||||
# Debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# Unnecessary files
|
||||
**/.git/
|
||||
.github/
|
||||
flatpak/*.xml
|
||||
flatpak/*.desktop
|
||||
flatpak/*.yml
|
||||
@@ -0,0 +1,72 @@
|
||||
name: Flatpak
|
||||
on: workflow_dispatch
|
||||
|
||||
jobs:
|
||||
prepare-repo:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: cache of container
|
||||
id: cache-container
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: prepare-dist
|
||||
key: ${{ runner.os }}-container-${{ hashFiles('prepare-dist') }}
|
||||
- name: Run latest-tag
|
||||
id: latest-tag
|
||||
uses: oprypin/find-latest-tag@v1
|
||||
with:
|
||||
repository:
|
||||
lumehq/lume
|
||||
#FIXME: lumehq after merged fix, now it just won't find tags
|
||||
# repository: ${{ github.repository }}
|
||||
|
||||
- name: Build container
|
||||
# if: steps.cache-container.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
docker buildx build -t flatpak-prepare-lume --build-arg=${{steps.latest-tag.outputs.tag}} --rm --output=. --target=final -f flatpak/Containerfile .
|
||||
- name: Copy flatpak files content
|
||||
run: |
|
||||
cp -r flatpak/*.xml flatpak/*.desktop flatpak/*.yml prepare-dist
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: repo-dist
|
||||
path: prepare-dist
|
||||
flatpak:
|
||||
name: flatpak-bundle
|
||||
needs: prepare-repo
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: bilelmoussaoui/flatpak-github-actions:gnome-45
|
||||
options: --privileged
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: repo-dist
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: flathub/shared-modules
|
||||
path: shared-modules
|
||||
- uses: flatpak/flatpak-github-actions/flatpak-builder@v6
|
||||
with:
|
||||
bundle: lume.flatpak
|
||||
manifest-path: nu.lume.Lume.yml
|
||||
restore-cache: false
|
||||
# cache-key: flatpak-builder-${{ github.sha }}
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
append_body: true
|
||||
files: lume.flatpak
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: geekyeggo/delete-artifact@v4
|
||||
with:
|
||||
name: repo-dist
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 20
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin
|
||||
@@ -67,4 +67,4 @@ jobs:
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
args: ${{ matrix.settings.args }}
|
||||
includeDebug: true
|
||||
includeDebug: false
|
||||
|
||||
@@ -13,25 +13,23 @@ node_modules
|
||||
.env.production.local
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
coverage/
|
||||
|
||||
# Turbo
|
||||
.turbo
|
||||
.turbo/
|
||||
|
||||
# Vercel
|
||||
.vercel
|
||||
.vercel/
|
||||
|
||||
# Build Outputs
|
||||
.next/
|
||||
out/
|
||||
build
|
||||
dist
|
||||
build/
|
||||
dist/
|
||||
|
||||
|
||||
# Debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
*.log*
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
[submodule "modules/depot"]
|
||||
path = modules/depot
|
||||
url = https://github.com/luminous-devs/depot.git
|
||||
@@ -4,7 +4,7 @@ Lume is a nostr client
|
||||
|
||||
### Usage
|
||||
|
||||
Download Lume for your platform here: [https://github.com/luminous-devs/lume/releases](https://github.com/luminous-devs/lume/releases)
|
||||
Download Lume for your platform here: [https://github.com/lumehq/lume/releases](https://github.com/lumehq/lume/releases)
|
||||
|
||||
Supported platform: macOS, Windows and Linux
|
||||
|
||||
@@ -19,7 +19,7 @@ Supported platform: macOS, Windows and Linux
|
||||
Clone project
|
||||
|
||||
```
|
||||
git clone https://github.com/luminous-devs/lume.git && cd lume
|
||||
git clone https://github.com/lumehq/lume.git && cd lume
|
||||
```
|
||||
|
||||
Install packages
|
||||
|
||||
@@ -9,21 +9,26 @@
|
||||
"dependencies": {
|
||||
"@columns/antenas": "workspace:^",
|
||||
"@columns/default": "workspace:^",
|
||||
"@columns/foryou": "workspace:^",
|
||||
"@columns/global": "workspace:^",
|
||||
"@columns/group": "workspace:^",
|
||||
"@columns/hashtag": "workspace:^",
|
||||
"@columns/thread": "workspace:^",
|
||||
"@columns/timeline": "workspace:^",
|
||||
"@columns/trending-notes": "workspace:^",
|
||||
"@columns/user": "workspace:^",
|
||||
"@columns/waifu": "workspace:^",
|
||||
"@getalby/sdk": "^3.2.3",
|
||||
"@lume/ark": "workspace:^",
|
||||
"@lume/icons": "workspace:^",
|
||||
"@lume/storage": "workspace:^",
|
||||
"@lume/ui": "workspace:^",
|
||||
"@lume/utils": "workspace:^",
|
||||
"@nostr-dev-kit/ndk": "^2.3.2",
|
||||
"@nostr-dev-kit/ndk": "^2.3.3",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
@@ -32,55 +37,41 @@
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@tanstack/react-query": "^5.17.9",
|
||||
"@tauri-apps/api": "2.0.0-alpha.13",
|
||||
"@tauri-apps/plugin-autostart": "2.0.0-alpha.5",
|
||||
"@tauri-apps/plugin-clipboard-manager": "2.0.0-alpha.5",
|
||||
"@tauri-apps/plugin-dialog": "2.0.0-alpha.5",
|
||||
"@tauri-apps/plugin-fs": "2.0.0-alpha.6",
|
||||
"@tauri-apps/plugin-http": "2.0.0-alpha.6",
|
||||
"@tauri-apps/plugin-notification": "2.0.0-alpha.5",
|
||||
"@tauri-apps/plugin-os": "2.0.0-alpha.6",
|
||||
"@tauri-apps/plugin-process": "2.0.0-alpha.5",
|
||||
"@tauri-apps/plugin-shell": "2.0.0-alpha.5",
|
||||
"@tauri-apps/plugin-sql": "2.0.0-alpha.5",
|
||||
"@tauri-apps/plugin-updater": "2.0.0-alpha.5",
|
||||
"@tauri-apps/plugin-upload": "2.0.0-alpha.5",
|
||||
"@vidstack/react": "^1.9.8",
|
||||
"framer-motion": "^10.18.0",
|
||||
"jotai": "^2.6.1",
|
||||
"@tanstack/react-query": "^5.17.19",
|
||||
"framer-motion": "^11.0.3",
|
||||
"i18next": "^23.8.1",
|
||||
"i18next-resources-to-backend": "^1.2.0",
|
||||
"jotai": "^2.6.3",
|
||||
"minidenticons": "^4.2.0",
|
||||
"nanoid": "^5.0.4",
|
||||
"nostr-fetch": "^0.15.0",
|
||||
"nostr-tools": "^1.17.0",
|
||||
"qrcode.react": "^3.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-currency-input-field": "^3.6.14",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.49.3",
|
||||
"react-router-dom": "^6.21.2",
|
||||
"smol-toml": "^1.1.3",
|
||||
"sonner": "^1.3.1",
|
||||
"unique-names-generator": "^4.7.1",
|
||||
"virtua": "^0.20.4"
|
||||
"react-i18next": "^14.0.1",
|
||||
"react-router-dom": "^6.21.3",
|
||||
"smol-toml": "^1.1.4",
|
||||
"sonner": "^1.4.0",
|
||||
"virtua": "^0.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lume/tailwindcss": "workspace:^",
|
||||
"@lume/tsconfig": "workspace:^",
|
||||
"@lume/types": "workspace:^",
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/react": "^18.2.47",
|
||||
"@types/node": "^20.11.10",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"cross-env": "^7.0.3",
|
||||
"encoding": "^0.1.13",
|
||||
"postcss": "^8.4.33",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.11",
|
||||
"vite": "^5.0.12",
|
||||
"vite-plugin-top-level-await": "^1.4.1",
|
||||
"vite-tsconfig-paths": "^4.2.3"
|
||||
"vite-tsconfig-paths": "^4.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 448 KiB |
|
Before Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 381 KiB After Width: | Height: | Size: 381 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 457 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 217 KiB After Width: | Height: | Size: 191 KiB |
@@ -1,4 +1,3 @@
|
||||
/* Vidstack */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -13,6 +12,10 @@
|
||||
.prose :where(iframe):not(:where([class~='not-prose'] *)) {
|
||||
@apply w-full h-auto mx-auto aspect-video;
|
||||
}
|
||||
|
||||
.shadow-toolbar {
|
||||
box-shadow: 0 0 #0000, 0 0 #0000, 0 8px 24px 0 rgba(0, 0, 0, .2), 0 2px 8px 0 rgba(0, 0, 0, .08), inset 0 0 0 1px rgba(0, 0, 0, .2), inset 0 0 0 2px hsla(0, 0%, 100%, .14)
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
@@ -39,3 +42,7 @@ input::-ms-clear {
|
||||
.border {
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
media-controller {
|
||||
@apply w-full;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ColumnProvider, LumeProvider } from "@lume/ark";
|
||||
import { StorageProvider } from "@lume/storage";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { Toaster } from "sonner";
|
||||
import i18n from "./i18n";
|
||||
import Router from "./router";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -14,15 +16,17 @@ const queryClient = new QueryClient({
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Toaster position="top-center" theme="system" closeButton />
|
||||
<StorageProvider>
|
||||
<LumeProvider>
|
||||
<ColumnProvider>
|
||||
<Router />
|
||||
</ColumnProvider>
|
||||
</LumeProvider>
|
||||
</StorageProvider>
|
||||
</QueryClientProvider>
|
||||
<I18nextProvider i18n={i18n} defaultNS={"translation"}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Toaster position="top-center" theme="system" closeButton />
|
||||
<StorageProvider>
|
||||
<LumeProvider>
|
||||
<ColumnProvider>
|
||||
<Router />
|
||||
</ColumnProvider>
|
||||
</LumeProvider>
|
||||
</StorageProvider>
|
||||
</QueryClientProvider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { resolveResource } from "@tauri-apps/api/path";
|
||||
import { readTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { locale } from "@tauri-apps/plugin-os";
|
||||
import i18n from "i18next";
|
||||
import resourcesToBackend from "i18next-resources-to-backend";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
const currentLocale = (await locale()).slice(0, 2);
|
||||
|
||||
i18n
|
||||
.use(
|
||||
resourcesToBackend(async (language: string) => {
|
||||
const file_path = await resolveResource(`locales/${language}.json`);
|
||||
return JSON.parse(await readTextFile(file_path));
|
||||
}),
|
||||
)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
lng: currentLocale,
|
||||
fallbackLng: "en",
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -35,81 +35,61 @@ export default function Router() {
|
||||
return { Component: HomeScreen };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "settings",
|
||||
element: <SettingsLayout />,
|
||||
children: [
|
||||
{
|
||||
path: "settings",
|
||||
element: <SettingsLayout />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
async lazy() {
|
||||
const { UserSettingScreen } = await import(
|
||||
"./routes/settings"
|
||||
);
|
||||
return { Component: UserSettingScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "edit-profile",
|
||||
async lazy() {
|
||||
const { EditProfileScreen } = await import(
|
||||
"./routes/settings/editProfile"
|
||||
);
|
||||
return { Component: EditProfileScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "edit-contact",
|
||||
async lazy() {
|
||||
const { EditContactScreen } = await import(
|
||||
"./routes/settings/editContact"
|
||||
);
|
||||
return { Component: EditContactScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "general",
|
||||
async lazy() {
|
||||
const { GeneralSettingScreen } = await import(
|
||||
"./routes/settings/general"
|
||||
);
|
||||
return { Component: GeneralSettingScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "backup",
|
||||
async lazy() {
|
||||
const { BackupSettingScreen } = await import(
|
||||
"./routes/settings/backup"
|
||||
);
|
||||
return { Component: BackupSettingScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "advanced",
|
||||
async lazy() {
|
||||
const { AdvancedSettingScreen } = await import(
|
||||
"./routes/settings/advanced"
|
||||
);
|
||||
return { Component: AdvancedSettingScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "nwc",
|
||||
async lazy() {
|
||||
const { NWCScreen } = await import("./routes/settings/nwc");
|
||||
return { Component: NWCScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "about",
|
||||
async lazy() {
|
||||
const { AboutScreen } = await import(
|
||||
"./routes/settings/about"
|
||||
);
|
||||
return { Component: AboutScreen };
|
||||
},
|
||||
},
|
||||
],
|
||||
index: true,
|
||||
async lazy() {
|
||||
const { GeneralSettingScreen } = await import(
|
||||
"./routes/settings/general"
|
||||
);
|
||||
return { Component: GeneralSettingScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "profile",
|
||||
async lazy() {
|
||||
const { ProfileSettingScreen } = await import(
|
||||
"./routes/settings/profile"
|
||||
);
|
||||
return { Component: ProfileSettingScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "backup",
|
||||
async lazy() {
|
||||
const { BackupSettingScreen } = await import(
|
||||
"./routes/settings/backup"
|
||||
);
|
||||
return { Component: BackupSettingScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "advanced",
|
||||
async lazy() {
|
||||
const { AdvancedSettingScreen } = await import(
|
||||
"./routes/settings/advanced"
|
||||
);
|
||||
return { Component: AdvancedSettingScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "nwc",
|
||||
async lazy() {
|
||||
const { NWCScreen } = await import("./routes/settings/nwc");
|
||||
return { Component: NWCScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "about",
|
||||
async lazy() {
|
||||
const { AboutScreen } = await import("./routes/settings/about");
|
||||
return { Component: AboutScreen };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -218,9 +198,6 @@ export default function Router() {
|
||||
},
|
||||
{
|
||||
path: "create",
|
||||
loader: async () => {
|
||||
return await ark.getOAuthServices();
|
||||
},
|
||||
async lazy() {
|
||||
const { CreateAccountScreen } = await import(
|
||||
"./routes/auth/create"
|
||||
@@ -228,6 +205,27 @@ export default function Router() {
|
||||
return { Component: CreateAccountScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "create-keys",
|
||||
async lazy() {
|
||||
const { CreateAccountKeys } = await import(
|
||||
"./routes/auth/create-keys"
|
||||
);
|
||||
return { Component: CreateAccountKeys };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "create-address",
|
||||
loader: async () => {
|
||||
return await ark.getOAuthServices();
|
||||
},
|
||||
async lazy() {
|
||||
const { CreateAccountAddress } = await import(
|
||||
"./routes/auth/create-address"
|
||||
);
|
||||
return { Component: CreateAccountAddress };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "login",
|
||||
async lazy() {
|
||||
@@ -269,42 +267,6 @@ export default function Router() {
|
||||
return { Component: OnboardingScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "tutorials/note",
|
||||
async lazy() {
|
||||
const { TutorialNoteScreen } = await import(
|
||||
"./routes/auth/tutorials/note"
|
||||
);
|
||||
return { Component: TutorialNoteScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "tutorials/widget",
|
||||
async lazy() {
|
||||
const { TutorialWidgetScreen } = await import(
|
||||
"./routes/auth/tutorials/widget"
|
||||
);
|
||||
return { Component: TutorialWidgetScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "tutorials/posting",
|
||||
async lazy() {
|
||||
const { TutorialPostingScreen } = await import(
|
||||
"./routes/auth/tutorials/posting"
|
||||
);
|
||||
return { Component: TutorialPostingScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "tutorials/finish",
|
||||
async lazy() {
|
||||
const { TutorialFinishScreen } = await import(
|
||||
"./routes/auth/tutorials/finish"
|
||||
);
|
||||
return { Component: TutorialFinishScreen };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { User } from "@lume/ark";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function ActivityRepost({ event }: { event: NDKEvent }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/activity/${event.id}`}
|
||||
@@ -14,7 +17,7 @@ export function ActivityRepost({ event }: { event: NDKEvent }) {
|
||||
<User.Avatar className="size-8 rounded-lg shrink-0" />
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<User.Name className="max-w-[8rem] font-semibold text-neutral-950 dark:text-neutral-50" />
|
||||
<p className="shrink-0">reposted</p>
|
||||
<p className="shrink-0">{t("activity.repost")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<User.Time
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { User } from "@lume/ark";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function ActivityText({ event }: { event: NDKEvent }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/activity/${event.id}`}
|
||||
@@ -14,7 +17,7 @@ export function ActivityText({ event }: { event: NDKEvent }) {
|
||||
<User.Avatar className="size-8 rounded-lg shrink-0" />
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<User.Name className="max-w-[8rem] font-semibold text-neutral-950 dark:text-neutral-50" />
|
||||
<p className="shrink-0">mention you</p>
|
||||
<p className="shrink-0">{t("activity.mention")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<User.Time
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { User } from "@lume/ark";
|
||||
import { compactNumber } from "@lume/utils";
|
||||
import { NDKEvent, zapInvoiceFromEvent } from "@nostr-dev-kit/ndk";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function ActivityZap({ event }: { event: NDKEvent }) {
|
||||
const { t } = useTranslation();
|
||||
const invoice = zapInvoiceFromEvent(event);
|
||||
|
||||
return (
|
||||
@@ -18,7 +20,7 @@ export function ActivityZap({ event }: { event: NDKEvent }) {
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<User.Name className="max-w-[8rem] font-semibold text-neutral-950 dark:text-neutral-50" />
|
||||
<p className="shrink-0">
|
||||
zapped {compactNumber.format(invoice.amount)} sats
|
||||
{t("activity.zap")} {compactNumber.format(invoice.amount)} sats
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FETCH_LIMIT } from "@lume/utils";
|
||||
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
|
||||
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityRepost } from "./activityRepost";
|
||||
import { ActivityText } from "./activityText";
|
||||
import { ActivityZap } from "./activityZap";
|
||||
@@ -12,6 +13,7 @@ export function ActivityList() {
|
||||
const ark = useArk();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ["activity"],
|
||||
@@ -86,7 +88,7 @@ export function ActivityList() {
|
||||
) : !allEvents.length ? (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center">
|
||||
<p className="mb-2 text-2xl">🎉</p>
|
||||
<p className="text-center font-medium">Yo! Nothing new yet.</p>
|
||||
<p className="text-center font-medium">{t("activity.empty")}</p>
|
||||
</div>
|
||||
) : (
|
||||
allEvents.map((event) => renderEvenKind(event))
|
||||
@@ -104,7 +106,7 @@ export function ActivityList() {
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="size-5" />
|
||||
Load more
|
||||
{t("global.loadMore")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { User } from "@lume/ark";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityRootNote } from "./rootNote";
|
||||
|
||||
export function ActivitySingleRepost({ event }: { event: NDKEvent }) {
|
||||
const { t } = useTranslation();
|
||||
const repostId = event.tags.find((el) => el[0] === "e")[1];
|
||||
|
||||
return (
|
||||
<div className="pb-3 flex flex-col">
|
||||
<div className="h-14 shrink-0 border-b border-neutral-100 dark:border-neutral-900 flex flex-col items-center justify-center px-3">
|
||||
<h3 className="text-center font-semibold leading-tight">Boost</h3>
|
||||
<h3 className="text-center font-semibold leading-tight">
|
||||
{t("activity.boost")}
|
||||
</h3>
|
||||
<p className="text-sm text-blue-500 font-medium leading-tight">
|
||||
@ Someone has reposted to your note
|
||||
{t("activity.boostSubtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
@@ -22,7 +26,7 @@ export function ActivitySingleRepost({ event }: { event: NDKEvent }) {
|
||||
</User.Provider>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="h-4 w-px bg-blue-500" />
|
||||
<h3 className="font-semibold">Reposted</h3>
|
||||
<h3 className="font-semibold capitalize">{t("activity.repost")}</h3>
|
||||
<div className="h-4 w-px bg-blue-500" />
|
||||
</div>
|
||||
<ActivityRootNote eventId={repostId} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Note, useArk } from "@lume/ark";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityRootNote } from "./rootNote";
|
||||
|
||||
export function ActivitySingleText({ event }: { event: NDKEvent }) {
|
||||
@@ -9,27 +10,35 @@ export function ActivitySingleText({ event }: { event: NDKEvent }) {
|
||||
tags: event.tags,
|
||||
});
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col justify-between">
|
||||
<div className="h-14 border-b border-neutral-100 dark:border-neutral-900 flex flex-col items-center justify-center px-3">
|
||||
<h3 className="text-center font-semibold leading-tight">
|
||||
Conversation
|
||||
{t("activity.conversation")}
|
||||
</h3>
|
||||
<p className="text-sm text-blue-500 font-medium leading-tight">
|
||||
@ Someone has replied to your note
|
||||
{t("activity.conversationSubtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-y-auto">
|
||||
<div className="max-w-xl mx-auto py-6">
|
||||
{thread ? (
|
||||
<div className="flex flex-col gap-3 mb-1">
|
||||
<ActivityRootNote eventId={thread.rootEventId} />
|
||||
<ActivityRootNote eventId={thread.replyEventId} />
|
||||
{thread.rootEventId ? (
|
||||
<ActivityRootNote eventId={thread.rootEventId} />
|
||||
) : null}
|
||||
{thread.replyEventId ? (
|
||||
<ActivityRootNote eventId={thread.replyEventId} />
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-teal-500 font-medium">New reply</p>
|
||||
<p className="text-teal-500 font-medium">
|
||||
{t("activity.newReply")}
|
||||
</p>
|
||||
<div className="flex-1 h-px bg-teal-300" />
|
||||
<div className="w-4 shrink-0 h-px bg-teal-300" />
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { activityUnreadAtom } from "@lume/utils";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { ActivityList } from "./components/list";
|
||||
|
||||
export function ActivityScreen() {
|
||||
const { t } = useTranslation();
|
||||
const setUnreadActivity = useSetAtom(activityUnreadAtom);
|
||||
|
||||
useEffect(() => {
|
||||
setUnreadActivity(0);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full rounded-xl shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:shadow-none dark:ring-1 dark:ring-white/10">
|
||||
<div className="h-full flex flex-col w-96 shrink-0 rounded-l-xl bg-white/50 backdrop-blur-xl dark:bg-black/50">
|
||||
<div className="h-14 shrink-0 flex items-center px-5 text-lg font-semibold border-b border-black/10 dark:border-white/10">
|
||||
Activity
|
||||
{t("activity.title")}
|
||||
</div>
|
||||
<ActivityList />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { CheckIcon, ChevronDownIcon, LoaderIcon } from "@lume/icons";
|
||||
import { useStorage } from "@lume/storage";
|
||||
import { onboardingAtom } from "@lume/utils";
|
||||
import NDK, {
|
||||
NDKEvent,
|
||||
NDKKind,
|
||||
NDKNip46Signer,
|
||||
NDKPrivateKeySigner,
|
||||
} from "@nostr-dev-kit/ndk";
|
||||
import * as Select from "@radix-ui/react-select";
|
||||
import { UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { Window } from "@tauri-apps/api/window";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLoaderData, useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const Item = ({ event }: { event: NDKEvent }) => {
|
||||
const domain = JSON.parse(event.content).nip05.replace("_@", "");
|
||||
|
||||
return (
|
||||
<Select.Item
|
||||
value={event.id}
|
||||
className="relative flex items-center pr-10 leading-none rounded-md select-none text-neutral-100 rounded-mg h-9 pl-7"
|
||||
>
|
||||
<Select.ItemText>@{domain}</Select.ItemText>
|
||||
<Select.ItemIndicator className="absolute left-0 inline-flex items-center justify-center transform h-7">
|
||||
<CheckIcon className="size-4" />
|
||||
</Select.ItemIndicator>
|
||||
</Select.Item>
|
||||
);
|
||||
};
|
||||
|
||||
export function CreateAccountAddress() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const services = useLoaderData() as NDKEvent[];
|
||||
const setOnboarding = useSetAtom(onboardingAtom);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [serviceId, setServiceId] = useState(services?.[0]?.id);
|
||||
const [loading, setIsLoading] = useState(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { isValid },
|
||||
} = useForm();
|
||||
|
||||
const getDomainName = (id: string) => {
|
||||
const event = services.find((ev) => ev.id === id);
|
||||
return JSON.parse(event.content).nip05.replace("_@", "") as string;
|
||||
};
|
||||
|
||||
const onSubmit = async (data: { username: string; email: string }) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const domain = getDomainName(serviceId);
|
||||
const service = services.find((ev) => ev.id === serviceId);
|
||||
|
||||
// generate ndk for nsecbunker
|
||||
const localSigner = NDKPrivateKeySigner.generate();
|
||||
const bunker = new NDK({
|
||||
explicitRelayUrls: [
|
||||
"wss://relay.nsecbunker.com/",
|
||||
"wss://nostr.vulpem.com/",
|
||||
],
|
||||
});
|
||||
await bunker.connect(2000);
|
||||
|
||||
// generate tmp remote singer for create account
|
||||
const remoteSigner = new NDKNip46Signer(
|
||||
bunker,
|
||||
service.pubkey,
|
||||
localSigner,
|
||||
);
|
||||
|
||||
// handle auth url request
|
||||
let unlisten: UnlistenFn;
|
||||
let authWindow: Window;
|
||||
let account: string = undefined;
|
||||
|
||||
remoteSigner.addListener("authUrl", async (authUrl: string) => {
|
||||
authWindow = new Window(`auth-${serviceId}`, {
|
||||
url: authUrl,
|
||||
title: domain,
|
||||
titleBarStyle: "overlay",
|
||||
width: 600,
|
||||
height: 650,
|
||||
center: true,
|
||||
closable: false,
|
||||
});
|
||||
unlisten = await authWindow.onCloseRequested(() => {
|
||||
if (!account) {
|
||||
setIsLoading(false);
|
||||
unlisten();
|
||||
|
||||
return authWindow.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// create new account
|
||||
account = await remoteSigner.createAccount(
|
||||
data.username,
|
||||
domain,
|
||||
data.email,
|
||||
);
|
||||
|
||||
if (!account) {
|
||||
unlisten();
|
||||
setIsLoading(false);
|
||||
|
||||
authWindow.close();
|
||||
|
||||
return toast.error("Failed to create new account, try again later");
|
||||
}
|
||||
|
||||
unlisten();
|
||||
authWindow.close();
|
||||
|
||||
// add account to storage
|
||||
await storage.createSetting("nsecbunker", "1");
|
||||
const newAccount = await storage.createAccount({
|
||||
pubkey: account,
|
||||
privkey: localSigner.privateKey,
|
||||
});
|
||||
ark.account = newAccount;
|
||||
|
||||
// get final signer with newly created account
|
||||
const finalSigner = new NDKNip46Signer(bunker, account, localSigner);
|
||||
await finalSigner.blockUntilReady();
|
||||
|
||||
// update main ndk instance signer
|
||||
ark.updateNostrSigner({ signer: finalSigner });
|
||||
|
||||
// remove default nsecbunker profile and contact list
|
||||
// await ark.createEvent({ kind: NDKKind.Metadata, content: "", tags: [] });
|
||||
await ark.createEvent({ kind: NDKKind.Contacts, content: "", tags: [] });
|
||||
|
||||
setIsLoading(false);
|
||||
setOnboarding({ open: true, newUser: true });
|
||||
|
||||
return navigate("/auth/onboarding", { replace: true });
|
||||
} catch (e) {
|
||||
setIsLoading(false);
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center justify-center w-full h-full">
|
||||
<div className="flex flex-col w-full max-w-md gap-8 mx-auto">
|
||||
<div className="flex flex-col gap-1 text-center items-center">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{t("signupWithProvider.title")}
|
||||
</h1>
|
||||
</div>
|
||||
{!services ? (
|
||||
<div className="flex items-center justify-center w-full">
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-3 mb-0"
|
||||
>
|
||||
<div className="flex flex-col gap-6 p-5 bg-neutral-950 rounded-2xl">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="text-sm font-semibold uppercase text-neutral-600"
|
||||
>
|
||||
{t("signupWithProvider.username")}
|
||||
</label>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between w-full gap-2 bg-neutral-900 rounded-xl">
|
||||
<input
|
||||
type={"text"}
|
||||
{...register("username", {
|
||||
required: true,
|
||||
minLength: 1,
|
||||
})}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
placeholder="alice"
|
||||
className="flex-1 min-w-0 text-xl bg-transparent border-transparent outline-none focus:outline-none focus:ring-0 focus:border-none h-14 ring-0 placeholder:text-neutral-600"
|
||||
/>
|
||||
<Select.Root value={serviceId} onValueChange={setServiceId}>
|
||||
<Select.Trigger className="inline-flex items-center justify-end gap-2 pr-3 text-xl font-semibold text-blue-500 w-max shrink-0">
|
||||
<Select.Value>@{getDomainName(serviceId)}</Select.Value>
|
||||
<Select.Icon>
|
||||
<ChevronDownIcon className="size-5" />
|
||||
</Select.Icon>
|
||||
</Select.Trigger>
|
||||
<Select.Portal>
|
||||
<Select.Content className="rounded-lg border border-white/20 bg-white/10 backdrop-blur-xl">
|
||||
<Select.Viewport className="p-3">
|
||||
<Select.Group>
|
||||
<Select.Label className="mb-2 text-sm font-medium uppercase px-7 text-neutral-600">
|
||||
{t("signupWithProvider.chooseProvider")}
|
||||
</Select.Label>
|
||||
{services.map((service) => (
|
||||
<Item key={service.id} event={service} />
|
||||
))}
|
||||
</Select.Group>
|
||||
</Select.Viewport>
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<span className="text-sm text-neutral-600">
|
||||
{t("signupWithProvider.usernameFooter")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="text-sm font-semibold uppercase text-neutral-600"
|
||||
>
|
||||
{t("signupWithProvider.email")}
|
||||
</label>
|
||||
<input
|
||||
type={"email"}
|
||||
{...register("email", { required: false })}
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="none"
|
||||
className="px-3 text-xl border-transparent rounded-xl h-14 bg-neutral-900 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-800"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-neutral-600">
|
||||
{t("signupWithProvider.emailFooter")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isValid}
|
||||
className="inline-flex items-center justify-center w-full text-lg h-12 font-medium text-white bg-blue-500 rounded-xl hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
) : (
|
||||
t("global.continue")
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { CheckIcon, EyeOffIcon, EyeOnIcon, LoaderIcon } from "@lume/icons";
|
||||
import { useStorage } from "@lume/storage";
|
||||
import { onboardingAtom } from "@lume/utils";
|
||||
import { NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
|
||||
import * as Checkbox from "@radix-ui/react-checkbox";
|
||||
import { desktopDir } from "@tauri-apps/api/path";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { writeTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { nanoid } from "nanoid";
|
||||
import { getPublicKey, nip19 } from "nostr-tools";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function CreateAccountKeys() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const setOnboarding = useSetAtom(onboardingAtom);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [t] = useTranslation();
|
||||
const [key, setKey] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [confirm, setConfirm] = useState({ c1: false, c2: false, c3: false });
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const privkey = nip19.decode(key).data as string;
|
||||
const signer = new NDKPrivateKeySigner(privkey);
|
||||
const pubkey = getPublicKey(privkey);
|
||||
|
||||
ark.updateNostrSigner({ signer });
|
||||
|
||||
const downloadPath = await desktopDir();
|
||||
const fileName = `nostr_keys_${nanoid(4)}.txt`;
|
||||
const filePath = await save({
|
||||
defaultPath: `${downloadPath}/${fileName}`,
|
||||
});
|
||||
|
||||
if (!filePath) {
|
||||
return toast.info("You need to save account keys before continue.");
|
||||
}
|
||||
|
||||
await writeTextFile(
|
||||
filePath,
|
||||
`Nostr Account\nGenerated by Lume (lume.nu)\n---\nPrivate key: ${key}`,
|
||||
);
|
||||
|
||||
const newAccount = await storage.createAccount({
|
||||
pubkey: pubkey,
|
||||
privkey: privkey,
|
||||
});
|
||||
ark.account = newAccount;
|
||||
|
||||
setLoading(false);
|
||||
setOnboarding({ open: true, newUser: true });
|
||||
|
||||
return navigate("/auth/onboarding", { replace: true });
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const privkey = NDKPrivateKeySigner.generate().privateKey;
|
||||
setKey(nip19.nsecEncode(privkey));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center justify-center w-full h-full">
|
||||
<div className="flex flex-col w-full max-w-md gap-8 mx-auto">
|
||||
<div className="flex flex-col gap-1 text-center items-center">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{t("signupWithSelfManage.title")}
|
||||
</h1>
|
||||
<p className="text-lg font-medium leading-snug text-neutral-600 dark:text-neutral-500">
|
||||
{t("signupWithSelfManage.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6 mb-0">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="relative">
|
||||
<input
|
||||
readOnly
|
||||
value={key}
|
||||
type={showKey ? "text" : "password"}
|
||||
className="pl-3 pr-14 w-full resize-none text-xl border-transparent rounded-xl h-14 bg-neutral-900 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-800"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowKey((state) => !state)}
|
||||
className="absolute right-2 top-2 size-10 inline-flex items-center justify-center rounded-lg text-white bg-neutral-800 hover:bg-neutral-700"
|
||||
>
|
||||
{showKey ? (
|
||||
<EyeOnIcon className="size-5" />
|
||||
) : (
|
||||
<EyeOffIcon className="size-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox.Root
|
||||
checked={confirm.c1}
|
||||
onCheckedChange={() =>
|
||||
setConfirm((state) => ({ ...state, c1: !state.c1 }))
|
||||
}
|
||||
className="flex size-7 appearance-none items-center justify-center rounded-lg bg-neutral-900 outline-none"
|
||||
id="confirm1"
|
||||
>
|
||||
<Checkbox.Indicator className="text-blue-500">
|
||||
<CheckIcon className="size-4" />
|
||||
</Checkbox.Indicator>
|
||||
</Checkbox.Root>
|
||||
<label
|
||||
className="text-sm leading-none text-neutral-500"
|
||||
htmlFor="confirm1"
|
||||
>
|
||||
{t("signupWithSelfManage.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-7 appearance-none items-center justify-center rounded-lg bg-neutral-900 outline-none"
|
||||
id="confirm2"
|
||||
>
|
||||
<Checkbox.Indicator className="text-blue-500">
|
||||
<CheckIcon className="size-4" />
|
||||
</Checkbox.Indicator>
|
||||
</Checkbox.Root>
|
||||
<label
|
||||
className="text-sm leading-none text-neutral-500"
|
||||
htmlFor="confirm2"
|
||||
>
|
||||
{t("signupWithSelfManage.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-7 appearance-none items-center justify-center rounded-lg bg-neutral-900 outline-none"
|
||||
id="confirm3"
|
||||
>
|
||||
<Checkbox.Indicator className="text-blue-500">
|
||||
<CheckIcon className="size-4" />
|
||||
</Checkbox.Indicator>
|
||||
</Checkbox.Root>
|
||||
<label
|
||||
className="text-sm leading-none text-neutral-500"
|
||||
htmlFor="confirm3"
|
||||
>
|
||||
{t("signupWithSelfManage.confirm3")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={!confirm.c1 || !confirm.c2 || !confirm.c3}
|
||||
className="inline-flex items-center justify-center w-full h-12 text-lg font-medium text-white bg-blue-500 rounded-xl hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
) : (
|
||||
t("signupWithSelfManage.button")
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,174 +1,23 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { CheckIcon, ChevronDownIcon, LoaderIcon } from "@lume/icons";
|
||||
import { useStorage } from "@lume/storage";
|
||||
import { onboardingAtom } from "@lume/utils";
|
||||
import NDK, {
|
||||
NDKEvent,
|
||||
NDKKind,
|
||||
NDKNip46Signer,
|
||||
NDKPrivateKeySigner,
|
||||
} from "@nostr-dev-kit/ndk";
|
||||
import * as Select from "@radix-ui/react-select";
|
||||
import { downloadDir } from "@tauri-apps/api/path";
|
||||
import { Window } from "@tauri-apps/api/window";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { writeTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { getPublicKey, nip19 } from "nostr-tools";
|
||||
import { LoaderIcon } from "@lume/icons";
|
||||
import { cn } from "@lume/utils";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useLoaderData, useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const Item = ({ event }: { event: NDKEvent }) => {
|
||||
const domain = JSON.parse(event.content).nip05.replace("_@", "");
|
||||
|
||||
return (
|
||||
<Select.Item
|
||||
value={event.id}
|
||||
className="relative flex items-center pr-10 leading-none rounded-md select-none text-neutral-100 rounded-mg h-9 pl-7"
|
||||
>
|
||||
<Select.ItemText>@{domain}</Select.ItemText>
|
||||
<Select.ItemIndicator className="absolute left-0 inline-flex items-center justify-center transform h-7">
|
||||
<CheckIcon className="size-4" />
|
||||
</Select.ItemIndicator>
|
||||
</Select.Item>
|
||||
);
|
||||
};
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export function CreateAccountScreen() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const navigate = useNavigate();
|
||||
const services = useLoaderData() as NDKEvent[];
|
||||
const setOnboarding = useSetAtom(onboardingAtom);
|
||||
|
||||
const [serviceId, setServiceId] = useState(services?.[0]?.id);
|
||||
const [loading, setIsLoading] = useState(false);
|
||||
const [t] = useTranslation();
|
||||
const [method, setMethod] = useState<"self" | "managed">("self");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { isValid },
|
||||
} = useForm();
|
||||
const next = () => {
|
||||
setLoading(true);
|
||||
|
||||
const getDomainName = (id: string) => {
|
||||
const event = services.find((ev) => ev.id === id);
|
||||
return JSON.parse(event.content).nip05.replace("_@", "") as string;
|
||||
};
|
||||
|
||||
const generateNostrKeys = async () => {
|
||||
const signer = NDKPrivateKeySigner.generate();
|
||||
const pubkey = getPublicKey(signer.privateKey);
|
||||
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
const nsec = nip19.nsecEncode(signer.privateKey);
|
||||
|
||||
ark.updateNostrSigner({ signer });
|
||||
|
||||
const downloadPath = await downloadDir();
|
||||
const fileName = `nostr_keys_${new Date().getTime().toString(36)}.txt`;
|
||||
const filePath = await save({
|
||||
defaultPath: `${downloadPath}/${fileName}`,
|
||||
});
|
||||
|
||||
if (filePath) {
|
||||
await writeTextFile(
|
||||
filePath,
|
||||
`Nostr account, generated by Lume (lume.nu)\nPublic key: ${npub}\nPrivate key: ${nsec}`,
|
||||
);
|
||||
} // else { user cancel action }
|
||||
|
||||
await storage.createAccount({
|
||||
pubkey: pubkey,
|
||||
privkey: signer.privateKey,
|
||||
});
|
||||
|
||||
setOnboarding(true);
|
||||
|
||||
return navigate("/auth/onboarding", { replace: true });
|
||||
};
|
||||
|
||||
const onSubmit = async (data: { username: string; email: string }) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const domain = getDomainName(serviceId);
|
||||
const service = services.find((ev) => ev.id === serviceId);
|
||||
|
||||
// generate ndk for nsecbunker
|
||||
const localSigner = NDKPrivateKeySigner.generate();
|
||||
const bunker = new NDK({
|
||||
explicitRelayUrls: [
|
||||
"wss://relay.nsecbunker.com/",
|
||||
"wss://nostr.vulpem.com/",
|
||||
],
|
||||
});
|
||||
await bunker.connect(2000);
|
||||
|
||||
// generate tmp remote singer for create account
|
||||
const remoteSigner = new NDKNip46Signer(
|
||||
bunker,
|
||||
service.pubkey,
|
||||
localSigner,
|
||||
);
|
||||
|
||||
// handle auth url request
|
||||
let authWindow: Window;
|
||||
remoteSigner.addListener("authUrl", (authUrl: string) => {
|
||||
authWindow = new Window(`auth-${serviceId}`, {
|
||||
url: authUrl,
|
||||
title: domain,
|
||||
titleBarStyle: "overlay",
|
||||
width: 415,
|
||||
height: 600,
|
||||
center: true,
|
||||
closable: false,
|
||||
});
|
||||
});
|
||||
|
||||
// create new account
|
||||
const account = await remoteSigner.createAccount(
|
||||
data.username,
|
||||
domain,
|
||||
data.email,
|
||||
);
|
||||
|
||||
if (!account) {
|
||||
authWindow.close();
|
||||
setIsLoading(false);
|
||||
|
||||
return toast.error("Failed to create new account, try again later");
|
||||
}
|
||||
|
||||
authWindow.close();
|
||||
|
||||
// add account to storage
|
||||
await storage.createSetting("nsecbunker", "1");
|
||||
const dbAccount = await storage.createAccount({
|
||||
pubkey: account,
|
||||
privkey: localSigner.privateKey,
|
||||
});
|
||||
ark.account = dbAccount;
|
||||
|
||||
// get final signer with newly created account
|
||||
const finalSigner = new NDKNip46Signer(bunker, account, localSigner);
|
||||
await finalSigner.blockUntilReady();
|
||||
|
||||
// update main ndk instance signer
|
||||
ark.updateNostrSigner({ signer: finalSigner });
|
||||
|
||||
// remove default nsecbunker profile and contact list
|
||||
await ark.createEvent({ kind: NDKKind.Metadata, content: "", tags: [] });
|
||||
await ark.createEvent({ kind: NDKKind.Contacts, content: "", tags: [] });
|
||||
|
||||
setOnboarding(true);
|
||||
setIsLoading(false);
|
||||
|
||||
return navigate("/auth/onboarding", { replace: true });
|
||||
} catch (e) {
|
||||
setIsLoading(false);
|
||||
toast.error(String(e));
|
||||
if (method === "self") {
|
||||
navigate("/auth/create-keys");
|
||||
} else {
|
||||
navigate("/auth/create-address");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -176,126 +25,84 @@ export function CreateAccountScreen() {
|
||||
<div className="relative flex items-center justify-center w-full h-full">
|
||||
<div className="flex flex-col w-full max-w-md gap-8 mx-auto">
|
||||
<div className="flex flex-col gap-1 text-center items-center">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
Let's get you set up on Nostr.
|
||||
</h1>
|
||||
<h1 className="text-2xl font-semibold">{t("signup.title")}</h1>
|
||||
<p className="text-lg font-medium leading-snug text-neutral-600 dark:text-neutral-500">
|
||||
With an account on Nostr, you'll be able to travel across all nostr
|
||||
clients, all your data are synced.
|
||||
{t("signup.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
{!services ? (
|
||||
<div className="flex items-center justify-center w-full">
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-3 mb-0"
|
||||
>
|
||||
<div className="flex flex-col gap-6 p-5 bg-neutral-950 rounded-2xl">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="text-sm font-semibold uppercase text-neutral-600"
|
||||
>
|
||||
Username *
|
||||
</label>
|
||||
<div className="flex items-center justify-between w-full gap-2 bg-neutral-900 rounded-xl">
|
||||
<input
|
||||
type={"text"}
|
||||
{...register("username", {
|
||||
required: true,
|
||||
minLength: 1,
|
||||
})}
|
||||
spellCheck={false}
|
||||
placeholder="satoshi"
|
||||
className="flex-1 min-w-0 text-xl bg-transparent border-transparent outline-none focus:outline-none focus:ring-0 focus:border-none h-14 ring-0 placeholder:text-neutral-600"
|
||||
/>
|
||||
<Select.Root value={serviceId} onValueChange={setServiceId}>
|
||||
<Select.Trigger className="inline-flex items-center justify-end gap-2 pr-3 text-xl font-semibold text-blue-500 w-max shrink-0">
|
||||
<Select.Value>@{getDomainName(serviceId)}</Select.Value>
|
||||
<Select.Icon>
|
||||
<ChevronDownIcon className="size-5" />
|
||||
</Select.Icon>
|
||||
</Select.Trigger>
|
||||
<Select.Portal>
|
||||
<Select.Content className="border rounded-lg bg-neutral-950 border-neutral-900">
|
||||
<Select.Viewport className="p-3">
|
||||
<Select.Group>
|
||||
<Select.Label className="mb-2 text-sm font-medium uppercase px-7 text-neutral-600">
|
||||
Public handles
|
||||
</Select.Label>
|
||||
{services.map((service) => (
|
||||
<Item key={service.id} event={service} />
|
||||
))}
|
||||
</Select.Group>
|
||||
</Select.Viewport>
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select.Root>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="text-sm font-semibold uppercase text-neutral-600"
|
||||
>
|
||||
Backup Email (Optional)
|
||||
</label>
|
||||
<input
|
||||
type={"email"}
|
||||
{...register("email", { required: false })}
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="none"
|
||||
className="px-3 text-xl border-transparent rounded-xl h-14 bg-neutral-900 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isValid}
|
||||
className="inline-flex items-center justify-center w-full text-lg h-12 font-medium text-white bg-blue-500 rounded-xl hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
) : (
|
||||
"Create Account"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-neutral-900" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="px-2 font-medium bg-black text-neutral-600">
|
||||
Or
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={generateNostrKeys}
|
||||
className="mb-2 inline-flex items-center justify-center w-full h-12 text-lg font-medium text-neutral-50 rounded-xl bg-neutral-950 hover:bg-neutral-900"
|
||||
>
|
||||
Generate Nostr Keys
|
||||
</button>
|
||||
<p className="text-sm text-center text-neutral-500">
|
||||
If you are using this option, please make sure keep your keys
|
||||
in safe place. You{" "}
|
||||
<span className="text-red-600">cannot recover</span> if it
|
||||
lost, all your data will be{" "}
|
||||
<span className="text-red-600">lost forever.</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMethod("self")}
|
||||
className={cn(
|
||||
"flex flex-col items-start px-4 py-3.5 bg-neutral-900 rounded-xl hover:bg-neutral-800",
|
||||
method === "self"
|
||||
? "ring-1 ring-offset-4 ring-offset-black ring-blue-500"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<p className="font-semibold">{t("signup.selfManageMethod")}</p>
|
||||
<p className="text-sm font-medium text-neutral-500">
|
||||
{t("signup.selfManageMethodDescription")}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMethod("managed")}
|
||||
className={cn(
|
||||
"flex flex-col items-start px-4 py-3.5 bg-neutral-900 rounded-xl hover:bg-neutral-800",
|
||||
method === "managed"
|
||||
? "ring-1 ring-offset-4 ring-offset-black ring-blue-500"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<p className="font-semibold">{t("signup.providerMethod")}</p>
|
||||
<span className="text-xs font-medium px-2.5 py-0.5 rounded-full bg-gradient-to-tr from-blue-300 via-sky-500 to-teal-200">
|
||||
Beta
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-neutral-500">
|
||||
{t("signup.providerMethodDescription")}
|
||||
</p>
|
||||
</button>
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={next}
|
||||
className="inline-flex items-center justify-center w-full h-12 text-lg font-medium text-white bg-blue-500 rounded-xl hover:bg-blue-600"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
) : (
|
||||
t("global.continue")
|
||||
)}
|
||||
</button>
|
||||
{method === "managed" ? (
|
||||
<div className="flex flex-col gap-1 text-sm text-neutral-500">
|
||||
<p className="text-sm font-semibold text-neutral-300">
|
||||
Attention:
|
||||
</p>
|
||||
<p>
|
||||
You're chosing Managed by Provider, this feature still in
|
||||
"Beta".
|
||||
</p>
|
||||
<p>
|
||||
Some functions still missing or not work as expected, you
|
||||
shouldn't create your main account with this method
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/kind-0/nsecbunkerd/blob/master/OAUTH-LIKE-FLOW.md"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useStorage } from "@lume/storage";
|
||||
import { getPublicKey, nip19 } from "nostr-tools";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -15,6 +16,7 @@ export function LoginWithKey() {
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { t } = useTranslation("loginWithPrivkey.subtitle");
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -52,19 +54,21 @@ export function LoginWithKey() {
|
||||
<div className="relative flex items-center justify-center w-full h-full">
|
||||
<div className="flex flex-col w-full max-w-md gap-8 mx-auto">
|
||||
<div className="flex flex-col gap-1 text-center items-center">
|
||||
<h1 className="text-2xl font-semibold">Enter your Private Key</h1>
|
||||
<p className="text-lg font-medium leading-snug text-neutral-600 dark:text-neutral-500">
|
||||
Lume will put your private key to{" "}
|
||||
<span className="text-teal-500">
|
||||
{storage.platform === "macos"
|
||||
? "Apple Keychain"
|
||||
: storage.platform === "windows"
|
||||
? "Credential Manager"
|
||||
: "Secret Service"}
|
||||
</span>
|
||||
.
|
||||
<br />
|
||||
It will be secured by your OS.
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{t("loginWithPrivkey.title")}
|
||||
</h1>
|
||||
<p className="text-lg font-medium whitespace-pre-line leading-snug text-neutral-600 dark:text-neutral-500">
|
||||
<Trans t={t}>
|
||||
Lume will put your private key to{" "}
|
||||
<span className="text-teal-500">
|
||||
{storage.platform === "macos"
|
||||
? "Apple Keychain"
|
||||
: storage.platform === "windows"
|
||||
? "Credential Manager"
|
||||
: "Secret Service"}
|
||||
</span>
|
||||
. It will be secured by your OS.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -107,7 +111,7 @@ export function LoginWithKey() {
|
||||
{loading ? (
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
) : (
|
||||
"Continue"
|
||||
t("global.continue")
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -5,6 +5,7 @@ import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -15,6 +16,7 @@ export function LoginWithNsecbunker() {
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -69,7 +71,7 @@ export function LoginWithNsecbunker() {
|
||||
<div className="flex flex-col w-full max-w-md gap-8 mx-auto">
|
||||
<div className="flex flex-col gap-1 text-center items-center">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
Enter your nsecbunker token
|
||||
{t("loginWithBunker.title")}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -101,7 +103,7 @@ export function LoginWithNsecbunker() {
|
||||
{loading ? (
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
) : (
|
||||
"Continue"
|
||||
t("global.continue")
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Window } from "@tauri-apps/api/window";
|
||||
import { fetch } from "@tauri-apps/plugin-http";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -19,6 +20,7 @@ export function LoginWithOAuth() {
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -130,7 +132,9 @@ export function LoginWithOAuth() {
|
||||
<div className="relative flex items-center justify-center w-full h-full">
|
||||
<div className="flex flex-col w-full max-w-md gap-8 mx-auto">
|
||||
<div className="flex flex-col gap-1 text-center items-center">
|
||||
<h1 className="text-2xl font-semibold">Enter your NIP-05 address</h1>
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{t("loginWithAddress.title")}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6">
|
||||
<form
|
||||
@@ -161,7 +165,7 @@ export function LoginWithOAuth() {
|
||||
{loading ? (
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
) : (
|
||||
"Continue"
|
||||
t("global.continue")
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function LoginScreen() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center justify-center w-full h-full">
|
||||
<div className="flex flex-col w-full max-w-md gap-8 mx-auto">
|
||||
<div className="flex flex-col gap-1 text-center items-center">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
Continue your experience on Nostr
|
||||
</h1>
|
||||
<h1 className="text-2xl font-semibold">{t("login.title")}</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -15,13 +16,13 @@ export function LoginScreen() {
|
||||
to="/auth/login-oauth"
|
||||
className="inline-flex items-center justify-center w-full h-12 text-lg font-medium text-white bg-blue-500 rounded-xl hover:bg-blue-600"
|
||||
>
|
||||
Login with address
|
||||
{t("login.loginWithAddress")}
|
||||
</Link>
|
||||
<Link
|
||||
to="/auth/login-nsecbunker"
|
||||
className="inline-flex items-center justify-center w-full h-12 text-lg font-medium text-neutral-50 rounded-xl bg-neutral-950 hover:bg-neutral-900"
|
||||
>
|
||||
Login with nsecbunker
|
||||
{t("login.loginWithBunker")}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -31,7 +32,7 @@ export function LoginScreen() {
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="px-2 font-medium bg-black text-neutral-600">
|
||||
Or (Not recommend)
|
||||
{t("login.or")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,11 +41,10 @@ export function LoginScreen() {
|
||||
to="/auth/login-key"
|
||||
className="mb-2 inline-flex items-center justify-center w-full h-12 text-lg font-medium text-neutral-50 rounded-xl bg-neutral-950 hover:bg-neutral-900"
|
||||
>
|
||||
Login with Private Key
|
||||
{t("login.loginWithPrivkey")}
|
||||
</Link>
|
||||
<p className="text-sm text-center text-neutral-500">
|
||||
Lume will store your Private Key in{" "}
|
||||
<span className="text-teal-600">OS Secure Storage</span>
|
||||
{t("login.footer")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,24 +2,22 @@ import { useArk } from "@lume/ark";
|
||||
import { InfoIcon, LoaderIcon } from "@lume/icons";
|
||||
import { useStorage } from "@lume/storage";
|
||||
import { TranslateRegisterModal } from "@lume/ui";
|
||||
import { FETCH_LIMIT } from "@lume/utils";
|
||||
import { NDKKind } from "@nostr-dev-kit/ndk";
|
||||
import * as Switch from "@radix-ui/react-switch";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
isPermissionGranted,
|
||||
requestPermission,
|
||||
} from "@tauri-apps/plugin-notification";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function OnboardingScreen() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [t] = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [apiKey, setAPIKey] = useState("");
|
||||
const [settings, setSettings] = useState({
|
||||
@@ -61,32 +59,7 @@ export function OnboardingScreen() {
|
||||
// get account contacts
|
||||
await ark.getUserContacts();
|
||||
|
||||
// refetch newsfeed
|
||||
await queryClient.prefetchInfiniteQuery({
|
||||
queryKey: ["timeline-9999"],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({
|
||||
signal,
|
||||
pageParam,
|
||||
}: {
|
||||
signal: AbortSignal;
|
||||
pageParam: number;
|
||||
}) => {
|
||||
const events = await ark.getInfiniteEvents({
|
||||
filter: {
|
||||
kinds: [NDKKind.Text, NDKKind.Repost],
|
||||
authors: ark.account.contacts,
|
||||
},
|
||||
limit: FETCH_LIMIT,
|
||||
pageParam,
|
||||
signal,
|
||||
});
|
||||
|
||||
return events;
|
||||
},
|
||||
});
|
||||
|
||||
navigate("/");
|
||||
navigate("/", { replace: true });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -117,13 +90,13 @@ export function OnboardingScreen() {
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full items-center justify-center">
|
||||
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
|
||||
<div className="mx-auto flex w-full max-w-md flex-col gap-8">
|
||||
<div className="flex flex-col gap-1 text-center items-center">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
You're almost ready to use Lume.
|
||||
{t("onboardingSettings.title")}
|
||||
</h1>
|
||||
<p className="text-lg font-medium leading-snug text-neutral-600 dark:text-neutral-500">
|
||||
Let's start personalizing your experience.
|
||||
{t("onboardingSettings.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -136,10 +109,11 @@ export function OnboardingScreen() {
|
||||
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-neutral-50 transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||
</Switch.Root>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">Push notification</h3>
|
||||
<h3 className="font-semibold text-lg">
|
||||
{t("onboardingSettings.notification.title")}
|
||||
</h3>
|
||||
<p className="text-neutral-500">
|
||||
Enabling push notifications will allow you to receive
|
||||
notifications from Lume.
|
||||
{t("onboardingSettings.notification.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,10 +126,11 @@ export function OnboardingScreen() {
|
||||
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-neutral-50 transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||
</Switch.Root>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">Low Power Mode</h3>
|
||||
<h3 className="font-semibold text-lg">
|
||||
{t("onboardingSettings.lowPower.title")}
|
||||
</h3>
|
||||
<p className="text-neutral-500">
|
||||
Limited relay connection and hide all media, sustainable for low
|
||||
network environment.
|
||||
{t("onboardingSettings.lowPower.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -169,11 +144,10 @@ export function OnboardingScreen() {
|
||||
</Switch.Root>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">
|
||||
Translation (nostr.wine)
|
||||
{t("onboardingSettings.translation.title")}
|
||||
</h3>
|
||||
<p className="text-neutral-500">
|
||||
Translate text to your preferred language, powered by Nostr
|
||||
Wine.
|
||||
{t("onboardingSettings.translation.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -194,7 +168,7 @@ export function OnboardingScreen() {
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="px-2 text-sm font-medium bg-neutral-950 text-neutral-600">
|
||||
Not have API ?
|
||||
Don't have an API key?
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -204,10 +178,7 @@ export function OnboardingScreen() {
|
||||
) : null}
|
||||
<div className="flex items-center gap-2 rounded-xl px-5 py-3 text-sm bg-blue-950 text-blue-300">
|
||||
<InfoIcon className="size-8" />
|
||||
<p>
|
||||
There are many more settings you can configure from the
|
||||
"Settings" screen. Be sure to visit it later.
|
||||
</p>
|
||||
<p>{t("onboardingSettings.footer")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -217,7 +188,7 @@ export function OnboardingScreen() {
|
||||
{loading ? (
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
) : (
|
||||
"Continue"
|
||||
t("global.continue")
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function TutorialFinishScreen() {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
|
||||
<div className="text-center">
|
||||
<img
|
||||
src="/icon.png"
|
||||
alt="Lume's logo"
|
||||
className="mx-auto mb-1 h-auto w-16"
|
||||
/>
|
||||
<h1 className="text-2xl font-light">
|
||||
Yo, you've understood basic features 🎉
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
|
||||
>
|
||||
Start using Lume
|
||||
</Link>
|
||||
<Link
|
||||
to="https://nostr.how/"
|
||||
target="_blank"
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-neutral-100 font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
>
|
||||
Learn more about Nostr
|
||||
</Link>
|
||||
<p className="text-center text-sm font-medium text-neutral-500 dark:text-neutral-600">
|
||||
If you've trouble when user Lume, you can report the issue{" "}
|
||||
<a
|
||||
href="github.com/luminous-devs/lume"
|
||||
target="_blank"
|
||||
className="text-blue-500 !underline"
|
||||
rel="noreferrer"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { TextNote } from "@lume/ark";
|
||||
import {
|
||||
EditIcon,
|
||||
ReactionIcon,
|
||||
ReplyIcon,
|
||||
RepostIcon,
|
||||
ZapIcon,
|
||||
} from "@lume/icons";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function TutorialNoteScreen() {
|
||||
const exampleEvent = new NDKEvent(undefined, {
|
||||
id: "a3527670dd9b178bf7c2a9ea673b63bc8bfe774942b196691145343623c45821",
|
||||
pubkey: "04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9",
|
||||
created_at: 1701355223,
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: "good morning nostr, stay humble and stack sats 🫡",
|
||||
sig: "9e0bd67ec25598744f20bff0fe360fdf190c4240edb9eea260e50f77e07f94ea767ececcc6270819b7f64e5e7ca1fe20b4971f46dc120e6db43114557f3a6dae",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full select-text items-center justify-center">
|
||||
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="inline-flex h-11 w-11 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
|
||||
<EditIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-light">
|
||||
What is a <span className="font-bold">Note?</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="px-3">
|
||||
Posts on Nostr based Social Network client are usually called
|
||||
'Notes.' Notes are arranged chronologically on the
|
||||
timeline and are updated in real-time.
|
||||
</p>
|
||||
<p className="px-3 font-semibold">Here is one example:</p>
|
||||
<TextNote event={exampleEvent} />
|
||||
<p className="px-3 font-semibold">
|
||||
Here are how you can interact with a note:
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 px-3">
|
||||
<div className="inline-flex gap-3">
|
||||
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
|
||||
<ReplyIcon className="h-5 w-5 text-blue-500" />
|
||||
</div>
|
||||
<p>
|
||||
Reply - Click on this button to reply to a note. It's also
|
||||
possible to reply to replies, continuing the conversation like a
|
||||
thread.
|
||||
</p>
|
||||
</div>
|
||||
<div className="inline-flex gap-3">
|
||||
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
|
||||
<ReactionIcon className="h-5 w-5 text-red-500" />
|
||||
</div>
|
||||
<p>
|
||||
Reaction - You can add reactions to the Note to express your
|
||||
concern.
|
||||
</p>
|
||||
</div>
|
||||
<div className="inline-flex gap-3">
|
||||
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
|
||||
<RepostIcon className="h-5 w-5 text-teal-500" />
|
||||
</div>
|
||||
<p>
|
||||
Repost - You can share that note to your own timeline. You can
|
||||
also quote them with your comments.
|
||||
</p>
|
||||
</div>
|
||||
<div className="inline-flex gap-3">
|
||||
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
|
||||
<ZapIcon className="h-5 w-5 text-orange-500" />
|
||||
</div>
|
||||
<p>
|
||||
Zap - You can send tip in Bitcoin to that note owner with
|
||||
zero-fees
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex gap-2 px-3">
|
||||
<Link
|
||||
to="/auth/finish"
|
||||
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-100 font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
>
|
||||
Back
|
||||
</Link>
|
||||
<Link
|
||||
to="/auth/tutorials/widget"
|
||||
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
|
||||
>
|
||||
Continue
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export function TutorialPostingScreen() {
|
||||
return <div></div>;
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { BellIcon, HomeIcon, PlusIcon } from "@lume/icons";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function TutorialWidgetScreen() {
|
||||
return (
|
||||
<div className="flex h-full w-full select-text items-center justify-center">
|
||||
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="inline-flex h-11 w-11 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
|
||||
<HomeIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-light">
|
||||
The concept of <span className="font-bold">Widgets</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 px-3">
|
||||
<p>
|
||||
Lume provides multiple widgets based on usage. You always can
|
||||
control what you need to show on your Home.
|
||||
</p>
|
||||
<p className="font-semibold">Default widgets:</p>
|
||||
<div className="inline-flex gap-3">
|
||||
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
|
||||
<HomeIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<p>Newsfeed - You can view notes from accounts you follow.</p>
|
||||
</div>
|
||||
<div className="inline-flex gap-3">
|
||||
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
|
||||
<BellIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<p>
|
||||
Notification - You can view all notifications related to your
|
||||
account.
|
||||
</p>
|
||||
</div>
|
||||
<p>
|
||||
If you want to add more widget, you can click to this button on Home
|
||||
Screen.
|
||||
</p>
|
||||
<div className="flex h-24 w-full items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-14 w-14 items-center justify-center rounded-full bg-neutral-200 text-neutral-900 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-5 flex gap-2">
|
||||
<Link
|
||||
to="/auth/tutorials/note"
|
||||
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-100 font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
>
|
||||
Back
|
||||
</Link>
|
||||
<Link
|
||||
to="/auth/tutorials/finish"
|
||||
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
|
||||
>
|
||||
Continue
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,8 @@
|
||||
import { LoaderIcon } from "@lume/icons";
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function WelcomeScreen() {
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const gotoCreateAccount = () => {
|
||||
setLoading(true);
|
||||
navigate("/auth/create");
|
||||
};
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-between w-full h-full">
|
||||
@@ -22,35 +15,28 @@ export function WelcomeScreen() {
|
||||
alt="lume"
|
||||
className="w-2/3"
|
||||
/>
|
||||
<p className="mt-5 text-lg font-medium leading-snug text-neutral-600 dark:text-neutral-500">
|
||||
Lume is your safe Nostr client to meet, explore and
|
||||
<br />
|
||||
freely sharing your though to everyone in nostrverse
|
||||
<p className="mt-5 text-lg whitespace-pre-line font-medium leading-snug text-neutral-600 dark:text-neutral-500">
|
||||
{t("welcome.title")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col w-full max-w-xs gap-2 mx-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={gotoCreateAccount}
|
||||
<Link
|
||||
to="/auth/create"
|
||||
className="inline-flex items-center justify-center w-full h-12 text-lg font-medium text-white bg-blue-500 rounded-xl hover:bg-blue-600"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
) : (
|
||||
"Create New Account"
|
||||
)}
|
||||
</button>
|
||||
{t("welcome.signup")}
|
||||
</Link>
|
||||
<Link
|
||||
to="/auth/login"
|
||||
className="inline-flex items-center justify-center w-full h-12 text-lg font-medium text-neutral-50 rounded-xl bg-neutral-950 hover:bg-neutral-900"
|
||||
>
|
||||
Login
|
||||
{t("welcome.login")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center h-11">
|
||||
<p className="text-neutral-800">
|
||||
Before joining Nostr, you can take time to learn more about Nostr{" "}
|
||||
<p className="text-neutral-700">
|
||||
{t("welcome.footer")}{" "}
|
||||
<Link
|
||||
to="https://nostr.com"
|
||||
target="_blank"
|
||||
|
||||
@@ -32,7 +32,7 @@ export function DepotContactCard() {
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus(false);
|
||||
toast.error(e);
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { CancelIcon, PlusIcon, UserAddIcon, UserRemoveIcon } from "@lume/icons";
|
||||
import { User } from "@lume/ui";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { resolveResource } from "@tauri-apps/api/path";
|
||||
import { resolveResource, resolve } from "@tauri-apps/api/path";
|
||||
import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { useEffect, useState } from "react";
|
||||
import { parse, stringify } from "smol-toml";
|
||||
import { toast } from "sonner";
|
||||
import { VITE_FLATPAK_RESOURCE } from "@lume/utils";
|
||||
|
||||
export function DepotMembers() {
|
||||
const [members, setMembers] = useState<Set<string>>(null);
|
||||
@@ -32,7 +33,7 @@ export function DepotMembers() {
|
||||
const updateMembers = async () => {
|
||||
setMembers(new Set(tmpMembers));
|
||||
|
||||
const defaultConfig = await resolveResource("resources/config.toml");
|
||||
const defaultConfig = VITE_FLATPAK_RESOURCE !== null ? await resolve("/",VITE_FLATPAK_RESOURCE) : await resolveResource("resources/config.toml");
|
||||
const config = await readTextFile(defaultConfig);
|
||||
const configContent = parse(config);
|
||||
|
||||
@@ -46,7 +47,7 @@ export function DepotMembers() {
|
||||
|
||||
useEffect(() => {
|
||||
async function loadConfig() {
|
||||
const defaultConfig = await resolveResource("resources/config.toml");
|
||||
const defaultConfig = VITE_FLATPAK_RESOURCE !== null ? await resolve("/",VITE_FLATPAK_RESOURCE) : await resolveResource("resources/config.toml");
|
||||
const config = await readTextFile(defaultConfig);
|
||||
const configContent = parse(config);
|
||||
setTmpMembers(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { LoaderIcon } from "@lume/icons";
|
||||
import { delay, VITE_FLATPAK_RESOURCE } from "@lume/utils";
|
||||
import { resolve, resolveResource } from "@tauri-apps/api/path";
|
||||
import { useStorage } from "@lume/storage";
|
||||
import { delay } from "@lume/utils";
|
||||
import { resolveResource } from "@tauri-apps/api/path";
|
||||
import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -21,7 +21,10 @@ export function DepotOnboardingScreen() {
|
||||
setLoading(true);
|
||||
|
||||
// get default config
|
||||
const defaultConfig = await resolveResource("resources/config.toml");
|
||||
const defaultConfig =
|
||||
VITE_FLATPAK_RESOURCE !== null
|
||||
? await resolve("/", VITE_FLATPAK_RESOURCE)
|
||||
: await resolveResource("resources/config.toml");
|
||||
const config = await readTextFile(defaultConfig);
|
||||
const parsedConfig = parse(config);
|
||||
|
||||
@@ -49,7 +52,7 @@ export function DepotOnboardingScreen() {
|
||||
navigate("/depot/");
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e);
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { useStorage } from "@lume/storage";
|
||||
import { downloadDir } from "@tauri-apps/api/path";
|
||||
import { message, save } from "@tauri-apps/plugin-dialog";
|
||||
@@ -11,6 +12,7 @@ interface RouteError {
|
||||
}
|
||||
|
||||
export function ErrorScreen() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const error = useRouteError() as RouteError;
|
||||
|
||||
@@ -51,7 +53,7 @@ export function ErrorScreen() {
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="relative flex h-screen w-screen items-center justify-center bg-blue-600 overflow-hidden rounded-t-xl"
|
||||
className="relative flex h-screen w-screen items-center justify-center bg-blue-500 overflow-hidden rounded-xl"
|
||||
>
|
||||
<div className="flex w-full max-w-2xl flex-col items-start gap-8">
|
||||
<div className="flex flex-col">
|
||||
@@ -93,10 +95,10 @@ export function ErrorScreen() {
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="text-xl font-semibold text-white">
|
||||
3. Report this issue to Lume's Devs
|
||||
3. Report this issue to Lume
|
||||
</div>
|
||||
<a
|
||||
href="https://github.com/luminous-devs/lume/issues/new"
|
||||
href="https://github.com/lumehq/lume/issues/new"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex h-9 w-28 items-center justify-center rounded-lg bg-blue-800 px-3 font-medium text-white hover:bg-blue-900"
|
||||
@@ -118,13 +120,13 @@ export function ErrorScreen() {
|
||||
</div>
|
||||
<div className="select-text text-lg font-medium text-blue-300">
|
||||
<p>
|
||||
While waiting for Lume's Devs to release the bug fixes,
|
||||
you always can use other Nostr clients with your account:
|
||||
While waiting for Lume release the bug fixes, you always can
|
||||
use other Nostr clients with your account:
|
||||
</p>
|
||||
<div className="mt-2 flex flex-col gap-1 text-white">
|
||||
<a
|
||||
className="hover:!underline"
|
||||
href="https://snort.social"
|
||||
href="https://snort.social/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
@@ -132,15 +134,15 @@ export function ErrorScreen() {
|
||||
</a>
|
||||
<a
|
||||
className="hover:!underline"
|
||||
href="https://primal.net"
|
||||
href="https://nostter.app/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
primal.net
|
||||
nostter
|
||||
</a>
|
||||
<a
|
||||
className="hover:!underline"
|
||||
href="https://nostrudel.ninja"
|
||||
href="https://nostrudel.ninja/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
||||
@@ -1,21 +1,33 @@
|
||||
import { Antenas } from "@columns/antenas";
|
||||
import { Default } from "@columns/default";
|
||||
import { ForYou } from "@columns/foryou";
|
||||
import { Global } from "@columns/global";
|
||||
import { Group } from "@columns/group";
|
||||
import { Hashtag } from "@columns/hashtag";
|
||||
import { Thread } from "@columns/thread";
|
||||
import { Timeline } from "@columns/timeline";
|
||||
import { TrendingNotes } from "@columns/trending-notes";
|
||||
import { User } from "@columns/user";
|
||||
import { Waifu } from "@columns/waifu";
|
||||
import { useColumnContext } from "@lume/ark";
|
||||
import { ArrowLeftIcon, ArrowRightIcon, PlusSquareIcon } from "@lume/icons";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
PlusIcon,
|
||||
PlusSquareIcon,
|
||||
} from "@lume/icons";
|
||||
import { IColumn } from "@lume/types";
|
||||
import { TutorialModal } from "@lume/ui/src/tutorial/modal";
|
||||
import { COL_TYPES } from "@lume/utils";
|
||||
import { useRef, useState } from "react";
|
||||
import { VList, VListHandle } from "virtua";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { VList } from "virtua";
|
||||
|
||||
export function HomeScreen() {
|
||||
const ref = useRef<VListHandle>(null);
|
||||
const { addColumn, columns } = useColumnContext();
|
||||
const { t } = useTranslation();
|
||||
const { columns, vlistRef, addColumn } = useColumnContext();
|
||||
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
|
||||
const renderItem = (column: IColumn) => {
|
||||
@@ -24,6 +36,8 @@ export function HomeScreen() {
|
||||
return <Default key={column.id} column={column} />;
|
||||
case COL_TYPES.newsfeed:
|
||||
return <Timeline key={column.id} column={column} />;
|
||||
case COL_TYPES.foryou:
|
||||
return <ForYou key={column.id} column={column} />;
|
||||
case COL_TYPES.thread:
|
||||
return <Thread key={column.id} column={column} />;
|
||||
case COL_TYPES.user:
|
||||
@@ -34,6 +48,12 @@ export function HomeScreen() {
|
||||
return <Group key={column.id} column={column} />;
|
||||
case COL_TYPES.antenas:
|
||||
return <Antenas key={column.id} column={column} />;
|
||||
case COL_TYPES.global:
|
||||
return <Global key={column.id} column={column} />;
|
||||
case COL_TYPES.trendingNotes:
|
||||
return <TrendingNotes key={column.id} column={column} />;
|
||||
case COL_TYPES.waifu:
|
||||
return <Waifu key={column.id} column={column} />;
|
||||
default:
|
||||
return <Default key={column.id} column={column} />;
|
||||
}
|
||||
@@ -42,21 +62,20 @@ export function HomeScreen() {
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
<VList
|
||||
id="timeline"
|
||||
ref={ref}
|
||||
ref={vlistRef}
|
||||
className="h-full w-full flex-nowrap overflow-x-auto !overflow-y-hidden scrollbar-none focus:outline-none"
|
||||
itemSize={420}
|
||||
tabIndex={0}
|
||||
horizontal
|
||||
onKeyDown={(e) => {
|
||||
if (!ref.current) return;
|
||||
if (!vlistRef.current) return;
|
||||
switch (e.code) {
|
||||
case "ArrowUp":
|
||||
case "ArrowLeft": {
|
||||
e.preventDefault();
|
||||
const prevIndex = Math.max(selectedIndex - 1, 0);
|
||||
setSelectedIndex(prevIndex);
|
||||
ref.current.scrollToIndex(prevIndex, {
|
||||
vlistRef.current.scrollToIndex(prevIndex, {
|
||||
align: "center",
|
||||
smooth: true,
|
||||
});
|
||||
@@ -67,7 +86,7 @@ export function HomeScreen() {
|
||||
e.preventDefault();
|
||||
const nextIndex = Math.min(selectedIndex + 1, columns.length - 1);
|
||||
setSelectedIndex(nextIndex);
|
||||
ref.current.scrollToIndex(nextIndex, {
|
||||
vlistRef.current.scrollToIndex(nextIndex, {
|
||||
align: "center",
|
||||
smooth: true,
|
||||
});
|
||||
@@ -79,38 +98,7 @@ export function HomeScreen() {
|
||||
}}
|
||||
>
|
||||
{columns.map((column) => renderItem(column))}
|
||||
<div className="w-[420px]" />
|
||||
</VList>
|
||||
<div className="absolute bottom-3 right-3">
|
||||
<div className="flex items-center gap-1 p-1 bg-black/30 dark:bg-white/30 backdrop-blur-xl rounded-xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const prevIndex = Math.max(selectedIndex - 1, 0);
|
||||
setSelectedIndex(prevIndex);
|
||||
ref.current.scrollToIndex(prevIndex, {
|
||||
align: "center",
|
||||
smooth: true,
|
||||
});
|
||||
}}
|
||||
className="inline-flex items-center justify-center rounded-lg text-white/70 hover:text-white hover:bg-black/30 size-10"
|
||||
>
|
||||
<ArrowLeftIcon className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const nextIndex = Math.min(selectedIndex + 1, columns.length - 1);
|
||||
setSelectedIndex(nextIndex);
|
||||
ref.current.scrollToIndex(nextIndex, {
|
||||
align: "center",
|
||||
smooth: true,
|
||||
});
|
||||
}}
|
||||
className="inline-flex items-center justify-center rounded-lg text-white/70 hover:text-white hover:bg-black/30 size-10"
|
||||
>
|
||||
<ArrowRightIcon className="size-5" />
|
||||
</button>
|
||||
<div className="w-[420px] h-full flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () =>
|
||||
@@ -120,14 +108,94 @@ export function HomeScreen() {
|
||||
content: "",
|
||||
})
|
||||
}
|
||||
className="inline-flex items-center justify-center rounded-lg text-white/70 hover:text-white hover:bg-black/30 size-10"
|
||||
className="size-16 inline-flex items-center justify-center hover:bg-neutral-100 dark:hover:bg-neutral-900 rounded-2xl"
|
||||
>
|
||||
<PlusSquareIcon className="size-5" />
|
||||
<PlusIcon className="size-6" />
|
||||
</button>
|
||||
<div className="w-px h-6 bg-white/10" />
|
||||
<TutorialModal />
|
||||
</div>
|
||||
</div>
|
||||
</VList>
|
||||
<Tooltip.Provider>
|
||||
<div className="absolute bottom-3 right-3">
|
||||
<div className="flex items-center gap-1 p-1 bg-black/50 dark:bg-white/30 backdrop-blur-xl rounded-xl shadow-toolbar">
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const prevIndex = Math.max(selectedIndex - 1, 0);
|
||||
setSelectedIndex(prevIndex);
|
||||
vlistRef.current.scrollToIndex(prevIndex, {
|
||||
align: "center",
|
||||
smooth: true,
|
||||
});
|
||||
}}
|
||||
className="inline-flex items-center justify-center rounded-lg text-white/70 hover:text-white hover:bg-black/30 size-10"
|
||||
>
|
||||
<ArrowLeftIcon className="size-5" />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade">
|
||||
{t("global.moveLeft")}
|
||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const nextIndex = Math.min(
|
||||
selectedIndex + 1,
|
||||
columns.length - 1,
|
||||
);
|
||||
setSelectedIndex(nextIndex);
|
||||
vlistRef.current.scrollToIndex(nextIndex, {
|
||||
align: "center",
|
||||
smooth: true,
|
||||
});
|
||||
}}
|
||||
className="inline-flex items-center justify-center rounded-lg text-white/70 hover:text-white hover:bg-black/30 size-10"
|
||||
>
|
||||
<ArrowRightIcon className="size-5" />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade">
|
||||
{t("global.moveRight")}
|
||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () =>
|
||||
await addColumn({
|
||||
kind: COL_TYPES.default,
|
||||
title: "",
|
||||
content: "",
|
||||
})
|
||||
}
|
||||
className="inline-flex items-center justify-center rounded-lg text-white/70 hover:text-white hover:bg-black/30 size-10"
|
||||
>
|
||||
<PlusSquareIcon className="size-5" />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade">
|
||||
{t("global.newColumn")}
|
||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
<div className="w-px h-6 bg-white/10" />
|
||||
<TutorialModal />
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,13 @@ import { FETCH_LIMIT } from "@lume/utils";
|
||||
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { VList } from "virtua";
|
||||
|
||||
export function RelayEventList({ relayUrl }: { relayUrl: string }) {
|
||||
const ark = useArk();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ["relay-events", relayUrl],
|
||||
@@ -37,14 +40,10 @@ export function RelayEventList({ relayUrl }: { relayUrl: string }) {
|
||||
if (!lastEvent) return;
|
||||
return lastEvent.created_at - 1;
|
||||
},
|
||||
select: (data) => data?.pages.flatMap((page) => page),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const allEvents = useMemo(
|
||||
() => (data ? data.pages.flatMap((page) => page) : []),
|
||||
[data],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
(event: NDKEvent) => {
|
||||
switch (event.kind) {
|
||||
@@ -64,7 +63,7 @@ export function RelayEventList({ relayUrl }: { relayUrl: string }) {
|
||||
{status === "pending" ? (
|
||||
<NoteSkeleton />
|
||||
) : (
|
||||
allEvents.map((item) => renderItem(item))
|
||||
data.map((item) => renderItem(item))
|
||||
)}
|
||||
<div className="flex h-16 items-center justify-center px-3 pb-3">
|
||||
{hasNextPage ? (
|
||||
@@ -79,7 +78,7 @@ export function RelayEventList({ relayUrl }: { relayUrl: string }) {
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="h-5 w-5" />
|
||||
Load more
|
||||
{t("global.loading")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { useRelaylist } from "@lume/ark";
|
||||
import { PlusIcon } from "@lume/icons";
|
||||
import { NDKRelayUrl } from "@nostr-dev-kit/ndk";
|
||||
import { normalizeRelayUrl } from "nostr-fetch";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const domainRegex = /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/;
|
||||
|
||||
export function RelayForm() {
|
||||
const { connectRelay } = useRelaylist();
|
||||
|
||||
const [relay, setRelay] = useState<{
|
||||
url: NDKRelayUrl;
|
||||
url: WebSocket["url"];
|
||||
purpose: "read" | "write" | undefined;
|
||||
}>({ url: "", purpose: undefined });
|
||||
|
||||
@@ -19,10 +16,8 @@ export function RelayForm() {
|
||||
if (relay.url.length < 1) return toast.info("Please enter relay url");
|
||||
try {
|
||||
const relayUrl = new URL(relay.url.replace(/\s/g, ""));
|
||||
if (
|
||||
domainRegex.test(relayUrl.host) &&
|
||||
(relayUrl.protocol === "wss:" || relayUrl.protocol === "ws:")
|
||||
) {
|
||||
|
||||
if (relayUrl.protocol === "wss:" || relayUrl.protocol === "ws:") {
|
||||
connectRelay.mutate(normalizeRelayUrl(relay.url));
|
||||
setRelay({ url: "", purpose: undefined });
|
||||
} else {
|
||||
|
||||
@@ -1,36 +1,55 @@
|
||||
import { useRelaylist } from "@lume/ark";
|
||||
import { PlusIcon, ShareIcon } from "@lume/icons";
|
||||
import { User, useRelaylist } from "@lume/ark";
|
||||
import { PlusIcon, SearchIcon } from "@lume/icons";
|
||||
import { normalizeRelayUrl } from "nostr-fetch";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function RelayItem({ url }: { url: string }) {
|
||||
export function RelayItem({ url, users }: { url: string; users?: string[] }) {
|
||||
const domain = new URL(url).hostname;
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { connectRelay } = useRelaylist();
|
||||
|
||||
return (
|
||||
<div className="flex h-14 w-full items-center justify-between border-b border-neutral-100 px-5 dark:border-neutral-950">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-neutral-500 dark:text-neutral-400">
|
||||
Relay:{" "}
|
||||
{t("global.relay")}:{" "}
|
||||
</span>
|
||||
<span className="max-w-[200px] truncate text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{url}
|
||||
</span>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
{users ? (
|
||||
<div className="isolate flex -space-x-2 mr-4">
|
||||
{users.slice(0, 4).map((item) => (
|
||||
<User.Provider pubkey={item}>
|
||||
<User.Root>
|
||||
<User.Avatar className="size-8 inline-block rounded-full ring-1 ring-neutral-100 dark:ring-neutral-900" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
))}
|
||||
{users.length > 4 ? (
|
||||
<div className="inline-flex size-8 items-center justify-center rounded-full bg-neutral-100 text-neutral-900 ring-1 ring-neutral-200 dark:bg-neutral-900 dark:text-neutral-100 dark:ring-neutral-800">
|
||||
<span className="text-xs font-medium">+{users.length - 4}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<Link
|
||||
to={`/relays/${domain}/`}
|
||||
className="inline-flex h-6 items-center justify-center gap-1 rounded bg-neutral-100 px-1.5 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
className="inline-flex h-8 items-center justify-center gap-2 rounded-lg bg-neutral-100 px-2 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<ShareIcon className="h-3 w-3" />
|
||||
Inspect
|
||||
<SearchIcon className="size-4" />
|
||||
{t("global.inspect")}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => connectRelay.mutate(normalizeRelayUrl(url))}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded bg-blue-100 text-blue-500 hover:bg-blue-200 dark:bg-blue-900 hover:dark:bg-blue-800"
|
||||
className="inline-flex size-8 items-center justify-center rounded-lg bg-blue-100 text-blue-500 hover:bg-blue-200 dark:bg-blue-900 hover:dark:bg-blue-800"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
<PlusIcon className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useArk, useRelay } from "@lume/ark";
|
||||
import { LoaderIcon, PlusIcon, ShareIcon } from "@lume/icons";
|
||||
import { User } from "@lume/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { VList } from "virtua";
|
||||
|
||||
export function RelayList() {
|
||||
const ark = useArk();
|
||||
const { connectRelay } = useRelay();
|
||||
const { status, data } = useQuery({
|
||||
queryKey: ["relays"],
|
||||
queryFn: async () => {
|
||||
return await ark.getAllRelaysFromContacts();
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const inspectRelay = (relayUrl: string) => {
|
||||
const url = new URL(relayUrl);
|
||||
navigate(`/relays/${url.hostname}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="col-span-2 bg-white">
|
||||
{status === "pending" ? (
|
||||
<div className="flex h-full w-full items-center justify-center pb-10">
|
||||
<div className="inline-flex flex-col items-center justify-center gap-2">
|
||||
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
|
||||
<p>Loading relay...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<VList className="h-full">
|
||||
<div className="inline-flex h-16 w-full items-center border-b border-neutral-100 px-3 dark:border-neutral-900">
|
||||
<h3 className="font-semibold">Relay discovery</h3>
|
||||
</div>
|
||||
{[...data].map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex h-14 w-full items-center justify-between border-b border-neutral-100 px-3 dark:border-neutral-900"
|
||||
>
|
||||
<div className="inline-flex items-center gap-2 divide-x divide-neutral-100 dark:divide-neutral-900">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inspectRelay(key)}
|
||||
className="inline-flex h-6 items-center justify-center gap-1 rounded bg-neutral-200 px-1.5 text-sm font-medium text-neutral-900 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<ShareIcon className="h-3 w-3" />
|
||||
Inspect
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => connectRelay.mutate(key)}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded text-neutral-900 hover:bg-neutral-200 dark:text-neutral-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2 pl-3">
|
||||
<span className="text-sm font-semibold text-neutral-500 dark:text-neutral-400">
|
||||
Relay:{" "}
|
||||
</span>
|
||||
<span className="max-w-[200px] truncate text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{key}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="isolate flex -space-x-2">
|
||||
{value.slice(0, 4).map((item) => (
|
||||
<User key={item} pubkey={item} variant="stacked" />
|
||||
))}
|
||||
{value.length > 4 ? (
|
||||
<div className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-neutral-200 text-neutral-900 ring-1 ring-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:ring-neutral-700">
|
||||
<span className="text-xs font-medium">+{value.length}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</VList>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { CancelIcon, RefreshIcon } from "@lume/icons";
|
||||
import { useArk, useRelaylist } from "@lume/ark";
|
||||
import { CancelIcon, LoaderIcon, RefreshIcon } from "@lume/icons";
|
||||
import { cn } from "@lume/utils";
|
||||
import { NDKKind } from "@nostr-dev-kit/ndk";
|
||||
import { NDKKind, NDKSubscriptionCacheUsage } from "@nostr-dev-kit/ndk";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RelayForm } from "./relayForm";
|
||||
|
||||
export function RelaySidebar({ className }: { className?: string }) {
|
||||
const ark = useArk();
|
||||
|
||||
const { status, data, refetch } = useQuery({
|
||||
const { t } = useTranslation();
|
||||
const { removeRelay } = useRelaylist();
|
||||
const { status, data, isRefetching, refetch } = useQuery({
|
||||
queryKey: ["relay-personal"],
|
||||
queryFn: async () => {
|
||||
const event = await ark.getEventByFilter({
|
||||
@@ -16,12 +19,15 @@ export function RelaySidebar({ className }: { className?: string }) {
|
||||
kinds: [NDKKind.RelayList],
|
||||
authors: [ark.account.pubkey],
|
||||
},
|
||||
cache: NDKSubscriptionCacheUsage.ONLY_RELAY,
|
||||
});
|
||||
|
||||
if (!event) return [];
|
||||
return event.tags.filter((tag) => tag[0] === "r");
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const currentRelays = new Set(
|
||||
@@ -36,23 +42,25 @@ export function RelaySidebar({ className }: { className?: string }) {
|
||||
)}
|
||||
>
|
||||
<div className="inline-flex items-center justify-between w-full h-14 px-3 border-b border-black/10 dark:border-white/10">
|
||||
<h3 className="font-semibold">Connected relays</h3>
|
||||
<h3 className="font-semibold">{t("relays.sidebar.title")}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
className="inline-flex items-center justify-center w-6 h-6 rounded-md shrink-0 hover:bg-neutral-100 dark:hover:bg-neutral-900"
|
||||
>
|
||||
<RefreshIcon className="w-4 h-4" />
|
||||
<RefreshIcon
|
||||
className={cn("size-4", isRefetching ? "animate-spin" : "")}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 px-3 mt-3">
|
||||
{status === "pending" ? (
|
||||
<p>Loading...</p>
|
||||
<div className="flex items-center justify-center w-full h-20 rounded-lg bg-black/10 dark:bg-white/10">
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
</div>
|
||||
) : !data.length ? (
|
||||
<div className="flex items-center justify-center w-full h-20 rounded-xl bg-neutral-50 dark:bg-neutral-950">
|
||||
<p className="text-sm font-medium">
|
||||
You not have personal relay list yet
|
||||
</p>
|
||||
<div className="flex items-center justify-center w-full h-20 rounded-lg bg-black/10 dark:bg-white/10">
|
||||
<p className="text-sm font-medium">{t("relays.sidebar.empty")}</p>
|
||||
</div>
|
||||
) : (
|
||||
data.map((item) => (
|
||||
@@ -73,7 +81,10 @@ export function RelaySidebar({ className }: { className?: string }) {
|
||||
</span>
|
||||
)}
|
||||
<p className="max-w-[20rem] truncate text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{item[1].replace("wss://", "").replace("ws://", "")}
|
||||
{item[1]
|
||||
.replace("wss://", "")
|
||||
.replace("ws://", "")
|
||||
.replace("/", "")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
@@ -84,9 +95,10 @@ export function RelaySidebar({ className }: { className?: string }) {
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="items-center justify-center hidden w-6 h-6 rounded group-hover:inline-flex hover:bg-neutral-300 dark:hover:bg-neutral-700"
|
||||
onClick={() => removeRelay.mutate(item[1])}
|
||||
className="items-center justify-center hidden size-6 rounded group-hover:inline-flex hover:bg-neutral-300 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<CancelIcon className="w-4 h-4 text-neutral-900 dark:text-neutral-100" />
|
||||
<CancelIcon className="size-4 text-neutral-900 dark:text-neutral-100" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { LoaderIcon, PlusIcon, ShareIcon } from "@lume/icons";
|
||||
import { LoaderIcon } from "@lume/icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { VList } from "virtua";
|
||||
import { RelayItem } from "./components/relayItem";
|
||||
|
||||
export function RelayFollowsScreen() {
|
||||
const ark = useArk();
|
||||
const { isLoading, data: relays } = useQuery({
|
||||
const {
|
||||
isLoading,
|
||||
isError,
|
||||
data: relays,
|
||||
} = useQuery({
|
||||
queryKey: ["relay-follows"],
|
||||
queryFn: async ({ signal }: { signal: AbortSignal }) => {
|
||||
return await ark.getAllRelaysFromContacts();
|
||||
const data = await ark.getAllRelaysFromContacts({ signal });
|
||||
if (!data) throw new Error("Failed to get relay list from contacts");
|
||||
return data;
|
||||
},
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
@@ -23,10 +30,18 @@ export function RelayFollowsScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !relays) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<p>Error</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VList itemSize={49}>
|
||||
{relays.map((item: string) => (
|
||||
<RelayItem key={item} url={item} />
|
||||
{[...relays].map(([key, value]) => (
|
||||
<RelayItem key={key} url={key} users={value} />
|
||||
))}
|
||||
</VList>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ export function RelayGlobalScreen() {
|
||||
},
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
import { RelaySidebar } from "./components/sidebar";
|
||||
|
||||
export function RelaysScreen() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="grid h-full w-full lg:grid-cols-4 xl:grid-cols-5 rounded-xl shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:shadow-none dark:ring-1 dark:ring-white/10">
|
||||
<RelaySidebar className="col-span-1" />
|
||||
@@ -20,7 +23,7 @@ export function RelaysScreen() {
|
||||
)
|
||||
}
|
||||
>
|
||||
Global
|
||||
{t("relays.global")}
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to={"/relays/follows/"}
|
||||
@@ -33,7 +36,7 @@ export function RelaysScreen() {
|
||||
)
|
||||
}
|
||||
>
|
||||
Follows
|
||||
{t("relays.follows")}
|
||||
</NavLink>
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { ArrowLeftIcon, LoaderIcon } from "@lume/icons";
|
||||
import { LoaderIcon } from "@lume/icons";
|
||||
import { NIP11 } from "@lume/types";
|
||||
import { User } from "@lume/ui";
|
||||
import { Suspense } from "react";
|
||||
import { Await, useLoaderData, useNavigate, useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Await, useLoaderData, useParams } from "react-router-dom";
|
||||
import { RelayEventList } from "./components/relayEventList";
|
||||
|
||||
export function RelayUrlScreen() {
|
||||
const { t } = useTranslation();
|
||||
const { url } = useParams();
|
||||
|
||||
const data: { relay?: { [key: string]: string } } = useLoaderData();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const getSoftwareName = (url: string) => {
|
||||
const filename = url.substring(url.lastIndexOf("/") + 1);
|
||||
@@ -32,7 +33,7 @@ export function RelayUrlScreen() {
|
||||
fallback={
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
Loading...
|
||||
{t("global.loading")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -40,7 +41,7 @@ export function RelayUrlScreen() {
|
||||
resolve={data.relay}
|
||||
errorElement={
|
||||
<div className="text-sm font-medium">
|
||||
<p>Could not load relay information 😬</p>
|
||||
<p>{t("relays.relayView.empty")}</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -55,7 +56,7 @@ export function RelayUrlScreen() {
|
||||
{resolvedRelay.pubkey ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||
Owner:
|
||||
{t("relays.relayView.owner")}:
|
||||
</h5>
|
||||
<div className="w-full rounded-lg bg-neutral-100 px-2 py-2 dark:bg-neutral-900">
|
||||
<User pubkey={resolvedRelay.pubkey} variant="simple" />
|
||||
@@ -65,7 +66,7 @@ export function RelayUrlScreen() {
|
||||
{resolvedRelay.contact ? (
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||
Contact:
|
||||
{t("relays.relayView.contact")}:
|
||||
</h5>
|
||||
<a
|
||||
href={`mailto:${resolvedRelay.contact}`}
|
||||
@@ -79,7 +80,7 @@ export function RelayUrlScreen() {
|
||||
) : null}
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||
Software:
|
||||
{t("relays.relayView.software")}:
|
||||
</h5>
|
||||
<a
|
||||
href={resolvedRelay.software}
|
||||
@@ -94,7 +95,7 @@ export function RelayUrlScreen() {
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||
Supported NIPs:
|
||||
{t("relays.relayView.nips")}:
|
||||
</h5>
|
||||
<div className="mt-2 grid grid-cols-7 gap-2">
|
||||
{resolvedRelay.supported_nips.map((item) => (
|
||||
@@ -113,26 +114,24 @@ export function RelayUrlScreen() {
|
||||
{resolvedRelay.limitation ? (
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||
Limitation
|
||||
{t("relays.relayView.limit")}
|
||||
</h5>
|
||||
<div className="flex flex-col gap-2 divide-y divide-white/5">
|
||||
{Object.keys(resolvedRelay.limitation).map(
|
||||
(key, index) => {
|
||||
return (
|
||||
<div
|
||||
key={key + index}
|
||||
className="flex items-baseline justify-between pt-2"
|
||||
>
|
||||
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{titleCase(key)}:
|
||||
</p>
|
||||
<p className="text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||
{resolvedRelay.limitation[key].toString()}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
{Object.keys(resolvedRelay.limitation).map((key) => {
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-baseline justify-between pt-2"
|
||||
>
|
||||
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{titleCase(key)}:
|
||||
</p>
|
||||
<p className="text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||
{resolvedRelay.limitation[key].toString()}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -144,10 +143,10 @@ export function RelayUrlScreen() {
|
||||
rel="noreferrer"
|
||||
className="inline-flex h-10 w-full items-center justify-center rounded-lg bg-blue-500 text-sm font-medium hover:bg-blue-600"
|
||||
>
|
||||
Open payment website
|
||||
{t("relays.relayView.payment")}
|
||||
</a>
|
||||
<span className="text-center text-xs text-neutral-600 dark:text-neutral-400">
|
||||
You need to make a payment to connect this relay
|
||||
{t("relays.relayView.paymentNote")}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,76 +1,75 @@
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import { relaunch } from '@tauri-apps/plugin-process';
|
||||
import { Update, check } from '@tauri-apps/plugin-updater';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { relaunch } from "@tauri-apps/plugin-process";
|
||||
import { Update, check } from "@tauri-apps/plugin-updater";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function AboutScreen() {
|
||||
const [version, setVersion] = useState('');
|
||||
const [newUpdate, setNewUpdate] = useState<Update>(null);
|
||||
const [t] = useTranslation();
|
||||
const [version, setVersion] = useState("");
|
||||
const [newUpdate, setNewUpdate] = useState<Update>(null);
|
||||
|
||||
const checkUpdate = async () => {
|
||||
const update = await check();
|
||||
if (!update) toast.info('There is no update available');
|
||||
setNewUpdate(update);
|
||||
};
|
||||
const checkUpdate = async () => {
|
||||
const update = await check();
|
||||
if (!update) toast.info("There is no update available");
|
||||
setNewUpdate(update);
|
||||
};
|
||||
|
||||
const installUpdate = async () => {
|
||||
await newUpdate.downloadAndInstall();
|
||||
await relaunch();
|
||||
};
|
||||
const installUpdate = async () => {
|
||||
await newUpdate.downloadAndInstall();
|
||||
await relaunch();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function loadVersion() {
|
||||
const appVersion = await getVersion();
|
||||
setVersion(appVersion);
|
||||
}
|
||||
useEffect(() => {
|
||||
async function loadVersion() {
|
||||
const appVersion = await getVersion();
|
||||
setVersion(appVersion);
|
||||
}
|
||||
|
||||
loadVersion();
|
||||
}, []);
|
||||
loadVersion();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-lg">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<img src="/icon.png" alt="Lume's logo" className="w-16 shrink-0" />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Lume</h1>
|
||||
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
Version {version}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto mt-4 flex w-full max-w-xs flex-col gap-2">
|
||||
{!newUpdate ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => checkUpdate()}
|
||||
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 text-sm font-medium text-white hover:bg-blue-600"
|
||||
>
|
||||
Check for update
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => installUpdate()}
|
||||
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 text-sm font-medium text-white hover:bg-blue-600"
|
||||
>
|
||||
Install {newUpdate.version}
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
to="https://lume.nu"
|
||||
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-100 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
>
|
||||
Website
|
||||
</Link>
|
||||
<Link
|
||||
to="https://github.com/luminous-devs/lume/issues"
|
||||
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-100 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
>
|
||||
Report a issue
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-lg">
|
||||
<div className="flex flex-col items-center">
|
||||
<h1 className="leading-tight text-xl font-semibold">Lume</h1>
|
||||
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
{t("settings.about.version")} {version}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mx-auto mt-4 flex w-full max-w-xs flex-col gap-2">
|
||||
{!newUpdate ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => checkUpdate()}
|
||||
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 text-sm font-medium text-white hover:bg-blue-600"
|
||||
>
|
||||
{t("settings.about.checkUpdate")}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => installUpdate()}
|
||||
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 text-sm font-medium text-white hover:bg-blue-600"
|
||||
>
|
||||
{t("settings.about.installUpdate")} {newUpdate.version}
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
to="https://lume.nu"
|
||||
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-100 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
>
|
||||
Website
|
||||
</Link>
|
||||
<Link
|
||||
to="https://github.com/lumehq/lume/issues"
|
||||
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-100 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
>
|
||||
Report a issue
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useStorage } from "@lume/storage";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function AdvancedSettingScreen() {
|
||||
const storage = useStorage();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const clearCache = async () => {
|
||||
await storage.clearCache();
|
||||
@@ -13,16 +15,18 @@ export function AdvancedSettingScreen() {
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="w-24 shrink-0 text-end text-sm font-semibold">
|
||||
Cache
|
||||
{t("settings.advanced.cache.title")}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{t("settings.advanced.cache.subtitle")}
|
||||
</div>
|
||||
<div className="text-sm">Use for boost up nostr connection</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => clearCache()}
|
||||
className="h-8 w-max rounded-lg px-3 text-sm font-semibold text-blue-500 bg-blue-100 hover:bg-blue-200"
|
||||
>
|
||||
Clear
|
||||
{t("settings.advanced.cache.button")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { EyeOffIcon } from "@lume/icons";
|
||||
import { useStorage } from "@lume/storage";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function BackupSettingScreen() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
|
||||
const [t] = useTranslation();
|
||||
const [privkey, setPrivkey] = useState(null);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
@@ -24,14 +28,12 @@ export function BackupSettingScreen() {
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-lg">
|
||||
<div className="mb-2 text-sm font-semibold">Private key</div>
|
||||
<div>
|
||||
{!privkey ? (
|
||||
<div className="inline-flex h-24 w-full items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
|
||||
You've stored private key on Lume
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{privkey ? (
|
||||
<div>
|
||||
<div className="mb-2 text-sm font-semibold">
|
||||
{t("settings.backup.privkey.title")}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
readOnly
|
||||
@@ -50,12 +52,12 @@ export function BackupSettingScreen() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removePrivkey()}
|
||||
className="mt-2 inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg bg-red-200 px-6 font-medium text-red-500 hover:bg-red-500 hover:text-white focus:outline-none dark:hover:text-white"
|
||||
className="mt-2 inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-red-200 dark:bg-red-800 px-6 font-medium text-red-500 hover:bg-red-500 hover:text-white focus:outline-none dark:hover:text-white"
|
||||
>
|
||||
Remove private key
|
||||
{t("settings.backup.privkey.button")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { LoaderIcon } from "@lume/icons";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function AvatarUpload({ setPicture }) {
|
||||
const ark = useArk();
|
||||
|
||||
const [t] = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const upload = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// upload image to nostr.build server
|
||||
// #TODO: support multiple server
|
||||
const image = await ark.upload({ fileExts: [] });
|
||||
|
||||
if (!image)
|
||||
toast.error("Failed to upload image, please try again later.");
|
||||
|
||||
setPicture(image);
|
||||
setLoading(false);
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error("Failed to upload image, please try again later.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={upload}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center justify-center w-36 font-medium rounded-lg h-8 bg-blue-500 hover:bg-blue-600 text-white disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
t("user.avatarButton")
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { EditIcon, LoaderIcon } from "@lume/icons";
|
||||
import { compactNumber } from "@lume/utils";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function ContactCard() {
|
||||
const ark = useArk();
|
||||
const { status, data } = useQuery({
|
||||
queryKey: ["contacts"],
|
||||
queryFn: async () => {
|
||||
const contacts = await ark.getUserContacts();
|
||||
return contacts;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="col-span-1 h-44 rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
|
||||
{status === "pending" ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col justify-between p-4">
|
||||
<h3 className="pt-1 text-5xl font-semibold tabular-nums text-neutral-900 dark:text-neutral-100">
|
||||
{compactNumber.format(data.length)}
|
||||
</h3>
|
||||
<div className="mt-auto flex h-6 w-full items-center justify-between">
|
||||
<p className="text-xl font-medium leading-none text-neutral-600 dark:text-neutral-400">
|
||||
Contacts
|
||||
</p>
|
||||
<Link
|
||||
to="/settings/edit-contact"
|
||||
className="inline-flex h-6 w-max items-center gap-1 rounded-full bg-neutral-200 px-2.5 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<EditIcon className="h-3 w-3" />
|
||||
Edit
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { LoaderIcon } from "@lume/icons";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function CoverUpload({ setBanner }) {
|
||||
const ark = useArk();
|
||||
|
||||
const [t] = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const upload = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// upload image to nostr.build server
|
||||
// #TODO: support multiple server
|
||||
const image = await ark.upload({ fileExts: [] });
|
||||
|
||||
if (!image)
|
||||
toast.error("Failed to upload image, please try again later.");
|
||||
|
||||
setBanner(image);
|
||||
setLoading(false);
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error("Failed to upload image, please try again later.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={upload}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center justify-center w-32 font-medium rounded-lg h-8 bg-blue-500 hover:bg-blue-600 text-white disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
t("user.coverButton")
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { LoaderIcon } from "@lume/icons";
|
||||
import { compactNumber } from "@lume/utils";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetch } from "@tauri-apps/plugin-http";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function PostCard() {
|
||||
const ark = useArk();
|
||||
const { status, data } = useQuery({
|
||||
queryKey: ["user-stats", ark.account.pubkey],
|
||||
queryFn: async ({ signal }: { signal: AbortSignal }) => {
|
||||
const res = await fetch(
|
||||
`https://api.nostr.band/v0/stats/profile/${ark.account.pubkey}`,
|
||||
{
|
||||
signal,
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Error");
|
||||
}
|
||||
|
||||
return await res.json();
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="col-span-1 h-44 rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
|
||||
{status === "pending" ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col justify-between p-4">
|
||||
<h3 className="pt-1 text-5xl font-semibold tabular-nums text-neutral-900 dark:text-neutral-100">
|
||||
{compactNumber.format(
|
||||
data.stats[ark.account.pubkey].pub_note_count,
|
||||
)}
|
||||
</h3>
|
||||
<div className="mt-auto flex h-6 w-full items-center justify-between">
|
||||
<p className="text-xl font-medium leading-none text-neutral-600 dark:text-neutral-400">
|
||||
Posts
|
||||
</p>
|
||||
<Link
|
||||
to={`/users/${ark.account.pubkey}`}
|
||||
className="inline-flex h-6 w-max items-center gap-1 rounded-full bg-neutral-200 px-2.5 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { useArk, useProfile } from "@lume/ark";
|
||||
import { EditIcon, LoaderIcon } from "@lume/icons";
|
||||
import { displayNpub } from "@lume/utils";
|
||||
import * as Avatar from "@radix-ui/react-avatar";
|
||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { minidenticon } from "minidenticons";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function ProfileCard() {
|
||||
const ark = useArk();
|
||||
const svgURI = `data:image/svg+xml;utf8,${encodeURIComponent(
|
||||
minidenticon(ark.account.pubkey, 90, 50),
|
||||
)}`;
|
||||
|
||||
const { isLoading, user } = useProfile(ark.account.pubkey);
|
||||
|
||||
const copyNpub = async () => {
|
||||
return await writeText(nip19.npubEncode(ark.account.pubkey));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-4 h-56 w-full rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
|
||||
{isLoading ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col justify-between p-4">
|
||||
<div className="flex h-10 w-full justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyNpub}
|
||||
className="inline-flex h-8 w-28 transform items-center justify-center gap-1.5 rounded-full bg-neutral-200 text-sm font-medium hover:bg-neutral-400 active:translate-y-1 dark:bg-neutral-800 dark:hover:bg-neutral-600"
|
||||
>
|
||||
Copy NPUB
|
||||
</button>
|
||||
<Link
|
||||
to="/settings/edit-profile"
|
||||
className="inline-flex h-8 w-20 items-center justify-center gap-1.5 rounded-full bg-neutral-200 text-sm font-medium hover:bg-neutral-400 dark:bg-neutral-800 dark:hover:bg-neutral-600"
|
||||
>
|
||||
<EditIcon className="h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<Avatar.Root className="shrink-0">
|
||||
<Avatar.Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={ark.account.pubkey}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: "auto" }}
|
||||
className="h-16 w-16 rounded-xl border border-neutral-200/50 shadow-[rgba(17,_17,_26,_0.1)_0px_0px_16px] dark:border-neutral-800/50"
|
||||
/>
|
||||
<Avatar.Fallback delayMs={300}>
|
||||
<img
|
||||
src={svgURI}
|
||||
alt={ark.account.pubkey}
|
||||
className="h-16 w-16 rounded-xl bg-black dark:bg-white"
|
||||
/>
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div>
|
||||
<h3 className="text-3xl font-semibold leading-8 text-neutral-900 dark:text-neutral-100">
|
||||
{user?.display_name || user?.name}
|
||||
</h3>
|
||||
<p className="text-lg text-neutral-700 dark:text-neutral-300">
|
||||
{user?.nip05 || displayNpub(ark.account.pubkey, 16)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { EditIcon, LoaderIcon } from "@lume/icons";
|
||||
import { compactNumber } from "@lume/utils";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function RelayCard() {
|
||||
const ark = useArk();
|
||||
|
||||
const { status, data } = useQuery({
|
||||
queryKey: ["relays", ark.account.pubkey],
|
||||
queryFn: async () => {
|
||||
const relays = await ark.getUserRelays({});
|
||||
return relays;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="col-span-1 h-44 rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
|
||||
{status === "pending" ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col justify-between p-4">
|
||||
<h3 className="pt-1 text-5xl font-semibold tabular-nums text-neutral-900 dark:text-neutral-100">
|
||||
{compactNumber.format(data?.relays?.length || 0)}
|
||||
</h3>
|
||||
<div className="mt-auto flex h-6 w-full items-center justify-between">
|
||||
<p className="text-xl font-medium leading-none text-neutral-600 dark:text-neutral-400">
|
||||
Relays
|
||||
</p>
|
||||
<Link
|
||||
to="/relays"
|
||||
className="inline-flex h-6 w-max items-center gap-1 rounded-full bg-neutral-200 px-2.5 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<EditIcon className="h-3 w-3" />
|
||||
Edit
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { LoaderIcon } from "@lume/icons";
|
||||
import { useStorage } from "@lume/storage";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function NWCForm({ setWalletConnectURL }) {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
|
||||
const [uri, setUri] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
if (!uri.startsWith("nostr+walletconnect:")) {
|
||||
toast.error(
|
||||
"Connect URI is required and must start with format nostr+walletconnect:, please check again",
|
||||
);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const uriObj = new URL(uri);
|
||||
const params = new URLSearchParams(uriObj.search);
|
||||
|
||||
if (params.has("relay") && params.has("secret")) {
|
||||
await storage.createPrivkey(`${ark.account.pubkey}-nwc`, uri);
|
||||
setWalletConnectURL(uri);
|
||||
setLoading(false);
|
||||
} else {
|
||||
setLoading(false);
|
||||
toast.error("Connect URI is not valid, please check again");
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900">
|
||||
<textarea
|
||||
name="walletConnectURL"
|
||||
value={uri}
|
||||
onChange={(e) => setUri(e.target.value)}
|
||||
placeholder="nostr+walletconnect://"
|
||||
className="h-40 w-full resize-none rounded-lg border-transparent bg-neutral-200 px-3 py-3 text-neutral-900 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:focus:ring-blue-800 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
|
||||
>
|
||||
{loading ? <LoaderIcon className="h-4 w-4 animate-spin" /> : "Connect"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { LoaderIcon } from "@lume/icons";
|
||||
import { compactNumber } from "@lume/utils";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetch } from "@tauri-apps/plugin-http";
|
||||
|
||||
export function ZapCard() {
|
||||
const ark = useArk();
|
||||
const { status, data } = useQuery({
|
||||
queryKey: ["user-stats", ark.account.pubkey],
|
||||
queryFn: async ({ signal }: { signal: AbortSignal }) => {
|
||||
const res = await fetch(
|
||||
`https://api.nostr.band/v0/stats/profile/${ark.account.pubkey}`,
|
||||
{
|
||||
signal,
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Error");
|
||||
}
|
||||
|
||||
return await res.json();
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="col-span-1 h-44 rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
|
||||
{status === "pending" ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col justify-between p-4">
|
||||
<h3 className="pt-1 text-5xl font-semibold tabular-nums text-neutral-900 dark:text-neutral-100">
|
||||
{compactNumber.format(
|
||||
data?.stats[ark.account.pubkey]?.zaps_received?.msats / 1000 || 0,
|
||||
)}
|
||||
</h3>
|
||||
<div className="mt-auto flex h-6 items-center text-xl font-medium leading-none text-neutral-600 dark:text-neutral-400">
|
||||
Sats received
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { LoaderIcon } from "@lume/icons";
|
||||
import { User } from "@lume/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export function EditContactScreen() {
|
||||
const ark = useArk();
|
||||
const { status, data } = useQuery({
|
||||
queryKey: ["contacts"],
|
||||
queryFn: async () => {
|
||||
return await ark.getUserContacts();
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-xl flex-col gap-3">
|
||||
{status === "pending" ? (
|
||||
<div className="flex h-10 w-full items-center justify-center">
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
data.map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="flex h-16 w-full items-center justify-between rounded-xl bg-neutral-100 px-2.5 dark:bg-neutral-900"
|
||||
>
|
||||
<User pubkey={item} variant="simple" />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CheckIcon, DarkIcon, LightIcon, SystemModeIcon } from "@lume/icons";
|
||||
import { DarkIcon, LightIcon, SystemModeIcon } from "@lume/icons";
|
||||
import { useStorage } from "@lume/storage";
|
||||
import { cn } from "@lume/utils";
|
||||
import * as Switch from "@radix-ui/react-switch";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrent } from "@tauri-apps/api/window";
|
||||
@@ -9,21 +10,17 @@ import {
|
||||
requestPermission,
|
||||
} from "@tauri-apps/plugin-notification";
|
||||
import { useEffect, useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function GeneralSettingScreen() {
|
||||
const storage = useStorage();
|
||||
|
||||
const [t] = useTranslation();
|
||||
const [apiKey, setAPIKey] = useState("");
|
||||
const [settings, setSettings] = useState({
|
||||
lowPower: false,
|
||||
autoupdate: false,
|
||||
...storage.settings,
|
||||
notification: false,
|
||||
autolaunch: false,
|
||||
outbox: false,
|
||||
media: true,
|
||||
hashtag: true,
|
||||
notification: true,
|
||||
translation: false,
|
||||
appearance: "system",
|
||||
});
|
||||
|
||||
@@ -100,47 +97,6 @@ export function GeneralSettingScreen() {
|
||||
|
||||
const permissionGranted = await isPermissionGranted();
|
||||
setSettings((prev) => ({ ...prev, notification: permissionGranted }));
|
||||
|
||||
const data = await storage.getAllSettings();
|
||||
if (!data) return;
|
||||
|
||||
for (const item of data) {
|
||||
if (item.key === "autoupdate")
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
autoupdate: !!parseInt(item.value),
|
||||
}));
|
||||
|
||||
if (item.key === "lowPower")
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
lowPower: !!parseInt(item.value),
|
||||
}));
|
||||
|
||||
if (item.key === "outbox")
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
outbox: !!parseInt(item.value),
|
||||
}));
|
||||
|
||||
if (item.key === "media")
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
media: !!parseInt(item.value),
|
||||
}));
|
||||
|
||||
if (item.key === "hashtag")
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
hashtag: !!parseInt(item.value),
|
||||
}));
|
||||
|
||||
if (item.key === "translation")
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
translation: !!parseInt(item.value),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
loadSettings();
|
||||
@@ -151,10 +107,12 @@ export function GeneralSettingScreen() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="w-24 shrink-0 text-end text-sm font-semibold">
|
||||
Update
|
||||
<div className="w-36 shrink-0 text-end text-sm font-semibold">
|
||||
{t("settings.general.update.title")}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{t("settings.general.update.subtitle")}
|
||||
</div>
|
||||
<div className="text-sm">Automatically download new update</div>
|
||||
</div>
|
||||
<Switch.Root
|
||||
checked={settings.autoupdate}
|
||||
@@ -166,11 +124,11 @@ export function GeneralSettingScreen() {
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="w-24 shrink-0 text-end text-sm font-semibold">
|
||||
Low Power
|
||||
<div className="w-36 shrink-0 text-end text-sm font-semibold">
|
||||
{t("settings.general.lowPower.title")}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
Sustainable for low network environment.
|
||||
{t("settings.general.lowPower.subtitle")}
|
||||
</div>
|
||||
</div>
|
||||
<Switch.Root
|
||||
@@ -183,10 +141,12 @@ export function GeneralSettingScreen() {
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="w-24 shrink-0 text-end text-sm font-semibold">
|
||||
Startup
|
||||
<div className="w-36 shrink-0 text-end text-sm font-semibold">
|
||||
{t("settings.general.startup.title")}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{t("settings.general.startup.subtitle")}
|
||||
</div>
|
||||
<div className="text-sm">Launch Lume at Login</div>
|
||||
</div>
|
||||
<Switch.Root
|
||||
checked={settings.autolaunch}
|
||||
@@ -198,10 +158,12 @@ export function GeneralSettingScreen() {
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="w-24 shrink-0 text-end text-sm font-semibold">
|
||||
Media
|
||||
<div className="w-36 shrink-0 text-end text-sm font-semibold">
|
||||
{t("settings.general.media.title")}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{t("settings.general.media.subtitle")}
|
||||
</div>
|
||||
<div className="text-sm">Automatically load media</div>
|
||||
</div>
|
||||
<Switch.Root
|
||||
checked={settings.media}
|
||||
@@ -213,10 +175,12 @@ export function GeneralSettingScreen() {
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="w-24 shrink-0 text-end text-sm font-semibold">
|
||||
Hashtag
|
||||
<div className="w-36 shrink-0 text-end text-sm font-semibold">
|
||||
{t("settings.general.hashtag.title")}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{t("settings.general.hashtag.subtitle")}
|
||||
</div>
|
||||
<div className="text-sm">Show all hashtags in content</div>
|
||||
</div>
|
||||
<Switch.Root
|
||||
checked={settings.hashtag}
|
||||
@@ -228,10 +192,12 @@ export function GeneralSettingScreen() {
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="w-24 shrink-0 text-end text-sm font-semibold">
|
||||
Notification
|
||||
<div className="w-36 shrink-0 text-end text-sm font-semibold">
|
||||
{t("settings.general.notification.title")}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{t("settings.general.notification.subtitle")}
|
||||
</div>
|
||||
<div className="text-sm">Automatically send notification</div>
|
||||
</div>
|
||||
<Switch.Root
|
||||
checked={settings.notification}
|
||||
@@ -244,10 +210,12 @@ export function GeneralSettingScreen() {
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="w-24 shrink-0 text-end text-sm font-semibold">
|
||||
Translation
|
||||
<div className="w-36 shrink-0 text-end text-sm font-semibold">
|
||||
{t("settings.general.translation.title")}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{t("settings.general.translation.subtitle")}
|
||||
</div>
|
||||
<div className="text-sm">Translate text to your language</div>
|
||||
</div>
|
||||
<Switch.Root
|
||||
checked={settings.translation}
|
||||
@@ -259,8 +227,8 @@ export function GeneralSettingScreen() {
|
||||
</div>
|
||||
{settings.translation ? (
|
||||
<div className="flex w-full items-center gap-8">
|
||||
<div className="w-24 shrink-0 text-end text-sm font-semibold">
|
||||
API Key
|
||||
<div className="w-36 shrink-0 text-end text-sm font-semibold">
|
||||
{t("global.apiKey")}
|
||||
</div>
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
@@ -276,15 +244,15 @@ export function GeneralSettingScreen() {
|
||||
onClick={saveApi}
|
||||
className="mr-1 h-7 w-16 text-sm font-medium shrink-0 inline-flex items-center justify-center rounded-md bg-neutral-200 dark:bg-neutral-800 hover:bg-neutral-300 dark:hover:bg-neutral-700"
|
||||
>
|
||||
Save
|
||||
{t("global.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex w-full items-start gap-8">
|
||||
<div className="w-24 shrink-0 text-end text-sm font-semibold">
|
||||
Appearance
|
||||
<div className="w-36 shrink-0 text-end text-sm font-semibold">
|
||||
{t("settings.general.appearance.title")}
|
||||
</div>
|
||||
<div className="flex flex-1 gap-6">
|
||||
<button
|
||||
@@ -293,7 +261,7 @@ export function GeneralSettingScreen() {
|
||||
className="flex flex-col items-center justify-center gap-0.5"
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
className={cn(
|
||||
"inline-flex h-11 w-11 items-center justify-center rounded-lg",
|
||||
settings.appearance === "light"
|
||||
? "bg-blue-500 text-white"
|
||||
@@ -303,7 +271,7 @@ export function GeneralSettingScreen() {
|
||||
<LightIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
Light
|
||||
{t("settings.general.appearance.light")}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
@@ -312,7 +280,7 @@ export function GeneralSettingScreen() {
|
||||
className="flex flex-col items-center justify-center gap-0.5"
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
className={cn(
|
||||
"inline-flex h-11 w-11 items-center justify-center rounded-lg",
|
||||
settings.appearance === "dark"
|
||||
? "bg-blue-500 text-white"
|
||||
@@ -322,7 +290,7 @@ export function GeneralSettingScreen() {
|
||||
<DarkIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
Dark
|
||||
{t("settings.general.appearance.dark")}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
@@ -331,7 +299,7 @@ export function GeneralSettingScreen() {
|
||||
className="flex flex-col items-center justify-center gap-0.5"
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
className={cn(
|
||||
"inline-flex h-11 w-11 items-center justify-center rounded-lg",
|
||||
settings.appearance === "auto"
|
||||
? "bg-blue-500 text-white"
|
||||
@@ -341,7 +309,7 @@ export function GeneralSettingScreen() {
|
||||
<SystemModeIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
System
|
||||
{t("settings.general.appearance.system")}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { ContactCard } from "./components/contactCard";
|
||||
import { PostCard } from "./components/postCard";
|
||||
import { ProfileCard } from "./components/profileCard";
|
||||
import { RelayCard } from "./components/relayCard";
|
||||
import { ZapCard } from "./components/zapCard";
|
||||
|
||||
export function UserSettingScreen() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-xl">
|
||||
<ProfileCard />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<ContactCard />
|
||||
<RelayCard />
|
||||
<PostCard />
|
||||
<ZapCard />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,153 +1,156 @@
|
||||
import { webln } from "@getalby/sdk";
|
||||
import { useArk } from "@lume/ark";
|
||||
import { CheckCircleIcon } from "@lume/icons";
|
||||
import { useStorage } from "@lume/storage";
|
||||
import * as Switch from "@radix-ui/react-switch";
|
||||
import { useEffect, useState } from "react";
|
||||
import { NWCForm } from "./components/walletConnectForm";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function NWCScreen() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
|
||||
const [t] = useTranslation();
|
||||
const [settings, setSettings] = useState({
|
||||
nwc: false,
|
||||
instantZap: storage.settings.instantZap,
|
||||
});
|
||||
const [walletConnectURL, setWalletConnectURL] = useState<null | string>(null);
|
||||
const [balance, setBalance] = useState(0);
|
||||
const [amount, setAmount] = useState("21");
|
||||
|
||||
const saveNWC = async () => {
|
||||
try {
|
||||
if (!walletConnectURL.startsWith("nostr+walletconnect:")) {
|
||||
return toast.error(
|
||||
"Connect URI is required and must start with format nostr+walletconnect:, please check again",
|
||||
);
|
||||
}
|
||||
|
||||
const uriObj = new URL(walletConnectURL);
|
||||
const params = new URLSearchParams(uriObj.search);
|
||||
|
||||
if (params.has("relay") && params.has("secret")) {
|
||||
await storage.createPrivkey(
|
||||
`${ark.account.pubkey}.nwc`,
|
||||
walletConnectURL,
|
||||
);
|
||||
|
||||
storage.nwc = walletConnectURL;
|
||||
|
||||
setWalletConnectURL(walletConnectURL);
|
||||
setSettings((state) => ({ ...state, nwc: true }));
|
||||
} else {
|
||||
return toast.error("Connect URI is not valid, please check again");
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleInstantZap = async () => {
|
||||
await storage.createSetting("instantZap", String(+!settings.instantZap));
|
||||
setSettings((state) => ({ ...state, instantZap: !settings.instantZap }));
|
||||
};
|
||||
|
||||
const saveAmount = async () => {
|
||||
await storage.createSetting("zapAmount", amount);
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
await storage.removePrivkey(`${ark.account.pubkey}-nwc`);
|
||||
setWalletConnectURL(null);
|
||||
};
|
||||
await storage.removePrivkey(`${ark.account.pubkey}.nwc`);
|
||||
|
||||
const loadBalance = async () => {
|
||||
const nwc = new webln.NostrWebLNProvider({
|
||||
nostrWalletConnectUrl: walletConnectURL,
|
||||
});
|
||||
await nwc.enable();
|
||||
|
||||
const balanceResponse = await nwc.getBalance();
|
||||
setBalance(balanceResponse.balance);
|
||||
|
||||
nwc.close();
|
||||
setWalletConnectURL("");
|
||||
setSettings((state) => ({ ...state, nwc: false }));
|
||||
storage.nwc = null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (walletConnectURL) loadBalance();
|
||||
}, [walletConnectURL]);
|
||||
|
||||
useEffect(() => {
|
||||
async function getNWC() {
|
||||
const nwc = await storage.loadPrivkey(`${ark.account.pubkey}-nwc`);
|
||||
if (nwc) setWalletConnectURL(nwc);
|
||||
if (storage.nwc) {
|
||||
setSettings((state) => ({ ...state, nwc: true }));
|
||||
setWalletConnectURL(storage.nwc);
|
||||
}
|
||||
getNWC();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex w-full flex-col gap-5">
|
||||
<div className="text-center">
|
||||
<h3 className="text-2xl font-semibold leading-tight">
|
||||
Nostr Wallet Connect
|
||||
</h3>
|
||||
<p className="text-lg font-medium leading-snug text-neutral-600 dark:text-neutral-500">
|
||||
Sending zap easily via Bitcoin Lightning.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mx-auto w-full max-w-lg">
|
||||
{!walletConnectURL ? (
|
||||
<NWCForm setWalletConnectURL={setWalletConnectURL} />
|
||||
) : (
|
||||
<div className="flex w-full flex-col gap-3 rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900">
|
||||
<div className="flex items-center justify-center gap-1.5 text-sm text-teal-500">
|
||||
<CheckCircleIcon className="h-4 w-4" />
|
||||
<div>You're using nostr wallet connect</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<textarea
|
||||
readOnly
|
||||
value={`${walletConnectURL.substring(0, 120)}****`}
|
||||
className="h-40 w-full resize-none rounded-lg border-transparent bg-neutral-200 px-3 py-3 text-neutral-900 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:focus:ring-blue-800 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
<div className="mx-auto w-full max-w-lg">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex w-full items-start gap-8">
|
||||
<div className="w-36 shrink-0 text-end text-sm font-semibold">
|
||||
{t("settings.zap.nwc")}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 w-full">
|
||||
<textarea
|
||||
spellCheck={false}
|
||||
value={walletConnectURL}
|
||||
onChange={(e) => setWalletConnectURL(e.target.value)}
|
||||
className="w-full h-24 resize-none border-transparent outline-none focus:outline-none focus:ring-0 focus:border-none rounded-lg ring-0 placeholder:text-neutral-600 bg-neutral-100 dark:bg-neutral-900"
|
||||
/>
|
||||
{!settings.nwc ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove()}
|
||||
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-neutral-200 px-6 font-medium text-red-500 hover:bg-red-500 hover:text-white focus:outline-none dark:bg-neutral-800 dark:text-neutral-100"
|
||||
onClick={saveNWC}
|
||||
className="h-8 w-16 text-sm font-medium shrink-0 inline-flex items-center justify-center rounded-md bg-neutral-200 dark:bg-neutral-800 hover:bg-neutral-300 dark:hover:bg-neutral-700"
|
||||
>
|
||||
Remove connection
|
||||
{t("global.save")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={remove}
|
||||
className="h-8 w-16 text-sm font-medium shrink-0 inline-flex items-center justify-center rounded-md bg-neutral-200 dark:bg-neutral-800 hover:bg-neutral-300 dark:hover:bg-neutral-700"
|
||||
>
|
||||
{t("global.delete")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{walletConnectURL ? (
|
||||
<div className="mt-5 flex flex-col">
|
||||
<h3 className="font-medium text-neutral-600 dark:text-neutral-400">
|
||||
Current balance
|
||||
</h3>
|
||||
<p className="text-2xl font-semibold">
|
||||
{new Intl.NumberFormat().format(balance)} sats
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-5 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h5 className="font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
Introduction
|
||||
</h5>
|
||||
<p className="text-neutral-600 dark:text-neutral-400">
|
||||
Nostr Wallet Connect (NWC) is a way for applications like
|
||||
Nostr clients to access a remote Lightning wallet through a
|
||||
standardized protocol.
|
||||
</p>
|
||||
<p className="text-neutral-600 dark:text-neutral-400">
|
||||
To learn more about the details have a look at{" "}
|
||||
<a
|
||||
href="https://github.com/nostr-protocol/nips/blob/master/47.md"
|
||||
target="_blank"
|
||||
className="text-blue-500"
|
||||
rel="noreferrer"
|
||||
>
|
||||
the specs (NIP47)
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h5 className="font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
About zapping
|
||||
</h5>
|
||||
<p className="text-neutral-600 dark:text-neutral-400">
|
||||
Lume doesn't take any commission or platform fees when
|
||||
you zap someone. Lume doesn't hold your Bitcoin
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h5 className="font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
Recommend wallet that support NWC
|
||||
</h5>
|
||||
<p className="text-neutral-600 dark:text-neutral-400">
|
||||
- Mutiny Wallet:{" "}
|
||||
<a
|
||||
href="https://www.mutinywallet.com/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500"
|
||||
>
|
||||
website
|
||||
</a>
|
||||
</p>
|
||||
<p className="text-neutral-600 dark:text-neutral-400">
|
||||
- Self hosted NWC on Umbrel :{" "}
|
||||
<a
|
||||
href="https://apps.umbrel.com/app/alby-nostr-wallet-connect"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500"
|
||||
>
|
||||
website
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{settings.nwc ? (
|
||||
<>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="w-36 shrink-0 text-end text-sm font-semibold">
|
||||
{t("settings.zap.instant.title")}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{t("settings.zap.instant.subtitle")}
|
||||
</div>
|
||||
</div>
|
||||
<Switch.Root
|
||||
checked={settings.instantZap}
|
||||
onClick={() => toggleInstantZap()}
|
||||
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
|
||||
>
|
||||
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||
</Switch.Root>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex w-full items-center gap-8">
|
||||
<div className="w-36 shrink-0 text-end text-sm font-semibold">
|
||||
{t("settings.zap.defaultAmount")}
|
||||
</div>
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
type="number"
|
||||
spellCheck={false}
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
className="w-full border-transparent outline-none focus:outline-none focus:ring-0 focus:border-none h-9 rounded-lg ring-0 placeholder:text-neutral-600 bg-neutral-100 dark:bg-neutral-900"
|
||||
/>
|
||||
<div className="h-9 absolute right-0 top-0 inline-flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveAmount}
|
||||
className="mr-1 h-7 w-16 text-sm font-medium shrink-0 inline-flex items-center justify-center rounded-md bg-neutral-200 dark:bg-neutral-800 hover:bg-neutral-300 dark:hover:bg-neutral-700"
|
||||
>
|
||||
{t("global.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,31 +1,29 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
LoaderIcon,
|
||||
PlusIcon,
|
||||
UnverifiedIcon,
|
||||
} from "@lume/icons";
|
||||
import { CheckCircleIcon, LoaderIcon, UnverifiedIcon } from "@lume/icons";
|
||||
import { useStorage } from "@lume/storage";
|
||||
import { NDKKind, NDKUserProfile } from "@nostr-dev-kit/ndk";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { AvatarUpload } from "./components/avatarUpload";
|
||||
import { CoverUpload } from "./components/coverUpload";
|
||||
|
||||
export function ProfileSettingScreen() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
export function EditProfileScreen() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [picture, setPicture] = useState("");
|
||||
const [banner, setBanner] = useState("");
|
||||
const [nip05, setNIP05] = useState({ verified: true, text: "" });
|
||||
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setError,
|
||||
formState: { isValid, errors },
|
||||
} = useForm({
|
||||
@@ -47,58 +45,14 @@ export function EditProfileScreen() {
|
||||
},
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const uploadAvatar = async () => {
|
||||
try {
|
||||
if (!ark.ndk.signer) return navigate("/new/privkey");
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const image = await ark.upload({});
|
||||
if (image) {
|
||||
setPicture(image);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
await message(`Upload failed, error: ${e}`, {
|
||||
title: "Lume",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const uploadBanner = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const image = await ark.upload({});
|
||||
|
||||
if (image) {
|
||||
setBanner(image);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
await message(`Upload failed, error: ${e}`, {
|
||||
title: "Lume",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (data: NDKUserProfile) => {
|
||||
// start loading
|
||||
setLoading(true);
|
||||
|
||||
let content = {
|
||||
...data,
|
||||
username: data.name,
|
||||
display_name: data.name,
|
||||
bio: data.about,
|
||||
image: data.picture,
|
||||
picture,
|
||||
banner,
|
||||
};
|
||||
|
||||
if (data.nip05) {
|
||||
@@ -106,13 +60,14 @@ export function EditProfileScreen() {
|
||||
pubkey: ark.account.pubkey,
|
||||
nip05: data.nip05,
|
||||
});
|
||||
|
||||
if (verify) {
|
||||
content = { ...content, nip05: data.nip05 };
|
||||
} else {
|
||||
setNIP05((prev) => ({ ...prev, verified: false }));
|
||||
setError("nip05", {
|
||||
type: "manual",
|
||||
message: "Can't verify your Lume ID / NIP-05, please check again",
|
||||
message: "Can't verify your NIP-05, please check again",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -125,72 +80,58 @@ export function EditProfileScreen() {
|
||||
|
||||
if (publish) {
|
||||
// invalid cache
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["user", ark.account.pubkey],
|
||||
await storage.clearProfileCache(ark.account.pubkey);
|
||||
await queryClient.setQueryData(["user", ark.account.pubkey], () => {
|
||||
return content;
|
||||
});
|
||||
// reset form
|
||||
reset();
|
||||
|
||||
// notify
|
||||
toast.success("You've updated profile successfully.");
|
||||
|
||||
// reset state
|
||||
setLoading(false);
|
||||
setPicture(null);
|
||||
setBanner(null);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mx-auto w-full max-w-lg">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="mb-0">
|
||||
<input type={"hidden"} {...register("picture")} value={picture} />
|
||||
<input type={"hidden"} {...register("banner")} value={banner} />
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="relative h-36 w-full">
|
||||
<div className="mb-5 flex flex-col items-center justify-center bg-neutral-100 dark:bg-neutral-900 rounded-xl">
|
||||
<div className="relative h-44 w-full">
|
||||
{banner ? (
|
||||
<img
|
||||
src={banner}
|
||||
alt="user's banner"
|
||||
className="h-full w-full rounded-xl object-cover"
|
||||
className="h-full w-full rounded-t-xl object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full rounded-xl bg-neutral-200 dark:bg-neutral-900" />
|
||||
<div className="h-full w-full rounded-t-xl bg-neutral-200 dark:bg-neutral-800" />
|
||||
)}
|
||||
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform overflow-hidden rounded-xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => uploadBanner()}
|
||||
className="inline-flex h-full w-full items-center justify-center bg-black/20 text-white"
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="absolute right-4 top-4">
|
||||
<CoverUpload setBanner={setBanner} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-5 px-4">
|
||||
<div className="relative z-10 -mt-7 h-14 w-14 overflow-hidden rounded-xl ring-2 ring-white dark:ring-black">
|
||||
<div className="-mt-7 mb-5 px-4 flex flex-col gap-4 items-center z-10 relative">
|
||||
<div className="size-14 overflow-hidden rounded-xl ring-2 ring-white dark:ring-black">
|
||||
<img
|
||||
src={picture}
|
||||
alt="user's avatar"
|
||||
className="h-14 w-14 rounded-xl object-cover"
|
||||
className="h-14 w-14 rounded-xl object-cover bg-white dark:bg-black"
|
||||
/>
|
||||
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => uploadAvatar()}
|
||||
className="inline-flex h-full w-full items-center justify-center rounded-xl bg-black/50 text-white"
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<AvatarUpload setPicture={setPicture} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="display_name"
|
||||
htmlFor="displayName"
|
||||
className="text-sm font-semibold uppercase tracking-wider"
|
||||
>
|
||||
Display Name
|
||||
{t("user.displayName")}
|
||||
</label>
|
||||
<input
|
||||
type={"text"}
|
||||
@@ -204,7 +145,7 @@ export function EditProfileScreen() {
|
||||
htmlFor="name"
|
||||
className="text-sm font-semibold uppercase tracking-wider"
|
||||
>
|
||||
Name
|
||||
{t("user.name")}
|
||||
</label>
|
||||
<input
|
||||
type={"text"}
|
||||
@@ -230,12 +171,12 @@ export function EditProfileScreen() {
|
||||
{nip05.verified ? (
|
||||
<span className="inline-flex h-6 items-center gap-1 rounded-full bg-teal-500 px-1 pr-1.5 text-xs font-medium text-white">
|
||||
<CheckCircleIcon className="h-4 w-4" />
|
||||
Verified
|
||||
{t("user.verified")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex h-6 items-center gap-1 rounded bg-red-500 pl-1 pr-1.5 text-xs font-medium text-white">
|
||||
<UnverifiedIcon className="h-4 w-4" />
|
||||
Unverified
|
||||
{t("user.unverified")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -251,7 +192,7 @@ export function EditProfileScreen() {
|
||||
htmlFor="website"
|
||||
className="text-sm font-semibold uppercase tracking-wider"
|
||||
>
|
||||
Website
|
||||
{t("user.website")}
|
||||
</label>
|
||||
<input
|
||||
type={"text"}
|
||||
@@ -265,7 +206,7 @@ export function EditProfileScreen() {
|
||||
htmlFor="website"
|
||||
className="text-sm font-semibold uppercase tracking-wider"
|
||||
>
|
||||
Lightning address
|
||||
{t("user.lna")}
|
||||
</label>
|
||||
<input
|
||||
type={"text"}
|
||||
@@ -279,24 +220,24 @@ export function EditProfileScreen() {
|
||||
htmlFor="about"
|
||||
className="text-sm font-semibold uppercase tracking-wider"
|
||||
>
|
||||
Bio
|
||||
{t("user.bio")}
|
||||
</label>
|
||||
<textarea
|
||||
{...register("about")}
|
||||
spellCheck={false}
|
||||
className="relative h-20 w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-2 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
|
||||
className="relative h-36 w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-2 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="absolute right-4 bottom-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isValid}
|
||||
className="mx-auto inline-flex h-11 w-full transform items-center justify-center gap-1 rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600 focus:outline-none active:translate-y-1 disabled:pointer-events-none disabled:opacity-50"
|
||||
disabled={!isValid || loading}
|
||||
className="inline-flex items-center justify-center w-24 pb-[2px] font-semibold border-t rounded-lg border-neutral-900 dark:border-neutral-800 h-9 bg-neutral-950 text-neutral-50 dark:bg-neutral-900 hover:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-white" />
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
"Update"
|
||||
t("global.update")
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -1,153 +0,0 @@
|
||||
import { useArk, useProfile } from "@lume/ark";
|
||||
import { useStorage } from "@lume/storage";
|
||||
import { NIP05 } from "@lume/ui";
|
||||
import { displayNpub } from "@lume/utils";
|
||||
import * as Avatar from "@radix-ui/react-avatar";
|
||||
import { minidenticon } from "minidenticons";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { UserStats } from "./stats";
|
||||
|
||||
export function UserProfile({ pubkey }: { pubkey: string }) {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
|
||||
const { user } = useProfile(pubkey);
|
||||
|
||||
const [followed, setFollowed] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const svgURI = `data:image/svg+xml;utf8,${encodeURIComponent(
|
||||
minidenticon(pubkey, 90, 50),
|
||||
)}`;
|
||||
|
||||
const follow = async () => {
|
||||
try {
|
||||
if (!ark.ndk.signer) return navigate("/new/privkey");
|
||||
setFollowed(true);
|
||||
|
||||
const add = await ark.createContact({ pubkey });
|
||||
|
||||
if (!add) {
|
||||
toast.success("You already follow this user");
|
||||
setFollowed(false);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e);
|
||||
setFollowed(false);
|
||||
}
|
||||
};
|
||||
|
||||
const unfollow = async () => {
|
||||
try {
|
||||
if (!ark.ndk.signer) return navigate("/new/privkey");
|
||||
setFollowed(false);
|
||||
|
||||
await ark.deleteContact({ pubkey });
|
||||
} catch (e) {
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (ark.account.contacts.includes(pubkey)) {
|
||||
setFollowed(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!user) return <p>Loading...</p>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-56 w-full overflow-hidden rounded-tl-lg">
|
||||
{user?.banner ? (
|
||||
<img
|
||||
src={user?.banner}
|
||||
alt="user banner"
|
||||
className="h-full w-full rounded-tl-lg object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full rounded-tl-lg bg-neutral-100 dark:bg-neutral-900" />
|
||||
)}
|
||||
</div>
|
||||
<div className="-mt-7 flex w-full flex-col items-center px-5">
|
||||
<Avatar.Root className="shrink-0">
|
||||
<Avatar.Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: "auto" }}
|
||||
className="h-14 w-14 rounded-lg bg-white object-cover ring-2 ring-neutral-100 dark:ring-neutral-900"
|
||||
/>
|
||||
<Avatar.Fallback delayMs={300}>
|
||||
<img
|
||||
src={svgURI}
|
||||
alt={pubkey}
|
||||
className="h-14 w-14 rounded-lg bg-black dark:bg-white"
|
||||
/>
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div className="mt-2 flex flex-1 flex-col gap-6">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="inline-flex flex-col items-center">
|
||||
<h5 className="text-center text-xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{user?.name ||
|
||||
user?.display_name ||
|
||||
user?.displayName ||
|
||||
"No name"}
|
||||
</h5>
|
||||
{user?.nip05 ? (
|
||||
<NIP05
|
||||
pubkey={pubkey}
|
||||
nip05={user.nip05}
|
||||
className="text-neutral-600 dark:text-neutral-400"
|
||||
/>
|
||||
) : (
|
||||
<span className="max-w-[15rem] truncate text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{displayNpub(pubkey, 16)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-6">
|
||||
{user?.about || user?.bio ? (
|
||||
<p className="mt-2 max-w-[500px] select-text break-words text-center text-neutral-900 dark:text-neutral-100">
|
||||
{user.about || user.bio}
|
||||
</p>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<UserStats pubkey={pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="inline-flex items-center justify-center gap-2">
|
||||
{followed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={unfollow}
|
||||
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-neutral-200 text-sm font-medium text-neutral-900 backdrop-blur-xl hover:bg-blue-500 hover:text-neutral-100 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-blue-600 dark:hover:text-neutral-100"
|
||||
>
|
||||
Unfollow
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={follow}
|
||||
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-neutral-200 text-sm font-medium text-neutral-900 backdrop-blur-xl hover:bg-blue-500 hover:text-neutral-100 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-blue-600 dark:hover:text-neutral-100"
|
||||
>
|
||||
Follow
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
to={`/chats/${pubkey}`}
|
||||
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-neutral-200 text-sm font-medium text-neutral-900 backdrop-blur-xl hover:bg-blue-500 hover:text-neutral-100 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-blue-600 dark:hover:text-neutral-100"
|
||||
>
|
||||
Message
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { LoaderIcon } from "@lume/icons";
|
||||
import { compactNumber } from "@lume/utils";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetch } from "@tauri-apps/plugin-http";
|
||||
|
||||
export function UserStats({ pubkey }: { pubkey: string }) {
|
||||
const { status, data } = useQuery({
|
||||
queryKey: ["user-stats", pubkey],
|
||||
queryFn: async ({ signal }: { signal: AbortSignal }) => {
|
||||
const res = await fetch(
|
||||
`https://api.nostr.band/v0/stats/profile/${pubkey}`,
|
||||
{
|
||||
signal,
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Error");
|
||||
}
|
||||
|
||||
return await res.json();
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
if (status === "pending") {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return <div className="flex w-full items-center justify-center" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center gap-10">
|
||||
<div className="inline-flex flex-col items-center gap-1">
|
||||
<span className="font-semibold leading-none text-neutral-900 dark:text-neutral-100">
|
||||
{compactNumber.format(data?.stats[pubkey]?.followers_pubkey_count) ??
|
||||
0}
|
||||
</span>
|
||||
<span className="text-sm leading-none text-neutral-500 dark:text-neutral-400">
|
||||
Followers
|
||||
</span>
|
||||
</div>
|
||||
<div className="inline-flex flex-col items-center gap-1">
|
||||
<span className="font-semibold leading-none text-neutral-900 dark:text-neutral-100">
|
||||
{compactNumber.format(
|
||||
data?.stats[pubkey]?.pub_following_pubkey_count,
|
||||
) ?? 0}
|
||||
</span>
|
||||
<span className="text-sm leading-none text-neutral-500 dark:text-neutral-400">
|
||||
Following
|
||||
</span>
|
||||
</div>
|
||||
<div className="inline-flex flex-col items-center gap-1">
|
||||
<span className="font-semibold leading-none text-neutral-900 dark:text-neutral-100">
|
||||
{compactNumber.format(
|
||||
data?.stats[pubkey]?.zaps_received?.msats / 1000 ?? 0,
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm leading-none text-neutral-500 dark:text-neutral-400">
|
||||
Zaps received
|
||||
</span>
|
||||
</div>
|
||||
<div className="inline-flex flex-col items-center gap-1">
|
||||
<span className="font-semibold leading-none text-neutral-900 dark:text-neutral-100">
|
||||
{compactNumber.format(
|
||||
data?.stats[pubkey]?.zaps_sent?.msats / 1000 ?? 0,
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm leading-none text-neutral-500 dark:text-neutral-400">
|
||||
Zaps sent
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { NoteSkeleton, RepostNote, TextNote, useArk } from "@lume/ark";
|
||||
import { ArrowRightCircleIcon, LoaderIcon } from "@lume/icons";
|
||||
import { FETCH_LIMIT } from "@lume/utils";
|
||||
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { UserProfile } from "./components/profile";
|
||||
|
||||
export function UserScreen() {
|
||||
const { pubkey } = useParams();
|
||||
const ark = useArk();
|
||||
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ["user-posts", pubkey],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({
|
||||
signal,
|
||||
pageParam,
|
||||
}: {
|
||||
signal: AbortSignal;
|
||||
pageParam: number;
|
||||
}) => {
|
||||
const events = await ark.getInfiniteEvents({
|
||||
filter: {
|
||||
kinds: [NDKKind.Text, NDKKind.Repost],
|
||||
authors: [pubkey],
|
||||
},
|
||||
limit: FETCH_LIMIT,
|
||||
pageParam,
|
||||
signal,
|
||||
});
|
||||
|
||||
return events;
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
const lastEvent = lastPage.at(-1);
|
||||
if (!lastEvent) return;
|
||||
return lastEvent.created_at - 1;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const allEvents = useMemo(
|
||||
() => (data ? data.pages.flatMap((page) => page) : []),
|
||||
[data],
|
||||
);
|
||||
|
||||
// render event match event kind
|
||||
const renderItem = (event: NDKEvent) => {
|
||||
switch (event.kind) {
|
||||
case NDKKind.Text:
|
||||
return <TextNote key={event.id} event={event} />;
|
||||
case NDKKind.Repost:
|
||||
return <RepostNote key={event.id} event={event} />;
|
||||
default:
|
||||
return <TextNote key={event.id} event={event} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-y-auto">
|
||||
<UserProfile pubkey={pubkey} />
|
||||
<div className="mt-6 h-full w-full border-t border-neutral-100 px-1.5 dark:border-neutral-900">
|
||||
<h3 className="mb-2 pt-4 text-center text-lg font-semibold leading-none text-neutral-900 dark:text-neutral-100">
|
||||
Latest posts
|
||||
</h3>
|
||||
<div className="mx-auto flex h-full max-w-[500px] flex-col justify-between gap-1.5 pb-4 pt-1.5">
|
||||
{status === "pending" ? (
|
||||
<NoteSkeleton />
|
||||
) : (
|
||||
allEvents.map((item) => renderItem(item))
|
||||
)}
|
||||
<div className="flex h-16 items-center justify-center px-3 pb-3">
|
||||
{hasNextPage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={!hasNextPage || isFetchingNextPage}
|
||||
className="inline-flex items-center justify-center w-full h-12 gap-2 font-medium bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800 rounded-xl focus:outline-none"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="size-5" />
|
||||
Load more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# build output
|
||||
dist/
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
@@ -0,0 +1,47 @@
|
||||
# Astro Starter Kit: Minimal
|
||||
|
||||
```sh
|
||||
npm create astro@latest -- --template minimal
|
||||
```
|
||||
|
||||
[](https://stackblitz.com/github/withastro/astro/tree/latest/examples/minimal)
|
||||
[](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/minimal)
|
||||
[](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/minimal/devcontainer.json)
|
||||
|
||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||
|
||||
## 🚀 Project Structure
|
||||
|
||||
Inside of your Astro project, you'll see the following folders and files:
|
||||
|
||||
```text
|
||||
/
|
||||
├── public/
|
||||
├── src/
|
||||
│ └── pages/
|
||||
│ └── index.astro
|
||||
└── package.json
|
||||
```
|
||||
|
||||
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
|
||||
|
||||
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
|
||||
|
||||
Any static assets, like images, can be placed in the `public/` directory.
|
||||
|
||||
## 🧞 Commands
|
||||
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
|
||||
| Command | Action |
|
||||
| :------------------------ | :----------------------------------------------- |
|
||||
| `npm install` | Installs dependencies |
|
||||
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||
| `npm run build` | Build your production site to `./dist/` |
|
||||
| `npm run preview` | Preview your build locally, before deploying |
|
||||
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||
|
||||
## 👀 Want to learn more?
|
||||
|
||||
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'astro/config';
|
||||
|
||||
import tailwind from "@astrojs/tailwind";
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
integrations: [tailwind()]
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@lume/web",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro check && astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.4.1",
|
||||
"@astrojs/tailwind": "^5.1.0",
|
||||
"@fontsource/geist-mono": "^5.0.1",
|
||||
"astro": "^4.2.6",
|
||||
"astro-seo-meta": "^4.1.0",
|
||||
"astro-seo-schema": "^4.0.0",
|
||||
"schema-dts": "^1.1.2",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<svg width="824" height="824" viewBox="0 0 824 824" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_564_71)">
|
||||
<rect width="824" height="824" rx="184" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<circle cx="267" cy="594" r="42" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
<circle cx="267" cy="594" r="42" fill="url(#paint0_radial_564_71)" fill-opacity="0.5" style=""/>
|
||||
<circle cx="267" cy="594" r="42" fill="url(#paint1_radial_564_71)" fill-opacity="0.3" style=""/>
|
||||
<circle cx="557" cy="594" r="42" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
<circle cx="557" cy="594" r="42" fill="url(#paint2_radial_564_71)" fill-opacity="0.5" style=""/>
|
||||
<circle cx="557" cy="594" r="42" fill="url(#paint3_radial_564_71)" fill-opacity="0.3" style=""/>
|
||||
<path d="M412 691C382.859 691 353.717 686.063 337.654 682.804C333.024 681.865 329.866 686.676 333.074 690.144C345.098 703.138 370.814 724 412 724C453.186 724 478.902 703.138 490.926 690.144C494.134 686.676 490.976 681.865 486.346 682.804C470.283 686.063 441.141 691 412 691Z" fill="url(#paint4_linear_564_71)" style=""/>
|
||||
</g>
|
||||
<defs>
|
||||
<radialGradient id="paint0_radial_564_71" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(241.807 578.038) rotate(112.103) scale(88.2816 69.6512)">
|
||||
<stop stop-color="white" style="stop-color:white;stop-opacity:1;"/>
|
||||
<stop offset="1" stop-opacity="0" style="stop-color:none;stop-opacity:0;"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint1_radial_564_71" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(288.309 621.165) rotate(126.504) scale(25.5816 15.4047)">
|
||||
<stop stop-color="white" style="stop-color:white;stop-opacity:1;"/>
|
||||
<stop offset="1" stop-opacity="0" style="stop-color:none;stop-opacity:0;"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint2_radial_564_71" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(531.807 578.038) rotate(112.103) scale(88.2816 69.6512)">
|
||||
<stop stop-color="white" style="stop-color:white;stop-opacity:1;"/>
|
||||
<stop offset="1" stop-opacity="0" style="stop-color:none;stop-opacity:0;"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint3_radial_564_71" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(578.309 621.165) rotate(126.504) scale(25.5816 15.4047)">
|
||||
<stop stop-color="white" style="stop-color:white;stop-opacity:1;"/>
|
||||
<stop offset="1" stop-opacity="0" style="stop-color:none;stop-opacity:0;"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="paint4_linear_564_71" x1="293.565" y1="686.595" x2="316.497" y2="774.784" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF9F5A" style="stop-color:#FF9F5A;stop-color:color(display-p3 1.0000 0.6235 0.3529);stop-opacity:1;"/>
|
||||
<stop offset="1" stop-color="#FF9F5A" style="stop-color:#FF9F5A;stop-color:color(display-p3 1.0000 0.6235 0.3529);stop-opacity:1;"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_564_71">
|
||||
<rect width="824" height="824" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1 @@
|
||||
/// <reference types="astro/client" />
|
||||
@@ -0,0 +1,155 @@
|
||||
---
|
||||
import { Seo } from "astro-seo-meta";
|
||||
---
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>Lume</title>
|
||||
<Seo
|
||||
title="Lume"
|
||||
description="A multiple columns Nostr client for desktop."
|
||||
keywords={[
|
||||
"nostr",
|
||||
"nostr client",
|
||||
"social network",
|
||||
"desktop app",
|
||||
"timeline",
|
||||
"application",
|
||||
"columns",
|
||||
]}
|
||||
themeColor="#fafafa"
|
||||
colorScheme="light"
|
||||
facebook={{
|
||||
image: "/og-image.jpg",
|
||||
url: "https://lume.nu",
|
||||
type: "website",
|
||||
}}
|
||||
twitter={{
|
||||
image: "/og-image.jpg",
|
||||
card: "summary",
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
class="w-full h-full antialiased font-mono bg-neutral-50 dark:bg-neutral-950 text-neutral-950 dark:text-neutral-50"
|
||||
>
|
||||
<div class="max-w-2xl mx-auto w-full py-16 md:px-0 px-2">
|
||||
<div class="flex flex-col gap-16">
|
||||
<div class="prose dark:prose-invert prose-neutral max-w-none">
|
||||
<h3>About Lume</h3>
|
||||
<p>
|
||||
Lume is a <b>Nostr client</b> for desktop include Linux, Windows and
|
||||
macOS. It is free and open source, you can look at source code <a
|
||||
href="https://github.com/lumehq/lume"
|
||||
target="_blank">on Github</a
|
||||
>. Lume is actively improving the app and adding new features, you
|
||||
can expect new update every month.
|
||||
</p>
|
||||
<a href="#download">Download</a>
|
||||
<h3>What is nostr & how does it work?</h3>
|
||||
<p>
|
||||
Nostr stands for Notes and Other Stuff Transmitted by Relays. It is
|
||||
an open, permission-less protocol that aims to provide
|
||||
censorship-resistance and interoperability. It can be used to create
|
||||
social networks or just about any other type of app (other stuff
|
||||
part of the acronym). It is not a single website or app, but the
|
||||
glue that holds together many apps (clients) and <b>Lume</b> is one of
|
||||
it.
|
||||
</p>
|
||||
<p>
|
||||
At its core, nostr consists of relays and events. A person does
|
||||
something (event) and this event is sent to a relay. The relay
|
||||
stores the event, then waits for another person to request it. The
|
||||
most common types of events are notes and reactions - the stuff
|
||||
social media is made of, but there are many other types of events.
|
||||
It works very similar to how any other app would work with a
|
||||
database, except in nostr there is no single database, rather a
|
||||
large number of relays that store the events.
|
||||
</p>
|
||||
<h3>Lume is multiple columns experience</h3>
|
||||
<p>
|
||||
Lume is display your timeline as multiple column, each column is
|
||||
each different content and you can define your experience
|
||||
</p>
|
||||
<p>
|
||||
You can create a column to display newsfeed from specific people,
|
||||
you can create a column to display all contents related to some
|
||||
hashtags. It all up to you.
|
||||
</p>
|
||||
<img
|
||||
src="https://image.nostr.build/fd3e3cdeb4fb9f0f3de5c5e668a11dcae55f50cc9a78fc2b57b063240191a0f9.png"
|
||||
alt="columns"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="w-full h-auto rounded-lg"
|
||||
/>
|
||||
<h3>"For You"</h3>
|
||||
<p>
|
||||
Unlike some social networks, they feed you by algorithm. In Lume,
|
||||
you totally control what to will see
|
||||
</p>
|
||||
<img
|
||||
src="https://image.nostr.build/5afd79de15929a4ac6f6e933791c942555baa4206fecee54fed61dde9fe167e1.png"
|
||||
alt="for you"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="w-full h-auto rounded-lg"
|
||||
/>
|
||||
<h3 id="download">Download and Explore</h3>
|
||||
<p>
|
||||
(Universal) macOS: <a
|
||||
href="https://github.com/lumehq/lume/releases/download/v3.0.0/Lume_3.0.0_universal.dmg"
|
||||
>Lume_3.0.0_universal.dmg
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
(x86-64) Windows 11: <a
|
||||
href="https://github.com/lumehq/lume/releases/download/v3.0.0/Lume_3.0.0_x64-setup.exe"
|
||||
>Lume_3.0.0_x64-setup.exe
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
(x86-64) Ubuntu: <a
|
||||
href="https://github.com/lumehq/lume/releases/download/v3.0.0/lume_3.0.0_amd64.deb"
|
||||
>lume_3.0.0_amd64.deb
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
(x86-64) Fedora: <a
|
||||
href="https://github.com/lumehq/lume/releases/download/v3.0.0/lume-3.0.0-1.x86_64.rpm"
|
||||
>lume-3.0.0-1.x86_64.rpm
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
(x86-64) Linux Flatpak: <a
|
||||
href="https://github.com/lumehq/lume/releases/download/v3.0.0/lume_3.0.0_amd64.flatpak"
|
||||
>lume_3.0.0_amd64.flatpak
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
(x86-64) Linux AppImage: <a
|
||||
href="https://github.com/lumehq/lume/releases/download/v3.0.0/lume_3.0.0_amd64.AppImage"
|
||||
>lume_3.0.0_amd64.AppImage
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
Support for ARM, RISC-V and Loongarch architecture are coming soon.
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-600">
|
||||
Supported by <a
|
||||
href="https://opensats.org"
|
||||
target="_blank"
|
||||
class="text-orange-500">Open Sats</a
|
||||
> and Community
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
|
||||
const defaultTheme = require("tailwindcss/defaultTheme");
|
||||
|
||||
export default {
|
||||
content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
mono: ["Geist Mono", ...defaultTheme.fontFamily.mono],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("@tailwindcss/typography")],
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict"
|
||||
}
|
||||
@@ -28,7 +28,8 @@
|
||||
glib
|
||||
dbus
|
||||
openssl_3
|
||||
librsvg
|
||||
librsvg
|
||||
libappindicator-gtk3
|
||||
];
|
||||
|
||||
packages = with pkgs; [
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
|
||||
index 21f5d9a5..9a46f36d 100644
|
||||
--- a/src-tauri/tauri.conf.json
|
||||
+++ b/src-tauri/tauri.conf.json
|
||||
@@ -64,7 +64,7 @@
|
||||
"shortDescription": "",
|
||||
"targets": "all",
|
||||
"updater": {
|
||||
- "active": true,
|
||||
+ "active": false,
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU3OTdCMkM3RjU5QzE2NzkKUldSNUZwejF4N0tYNTVHYjMrU0JkL090SlEyNUVLYU5TM2hTU3RXSWtEWngrZWJ4a0pydUhXZHEK",
|
||||
"windows": {
|
||||
"installMode": "quiet"
|
||||
@@ -0,0 +1,52 @@
|
||||
FROM node:20-slim as prepare
|
||||
|
||||
RUN apt update && apt install -y git
|
||||
|
||||
# Taken from tauri docs https://beta.tauri.app/guides/prerequisites/#rust
|
||||
RUN apt install libwebkit2gtk-4.1-dev -y \
|
||||
build-essential \
|
||||
curl \
|
||||
wget \
|
||||
file \
|
||||
libssl-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
protobuf-compiler \
|
||||
librsvg2-dev
|
||||
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
|
||||
|
||||
FROM prepare as build
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
#RUN corepack prepare pnpm@latest --activate
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
ADD . /lume/.
|
||||
|
||||
WORKDIR /lume
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Path for disable updater
|
||||
#ADD flatpak/0001-disable-tauri-updater.patch .
|
||||
#RUN patch -p1 -t -i flatpak/0001-disable-tauri-updater.patch
|
||||
|
||||
#ENV VITE_FLATPAK_RESOURCE="/app/lib/lume/resources/config.toml"
|
||||
|
||||
# debian build
|
||||
RUN pnpm tauri build -b deb
|
||||
|
||||
ARG VERSION=3.0.1
|
||||
ARG ARCH=amd64
|
||||
|
||||
RUN cp -r ./src-tauri/target/release/bundle/deb/lume_${VERSION}_${ARCH}/data lume-package
|
||||
|
||||
FROM scratch as final
|
||||
|
||||
COPY --from=build lume/lume-package prepare-dist
|
||||
#ADD flatpak/*.xml flatpak/*.desktop flatpak/*.yml prepare-dist
|
||||
@@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>
|
||||
nu.lume.Lume
|
||||
</id>
|
||||
<launchable type="desktop-id">
|
||||
nu.lume.Lume.desktop
|
||||
</launchable>
|
||||
<name>
|
||||
Lume
|
||||
</name>
|
||||
<summary>
|
||||
A cross-platform desktop nostr client
|
||||
</summary>
|
||||
<developer_name>
|
||||
Ren Amamiya
|
||||
</developer_name>
|
||||
<metadata_license>
|
||||
CC0-1.0
|
||||
</metadata_license>
|
||||
<project_license>
|
||||
GPL-3.0-only
|
||||
</project_license>
|
||||
<url type="homepage">
|
||||
https://lume.nu
|
||||
</url>
|
||||
<url type="bugtracker">
|
||||
https://github.com/lumehq/lume/issues
|
||||
</url>
|
||||
<url type="donation">
|
||||
https://nostree.me/npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445
|
||||
</url>
|
||||
<supports>
|
||||
<control>
|
||||
pointing
|
||||
</control>
|
||||
<control>
|
||||
keyboard
|
||||
</control>
|
||||
<control>
|
||||
touch
|
||||
</control>
|
||||
</supports>
|
||||
<description>
|
||||
<p>
|
||||
Lume a cross-platform nostr client, supported nsecbunker, chats and notifications
|
||||
</p>
|
||||
</description>
|
||||
<custom>
|
||||
<value key="Purism::form_factor">
|
||||
workstation
|
||||
</value>
|
||||
<value key="Purism::form_factor">
|
||||
mobile
|
||||
</value>
|
||||
</custom>
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<image>
|
||||
https://raw.githubusercontent.com/lumehq/lume/flatpak/screenshots/login-screen.png
|
||||
</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>
|
||||
https://raw.githubusercontent.com/lumehq/lume/flatpak/screenshots/collumns.png
|
||||
</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>
|
||||
https://raw.githubusercontent.com/lumehq/lume/flatpak/screenshots/home-screen.png
|
||||
</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<releases>
|
||||
<release version="3.0.1" date="2024-02-02" />
|
||||
</releases>
|
||||
<content_rating type="oars-1.1" />
|
||||
</component>
|
||||
@@ -0,0 +1,12 @@
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
|
||||
Name=Lume
|
||||
Comment=A cross-platform desktop nostr client
|
||||
Icon=lume
|
||||
Exec=lume
|
||||
Terminal=false
|
||||
Categories=Network;InstantMessaging;
|
||||
Keywords=nostr;client;chat;
|
||||
X-Purism-FormFactor=Workstation;
|
||||
@@ -0,0 +1,40 @@
|
||||
id: nu.lume.Lume
|
||||
runtime: org.gnome.Platform
|
||||
runtime-version: '45'
|
||||
sdk: org.gnome.Sdk
|
||||
command: lume
|
||||
rename-icon: lume
|
||||
|
||||
finish-args:
|
||||
- --socket=wayland
|
||||
- --socket=fallback-x11
|
||||
- --socket=pulseaudio
|
||||
- --share=ipc
|
||||
- --share=network
|
||||
#- --filesystem=home
|
||||
#- --filesystem=xdg-download
|
||||
- --talk-name=org.freedesktop.secrets
|
||||
- --talk-name=org.freedesktop.Notifications
|
||||
- --talk-name=org.kde.StatusNotifierWatcher
|
||||
- --filesystem=xdg-run/keyring
|
||||
- --device=dri
|
||||
|
||||
modules:
|
||||
- shared-modules/libappindicator/libappindicator-gtk3-12.10.json
|
||||
- name: lume
|
||||
sources:
|
||||
- type: dir
|
||||
path: usr
|
||||
- type: file
|
||||
path: nu.lume.Lume.desktop
|
||||
- type: file
|
||||
path: nu.lume.Lume.appdata.xml
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
- install -Dm755 bin/lume /app/bin/lume
|
||||
- mkdir -p /app/lib/lume/resources
|
||||
- cp -r lib/lume/resources /app/lib/lume/resources
|
||||
- mkdir -p /app/share/icons/hicolor/
|
||||
- cp -r share/icons/hicolor/ /app/share/icons/
|
||||
- install -Dm644 nu.lume.Lume.appdata.xml /app/share/metainfo/nu.lume.Lume.appdata.xml
|
||||
- install -Dm644 nu.lume.Lume.desktop /app/share/applications/nu.lume.Lume.desktop
|
||||
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 606 KiB |
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "lume",
|
||||
"private": true,
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.1",
|
||||
"scripts": {
|
||||
"build": "turbo build",
|
||||
"dev": "turbo dev",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.4.1",
|
||||
"@tauri-apps/cli": "2.0.0-alpha.20",
|
||||
"@biomejs/biome": "^1.5.3",
|
||||
"@tauri-apps/cli": "2.0.0-alpha.21",
|
||||
"turbo": "^1.11.3"
|
||||
},
|
||||
"packageManager": "pnpm@8.9.0",
|
||||
@@ -17,6 +17,19 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "2.0.0-alpha.14",
|
||||
"@tauri-apps/plugin-autostart": "2.0.0-alpha.5",
|
||||
"@tauri-apps/plugin-clipboard-manager": "2.0.0-alpha.5",
|
||||
"@tauri-apps/plugin-dialog": "2.0.0-alpha.5",
|
||||
"@tauri-apps/plugin-fs": "2.0.0-alpha.6",
|
||||
"@tauri-apps/plugin-http": "2.0.0-alpha.6",
|
||||
"@tauri-apps/plugin-notification": "2.0.0-alpha.5",
|
||||
"@tauri-apps/plugin-os": "2.0.0-alpha.6",
|
||||
"@tauri-apps/plugin-process": "2.0.0-alpha.5",
|
||||
"@tauri-apps/plugin-shell": "2.0.0-alpha.5",
|
||||
"@tauri-apps/plugin-sql": "2.0.0-alpha.5",
|
||||
"@tauri-apps/plugin-updater": "2.0.0-alpha.5",
|
||||
"@tauri-apps/plugin-upload": "2.0.0-alpha.5",
|
||||
"million": "^2.6.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"@lume/ndk-cache-tauri": "workspace:^",
|
||||
"@lume/storage": "workspace:^",
|
||||
"@lume/utils": "workspace:^",
|
||||
"@nostr-dev-kit/ndk": "^2.3.2",
|
||||
"@nostr-dev-kit/ndk": "^2.3.3",
|
||||
"@nostr-fetch/adapter-ndk": "^0.15.0",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
@@ -18,22 +18,12 @@
|
||||
"@radix-ui/react-hover-card": "^1.0.7",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@tanstack/react-query": "^5.17.9",
|
||||
"@tauri-apps/api": "2.0.0-alpha.13",
|
||||
"@tauri-apps/plugin-clipboard-manager": "2.0.0-alpha.5",
|
||||
"@tauri-apps/plugin-dialog": "2.0.0-alpha.5",
|
||||
"@tauri-apps/plugin-fs": "2.0.0-alpha.6",
|
||||
"@tauri-apps/plugin-http": "2.0.0-alpha.6",
|
||||
"@tauri-apps/plugin-os": "2.0.0-alpha.6",
|
||||
"@tauri-apps/plugin-process": "2.0.0-alpha.5",
|
||||
"@tauri-apps/plugin-sql": "2.0.0-alpha.5",
|
||||
"@tauri-apps/plugin-updater": "2.0.0-alpha.5",
|
||||
"@tauri-apps/plugin-upload": "2.0.0-alpha.5",
|
||||
"@vidstack/react": "^1.9.8",
|
||||
"@tanstack/react-query": "^5.17.19",
|
||||
"get-urls": "^12.1.0",
|
||||
"jotai": "^2.6.1",
|
||||
"jotai": "^2.6.3",
|
||||
"linkify-react": "^4.1.3",
|
||||
"linkifyjs": "^4.1.3",
|
||||
"media-chrome": "^2.1.0",
|
||||
"minidenticons": "^4.2.0",
|
||||
"nanoid": "^5.0.4",
|
||||
"nostr-fetch": "^0.15.0",
|
||||
@@ -42,18 +32,21 @@
|
||||
"re-resizable": "^6.9.11",
|
||||
"react": "^18.2.0",
|
||||
"react-currency-input-field": "^3.6.14",
|
||||
"react-router-dom": "^6.21.2",
|
||||
"react-i18next": "^14.0.1",
|
||||
"react-router-dom": "^6.21.3",
|
||||
"react-string-replace": "^1.1.1",
|
||||
"sonner": "^1.3.1",
|
||||
"sonner": "^1.4.0",
|
||||
"string-strip-html": "^13.4.5",
|
||||
"tippy.js": "^6.3.7",
|
||||
"use-context-selector": "^1.4.1"
|
||||
"use-context-selector": "^1.4.1",
|
||||
"virtua": "^0.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lume/tailwindcss": "workspace:^",
|
||||
"@lume/tsconfig": "workspace:^",
|
||||
"@lume/types": "workspace:^",
|
||||
"@types/react": "^18.2.47",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"@types/react": "^18.2.48",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
|
||||
@@ -62,6 +62,10 @@ export class Ark {
|
||||
return sub;
|
||||
}
|
||||
|
||||
public getNDKEvent(event: NostrEvent) {
|
||||
return new NDKEvent(this.ndk, event);
|
||||
}
|
||||
|
||||
public async createEvent({
|
||||
kind,
|
||||
tags,
|
||||
@@ -105,7 +109,12 @@ export class Ark {
|
||||
|
||||
public getCleanPubkey(pubkey: string) {
|
||||
try {
|
||||
let hexstring = pubkey.replace("nostr:", "").split("'")[0];
|
||||
let hexstring = pubkey
|
||||
.replace("nostr:", "")
|
||||
.split("'")[0]
|
||||
.split(".")[0]
|
||||
.split(",")[0]
|
||||
.split("?")[0];
|
||||
|
||||
if (
|
||||
hexstring.startsWith("npub1") ||
|
||||
@@ -128,24 +137,10 @@ export class Ark {
|
||||
public async getUserProfile(pubkey?: string) {
|
||||
try {
|
||||
const currentUserPubkey = this.account.pubkey;
|
||||
|
||||
// get clean pubkey without any special characters
|
||||
let hexstring = pubkey
|
||||
? pubkey.replace("nostr:", "").split("'")[0]
|
||||
const hexstring = pubkey
|
||||
? this.getCleanPubkey(pubkey)
|
||||
: currentUserPubkey;
|
||||
|
||||
if (
|
||||
hexstring.startsWith("npub1") ||
|
||||
hexstring.startsWith("nprofile1") ||
|
||||
hexstring.startsWith("naddr1")
|
||||
) {
|
||||
const decoded = nip19.decode(hexstring);
|
||||
|
||||
if (decoded.type === "nprofile") hexstring = decoded.data.pubkey;
|
||||
if (decoded.type === "npub") hexstring = decoded.data;
|
||||
if (decoded.type === "naddr") hexstring = decoded.data.pubkey;
|
||||
}
|
||||
|
||||
const user = this.ndk.getUser({
|
||||
pubkey: hexstring,
|
||||
});
|
||||
@@ -154,34 +149,19 @@ export class Ark {
|
||||
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST,
|
||||
});
|
||||
|
||||
if (!profile) return null;
|
||||
return profile;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} catch {
|
||||
throw new Error("user not found");
|
||||
}
|
||||
}
|
||||
|
||||
public async getUserContacts(pubkey?: string) {
|
||||
try {
|
||||
const currentUserPubkey = this.account.pubkey;
|
||||
|
||||
// get clean pubkey without any special characters
|
||||
let hexstring = pubkey
|
||||
? pubkey.replace("nostr:", "").split("'")[0]
|
||||
const hexstring = pubkey
|
||||
? this.getCleanPubkey(pubkey)
|
||||
: currentUserPubkey;
|
||||
|
||||
if (
|
||||
hexstring.startsWith("npub1") ||
|
||||
hexstring.startsWith("nprofile1") ||
|
||||
hexstring.startsWith("naddr1")
|
||||
) {
|
||||
const decoded = nip19.decode(hexstring);
|
||||
|
||||
if (decoded.type === "nprofile") hexstring = decoded.data.pubkey;
|
||||
if (decoded.type === "npub") hexstring = decoded.data;
|
||||
if (decoded.type === "naddr") hexstring = decoded.data.pubkey;
|
||||
}
|
||||
|
||||
const user = this.ndk.getUser({
|
||||
pubkey: hexstring,
|
||||
});
|
||||
@@ -190,8 +170,9 @@ export class Ark {
|
||||
(user) => user.pubkey,
|
||||
);
|
||||
|
||||
if (!pubkey || pubkey === this.account.pubkey)
|
||||
if (!pubkey || pubkey === this.account.pubkey) {
|
||||
this.account.contacts = contacts;
|
||||
}
|
||||
|
||||
return contacts;
|
||||
} catch (e) {
|
||||
@@ -252,8 +233,8 @@ export class Ark {
|
||||
return [...events];
|
||||
}
|
||||
|
||||
public async getEventById({ id }: { id: string }) {
|
||||
let eventId: string = id;
|
||||
public getCleanEventId(id: string) {
|
||||
let eventId: string = id.replace("nostr:", "").split("'")[0].split(".")[0];
|
||||
|
||||
if (
|
||||
eventId.startsWith("nevent1") ||
|
||||
@@ -261,33 +242,40 @@ export class Ark {
|
||||
eventId.startsWith("naddr1")
|
||||
) {
|
||||
const decode = nip19.decode(eventId);
|
||||
|
||||
if (decode.type === "nevent") eventId = decode.data.id;
|
||||
if (decode.type === "note") eventId = decode.data;
|
||||
|
||||
if (decode.type === "naddr") {
|
||||
return await this.ndk.fetchEvent({
|
||||
kinds: [decode.data.kind],
|
||||
"#d": [decode.data.identifier],
|
||||
authors: [decode.data.pubkey],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return await this.ndk.fetchEvent(id, {
|
||||
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST,
|
||||
});
|
||||
return eventId;
|
||||
}
|
||||
|
||||
public async getEventByFilter({ filter }: { filter: NDKFilter }) {
|
||||
public async getEventById(id: string) {
|
||||
try {
|
||||
const eventId = this.getCleanEventId(id);
|
||||
return await this.ndk.fetchEvent(eventId);
|
||||
} catch {
|
||||
throw new Error("event not found");
|
||||
}
|
||||
}
|
||||
|
||||
public async getEventByFilter({
|
||||
filter,
|
||||
cache,
|
||||
}: { filter: NDKFilter; cache?: NDKSubscriptionCacheUsage }) {
|
||||
const event = await this.ndk.fetchEvent(filter, {
|
||||
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST,
|
||||
cacheUsage: cache || NDKSubscriptionCacheUsage.CACHE_FIRST,
|
||||
});
|
||||
|
||||
if (!event) return null;
|
||||
return event;
|
||||
}
|
||||
|
||||
public async getEvents(filter: NDKFilter) {
|
||||
const events = await this.ndk.fetchEvents(filter);
|
||||
if (!events) return [];
|
||||
return [...events];
|
||||
}
|
||||
|
||||
public getEventThread({
|
||||
content,
|
||||
tags,
|
||||
@@ -298,7 +286,7 @@ export class Ark {
|
||||
if (content.includes("nostr:note1") || content.includes("nostr:nevent1"))
|
||||
return null;
|
||||
|
||||
const events = tags.filter((el) => el[0] === "e");
|
||||
const events = tags.filter((el) => el[0] === "e" && el[3] !== "mention");
|
||||
|
||||
if (!events.length) return null;
|
||||
|
||||
@@ -324,19 +312,17 @@ export class Ark {
|
||||
};
|
||||
}
|
||||
|
||||
public async getThreads({ id }: { id: string }) {
|
||||
public async getThreads(id: string) {
|
||||
const eventId = this.getCleanEventId(id);
|
||||
const fetcher = NostrFetcher.withCustomPool(ndkAdapter(this.ndk));
|
||||
const relayUrls = Array.from(this.ndk.pool.relays.keys());
|
||||
|
||||
try {
|
||||
const relayUrls = [...this.ndk.pool.relays.values()].map(
|
||||
(item) => item.url,
|
||||
);
|
||||
|
||||
const rawEvents = (await fetcher.fetchAllEvents(
|
||||
relayUrls,
|
||||
{
|
||||
kinds: [NDKKind.Text],
|
||||
"#e": [id],
|
||||
"#e": [eventId],
|
||||
},
|
||||
{ since: 0 },
|
||||
{ sort: true },
|
||||
@@ -349,7 +335,9 @@ export class Ark {
|
||||
if (events.length > 0) {
|
||||
const replies = new Set();
|
||||
for (const event of events) {
|
||||
const tags = event.tags.filter((el) => el[0] === "e" && el[1] !== id);
|
||||
const tags = event.tags.filter(
|
||||
(el) => el[0] === "e" && el[1] !== id && el[3] !== "mention",
|
||||
);
|
||||
if (tags.length > 0) {
|
||||
for (const tag of tags) {
|
||||
const rootIndex = events.findIndex((el) => el.id === tag[1]);
|
||||
@@ -377,11 +365,9 @@ export class Ark {
|
||||
}
|
||||
}
|
||||
|
||||
public async getAllRelaysFromContacts() {
|
||||
public async getAllRelaysFromContacts({ signal }: { signal: AbortSignal }) {
|
||||
const fetcher = NostrFetcher.withCustomPool(ndkAdapter(this.ndk));
|
||||
const connectedRelays = this.ndk.pool
|
||||
.connectedRelays()
|
||||
.map((item) => item.url);
|
||||
const connectedRelays = Array.from(this.ndk.pool.relays.keys());
|
||||
|
||||
try {
|
||||
const relayMap = new Map<string, string[]>();
|
||||
@@ -392,15 +378,19 @@ export class Ark {
|
||||
},
|
||||
{ kinds: [NDKKind.RelayList] },
|
||||
1,
|
||||
{ abortSignal: signal },
|
||||
);
|
||||
|
||||
for await (const { author, events } of relayEvents) {
|
||||
if (events[0]) {
|
||||
for (const tag of events[0].tags) {
|
||||
const users = relayMap.get(tag[1]);
|
||||
|
||||
if (!users) relayMap.set(tag[1], [author]);
|
||||
users.push(author);
|
||||
if (events.length) {
|
||||
const relayTags = events[0].tags.filter((item) => item[0] === "r");
|
||||
for (const tag of relayTags) {
|
||||
const item = relayMap.get(tag[1]);
|
||||
if (item?.length) {
|
||||
item.push(author);
|
||||
} else {
|
||||
relayMap.set(tag[1], [author]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -427,9 +417,7 @@ export class Ark {
|
||||
dedup?: boolean;
|
||||
}) {
|
||||
const fetcher = NostrFetcher.withCustomPool(ndkAdapter(this.ndk));
|
||||
const relayUrls = [...this.ndk.pool.relays.values()].map(
|
||||
(item) => item.url,
|
||||
);
|
||||
const relayUrls = Array.from(this.ndk.pool.relays.keys());
|
||||
const seenIds = new Set<string>();
|
||||
const dedupQueue = new Set<string>();
|
||||
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import {
|
||||
HorizontalDotsIcon,
|
||||
ChevronDownIcon,
|
||||
MoveLeftIcon,
|
||||
MoveRightIcon,
|
||||
RefreshIcon,
|
||||
ThreadIcon,
|
||||
TrashIcon,
|
||||
} from "@lume/icons";
|
||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { InterestModal } from "./interestModal";
|
||||
import { useColumnContext } from "./provider";
|
||||
|
||||
export function ColumnHeader({
|
||||
id,
|
||||
title,
|
||||
queryKey,
|
||||
icon,
|
||||
}: {
|
||||
id: number;
|
||||
title: string;
|
||||
queryKey?: string[];
|
||||
icon?: ReactNode;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { moveColumn, removeColumn } = useColumnContext();
|
||||
|
||||
const refresh = async () => {
|
||||
@@ -42,70 +42,71 @@ export function ColumnHeader({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between w-full px-3 border-b h-11 shrink-0 border-neutral-100 dark:border-neutral-900">
|
||||
<div className="inline-flex items-center gap-4">
|
||||
<div className="inline-flex items-center flex-1 gap-2 text-neutral-800 dark:text-neutral-200">
|
||||
{icon ? icon : <ThreadIcon className="size-4" />}
|
||||
<div className="text-sm font-medium">{title}</div>
|
||||
</div>
|
||||
<DropdownMenu.Root>
|
||||
<div className="flex items-center justify-center gap-2 px-3 w-full border-b h-11 shrink-0 border-neutral-100 dark:border-neutral-900">
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<div className="text-[13px] font-medium">{title}</div>
|
||||
<ChevronDownIcon className="size-5" />
|
||||
</div>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
sideOffset={5}
|
||||
className="flex w-[200px] p-2 flex-col overflow-hidden rounded-2xl bg-white/50 dark:bg-black/50 ring-1 ring-black/10 dark:ring-white/10 backdrop-blur-2xl focus:outline-none"
|
||||
>
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
>
|
||||
<RefreshIcon className="size-4" />
|
||||
{t("global.refresh")}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
{queryKey?.[0] === "foryou-9998" ? (
|
||||
<DropdownMenu.Item asChild>
|
||||
<InterestModal
|
||||
queryKey={queryKey}
|
||||
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
/>
|
||||
</DropdownMenu.Item>
|
||||
) : null}
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={moveLeft}
|
||||
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
>
|
||||
<MoveLeftIcon className="size-4" />
|
||||
{t("global.moveLeft")}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={moveRight}
|
||||
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
>
|
||||
<MoveRightIcon className="size-4" />
|
||||
{t("global.moveRight")}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator className="h-px my-1 bg-black/10 dark:bg-white/10" />
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={deleteWidget}
|
||||
className="inline-flex items-center gap-3 px-3 text-sm font-medium text-red-500 rounded-lg h-9 hover:bg-red-500 hover:text-red-50 focus:outline-none"
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
{t("global.delete")}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</div>
|
||||
<div>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center w-6 h-6"
|
||||
>
|
||||
<HorizontalDotsIcon className="size-4" />
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content className="flex w-[200px] p-2 flex-col overflow-hidden rounded-xl border border-neutral-100 bg-neutral-50 dark:bg-neutral-950 focus:outline-none dark:border-neutral-900">
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
className="inline-flex items-center gap-2 px-3 text-sm font-medium rounded-lg h-9 text-neutral-700 hover:bg-blue-100 hover:text-blue-500 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-900 dark:hover:text-neutral-50"
|
||||
>
|
||||
<RefreshIcon className="size-5" />
|
||||
Refresh
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={moveLeft}
|
||||
className="inline-flex items-center gap-2 px-3 text-sm font-medium rounded-lg h-9 text-neutral-700 hover:bg-blue-100 hover:text-blue-500 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-900 dark:hover:text-neutral-50"
|
||||
>
|
||||
<MoveLeftIcon className="size-5" />
|
||||
Move left
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={moveRight}
|
||||
className="inline-flex items-center gap-2 px-3 text-sm font-medium rounded-lg h-9 text-neutral-700 hover:bg-blue-100 hover:text-blue-500 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-900 dark:hover:text-neutral-50"
|
||||
>
|
||||
<MoveRightIcon className="size-5" />
|
||||
Move right
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator className="h-px my-1 bg-neutral-100 dark:bg-neutral-900" />
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={deleteWidget}
|
||||
className="inline-flex items-center gap-2 px-3 text-sm font-medium text-red-500 rounded-lg h-9 hover:bg-red-500 hover:text-red-50 focus:outline-none"
|
||||
>
|
||||
<TrashIcon className="size-5" />
|
||||
Delete
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenu.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { ArrowLeftIcon, EditInterestIcon, LoaderIcon } from "@lume/icons";
|
||||
import { useStorage } from "@lume/storage";
|
||||
import { TOPICS, cn } from "@lume/utils";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function InterestModal({
|
||||
queryKey,
|
||||
className,
|
||||
children,
|
||||
}: { queryKey: string[]; className?: string; children?: ReactNode }) {
|
||||
const storage = useStorage();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [t] = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hashtags, setHashtags] = useState(storage.interests?.hashtags || []);
|
||||
|
||||
const toggleHashtag = (item: string) => {
|
||||
const arr = hashtags.includes(item)
|
||||
? hashtags.filter((i) => i !== item)
|
||||
: [...hashtags, item];
|
||||
setHashtags(arr);
|
||||
};
|
||||
|
||||
const toggleAll = (item: string[]) => {
|
||||
const sets = new Set([...hashtags, ...item]);
|
||||
setHashtags([...sets]);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const save = await storage.createSetting(
|
||||
"interests",
|
||||
JSON.stringify({ hashtags }),
|
||||
);
|
||||
|
||||
if (save) {
|
||||
storage.interests = { hashtags, users: [], words: [] };
|
||||
await queryClient.refetchQueries({ queryKey });
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
setOpen(false);
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Trigger
|
||||
className={cn(
|
||||
"inline-flex items-center gap-3 px-3 rounded-lg h-9 focus:outline-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children ? (
|
||||
children
|
||||
) : (
|
||||
<>
|
||||
<EditInterestIcon className="size-4" />
|
||||
{t("interests.edit")}
|
||||
</>
|
||||
)}
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/10 backdrop-blur-sm dark:bg-white/10" />
|
||||
<Dialog.Content className="fixed inset-0 z-50 flex items-center justify-center min-h-full">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="h-20 absolute top-0 left-0 w-full"
|
||||
/>
|
||||
<div className="relative w-full max-w-xl xl:max-w-2xl bg-white h-[600px] xl:h-[700px] rounded-xl dark:bg-black overflow-hidden">
|
||||
<div className="w-full h-full flex flex-col">
|
||||
<div className="h-16 shrink-0 px-8 border-b border-neutral-100 dark:border-neutral-900 flex w-full items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<h3 className="font-semibold">{t("interests.edit")}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex-1 min-h-0 flex flex-col justify-between">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-8 py-8">
|
||||
<div className="flex flex-col gap-8">
|
||||
{TOPICS.map((topic) => (
|
||||
<div key={topic.title} className="flex flex-col gap-4">
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<div className="inline-flex items-center gap-2.5">
|
||||
<img
|
||||
src={topic.icon}
|
||||
alt={topic.title}
|
||||
className="size-8 object-cover rounded-lg"
|
||||
/>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{topic.title}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleAll(topic.content)}
|
||||
className="text-sm font-medium text-blue-500"
|
||||
>
|
||||
{t("interests.followAll")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{topic.content.map((hashtag) => (
|
||||
<button
|
||||
key={hashtag}
|
||||
type="button"
|
||||
onClick={() => toggleHashtag(hashtag)}
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full bg-neutral-100 dark:bg-neutral-900 border border-transparent px-2 py-1 text-sm font-medium",
|
||||
hashtags.includes(hashtag)
|
||||
? "border-blue-500 text-blue-500"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{hashtag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-16 shrink-0 w-full flex items-center px-8 justify-center gap-2 border-t border-neutral-100 dark:border-neutral-900 bg-neutral-50 dark:bg-neutral-950">
|
||||
<Dialog.Close className="inline-flex h-9 flex-1 gap-2 shrink-0 items-center justify-center rounded-lg bg-neutral-100 font-medium dark:bg-neutral-900 dark:hover:bg-neutral-800 hover:bg-blue-200">
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
{t("global.cancel")}
|
||||
</Dialog.Close>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
className="inline-flex h-9 flex-1 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.save")
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronUpIcon } from "@lume/icons";
|
||||
import { ArrowUpIcon } from "@lume/icons";
|
||||
import { NDKEvent, NDKFilter } from "@nostr-dev-kit/ndk";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useArk } from "../../hooks/useArk";
|
||||
@@ -34,14 +34,14 @@ export function ColumnLiveWidget({
|
||||
if (!events.length) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute left-0 z-50 flex items-center justify-center w-full top-11 h-11">
|
||||
<div className="absolute left-0 z-40 flex items-center justify-center w-full top-12 h-11">
|
||||
<button
|
||||
type="button"
|
||||
onClick={update}
|
||||
className="inline-flex items-center justify-center h-8 gap-1 pl-2 pr-2.5 text-sm font-semibold rounded-full w-max bg-neutral-950 dark:bg-neutral-50 hover:bg-neutral-900 dark:hover:bg-neutral-100 text-neutral-50 dark:text-neutral-950"
|
||||
className="inline-flex items-center justify-center h-9 gap-1 pl-4 pr-3 text-sm font-semibold rounded-full w-max bg-neutral-950 dark:bg-neutral-50 hover:bg-neutral-900 dark:hover:bg-neutral-100 text-neutral-50 dark:text-neutral-950"
|
||||
>
|
||||
<ChevronUpIcon className="w-4 h-4" />
|
||||
{events.length} {events.length === 1 ? "new note" : "new notes"}
|
||||
<ArrowUpIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,16 +2,21 @@ import { useStorage } from "@lume/storage";
|
||||
import { IColumn } from "@lume/types";
|
||||
import { COL_TYPES } from "@lume/utils";
|
||||
import {
|
||||
ReactNode,
|
||||
type MutableRefObject,
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import { type VListHandle } from "virtua";
|
||||
|
||||
type ColumnContext = {
|
||||
columns: IColumn[];
|
||||
vlistRef: MutableRefObject<VListHandle>;
|
||||
addColumn: (column: IColumn) => Promise<void>;
|
||||
removeColumn: (id: number) => Promise<void>;
|
||||
moveColumn: (id: number, position: "left" | "right") => void;
|
||||
@@ -23,6 +28,8 @@ const ColumnContext = createContext<ColumnContext>(null);
|
||||
|
||||
export function ColumnProvider({ children }: { children: ReactNode }) {
|
||||
const storage = useStorage();
|
||||
const vlistRef = useRef<VListHandle>(null);
|
||||
|
||||
const [columns, setColumns] = useState<IColumn[]>([
|
||||
{
|
||||
id: 9999,
|
||||
@@ -30,6 +37,12 @@ export function ColumnProvider({ children }: { children: ReactNode }) {
|
||||
content: "",
|
||||
kind: COL_TYPES.newsfeed,
|
||||
},
|
||||
{
|
||||
id: 9998,
|
||||
title: "For You",
|
||||
content: "",
|
||||
kind: COL_TYPES.foryou,
|
||||
},
|
||||
]);
|
||||
|
||||
const loadAllColumns = useCallback(async () => {
|
||||
@@ -42,10 +55,18 @@ export function ColumnProvider({ children }: { children: ReactNode }) {
|
||||
column.title,
|
||||
column.content,
|
||||
);
|
||||
if (result) setColumns((prev) => [...prev, result]);
|
||||
if (result) {
|
||||
setColumns((prev) => [...prev, result]);
|
||||
vlistRef?.current.scrollToIndex(columns.length);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const removeColumn = useCallback(async (id: number) => {
|
||||
if (id === 9998 || id === 9999) {
|
||||
toast.info("You cannot remove default column");
|
||||
return;
|
||||
}
|
||||
|
||||
await storage.removeColumn(id);
|
||||
setColumns((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
@@ -100,6 +121,7 @@ export function ColumnProvider({ children }: { children: ReactNode }) {
|
||||
<ColumnContext.Provider
|
||||
value={{
|
||||
columns,
|
||||
vlistRef,
|
||||
addColumn,
|
||||
removeColumn,
|
||||
moveColumn,
|
||||
|
||||