mirror of
https://github.com/lumehq/lume.git
synced 2025-03-17 21:32:32 +01:00
chore: monorepo
This commit is contained in:
parent
a6da07cd3f
commit
227c2ddefa
@ -1,3 +0,0 @@
|
||||
/**/node_modules/*
|
||||
node_modules/
|
||||
dist/
|
49
.eslintrc.js
49
.eslintrc.js
@ -1,49 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
'import/resolver': {
|
||||
node: {
|
||||
paths: ['src'],
|
||||
extensions: ['.js', '.jsx', '.ts', '.tsx'],
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
amd: true,
|
||||
node: true,
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:jsx-a11y/recommended',
|
||||
'prettier'
|
||||
],
|
||||
plugins: [],
|
||||
rules: {
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'jsx-a11y/accessible-emoji': 'off',
|
||||
'react/prop-types': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'jsx-a11y/anchor-is-valid': [
|
||||
'error',
|
||||
{
|
||||
components: ['Link'],
|
||||
specialLink: ['hrefLeft', 'hrefRight'],
|
||||
aspects: ['invalidHref', 'preferButton'],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
61
.gitignore
vendored
61
.gitignore
vendored
@ -1,33 +1,38 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
# Debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
out
|
||||
*.local
|
||||
.next
|
||||
.vscode
|
||||
*.db
|
||||
*.db-journal
|
||||
bun.lockb
|
||||
|
||||
.direnv
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
# Misc
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
/.gtm/
|
||||
*.pem
|
||||
|
@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
pnpm exec lint-staged
|
@ -1,9 +0,0 @@
|
||||
.tmp
|
||||
.cache/
|
||||
coverage/
|
||||
.nyc_output/
|
||||
**/.yarn/**
|
||||
**/.pnp.*
|
||||
/dist*/
|
||||
node_modules/
|
||||
src-tauri/
|
21
.prettierrc
21
.prettierrc
@ -1,21 +0,0 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"printWidth": 90,
|
||||
"useTabs": false,
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false,
|
||||
"importOrder": [
|
||||
"^@app/(.*)$",
|
||||
"^@libs/(.*)$",
|
||||
"^@shared/(.*)$",
|
||||
"^@utils/(.*)$",
|
||||
"^[./]"
|
||||
],
|
||||
"importOrderSeparation": false,
|
||||
"importOrderSortSpecifiers": true,
|
||||
"plugins": ["@trivago/prettier-plugin-sort-imports", "prettier-plugin-tailwindcss"],
|
||||
"pluginSearchDirs": false
|
||||
}
|
@ -8,6 +8,6 @@
|
||||
class="relative h-screen w-screen cursor-default select-none overflow-hidden font-sans text-neutral-950 antialiased dark:text-neutral-50"
|
||||
>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
<script type="module" src="./src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
101
apps/desktop/package.json
Normal file
101
apps/desktop/package.json
Normal file
@ -0,0 +1,101 @@
|
||||
{
|
||||
"name": "lume",
|
||||
"description": "the communication app",
|
||||
"private": true,
|
||||
"version": "3.0.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@columns/notification": "workspace:^",
|
||||
"@columns/timeline": "workspace:^",
|
||||
"@getalby/sdk": "^3.2.1",
|
||||
"@lume/ark": "workspace:^",
|
||||
"@lume/icons": "workspace:^",
|
||||
"@lume/ui": "workspace:^",
|
||||
"@lume/utils": "workspace:^",
|
||||
"@nostr-dev-kit/ndk": "^2.3.1",
|
||||
"@nostr-fetch/adapter-ndk": "^0.14.1",
|
||||
"@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-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-hover-card": "^1.0.7",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-toolbar": "^1.0.4",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@tanstack/react-query": "^5.14.2",
|
||||
"@tauri-apps/api": "2.0.0-alpha.11",
|
||||
"@tauri-apps/plugin-autostart": "2.0.0-alpha.3",
|
||||
"@tauri-apps/plugin-clipboard-manager": "2.0.0-alpha.3",
|
||||
"@tauri-apps/plugin-dialog": "2.0.0-alpha.3",
|
||||
"@tauri-apps/plugin-fs": "2.0.0-alpha.3",
|
||||
"@tauri-apps/plugin-http": "2.0.0-alpha.3",
|
||||
"@tauri-apps/plugin-notification": "2.0.0-alpha.3",
|
||||
"@tauri-apps/plugin-os": "2.0.0-alpha.4",
|
||||
"@tauri-apps/plugin-process": "2.0.0-alpha.3",
|
||||
"@tauri-apps/plugin-shell": "2.0.0-alpha.3",
|
||||
"@tauri-apps/plugin-sql": "2.0.0-alpha.3",
|
||||
"@tauri-apps/plugin-updater": "2.0.0-alpha.3",
|
||||
"@tauri-apps/plugin-upload": "2.0.0-alpha.3",
|
||||
"@tiptap/extension-character-count": "^2.1.13",
|
||||
"@tiptap/extension-document": "^2.1.13",
|
||||
"@tiptap/extension-image": "^2.1.13",
|
||||
"@tiptap/extension-mention": "^2.1.13",
|
||||
"@tiptap/extension-paragraph": "^2.1.13",
|
||||
"@tiptap/extension-placeholder": "^2.1.13",
|
||||
"@tiptap/extension-text": "^2.1.13",
|
||||
"@tiptap/pm": "^2.1.13",
|
||||
"@tiptap/react": "^2.1.13",
|
||||
"@tiptap/starter-kit": "^2.1.13",
|
||||
"@tiptap/suggestion": "^2.1.13",
|
||||
"@vidstack/react": "^1.9.8",
|
||||
"clsx": "^2.0.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"framer-motion": "^10.16.16",
|
||||
"html-to-text": "^9.0.5",
|
||||
"light-bolt11-decoder": "^3.0.0",
|
||||
"lru-cache": "^10.1.0",
|
||||
"markdown-to-jsx": "^7.3.2",
|
||||
"minidenticons": "^4.2.0",
|
||||
"nanoid": "^5.0.4",
|
||||
"nostr-fetch": "^0.14.1",
|
||||
"nostr-tools": "^1.17.0",
|
||||
"qrcode.react": "^3.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-currency-input-field": "^3.6.12",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.49.2",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"react-router-dom": "^6.21.0",
|
||||
"react-string-replace": "^1.1.1",
|
||||
"smol-toml": "^1.1.3",
|
||||
"sonner": "^1.2.4",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-markdown": "^0.8.8",
|
||||
"virtua": "^0.18.0",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lume/tailwindcss": "workspace:^",
|
||||
"@lume/tsconfig": "workspace:^",
|
||||
"@lume/types": "workspace:^",
|
||||
"@types/html-to-text": "^9.0.4",
|
||||
"@types/node": "^20.10.5",
|
||||
"@types/react": "^18.2.45",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"cross-env": "^7.0.3",
|
||||
"encoding": "^0.1.13",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^4.5.1",
|
||||
"vite-tsconfig-paths": "^4.2.2"
|
||||
}
|
||||
}
|
6138
apps/desktop/pnpm-lock.yaml
generated
Normal file
6138
apps/desktop/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
314
apps/desktop/src/app.tsx
Normal file
314
apps/desktop/src/app.tsx
Normal file
@ -0,0 +1,314 @@
|
||||
import { useStorage } from "@lume/ark";
|
||||
import { LoaderIcon } from "@lume/icons";
|
||||
import {
|
||||
AppLayout,
|
||||
AuthLayout,
|
||||
ComposerLayout,
|
||||
HomeLayout,
|
||||
SettingsLayout,
|
||||
} from "@lume/ui";
|
||||
import { fetch } from "@tauri-apps/plugin-http";
|
||||
import {
|
||||
RouterProvider,
|
||||
createBrowserRouter,
|
||||
defer,
|
||||
redirect,
|
||||
} from "react-router-dom";
|
||||
import { ErrorScreen } from "./routes/error";
|
||||
|
||||
export default function App() {
|
||||
const storage = useStorage();
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
element: <AppLayout platform={storage.platform} />,
|
||||
children: [
|
||||
{
|
||||
path: "/",
|
||||
element: <HomeLayout />,
|
||||
errorElement: <ErrorScreen />,
|
||||
loader: async () => {
|
||||
if (!storage.account) return redirect("auth/welcome");
|
||||
return null;
|
||||
},
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
async lazy() {
|
||||
const { HomeScreen } = await import("./routes/home");
|
||||
return { Component: HomeScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "nwc",
|
||||
async lazy() {
|
||||
const { NWCScreen } = await import("./routes/nwc");
|
||||
return { Component: NWCScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "relays",
|
||||
async lazy() {
|
||||
const { RelaysScreen } = await import("./routes/relays");
|
||||
return { Component: RelaysScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "relays/:url",
|
||||
loader: async ({ params }) => {
|
||||
return defer({
|
||||
relay: fetch(`https://${params.url}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/nostr+json",
|
||||
},
|
||||
}).then((res) => res.json()),
|
||||
});
|
||||
},
|
||||
async lazy() {
|
||||
const { RelayScreen } = await import("./routes/relays/relay");
|
||||
return { Component: RelayScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "new",
|
||||
element: <ComposerLayout />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
async lazy() {
|
||||
const { NewPostScreen } = await import("./routes/new/post");
|
||||
return { Component: NewPostScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "article",
|
||||
async lazy() {
|
||||
const { NewArticleScreen } = await import(
|
||||
"./routes/new/article"
|
||||
);
|
||||
return { Component: NewArticleScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "file",
|
||||
async lazy() {
|
||||
const { NewFileScreen } = await import("./routes/new/file");
|
||||
return { Component: NewFileScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "privkey",
|
||||
async lazy() {
|
||||
const { NewPrivkeyScreen } = await import(
|
||||
"./routes/new/privkey"
|
||||
);
|
||||
return { Component: NewPrivkeyScreen };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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: "about",
|
||||
async lazy() {
|
||||
const { AboutScreen } = await import(
|
||||
"./routes/settings/about"
|
||||
);
|
||||
return { Component: AboutScreen };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "depot",
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
loader: () => {
|
||||
const depot = storage.checkDepot();
|
||||
if (!depot) return redirect("/depot/onboarding/");
|
||||
return null;
|
||||
},
|
||||
async lazy() {
|
||||
const { DepotScreen } = await import("./routes/depot");
|
||||
return { Component: DepotScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "onboarding",
|
||||
async lazy() {
|
||||
const { DepotOnboardingScreen } = await import(
|
||||
"./routes/depot/onboarding"
|
||||
);
|
||||
return { Component: DepotOnboardingScreen };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "auth",
|
||||
element: <AuthLayout platform={storage.platform} />,
|
||||
errorElement: <ErrorScreen />,
|
||||
children: [
|
||||
{
|
||||
path: "welcome",
|
||||
async lazy() {
|
||||
const { WelcomeScreen } = await import("./routes/auth/welcome");
|
||||
return { Component: WelcomeScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "create",
|
||||
async lazy() {
|
||||
const { CreateAccountScreen } = await import(
|
||||
"./routes/auth/create"
|
||||
);
|
||||
return { Component: CreateAccountScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "import",
|
||||
async lazy() {
|
||||
const { ImportAccountScreen } = await import(
|
||||
"./routes/auth/import"
|
||||
);
|
||||
return { Component: ImportAccountScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "onboarding",
|
||||
async lazy() {
|
||||
const { OnboardingScreen } = await import(
|
||||
"./routes/auth/onboarding"
|
||||
);
|
||||
return { Component: OnboardingScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "follow",
|
||||
async lazy() {
|
||||
const { FollowScreen } = await import("./routes/auth/follow");
|
||||
return { Component: FollowScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "finish",
|
||||
async lazy() {
|
||||
const { FinishScreen } = await import("./routes/auth/finish");
|
||||
return { Component: FinishScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
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 };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
return (
|
||||
<RouterProvider
|
||||
router={router}
|
||||
fallbackElement={
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<LoaderIcon className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
}
|
||||
future={{ v7_startTransition: true }}
|
||||
/>
|
||||
);
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import { LumeProvider } from '@lume/ark';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { Toaster } from 'sonner';
|
||||
import { LumeProvider } from '@libs/ark';
|
||||
import App from './app';
|
||||
import './app.css';
|
||||
|
50
apps/desktop/src/routes/auth/components/avatarUploader.tsx
Normal file
50
apps/desktop/src/routes/auth/components/avatarUploader.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { LoaderIcon } from "@lume/icons";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import { Dispatch, SetStateAction, useState } from "react";
|
||||
|
||||
export function AvatarUploader({
|
||||
setPicture,
|
||||
}: {
|
||||
setPicture: Dispatch<SetStateAction<string>>;
|
||||
}) {
|
||||
const ark = useArk();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const uploadAvatar = async () => {
|
||||
try {
|
||||
// start loading
|
||||
setLoading(true);
|
||||
|
||||
const image = await ark.upload({});
|
||||
|
||||
if (image) {
|
||||
setPicture(image);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return;
|
||||
} catch (e) {
|
||||
// stop loading
|
||||
setLoading(false);
|
||||
await message(`Upload failed, error: ${e}`, {
|
||||
title: "Lume",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => uploadAvatar()}
|
||||
className="inline-flex items-center justify-center rounded-lg border border-blue-200 bg-blue-100 px-2 py-1.5 text-sm font-medium text-blue-500 hover:border-blue-300 hover:bg-blue-200 dark:border-blue-800 dark:bg-blue-900 dark:text-blue-500 dark:hover:border-blue-800 dark:hover:bg-blue-800"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Change avatar"
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
313
apps/desktop/src/routes/auth/create.tsx
Normal file
313
apps/desktop/src/routes/auth/create.tsx
Normal file
@ -0,0 +1,313 @@
|
||||
import { useArk, useStorage } from "@lume/ark";
|
||||
import { ArrowLeftIcon, InfoIcon, LoaderIcon } from "@lume/icons";
|
||||
import { User } from "@lume/ui";
|
||||
import { NDKKind, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
|
||||
import { downloadDir } from "@tauri-apps/api/path";
|
||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { writeTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { motion } from "framer-motion";
|
||||
import { minidenticon } from "minidenticons";
|
||||
import { generatePrivateKey, getPublicKey, nip19 } from "nostr-tools";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { AvatarUploader } from "./components/avatarUploader";
|
||||
|
||||
export function CreateAccountScreen() {
|
||||
const [picture, setPicture] = useState("");
|
||||
const [downloaded, setDownloaded] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [keys, setKeys] = useState<null | {
|
||||
npub: string;
|
||||
nsec: string;
|
||||
}>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { isDirty, isValid },
|
||||
} = useForm();
|
||||
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const svgURI = `data:image/svg+xml;utf8,${encodeURIComponent(
|
||||
minidenticon("lume new account", 90, 50),
|
||||
)}`;
|
||||
|
||||
const onSubmit = async (data: { name: string; about: string }) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const profile = {
|
||||
...data,
|
||||
name: data.name,
|
||||
display_name: data.name,
|
||||
bio: data.about,
|
||||
picture: picture,
|
||||
avatar: picture,
|
||||
};
|
||||
|
||||
const userPrivkey = generatePrivateKey();
|
||||
const userPubkey = getPublicKey(userPrivkey);
|
||||
const userNpub = nip19.npubEncode(userPubkey);
|
||||
const userNsec = nip19.nsecEncode(userPrivkey);
|
||||
|
||||
const signer = new NDKPrivateKeySigner(userPrivkey);
|
||||
ark.updateNostrSigner({ signer });
|
||||
|
||||
const publish = await ark.createEvent({
|
||||
content: JSON.stringify(profile),
|
||||
kind: NDKKind.Metadata,
|
||||
tags: [],
|
||||
});
|
||||
|
||||
if (publish) {
|
||||
await storage.createAccount({
|
||||
id: userNpub,
|
||||
pubkey: userPubkey,
|
||||
privkey: userPrivkey,
|
||||
});
|
||||
|
||||
setKeys({ npub: userNpub, nsec: userNsec });
|
||||
setLoading(false);
|
||||
} else {
|
||||
toast.error("Cannot publish user profile, please try again later.");
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (e) {
|
||||
return toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const copyNsec = async () => {
|
||||
await writeText(keys.nsec);
|
||||
};
|
||||
|
||||
const download = async () => {
|
||||
try {
|
||||
const downloadPath = await downloadDir();
|
||||
const fileName = `nostr_keys_${new Date().toISOString()}.txt`;
|
||||
const filePath = await save({
|
||||
defaultPath: `${downloadPath}/${fileName}`,
|
||||
});
|
||||
|
||||
if (filePath) {
|
||||
await writeTextFile(
|
||||
filePath,
|
||||
`Nostr account, generated by Lume (lume.nu)\nPublic key: ${keys.npub}\nPrivate key: ${keys.nsec}`,
|
||||
);
|
||||
|
||||
setDownloaded(true);
|
||||
} // else { user cancel action }
|
||||
} catch (e) {
|
||||
return toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full items-center justify-center">
|
||||
<div className="absolute left-[8px] top-2">
|
||||
{!keys ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(-1)}
|
||||
className="group inline-flex items-center gap-2 text-sm font-medium"
|
||||
>
|
||||
<div className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-neutral-200 text-neutral-800 group-hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-200 dark:group-hover:bg-neutral-700">
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
</div>
|
||||
Back
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
|
||||
<h1 className="text-center text-2xl font-semibold">
|
||||
Let's set up your account.
|
||||
</h1>
|
||||
<div className="flex flex-col gap-3">
|
||||
{!keys ? (
|
||||
<div className="rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="mb-0 flex flex-col"
|
||||
>
|
||||
<input
|
||||
type={"hidden"}
|
||||
{...register("picture")}
|
||||
value={picture}
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-semibold">Avatar</span>
|
||||
<div className="flex h-36 w-full flex-col items-center justify-center gap-3 rounded-lg bg-neutral-100 dark:bg-neutral-900">
|
||||
{picture.length > 0 ? (
|
||||
<img
|
||||
src={picture}
|
||||
alt="user's avatar"
|
||||
className="h-14 w-14 rounded-xl object-cover"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={svgURI}
|
||||
alt="user's avatar"
|
||||
className="h-14 w-14 rounded-xl bg-black dark:bg-white"
|
||||
/>
|
||||
)}
|
||||
<AvatarUploader setPicture={setPicture} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="name" className="font-semibold">
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
type={"text"}
|
||||
{...register("name", {
|
||||
required: true,
|
||||
minLength: 1,
|
||||
})}
|
||||
spellCheck={false}
|
||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="about" className="font-semibold">
|
||||
Bio
|
||||
</label>
|
||||
<textarea
|
||||
{...register("about")}
|
||||
spellCheck={false}
|
||||
className="relative h-24 w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-2 !outline-none placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2 rounded-lg bg-blue-100 p-3 text-sm text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
<InfoIcon className="h-8 w-8" />
|
||||
<p>
|
||||
There are many more settings you can configure from the
|
||||
"Settings" screen. Be sure to visit it later.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isDirty || !isValid}
|
||||
className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Create and Continue"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
}}
|
||||
className="rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950"
|
||||
>
|
||||
<User pubkey={keys.npub} variant="simple" />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 80 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
}}
|
||||
className="rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950"
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h5 className="font-semibold">Backup account</h5>
|
||||
<div>
|
||||
<p className="mb-2 select-text text-sm text-neutral-800 dark:text-neutral-200">
|
||||
Your private key is your password. If you lose this key,
|
||||
you will lose access to your account! Copy it and keep it
|
||||
in a safe place.{" "}
|
||||
<span className="text-red-500">
|
||||
There is no way to reset your private key.
|
||||
</span>
|
||||
</p>
|
||||
<p className="select-text text-sm text-neutral-800 dark:text-neutral-200">
|
||||
Public key is used for sharing with other people so that
|
||||
they can find you using the public key.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="nsec" className="text-sm font-semibold">
|
||||
Private key
|
||||
</label>
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
readOnly
|
||||
value={`${keys.nsec.substring(
|
||||
0,
|
||||
10,
|
||||
)}**************************`}
|
||||
className="h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||
/>
|
||||
<div className="absolute right-0 top-0 inline-flex h-11 items-center justify-center px-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyNsec}
|
||||
className="rounded-md bg-neutral-200 px-2 py-1 text-sm font-medium hover:bg-neutral-400 dark:bg-neutral-700 dark:hover:bg-neutral-600"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="nsec" className="text-sm font-semibold">
|
||||
Public key
|
||||
</label>
|
||||
<input
|
||||
readOnly
|
||||
value={keys.npub}
|
||||
className="h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!downloaded ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => download()}
|
||||
className="mt-1 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
|
||||
>
|
||||
Download account keys
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
{downloaded ? (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
}}
|
||||
className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
|
||||
type="button"
|
||||
onClick={() => navigate("/auth/onboarding")}
|
||||
>
|
||||
Finish
|
||||
</motion.button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
277
apps/desktop/src/routes/auth/follow.tsx
Normal file
277
apps/desktop/src/routes/auth/follow.tsx
Normal file
@ -0,0 +1,277 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
CancelIcon,
|
||||
ChevronDownIcon,
|
||||
LoaderIcon,
|
||||
PlusIcon,
|
||||
} from "@lume/icons";
|
||||
import { User } from "@lume/ui";
|
||||
import * as Accordion from "@radix-ui/react-accordion";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
const POPULAR_USERS = [
|
||||
"npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6",
|
||||
"npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m",
|
||||
"npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s",
|
||||
"npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z",
|
||||
"npub1az9xj85cmxv8e9j9y80lvqp97crsqdu2fpu3srwthd99qfu9qsgstam8y8",
|
||||
"npub1a2cww4kn9wqte4ry70vyfwqyqvpswksna27rtxd8vty6c74era8sdcw83a",
|
||||
"npub168ghgug469n4r2tuyw05dmqhqv5jcwm7nxytn67afmz8qkc4a4zqsu2dlc",
|
||||
"npub133vj8ycevdle0cq8mtgddq0xtn34kxkwxvak983dx0u5vhqnycyqj6tcza",
|
||||
"npub18ams6ewn5aj2n3wt2qawzglx9mr4nzksxhvrdc4gzrecw7n5tvjqctp424",
|
||||
"npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac",
|
||||
"npub1prya33fnqerq0fljwjtp77ehtu7jlsjt5ydhwveuwmqdsdm6k8esk42xcv",
|
||||
"npub19mduaf5569jx9xz555jcx3v06mvktvtpu0zgk47n4lcpjsz43zzqhj6vzk",
|
||||
];
|
||||
const LUME_USERS = [
|
||||
"npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445",
|
||||
];
|
||||
|
||||
export function FollowScreen() {
|
||||
const ark = useArk();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { status, data } = useQuery({
|
||||
queryKey: ["trending-profiles-widget"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("https://api.nostr.band/v0/trending/profiles");
|
||||
if (!res.ok) {
|
||||
throw new Error("Error");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [follows, setFollows] = useState<string[]>([]);
|
||||
|
||||
// toggle follow state
|
||||
const toggleFollow = (pubkey: string) => {
|
||||
const arr = follows.includes(pubkey)
|
||||
? follows.filter((i) => i !== pubkey)
|
||||
: [...follows, pubkey];
|
||||
setFollows(arr);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
if (!follows.length) return navigate("/auth/finish");
|
||||
|
||||
const publish = await ark.newContactList({
|
||||
tags: follows.map((item) => {
|
||||
if (item.startsWith("npub1"))
|
||||
return ["p", nip19.decode(item).data as string];
|
||||
return ["p", item];
|
||||
}),
|
||||
});
|
||||
|
||||
if (publish) {
|
||||
setLoading(false);
|
||||
return navigate("/auth/finish");
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
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="text-center">
|
||||
<h1 className="text-2xl font-semibold">Dive into the nostrverse</h1>
|
||||
<h2 className="text-neutral-700 dark:text-neutral-300">
|
||||
Try following some users that interest you
|
||||
<br />
|
||||
to build up your timeline.
|
||||
</h2>
|
||||
</div>
|
||||
<Accordion.Root type="single" defaultValue="recommended" collapsible>
|
||||
<Accordion.Item
|
||||
value="recommended"
|
||||
className="mb-3 overflow-hidden rounded-xl"
|
||||
>
|
||||
<Accordion.Trigger className="flex h-12 w-full items-center justify-between rounded-t-xl bg-neutral-100 px-3 font-medium dark:bg-neutral-900">
|
||||
Popular users
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</Accordion.Trigger>
|
||||
<Accordion.Content>
|
||||
<div className="flex h-[420px] w-full flex-col gap-3 overflow-y-auto rounded-b-xl bg-neutral-50 p-3 dark:bg-neutral-950">
|
||||
{POPULAR_USERS.map((pubkey) => (
|
||||
<div
|
||||
key={pubkey}
|
||||
className="flex h-max w-full shrink-0 flex-col overflow-hidden rounded-lg border border-neutral-100 bg-white dark:border-neutral-900 dark:bg-black"
|
||||
>
|
||||
<div className="p-3">
|
||||
<User pubkey={pubkey} variant="large" />
|
||||
</div>
|
||||
<div className="border-t border-neutral-100 px-3 py-4 dark:border-neutral-900">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleFollow(pubkey)}
|
||||
className={twMerge(
|
||||
"inline-flex h-9 w-full items-center justify-center gap-1 rounded-lg font-medium text-white",
|
||||
follows.includes(pubkey)
|
||||
? "bg-red-500 hover:bg-red-600"
|
||||
: "bg-blue-500 hover:bg-blue-600",
|
||||
)}
|
||||
>
|
||||
{follows.includes(pubkey) ? (
|
||||
<>
|
||||
<CancelIcon className="h-4 w-4" />
|
||||
Unfollow
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Follow
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item
|
||||
value="trending"
|
||||
className="mb-3 overflow-hidden rounded-xl"
|
||||
>
|
||||
<Accordion.Trigger className="flex h-12 w-full items-center justify-between rounded-t-xl bg-neutral-100 px-3 font-medium dark:bg-neutral-900">
|
||||
Trending users
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</Accordion.Trigger>
|
||||
<Accordion.Content>
|
||||
<div className="flex h-[420px] w-full flex-col gap-3 overflow-y-auto rounded-b-xl bg-neutral-50 p-3 dark:bg-neutral-950">
|
||||
{status === "pending" ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
data?.profiles.map(
|
||||
(item: {
|
||||
pubkey: string;
|
||||
profile: { content: string };
|
||||
}) => (
|
||||
<div
|
||||
key={item.pubkey}
|
||||
className="flex h-max w-full shrink-0 flex-col overflow-hidden rounded-lg border border-neutral-100 bg-white dark:border-neutral-900 dark:bg-black"
|
||||
>
|
||||
<div className="p-3">
|
||||
<User pubkey={item.pubkey} variant="large" />
|
||||
</div>
|
||||
<div className="border-t border-neutral-100 px-3 py-4 dark:border-neutral-900">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleFollow(item.pubkey)}
|
||||
className={twMerge(
|
||||
"inline-flex h-9 w-full items-center justify-center gap-1 rounded-lg font-medium text-white",
|
||||
follows.includes(item.pubkey)
|
||||
? "bg-red-500 hover:bg-red-600"
|
||||
: "bg-blue-500 hover:bg-blue-600",
|
||||
)}
|
||||
>
|
||||
{follows.includes(item.pubkey) ? (
|
||||
<>
|
||||
<CancelIcon className="h-4 w-4" />
|
||||
Unfollow
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Follow
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item
|
||||
value="lume"
|
||||
className="mb-3 overflow-hidden rounded-xl"
|
||||
>
|
||||
<Accordion.Trigger className="flex h-12 w-full items-center justify-between rounded-t-xl bg-neutral-100 px-3 font-medium dark:bg-neutral-900">
|
||||
Lume team
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</Accordion.Trigger>
|
||||
<Accordion.Content>
|
||||
<div className="flex h-[420px] w-full flex-col gap-3 overflow-y-auto rounded-b-xl bg-neutral-50 p-3 dark:bg-neutral-950">
|
||||
{LUME_USERS.map((pubkey) => (
|
||||
<div
|
||||
key={pubkey}
|
||||
className="flex h-max w-full shrink-0 flex-col overflow-hidden rounded-lg border border-neutral-100 bg-white dark:border-neutral-900 dark:bg-black"
|
||||
>
|
||||
<div className="p-3">
|
||||
<User pubkey={pubkey} variant="large" />
|
||||
</div>
|
||||
<div className="border-t border-neutral-100 px-3 py-4 dark:border-neutral-900">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleFollow(pubkey)}
|
||||
className={twMerge(
|
||||
"inline-flex h-9 w-full items-center justify-center gap-1 rounded-lg font-medium text-white",
|
||||
follows.includes(pubkey)
|
||||
? "bg-red-500 hover:bg-red-600"
|
||||
: "bg-blue-500 hover:bg-blue-600",
|
||||
)}
|
||||
>
|
||||
{follows.includes(pubkey) ? (
|
||||
<>
|
||||
<CancelIcon className="h-4 w-4" />
|
||||
Unfollow
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Follow
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion.Root>
|
||||
</div>
|
||||
<div className="absolute bottom-3 right-3 flex w-full items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(-1)}
|
||||
className="inline-flex h-11 w-max items-center justify-center gap-2 rounded-lg bg-neutral-100 px-3 font-semibold hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-blue-800"
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={loading}
|
||||
className="inline-flex h-11 w-max items-center justify-center gap-2 rounded-lg bg-blue-500 px-3 font-semibold text-white hover:bg-blue-600"
|
||||
>
|
||||
Continue
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowRightIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
330
apps/desktop/src/routes/auth/import.tsx
Normal file
330
apps/desktop/src/routes/auth/import.tsx
Normal file
@ -0,0 +1,330 @@
|
||||
import { useArk, useStorage } from "@lume/ark";
|
||||
import { ArrowLeftIcon, LoaderIcon } from "@lume/icons";
|
||||
import { User } from "@lume/ui";
|
||||
import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
|
||||
import { readText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { open } from "@tauri-apps/plugin-shell";
|
||||
import { motion } from "framer-motion";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function ImportAccountScreen() {
|
||||
const [npub, setNpub] = useState<string>("");
|
||||
const [nsec, setNsec] = useState<string>("");
|
||||
const [pubkey, setPubkey] = useState<undefined | string>(undefined);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [created, setCreated] = useState({ ok: false, remote: false });
|
||||
const [savedPrivkey, setSavedPrivkey] = useState(false);
|
||||
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const submitNpub = async () => {
|
||||
if (npub.length < 6) return toast.error("You must enter valid npub");
|
||||
if (!npub.startsWith("npub1"))
|
||||
return toast.error("npub must be starts with npub1");
|
||||
|
||||
try {
|
||||
const pubkey = nip19.decode(npub).data as string;
|
||||
setPubkey(pubkey);
|
||||
} catch (e) {
|
||||
return toast.error(`npub invalid: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
const connectNsecBunker = async () => {
|
||||
if (npub.length < 6) return toast.error("You must enter valid npub");
|
||||
if (!npub.startsWith("npub1"))
|
||||
return toast.error("npub must be starts with npub1");
|
||||
|
||||
try {
|
||||
const pubkey = nip19.decode(npub.split("#")[0]).data as string;
|
||||
const localSigner = NDKPrivateKeySigner.generate();
|
||||
|
||||
await storage.createSetting("nsecbunker", "1");
|
||||
await storage.createPrivkey(`${npub}-nsecbunker`, localSigner.privateKey);
|
||||
|
||||
// open nsecbunker web app in default browser
|
||||
await open("https://app.nsecbunker.com/keys");
|
||||
|
||||
const bunker = new NDK({
|
||||
explicitRelayUrls: [
|
||||
"wss://relay.nsecbunker.com",
|
||||
"wss://nostr.vulpem.com",
|
||||
],
|
||||
});
|
||||
await bunker.connect();
|
||||
|
||||
const remoteSigner = new NDKNip46Signer(bunker, npub, localSigner);
|
||||
await remoteSigner.blockUntilReady();
|
||||
ark.updateNostrSigner({ signer: remoteSigner });
|
||||
|
||||
setPubkey(pubkey);
|
||||
setCreated({ ok: false, remote: true });
|
||||
} catch (e) {
|
||||
return toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const changeAccount = async () => {
|
||||
setNpub("");
|
||||
setPubkey("");
|
||||
};
|
||||
|
||||
const createAccount = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// add account to db
|
||||
await storage.createAccount({ id: npub, pubkey });
|
||||
|
||||
// get account contacts
|
||||
await ark.getUserContacts({ pubkey });
|
||||
|
||||
setCreated((prev) => ({ ...prev, ok: true }));
|
||||
setLoading(false);
|
||||
|
||||
if (created.remote) navigate("/auth/onboarding");
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
return toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const pasteNsec = async () => {
|
||||
const tempNsec = await readText();
|
||||
setNsec(tempNsec);
|
||||
};
|
||||
|
||||
const submitNsec = async () => {
|
||||
if (savedPrivkey) return;
|
||||
if (nsec.length > 50 && nsec.startsWith("nsec1")) {
|
||||
try {
|
||||
const privkey = nip19.decode(nsec).data as string;
|
||||
await storage.createPrivkey(pubkey, privkey);
|
||||
ark.updateNostrSigner({ signer: new NDKPrivateKeySigner(privkey) });
|
||||
|
||||
setSavedPrivkey(true);
|
||||
} catch (e) {
|
||||
return toast(`nsec invalid: ${e}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full items-center justify-center">
|
||||
<div className="absolute left-[8px] top-2">
|
||||
{!created ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(-1)}
|
||||
className="inline-flex items-center gap-2 text-sm font-medium"
|
||||
>
|
||||
<div className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-neutral-200 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-200">
|
||||
<ArrowLeftIcon className="h-5 w-5" />
|
||||
</div>
|
||||
Back
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
|
||||
<h1 className="text-center text-2xl font-semibold">
|
||||
Import your account.
|
||||
</h1>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="npub" className="font-semibold">
|
||||
Enter your public key:
|
||||
</label>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<input
|
||||
readOnly={!!pubkey}
|
||||
name="npub"
|
||||
type="text"
|
||||
value={npub}
|
||||
onChange={(e) => setNpub(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
placeholder="npub1"
|
||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||
/>
|
||||
{!pubkey ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitNpub}
|
||||
className="h-11 w-full shrink-0 rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={connectNsecBunker}
|
||||
className="h-11 w-full shrink-0 rounded-lg bg-neutral-200 font-medium hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
|
||||
>
|
||||
Continue with nsecBunker
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{npub.indexOf("#") > -1 ? (
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
You're using nsecbunker token, keep in mind it only can
|
||||
redeem one-time, you need to login again in the next launch
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{pubkey ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
}}
|
||||
className="rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950"
|
||||
>
|
||||
<h5 className="mb-1.5 font-semibold">Account found</h5>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="flex h-full w-full items-center justify-between rounded-lg bg-neutral-100 px-4 py-3 dark:bg-neutral-900">
|
||||
<User pubkey={pubkey} variant="simple" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={changeAccount}
|
||||
className="h-8 w-max shrink-0 rounded-lg bg-neutral-200 px-2.5 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
{!created.ok ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={createAccount}
|
||||
className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Continue"
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
{created.ok ? (
|
||||
<>
|
||||
{!created.remote ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
}}
|
||||
className="rounded-lg bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200"
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="nsec" className="font-semibold">
|
||||
Enter your private key (optional):
|
||||
</label>
|
||||
<div className="inline-flex w-full items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
name="nsec"
|
||||
type="text"
|
||||
value={nsec}
|
||||
onChange={(e) => setNsec(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
placeholder="nsec1"
|
||||
className="h-11 w-full rounded-lg border-transparent bg-neutral-200 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-800 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||
/>
|
||||
{nsec.length < 5 ? (
|
||||
<div className="absolute right-0 top-0 inline-flex h-11 items-center justify-center px-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={pasteNsec}
|
||||
className="rounded-md bg-neutral-300 px-2 py-1 text-sm font-medium hover:bg-neutral-400 dark:bg-neutral-700 dark:hover:bg-neutral-600"
|
||||
>
|
||||
Paste
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{nsec.length > 5 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitNsec}
|
||||
className={twMerge(
|
||||
"h-11 w-24 shrink-0 rounded-lg font-semibold text-white",
|
||||
!savedPrivkey
|
||||
? "bg-blue-500 hover:bg-blue-600"
|
||||
: "bg-teal-500 hover:bg-teal-600",
|
||||
)}
|
||||
>
|
||||
{savedPrivkey ? "Saved" : "Save"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 select-text">
|
||||
<p className="text-sm">
|
||||
<b>Private Key</b> is used to sign your event. For
|
||||
example, if you want to make a new post or send a message
|
||||
to your contact, you need to use your private key to sign
|
||||
this event.
|
||||
</p>
|
||||
<h5 className="mt-2 font-semibold">
|
||||
1. In case you store private key in Lume
|
||||
</h5>
|
||||
<p className="text-sm">
|
||||
Lume will put your private key to{" "}
|
||||
<b>
|
||||
{storage.platform === "macos"
|
||||
? "Apple Keychain (macOS)"
|
||||
: storage.platform === "windows"
|
||||
? "Credential Manager (Windows)"
|
||||
: "Secret Service (Linux)"}
|
||||
</b>
|
||||
, it will be secured by your OS
|
||||
</p>
|
||||
<h5 className="mt-2 font-semibold">
|
||||
2. In case you do not store private key in Lume
|
||||
</h5>
|
||||
<p className="text-sm">
|
||||
When you make an event that requires a sign by your
|
||||
private key, Lume will show a prompt for you to enter
|
||||
private key. It will be cleared after signing and not
|
||||
stored anywhere.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
<motion.button
|
||||
initial={{ opacity: 0, y: 80 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
}}
|
||||
className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
|
||||
type="button"
|
||||
onClick={() => navigate("/auth/onboarding")}
|
||||
>
|
||||
Continue
|
||||
</motion.button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
148
apps/desktop/src/routes/auth/onboarding.tsx
Normal file
148
apps/desktop/src/routes/auth/onboarding.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
import { useStorage } from "@lume/ark";
|
||||
import { InfoIcon } from "@lume/icons";
|
||||
import * as Switch from "@radix-ui/react-switch";
|
||||
import {
|
||||
isPermissionGranted,
|
||||
requestPermission,
|
||||
} from "@tauri-apps/plugin-notification";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export function OnboardingScreen() {
|
||||
const storage = useStorage();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [settings, setSettings] = useState({
|
||||
autoupdate: false,
|
||||
outbox: false,
|
||||
notification: false,
|
||||
});
|
||||
|
||||
const next = () => {
|
||||
if (!storage.account.contacts.length) return navigate("/auth/follow");
|
||||
return navigate("/auth/finish");
|
||||
};
|
||||
|
||||
const toggleOutbox = async () => {
|
||||
await storage.createSetting("outbox", String(+!settings.outbox));
|
||||
// update state
|
||||
setSettings((prev) => ({ ...prev, outbox: !settings.outbox }));
|
||||
};
|
||||
|
||||
const toggleAutoupdate = async () => {
|
||||
await storage.createSetting("autoupdate", String(+!settings.autoupdate));
|
||||
// update state
|
||||
setSettings((prev) => ({ ...prev, autoupdate: !settings.autoupdate }));
|
||||
};
|
||||
|
||||
const toggleNofitication = async () => {
|
||||
await requestPermission();
|
||||
// update state
|
||||
setSettings((prev) => ({ ...prev, notification: !settings.notification }));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function loadSettings() {
|
||||
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 === "outbox")
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
outbox: !!parseInt(item.value),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
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="text-center">
|
||||
<h1 className="text-2xl font-light text-neutral-900 dark:text-neutral-100">
|
||||
You're almost ready to use Lume.
|
||||
</h1>
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
Let's start personalizing your experience.
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
|
||||
<Switch.Root
|
||||
checked={settings.autoupdate}
|
||||
onClick={() => toggleAutoupdate()}
|
||||
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
|
||||
>
|
||||
<Switch.Thumb className="block 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>
|
||||
<h3 className="font-semibold">Auto check for update on Login</h3>
|
||||
<p className="text-sm">
|
||||
Keep Lume up to date with latest version, always have new
|
||||
features and bug free.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
|
||||
<Switch.Root
|
||||
checked={settings.notification}
|
||||
onClick={() => toggleNofitication()}
|
||||
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
|
||||
>
|
||||
<Switch.Thumb className="block 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>
|
||||
<h3 className="font-semibold">Push notification</h3>
|
||||
<p className="text-sm">
|
||||
Enabling push notifications will allow you to receive
|
||||
notifications from Lume directly on your device.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
|
||||
<Switch.Root
|
||||
checked={settings.outbox}
|
||||
onClick={() => toggleOutbox()}
|
||||
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
|
||||
>
|
||||
<Switch.Thumb className="block 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>
|
||||
<h3 className="font-semibold">Use Gossip model (recommended)</h3>
|
||||
<p className="text-sm">
|
||||
Automatically discover relays to connect based on the
|
||||
preferences of each author.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-lg bg-blue-100 p-3 text-sm text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
<InfoIcon className="h-8 w-8" />
|
||||
<p>
|
||||
There are many more settings you can configure from the
|
||||
"Settings" screen. Be sure to visit it later.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={next}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
46
apps/desktop/src/routes/auth/tutorials/finish.tsx
Normal file
46
apps/desktop/src/routes/auth/tutorials/finish.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
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>
|
||||
);
|
||||
}
|
102
apps/desktop/src/routes/auth/tutorials/note.tsx
Normal file
102
apps/desktop/src/routes/auth/tutorials/note.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
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>
|
||||
);
|
||||
}
|
67
apps/desktop/src/routes/auth/tutorials/widget.tsx
Normal file
67
apps/desktop/src/routes/auth/tutorials/widget.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
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>
|
||||
);
|
||||
}
|
71
apps/desktop/src/routes/depot/components/contact.tsx
Normal file
71
apps/desktop/src/routes/depot/components/contact.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import { useArk, useStorage } from "@lume/ark";
|
||||
import { LoaderIcon, RunIcon } from "@lume/icons";
|
||||
import { User } from "@lume/ui";
|
||||
import { NDKKind } from "@nostr-dev-kit/ndk";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function DepotContactCard() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
|
||||
const [status, setStatus] = useState(false);
|
||||
|
||||
const backupContact = async () => {
|
||||
try {
|
||||
setStatus(true);
|
||||
|
||||
const event = await ark.getEventByFilter({
|
||||
filter: {
|
||||
authors: [storage.account.pubkey],
|
||||
kinds: [NDKKind.Contacts],
|
||||
},
|
||||
});
|
||||
|
||||
// broadcast to depot
|
||||
const publish = await event.publish();
|
||||
|
||||
if (publish) {
|
||||
setStatus(false);
|
||||
toast.success("Backup contact list successfully.");
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus(false);
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-56 w-full flex-col gap-2 overflow-hidden rounded-xl bg-neutral-100 p-2 dark:bg-neutral-900">
|
||||
<div className="flex flex-1 items-center justify-center rounded-lg bg-neutral-200 dark:bg-neutral-800">
|
||||
<div className="isolate flex -space-x-2">
|
||||
{storage.account.contacts?.slice(0, 8).map((item) => (
|
||||
<User key={item} pubkey={item} variant="ministacked" />
|
||||
))}
|
||||
{storage.account.contacts?.length > 8 ? (
|
||||
<div className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-neutral-300 text-neutral-900 ring-1 ring-white dark:bg-neutral-700 dark:text-neutral-100 dark:ring-black">
|
||||
<span className="text-[8px] font-medium">
|
||||
+{storage.account.contacts?.length - 8}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="inline-flex shrink-0 items-center justify-between">
|
||||
<div className="text-sm font-medium">Contacts</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={backupContact}
|
||||
className="inline-flex h-8 w-max items-center justify-center gap-2 rounded-md bg-blue-500 pl-2 pr-3 font-medium text-white shadow shadow-blue-500/50 hover:bg-blue-600"
|
||||
>
|
||||
{status ? (
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RunIcon className="size-4" />
|
||||
)}
|
||||
Backup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
150
apps/desktop/src/routes/depot/components/members.tsx
Normal file
150
apps/desktop/src/routes/depot/components/members.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
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 { 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";
|
||||
|
||||
export function DepotMembers() {
|
||||
const [members, setMembers] = useState<Set<string>>(null);
|
||||
const [tmpMembers, setTmpMembers] = useState<Array<string>>([]);
|
||||
const [newMember, setNewMember] = useState("");
|
||||
|
||||
const addMember = async () => {
|
||||
if (!newMember.startsWith("npub1"))
|
||||
return toast.error("You need to enter a valid npub");
|
||||
|
||||
try {
|
||||
const pubkey = nip19.decode(newMember).data as string;
|
||||
setTmpMembers((prev) => [...prev, pubkey]);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const removeMember = (member: string) => {
|
||||
setTmpMembers((prev) => prev.filter((item) => item !== member));
|
||||
};
|
||||
|
||||
const updateMembers = async () => {
|
||||
setMembers(new Set(tmpMembers));
|
||||
|
||||
const defaultConfig = await resolveResource("resources/config.toml");
|
||||
const config = await readTextFile(defaultConfig);
|
||||
const configContent = parse(config);
|
||||
|
||||
// biome-ignore lint/complexity/useLiteralKeys: <explanation>
|
||||
configContent.authorization["pubkey_whitelist"] = [...members];
|
||||
|
||||
const newConfig = stringify(configContent);
|
||||
|
||||
return await writeTextFile(defaultConfig, newConfig);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function loadConfig() {
|
||||
const defaultConfig = await resolveResource("resources/config.toml");
|
||||
const config = await readTextFile(defaultConfig);
|
||||
const configContent = parse(config);
|
||||
setTmpMembers(
|
||||
// biome-ignore lint/complexity/useLiteralKeys: <explanation>
|
||||
Array.from(configContent.authorization["pubkey_whitelist"]),
|
||||
);
|
||||
}
|
||||
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog.Root>
|
||||
<div className="flex items-center justify-between rounded-lg bg-neutral-50 p-5 dark:bg-neutral-950">
|
||||
<div className="flex flex-col items-start">
|
||||
<h3 className="text-lg font-semibold">Members</h3>
|
||||
<p className="text-neutral-700 dark:text-neutral-300">
|
||||
Only allowed users can publish event to your Depot
|
||||
</p>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<div className="isolate flex -space-x-2">
|
||||
{tmpMembers.slice(0, 5).map((item) => (
|
||||
<User key={item} pubkey={item} variant="stacked" />
|
||||
))}
|
||||
{tmpMembers.length > 5 ? (
|
||||
<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">
|
||||
+{tmpMembers.length}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Dialog.Trigger className="inline-flex h-8 w-max items-center justify-center gap-1 rounded-lg bg-blue-500 px-3 text-white hover:bg-blue-600">
|
||||
<UserAddIcon className="size-4" />
|
||||
Manage
|
||||
</Dialog.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/20 backdrop-blur-sm dark:bg-black/20" />
|
||||
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
||||
<div className="relative h-min w-full max-w-xl overflow-hidden rounded-xl bg-white dark:bg-black">
|
||||
<div className="inline-flex h-14 w-full shrink-0 items-center justify-between border-b border-neutral-100 px-5 dark:border-neutral-900">
|
||||
<Dialog.Title className="text-center font-semibold">
|
||||
Manage member
|
||||
</Dialog.Title>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={updateMembers}
|
||||
className="inline-flex h-8 w-max items-center justify-center rounded-lg bg-blue-500 px-2.5 text-sm font-medium text-white hover:bg-blue-600"
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
<Dialog.Close className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800">
|
||||
<CancelIcon className="size-4" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pb-3">
|
||||
<div className="relative mb-2 mt-4 w-full px-5">
|
||||
<input
|
||||
type="text"
|
||||
spellCheck={false}
|
||||
value={newMember}
|
||||
onChange={(e) => setNewMember(e.target.value)}
|
||||
placeholder="npub1..."
|
||||
className="h-11 w-full rounded-lg border-transparent bg-neutral-100 pl-3 pr-20 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addMember}
|
||||
className="absolute right-7 top-1/2 inline-flex h-7 w-max -translate-y-1/2 transform items-center justify-center gap-1 rounded-md bg-neutral-200 px-2.5 text-sm font-medium text-blue-500 hover:bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
{tmpMembers.map((member) => (
|
||||
<div
|
||||
key={member}
|
||||
className="group flex items-center justify-between px-5 py-2 hover:bg-neutral-100 dark:hover:bg-neutral-900"
|
||||
>
|
||||
<User pubkey={member} variant="simple" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMember(member)}
|
||||
className="hidden size-6 items-center justify-center rounded-md bg-neutral-200 group-hover:inline-flex hover:bg-red-200 dark:bg-neutral-800 dark:hover:bg-red-800 dark:hover:text-red-200"
|
||||
>
|
||||
<UserRemoveIcon className="size-4 text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
60
apps/desktop/src/routes/depot/components/profile.tsx
Normal file
60
apps/desktop/src/routes/depot/components/profile.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { useArk, useStorage } from "@lume/ark";
|
||||
import { LoaderIcon, RunIcon } from "@lume/icons";
|
||||
import { User } from "@lume/ui";
|
||||
import { NDKKind } from "@nostr-dev-kit/ndk";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function DepotProfileCard() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
|
||||
const [status, setStatus] = useState(false);
|
||||
|
||||
const backupProfile = async () => {
|
||||
try {
|
||||
setStatus(true);
|
||||
|
||||
const event = await ark.getEventByFilter({
|
||||
filter: {
|
||||
authors: [storage.account.pubkey],
|
||||
kinds: [NDKKind.Metadata],
|
||||
},
|
||||
});
|
||||
|
||||
// broadcast to depot
|
||||
const publish = await event.publish();
|
||||
|
||||
if (publish) {
|
||||
setStatus(false);
|
||||
toast.success("Backup profile successfully.");
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus(false);
|
||||
toast.error(JSON.stringify(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-56 w-full flex-col gap-2 overflow-hidden rounded-xl bg-neutral-100 p-2 dark:bg-neutral-900">
|
||||
<div className="flex flex-1 items-center justify-center rounded-lg bg-neutral-200 dark:bg-neutral-800">
|
||||
<User pubkey={storage.account.pubkey} variant="simple" />
|
||||
</div>
|
||||
<div className="inline-flex shrink-0 items-center justify-between">
|
||||
<div className="text-sm font-medium">Profile</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={backupProfile}
|
||||
className="inline-flex h-8 w-max items-center justify-center gap-2 rounded-md bg-blue-500 pl-2 pr-3 font-medium text-white shadow shadow-blue-500/50 hover:bg-blue-600"
|
||||
>
|
||||
{status ? (
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RunIcon className="size-4" />
|
||||
)}
|
||||
Backup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
74
apps/desktop/src/routes/depot/components/relays.tsx
Normal file
74
apps/desktop/src/routes/depot/components/relays.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import { useArk, useStorage } from "@lume/ark";
|
||||
import { LoaderIcon, RunIcon } from "@lume/icons";
|
||||
import { NDKKind } from "@nostr-dev-kit/ndk";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function DepotRelaysCard() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
|
||||
const [status, setStatus] = useState(false);
|
||||
const [relaySize, setRelaySize] = useState(0);
|
||||
|
||||
const backupRelays = async () => {
|
||||
try {
|
||||
setStatus(true);
|
||||
|
||||
const event = await ark.getEventByFilter({
|
||||
filter: {
|
||||
authors: [storage.account.pubkey],
|
||||
kinds: [NDKKind.RelayList],
|
||||
},
|
||||
});
|
||||
|
||||
// broadcast to depot
|
||||
const publish = await event.publish();
|
||||
|
||||
if (publish) {
|
||||
setStatus(false);
|
||||
toast.success("Backup profile successfully.");
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus(false);
|
||||
toast.error(JSON.stringify(e));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function loadRelays() {
|
||||
const event = await ark.getEventByFilter({
|
||||
filter: {
|
||||
authors: [storage.account.pubkey],
|
||||
kinds: [NDKKind.RelayList],
|
||||
},
|
||||
});
|
||||
if (event) setRelaySize(event.tags.length);
|
||||
}
|
||||
|
||||
loadRelays();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-56 w-full flex-col gap-2 overflow-hidden rounded-xl bg-neutral-100 p-2 dark:bg-neutral-900">
|
||||
<div className="flex flex-1 items-center justify-center rounded-lg bg-neutral-200 dark:bg-neutral-800">
|
||||
<p className="text-lg font-semibold">{relaySize} relays</p>
|
||||
</div>
|
||||
<div className="inline-flex shrink-0 items-center justify-between">
|
||||
<div className="text-sm font-medium">Relay List</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={backupRelays}
|
||||
className="inline-flex h-8 w-max items-center justify-center gap-2 rounded-md bg-blue-500 pl-2 pr-3 font-medium text-white shadow shadow-blue-500/50 hover:bg-blue-600"
|
||||
>
|
||||
{status ? (
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RunIcon className="size-4" />
|
||||
)}
|
||||
Backup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
221
apps/desktop/src/routes/depot/index.tsx
Normal file
221
apps/desktop/src/routes/depot/index.tsx
Normal file
@ -0,0 +1,221 @@
|
||||
import { useArk, useStorage } from "@lume/ark";
|
||||
import { ChevronDownIcon, DepotIcon, GossipIcon } from "@lume/icons";
|
||||
import { NDKKind } from "@nostr-dev-kit/ndk";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { appConfigDir } from "@tauri-apps/api/path";
|
||||
import { invoke } from "@tauri-apps/api/primitives";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DepotContactCard } from "./components/contact";
|
||||
import { DepotMembers } from "./components/members";
|
||||
import { DepotProfileCard } from "./components/profile";
|
||||
import { DepotRelaysCard } from "./components/relays";
|
||||
|
||||
export function DepotScreen() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
|
||||
const [dataPath, setDataPath] = useState("");
|
||||
const [tunnelUrl, setTunnelUrl] = useState("");
|
||||
|
||||
const openFolder = async () => {
|
||||
await invoke("show_in_folder", {
|
||||
path: `${dataPath}/nostr.db`,
|
||||
});
|
||||
};
|
||||
|
||||
const updateRelayList = async () => {
|
||||
try {
|
||||
if (tunnelUrl.length < 1)
|
||||
return toast.info("Please enter a valid relay url");
|
||||
if (!tunnelUrl.startsWith("ws"))
|
||||
return toast.info("Please enter a valid relay url");
|
||||
|
||||
const relayUrl = new URL(tunnelUrl.replace(/\s/g, ""));
|
||||
if (!/^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/.test(relayUrl.host)) return;
|
||||
|
||||
const relayEvent = await ark.getEventByFilter({
|
||||
filter: {
|
||||
authors: [storage.account.pubkey],
|
||||
kinds: [NDKKind.RelayList],
|
||||
},
|
||||
});
|
||||
|
||||
let publish: { id: string; seens: string[] };
|
||||
|
||||
if (!relayEvent) {
|
||||
publish = await ark.createEvent({
|
||||
kind: NDKKind.RelayList,
|
||||
tags: [["r", tunnelUrl, ""]],
|
||||
});
|
||||
}
|
||||
|
||||
const newTags = relayEvent.tags ?? [];
|
||||
newTags.push(["r", tunnelUrl, ""]);
|
||||
|
||||
publish = await ark.createEvent({
|
||||
kind: NDKKind.RelayList,
|
||||
tags: newTags,
|
||||
});
|
||||
|
||||
if (publish) {
|
||||
await storage.createSetting("tunnel_url", tunnelUrl);
|
||||
toast.success("Update relay list successfully.");
|
||||
|
||||
setTunnelUrl("");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast.error("Error");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function loadConfig() {
|
||||
const appDir = await appConfigDir();
|
||||
setDataPath(appDir);
|
||||
}
|
||||
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
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-[inset_0_0_0.5px_1px_hsla(0,0%,100%,0.075),0_0_0_1px_hsla(0,0%,0%,0.05),0_0.3px_0.4px_hsla(0,0%,0%,0.02),0_0.9px_1.5px_hsla(0,0%,0%,0.045),0_3.5px_6px_hsla(0,0%,0%,0.09)]">
|
||||
<div className="h-full w-72 shrink-0 rounded-l-xl bg-white/50 px-8 pt-8 backdrop-blur-xl dark:bg-black/50">
|
||||
<div className="flex flex-col justify-center gap-4">
|
||||
<div className="size-16 rounded-xl bg-gradient-to-bl from-teal-300 to-teal-600 p-1">
|
||||
<div className="relative inline-flex h-full w-full items-center justify-center overflow-hidden rounded-lg bg-gradient-to-bl from-teal-400 to-teal-700 shadow-sm shadow-white/20">
|
||||
<DepotIcon className="size-8 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold">Depot is running</h1>
|
||||
</div>
|
||||
<div className="mt-8 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="text-sm font-medium">Relay URL</div>
|
||||
<div className="inline-flex h-10 w-full select-text items-center rounded-lg bg-black/10 px-3 text-sm backdrop-blur-xl dark:bg-white/10">
|
||||
ws://localhost:6090
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="text-sm font-medium">Database</div>
|
||||
<div className="inline-flex h-10 w-full items-center gap-2 truncate rounded-lg bg-black/10 p-1 backdrop-blur-xl dark:bg-white/10">
|
||||
<p className="shrink-0 pl-2 text-sm">nostr.db (SQLite)</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openFolder}
|
||||
className="inline-flex h-full w-full items-center justify-center rounded-md bg-white text-sm font-medium shadow hover:bg-blue-500 hover:text-white dark:bg-black"
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto rounded-r-xl bg-white pb-20 dark:bg-black">
|
||||
<div className="mb-5 flex h-12 items-center border-b border-neutral-100 px-5 dark:border-neutral-900">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
Actions
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5 px-5">
|
||||
<Collapsible.Root
|
||||
defaultOpen
|
||||
className="flex flex-col overflow-hidden rounded-xl border border-transparent bg-neutral-50 data-[state=open]:border-blue-500 dark:bg-neutral-950"
|
||||
>
|
||||
<Collapsible.Trigger className="flex h-20 items-center justify-between px-5 hover:bg-neutral-100 dark:hover:bg-neutral-900">
|
||||
<div className="flex flex-col items-start">
|
||||
<h3 className="text-lg font-semibold">Expose</h3>
|
||||
<p className="text-neutral-700 dark:text-neutral-300">
|
||||
Make your Depot visible in the Internet, everyone can connect
|
||||
into it.
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDownIcon className="size-5 shrink-0" />
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<div className="flex w-full flex-col gap-4 p-5">
|
||||
<div>
|
||||
<p className="mb-1 font-medium">ngrok</p>
|
||||
<input
|
||||
readOnly
|
||||
value="ngrok http --domain=<your_domain> 6090"
|
||||
className="h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 font-medium">Cloudflare Tunnel</p>
|
||||
<input
|
||||
readOnly
|
||||
value="cloudflared tunnel --url localhost:6090"
|
||||
className="h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 font-medium">Local Tunnel</p>
|
||||
<input
|
||||
readOnly
|
||||
value="lt --port 6090"
|
||||
className="h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 border-t border-neutral-100 pt-4 dark:border-neutral-900">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<GossipIcon className="size-5 text-blue-500" />
|
||||
<h3 className="mb-1 font-semibold">
|
||||
Support Gossip Model (Recommended)
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-xl">
|
||||
<p className=" text-balance">
|
||||
By adding to Relay List, other Nostr Client which support
|
||||
Gossip Model will automatically connect to your Depot and
|
||||
improve the discoverability.
|
||||
</p>
|
||||
<div className="mt-2 inline-flex w-full items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={tunnelUrl}
|
||||
onChange={(e) => setTunnelUrl(e.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder="wss://"
|
||||
className="h-10 flex-1 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={updateRelayList}
|
||||
className="inline-flex h-10 w-max shrink-0 items-center justify-center rounded-lg bg-blue-500 px-4 font-medium text-white hover:bg-blue-600"
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
<Collapsible.Root className="flex flex-col overflow-hidden rounded-xl border border-transparent bg-neutral-50 data-[state=open]:border-blue-500 dark:bg-neutral-950">
|
||||
<Collapsible.Trigger className="flex h-20 items-center justify-between px-5 hover:bg-neutral-100 dark:hover:bg-neutral-900">
|
||||
<div className="flex flex-col items-start">
|
||||
<h3 className="text-lg font-semibold">Backup (Recommended)</h3>
|
||||
<p className="text-neutral-700 dark:text-neutral-300">
|
||||
Backup all your data to Depot, it always live on your machine.
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDownIcon className="size-5 shrink-0" />
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<div className="grid grid-cols-3 gap-4 px-5 py-5">
|
||||
<DepotProfileCard />
|
||||
<DepotContactCard />
|
||||
<DepotRelaysCard />
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
<DepotMembers />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
99
apps/desktop/src/routes/depot/onboarding.tsx
Normal file
99
apps/desktop/src/routes/depot/onboarding.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import { useArk, useStorage } from "@lume/ark";
|
||||
import { LoaderIcon } from "@lume/icons";
|
||||
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";
|
||||
import { parse, stringify } from "smol-toml";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function DepotOnboardingScreen() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const launchDepot = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// get default config
|
||||
const defaultConfig = await resolveResource("resources/config.toml");
|
||||
const config = await readTextFile(defaultConfig);
|
||||
const parsedConfig = parse(config);
|
||||
|
||||
// add current user to whitelist
|
||||
// biome-ignore lint/complexity/useLiteralKeys: <explanation>
|
||||
parsedConfig.authorization["pubkey_whitelist"].push(
|
||||
storage.account.pubkey,
|
||||
);
|
||||
|
||||
// update new config
|
||||
const newConfig = stringify(parsedConfig);
|
||||
await writeTextFile(defaultConfig, newConfig);
|
||||
|
||||
// launch depot
|
||||
await storage.launchDepot();
|
||||
await storage.createSetting("depot", "1");
|
||||
await delay(2000); // delay 2s to make sure depot is running
|
||||
|
||||
// default depot url: ws://localhost:6090
|
||||
// #TODO: user can custom depot url
|
||||
const connect = await ark.connectDepot();
|
||||
|
||||
if (connect) {
|
||||
toast.success("Your Depot is successfully launch.");
|
||||
setLoading(false);
|
||||
|
||||
navigate("/depot/");
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-10 rounded-xl bg-white shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:bg-black dark:shadow-[inset_0_0_0.5px_1px_hsla(0,0%,100%,0.075),0_0_0_1px_hsla(0,0%,0%,0.05),0_0.3px_0.4px_hsla(0,0%,0%,0.02),0_0.9px_1.5px_hsla(0,0%,0%,0.045),0_3.5px_6px_hsla(0,0%,0%,0.09)]">
|
||||
<div className="flex flex-col items-center gap-8">
|
||||
<div className="text-center">
|
||||
<h1 className="mb-1 text-3xl font-semibold text-neutral-400 dark:text-neutral-600">
|
||||
Run your Personal Nostr Relay inside Lume
|
||||
</h1>
|
||||
<h2 className="text-4xl font-semibold">Your Relay, Your Control.</h2>
|
||||
</div>
|
||||
<div className="rounded-xl bg-blue-100 p-1.5 dark:bg-blue-900">
|
||||
<button
|
||||
type="button"
|
||||
onClick={launchDepot}
|
||||
className="inline-flex h-11 w-36 transform items-center justify-center gap-2 rounded-lg bg-blue-500 font-medium text-white active:translate-y-1"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<LoaderIcon className="h-5 w-5 animate-spin" />
|
||||
Launching...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="h-5 w-5"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M12 2.25a.75.75 0 0 1 .75.75v9a.75.75 0 0 1-1.5 0V3a.75.75 0 0 1 .75-.75ZM6.166 5.106a.75.75 0 0 1 0 1.06 8.25 8.25 0 1 0 11.668 0 .75.75 0 1 1 1.06-1.06c3.808 3.807 3.808 9.98 0 13.788-3.807 3.808-9.98 3.808-13.788 0-3.808-3.807-3.808-9.98 0-13.788a.75.75 0 0 1 1.06 0Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Launch
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
157
apps/desktop/src/routes/error.tsx
Normal file
157
apps/desktop/src/routes/error.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
import { useStorage } from "@lume/ark";
|
||||
import { downloadDir } from "@tauri-apps/api/path";
|
||||
import { message, save } from "@tauri-apps/plugin-dialog";
|
||||
import { writeTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { relaunch } from "@tauri-apps/plugin-process";
|
||||
import { useRouteError } from "react-router-dom";
|
||||
|
||||
interface RouteError {
|
||||
statusText: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function ErrorScreen() {
|
||||
const storage = useStorage();
|
||||
const error = useRouteError() as RouteError;
|
||||
|
||||
const restart = async () => {
|
||||
await relaunch();
|
||||
};
|
||||
|
||||
const download = async () => {
|
||||
try {
|
||||
const downloadPath = await downloadDir();
|
||||
const fileName = `nostr_keys_${new Date().toISOString()}.txt`;
|
||||
const filePath = await save({
|
||||
defaultPath: `${downloadPath}/${fileName}`,
|
||||
});
|
||||
const nsec = await storage.loadPrivkey(storage.account.pubkey);
|
||||
|
||||
if (filePath) {
|
||||
if (nsec) {
|
||||
await writeTextFile(
|
||||
filePath,
|
||||
`Nostr account, generated by Lume (lume.nu)\nPublic key: ${storage.account.id}\nPrivate key: ${nsec}`,
|
||||
);
|
||||
} else {
|
||||
await writeTextFile(
|
||||
filePath,
|
||||
`Nostr account, generated by Lume (lume.nu)\nPublic key: ${storage.account.id}`,
|
||||
);
|
||||
}
|
||||
} // else { user cancel action }
|
||||
} catch (e) {
|
||||
await message(e, {
|
||||
title: "Cannot download account keys",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="relative flex h-screen w-screen items-center justify-center bg-blue-600"
|
||||
>
|
||||
<div className="flex w-full max-w-2xl flex-col items-start gap-8">
|
||||
<div className="flex flex-col">
|
||||
<h1 className="mb-3 text-4xl font-semibold text-blue-400">
|
||||
Sorry, an unexpected error has occurred.
|
||||
</h1>
|
||||
<h3 className="text-3xl font-semibold leading-snug text-white">
|
||||
Don't panic, your account is safe.
|
||||
<br />
|
||||
Here are what things you can do:
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<div className="flex items-center justify-between rounded-xl bg-blue-700 px-3 py-4">
|
||||
<div className="text-xl font-semibold text-white">
|
||||
1. Try to close and re-open the app
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => restart()}
|
||||
className="h-9 w-28 rounded-lg bg-blue-800 px-3 font-medium text-white hover:bg-blue-900"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-xl bg-blue-700 px-3 py-4">
|
||||
<div className="text-xl font-semibold text-white">
|
||||
2. Backup Nostr account
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => download()}
|
||||
className="h-9 w-28 rounded-lg bg-blue-800 px-3 font-medium text-white hover:bg-blue-900"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-xl bg-blue-700 px-3 py-4">
|
||||
<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
|
||||
</div>
|
||||
<a
|
||||
href="https://github.com/luminous-devs/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"
|
||||
>
|
||||
Report
|
||||
</a>
|
||||
</div>
|
||||
<div className="inline-flex h-16 items-center justify-center overflow-y-auto rounded-lg border border-dashed border-red-300 bg-blue-800 px-5">
|
||||
<p className="select-text break-all text-red-400">
|
||||
{error.statusText || error.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-blue-700 px-3 py-4">
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<div className="text-xl font-semibold text-white">
|
||||
4. Use another Nostr client
|
||||
</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:
|
||||
</p>
|
||||
<div className="mt-2 flex flex-col gap-1 text-white">
|
||||
<a
|
||||
className="hover:!underline"
|
||||
href="https://snort.social"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
snort.social
|
||||
</a>
|
||||
<a
|
||||
className="hover:!underline"
|
||||
href="https://primal.net"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
primal.net
|
||||
</a>
|
||||
<a
|
||||
className="hover:!underline"
|
||||
href="https://nostrudel.ninja"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
nostrudel.ninja
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
104
apps/desktop/src/routes/home/components/newsfeed.tsx
Normal file
104
apps/desktop/src/routes/home/components/newsfeed.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import { RepostNote, TextNote, Widget, useArk, useStorage } from "@lume/ark";
|
||||
import { ArrowRightCircleIcon, LoaderIcon, TimelineIcon } 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, useRef } from "react";
|
||||
import { VList, VListHandle } from "virtua";
|
||||
|
||||
export function NewsfeedWidget() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const ref = useRef<VListHandle>();
|
||||
|
||||
const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ["newsfeed"],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({
|
||||
signal,
|
||||
pageParam,
|
||||
}: {
|
||||
signal: AbortSignal;
|
||||
pageParam: number;
|
||||
}) => {
|
||||
const events = await ark.getInfiniteEvents({
|
||||
filter: {
|
||||
kinds: [NDKKind.Text, NDKKind.Repost],
|
||||
authors: !storage.account.contacts.length
|
||||
? [storage.account.pubkey]
|
||||
: storage.account.contacts,
|
||||
},
|
||||
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],
|
||||
);
|
||||
|
||||
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 (
|
||||
<Widget.Root>
|
||||
<Widget.Header
|
||||
id="9999"
|
||||
queryKey={["newsfeed"]}
|
||||
title="Timeline"
|
||||
icon={<TimelineIcon className="h-5 w-5" />}
|
||||
/>
|
||||
<Widget.Content>
|
||||
<VList ref={ref} overscan={2} className="flex-1">
|
||||
{isLoading ? (
|
||||
<div className="inline-flex h-16 items-center justify-center gap-2 px-3 py-1.5">
|
||||
<LoaderIcon className="size-5" />
|
||||
Loading
|
||||
</div>
|
||||
) : (
|
||||
allEvents.map((item) => renderItem(item))
|
||||
)}
|
||||
<div className="flex h-16 items-center justify-center px-3 py-3">
|
||||
{hasNextPage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={!hasNextPage || isFetchingNextPage}
|
||||
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<LoaderIcon className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="h-5 w-5" />
|
||||
Load more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</VList>
|
||||
</Widget.Content>
|
||||
</Widget.Root>
|
||||
);
|
||||
}
|
174
apps/desktop/src/routes/home/components/notification.tsx
Normal file
174
apps/desktop/src/routes/home/components/notification.tsx
Normal file
@ -0,0 +1,174 @@
|
||||
import { NoteSkeleton, TextNote, Widget, useArk, useStorage } from "@lume/ark";
|
||||
import {
|
||||
AnnouncementIcon,
|
||||
ArrowRightCircleIcon,
|
||||
LoaderIcon,
|
||||
} from "@lume/icons";
|
||||
import { FETCH_LIMIT } from "@lume/utils";
|
||||
import { NDKEvent, NDKKind, NDKSubscription } from "@nostr-dev-kit/ndk";
|
||||
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { sendNativeNotification } from "apps/desktop/src/utils/notification";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { VList } from "virtua";
|
||||
|
||||
export function NotificationWidget() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ["notification"],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({
|
||||
signal,
|
||||
pageParam,
|
||||
}: {
|
||||
signal: AbortSignal;
|
||||
pageParam: number;
|
||||
}) => {
|
||||
const events = await ark.getInfiniteEvents({
|
||||
filter: {
|
||||
kinds: [
|
||||
NDKKind.Text,
|
||||
NDKKind.Repost,
|
||||
NDKKind.Reaction,
|
||||
NDKKind.Zap,
|
||||
],
|
||||
"#p": [storage.account.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,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const allEvents = useMemo(
|
||||
() => (data ? data.pages.flatMap((page) => page) : []),
|
||||
[data],
|
||||
);
|
||||
|
||||
const renderEvent = (event: NDKEvent) => {
|
||||
if (event.pubkey === storage.account.pubkey) return null;
|
||||
return <TextNote key={event.id} event={event} />;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let sub: NDKSubscription = undefined;
|
||||
|
||||
if (status === "success" && storage.account) {
|
||||
const filter = {
|
||||
kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Reaction, NDKKind.Zap],
|
||||
"#p": [storage.account.pubkey],
|
||||
since: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
sub = ark.subscribe({
|
||||
filter,
|
||||
closeOnEose: false,
|
||||
cb: async (event) => {
|
||||
queryClient.setQueryData(
|
||||
["notification"],
|
||||
(prev: { pageParams: number; pages: Array<NDKEvent[]> }) => ({
|
||||
...prev,
|
||||
pages: [[event], ...prev.pages],
|
||||
}),
|
||||
);
|
||||
|
||||
const profile = await ark.getUserProfile({ pubkey: event.pubkey });
|
||||
|
||||
switch (event.kind) {
|
||||
case NDKKind.Text:
|
||||
return await sendNativeNotification(
|
||||
`${
|
||||
profile.displayName || profile.name
|
||||
} has replied to your note`,
|
||||
);
|
||||
case NDKKind.Repost:
|
||||
return await sendNativeNotification(
|
||||
`${
|
||||
profile.displayName || profile.name
|
||||
} has reposted to your note`,
|
||||
);
|
||||
case NDKKind.Reaction:
|
||||
return await sendNativeNotification(
|
||||
`${profile.displayName || profile.name} has reacted ${
|
||||
event.content
|
||||
} to your note`,
|
||||
);
|
||||
case NDKKind.Zap:
|
||||
return await sendNativeNotification(
|
||||
`${
|
||||
profile.displayName || profile.name
|
||||
} has zapped to your note`,
|
||||
);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (sub) sub.stop();
|
||||
};
|
||||
}, [status]);
|
||||
|
||||
return (
|
||||
<Widget.Root>
|
||||
<Widget.Header
|
||||
id="9998"
|
||||
queryKey={["notification"]}
|
||||
title="Notification"
|
||||
icon={<AnnouncementIcon className="h-5 w-5" />}
|
||||
/>
|
||||
<Widget.Content>
|
||||
<VList className="flex-1" overscan={2}>
|
||||
{status === "pending" ? (
|
||||
<NoteSkeleton />
|
||||
) : allEvents.length < 1 ? (
|
||||
<div className="my-3 flex w-full items-center justify-center gap-2">
|
||||
<div>🎉</div>
|
||||
<p className="text-center font-medium text-neutral-900 dark:text-neutral-100">
|
||||
Hmm! Nothing new yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
allEvents.map((event) => renderEvent(event))
|
||||
)}
|
||||
<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 h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="h-5 w-5" />
|
||||
Load more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</VList>
|
||||
</Widget.Content>
|
||||
</Widget.Root>
|
||||
);
|
||||
}
|
100
apps/desktop/src/routes/home/index.tsx
Normal file
100
apps/desktop/src/routes/home/index.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import { NotificationColumn } from "@columns/notification";
|
||||
import { TimelineColumn } from "@columns/timeline";
|
||||
import { useStorage } from "@lume/ark";
|
||||
import { LoaderIcon } from "@lume/icons";
|
||||
import { WidgetProps } from "@lume/types";
|
||||
import { WIDGET_KIND } from "@lume/utils";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRef, useState } from "react";
|
||||
import { VList, VListHandle } from "virtua";
|
||||
|
||||
export function HomeScreen() {
|
||||
const storage = useStorage();
|
||||
const ref = useRef<VListHandle>(null);
|
||||
|
||||
const { isLoading, data } = useQuery({
|
||||
queryKey: ["widgets"],
|
||||
queryFn: async () => {
|
||||
const dbWidgets = await storage.getWidgets();
|
||||
const defaultWidgets = [
|
||||
{
|
||||
id: "9999",
|
||||
title: "Newsfeed",
|
||||
content: "",
|
||||
kind: WIDGET_KIND.newsfeed,
|
||||
},
|
||||
];
|
||||
|
||||
return [...defaultWidgets, ...dbWidgets];
|
||||
},
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
|
||||
const renderItem = (widget: WidgetProps) => {
|
||||
switch (widget.kind) {
|
||||
case WIDGET_KIND.notification:
|
||||
return <NotificationColumn key={widget.id} />;
|
||||
case WIDGET_KIND.newsfeed:
|
||||
return <TimelineColumn key={widget.id} />;
|
||||
default:
|
||||
return <TimelineColumn key={widget.id} />;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<LoaderIcon className="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<VList
|
||||
ref={ref}
|
||||
className="h-full w-full flex-nowrap overflow-x-auto !overflow-y-hidden scrollbar-none focus:outline-none"
|
||||
initialItemSize={420}
|
||||
tabIndex={0}
|
||||
horizontal
|
||||
onKeyDown={(e) => {
|
||||
if (!ref.current) return;
|
||||
switch (e.code) {
|
||||
case "ArrowUp":
|
||||
case "ArrowLeft": {
|
||||
e.preventDefault();
|
||||
const prevIndex = Math.max(selectedIndex - 1, 0);
|
||||
setSelectedIndex(prevIndex);
|
||||
ref.current.scrollToIndex(prevIndex, {
|
||||
align: "center",
|
||||
smooth: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "ArrowDown":
|
||||
case "ArrowRight": {
|
||||
e.preventDefault();
|
||||
const nextIndex = Math.min(selectedIndex + 1, data.length - 1);
|
||||
setSelectedIndex(nextIndex);
|
||||
ref.current.scrollToIndex(nextIndex, {
|
||||
align: "center",
|
||||
smooth: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{data.map((widget) => renderItem(widget))}
|
||||
<div className="h-full w-[200px]" />
|
||||
</VList>
|
||||
</div>
|
||||
);
|
||||
}
|
314
apps/desktop/src/routes/new/article.tsx
Normal file
314
apps/desktop/src/routes/new/article.tsx
Normal file
@ -0,0 +1,314 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import {
|
||||
BoldIcon,
|
||||
Heading1Icon,
|
||||
Heading2Icon,
|
||||
Heading3Icon,
|
||||
ItalicIcon,
|
||||
LoaderIcon,
|
||||
ThreadsIcon,
|
||||
} from "@lume/icons";
|
||||
import { NDKKind, NDKTag } from "@nostr-dev-kit/ndk";
|
||||
import CharacterCount from "@tiptap/extension-character-count";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { EditorContent, FloatingMenu, useEditor } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Markdown } from "tiptap-markdown";
|
||||
import {
|
||||
ArticleCoverUploader,
|
||||
MediaUploader,
|
||||
MentionPopup,
|
||||
} from "./components";
|
||||
|
||||
export function NewArticleScreen() {
|
||||
const ark = useArk();
|
||||
|
||||
const [height, setHeight] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [summary, setSummary] = useState({ open: false, content: "" });
|
||||
const [cover, setCover] = useState("");
|
||||
|
||||
const navigate = useNavigate();
|
||||
const containerRef = useRef(null);
|
||||
const ident = useMemo(() => String(Date.now()), []);
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure(),
|
||||
Placeholder.configure({ placeholder: "Type something..." }),
|
||||
Image.configure({
|
||||
HTMLAttributes: {
|
||||
class:
|
||||
"rounded-lg w-full object-cover h-auto max-h-[400px] border border-neutral-200 dark:border-neutral-800 outline outline-1 outline-offset-0 outline-neutral-300 dark:outline-neutral-700",
|
||||
},
|
||||
}),
|
||||
CharacterCount.configure(),
|
||||
Markdown.configure({
|
||||
html: false,
|
||||
tightLists: true,
|
||||
linkify: true,
|
||||
transformPastedText: true,
|
||||
}),
|
||||
],
|
||||
content: JSON.parse(localStorage.getItem("editor-post") || "{}"),
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
"outline-none prose prose-lg prose-neutral max-w-none select-text whitespace-pre-line break-words dark:prose-invert hover:prose-a:text-blue-500",
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
const jsonContent = JSON.stringify(editor.getJSON());
|
||||
localStorage.setItem("editor-article", jsonContent);
|
||||
},
|
||||
});
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
if (!ark.ndk.signer) return navigate("/new/privkey");
|
||||
|
||||
setLoading(true);
|
||||
|
||||
// get markdown content
|
||||
const content = editor.storage.markdown.getMarkdown();
|
||||
|
||||
// define tags
|
||||
const tags: NDKTag[] = [
|
||||
["d", ident],
|
||||
["title", title],
|
||||
["image", cover],
|
||||
["summary", summary.content],
|
||||
["published_at", String(Math.floor(Date.now() / 1000))],
|
||||
];
|
||||
|
||||
// add hashtag to tags if present
|
||||
const hashtags = content
|
||||
.split(/\s/gm)
|
||||
.filter((s: string) => s.startsWith("#"));
|
||||
|
||||
if (hashtags) {
|
||||
for (const tag of hashtags) {
|
||||
tags.push(["t", tag.replace("#", "")]);
|
||||
}
|
||||
}
|
||||
|
||||
// publish
|
||||
const publish = await ark.createEvent({
|
||||
content,
|
||||
tags,
|
||||
kind: NDKKind.Article,
|
||||
});
|
||||
|
||||
if (publish) {
|
||||
toast.success(
|
||||
`Broadcasted to ${publish.seens.length} relays successfully.`,
|
||||
);
|
||||
|
||||
// update state
|
||||
setLoading(false);
|
||||
|
||||
// reset editor
|
||||
editor.commands.clearContent();
|
||||
localStorage.setItem("editor-article", "{}");
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setHeight(containerRef.current.clientHeight);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col justify-between">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div
|
||||
className="flex flex-col gap-4"
|
||||
ref={containerRef}
|
||||
style={{ height: `${height}px` }}
|
||||
>
|
||||
{cover ? (
|
||||
<img
|
||||
src={cover}
|
||||
alt="post cover"
|
||||
className="h-72 w-full rounded-lg object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<div className="group flex justify-between gap-2">
|
||||
<input
|
||||
name="title"
|
||||
className="h-9 flex-1 border-none bg-transparent px-0 text-2xl font-semibold text-neutral-900 shadow-none outline-none placeholder:text-neutral-400 focus:border-none focus:outline-none focus:ring-0 dark:text-neutral-100 dark:placeholder:text-neutral-600"
|
||||
placeholder="Untitled"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<div
|
||||
className={twMerge(
|
||||
"inline-flex shrink-0 gap-2 group-hover:inline-flex",
|
||||
title.length > 0 ? "" : "hidden",
|
||||
)}
|
||||
>
|
||||
<ArticleCoverUploader setCover={setCover} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSummary((prev) => ({ ...prev, open: !prev.open }))
|
||||
}
|
||||
className="inline-flex h-9 w-max items-center gap-2 rounded-lg bg-neutral-100 px-2.5 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<ThreadsIcon className="h-4 w-4" />
|
||||
Add summary
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{summary.open ? (
|
||||
<div className="flex gap-3">
|
||||
<div className="h-16 w-1 shrink-0 rounded-full bg-neutral-200 dark:bg-neutral-800" />
|
||||
<div className="flex-1">
|
||||
<textarea
|
||||
className="h-16 w-full border-none bg-transparent px-1 py-1 text-neutral-900 shadow-none outline-none placeholder:text-neutral-400 dark:text-neutral-100 dark:placeholder:text-neutral-600"
|
||||
placeholder="A brief summary of your article"
|
||||
value={summary.content}
|
||||
onChange={(e) =>
|
||||
setSummary((prev) => ({ ...prev, content: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
{editor && (
|
||||
<FloatingMenu
|
||||
editor={editor}
|
||||
tippyOptions={{ duration: 100 }}
|
||||
className="ml-36 inline-flex h-10 items-center gap-1 rounded-lg border border-neutral-200 bg-neutral-100 px-px dark:border-neutral-800 dark:bg-neutral-900"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
editor.chain().focus().toggleHeading({ level: 1 }).run()
|
||||
}
|
||||
className={twMerge(
|
||||
"inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950",
|
||||
editor.isActive("heading", { level: 1 })
|
||||
? "bg-white shadow dark:bg-black"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<Heading1Icon className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
editor.chain().focus().toggleHeading({ level: 2 }).run()
|
||||
}
|
||||
className={twMerge(
|
||||
"inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950",
|
||||
editor.isActive("heading", { level: 2 })
|
||||
? "bg-white shadow dark:bg-black"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<Heading2Icon className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
editor.chain().focus().toggleHeading({ level: 2 }).run()
|
||||
}
|
||||
className={twMerge(
|
||||
"inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950",
|
||||
editor.isActive("heading", { level: 3 })
|
||||
? "bg-white shadow dark:bg-black"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<Heading3Icon className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
className={twMerge(
|
||||
"inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950",
|
||||
editor.isActive("bold")
|
||||
? "bg-white shadow dark:bg-black"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<BoldIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
className={twMerge(
|
||||
"inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950",
|
||||
editor.isActive("italic")
|
||||
? "bg-white shadow dark:bg-black"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<ItalicIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</FloatingMenu>
|
||||
)}
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
spellCheck="false"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-3 flex h-12 w-full items-center rounded-lg bg-yellow-100 px-3 text-yellow-700">
|
||||
<p className="text-sm">
|
||||
Article editor is still in beta. If you need a stable and more
|
||||
reliable feature, you can use <b>Habla (habla.news)</b> instead.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-16 w-full items-center justify-between border-t border-neutral-100 dark:border-neutral-900">
|
||||
<div className="inline-flex items-center gap-3">
|
||||
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
|
||||
{editor?.storage?.characterCount.characters()} characters
|
||||
</span>
|
||||
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
|
||||
-
|
||||
</span>
|
||||
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
|
||||
<b>Identifier:</b>
|
||||
{ident}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<MediaUploader editor={editor} />
|
||||
<MentionPopup editor={editor} />
|
||||
</div>
|
||||
<div className="mx-3 h-6 w-px bg-neutral-200 dark:bg-neutral-800" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={editor?.isEmpty}
|
||||
className="inline-flex h-9 w-max items-center justify-center rounded-lg bg-blue-500 px-2.5 font-medium text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading === true ? (
|
||||
<LoaderIcon className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
"Publish article"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,86 @@
|
||||
import { ImageIcon, LoaderIcon } from "@lume/icons";
|
||||
import { message, open } from "@tauri-apps/plugin-dialog";
|
||||
import { readBinaryFile } from "@tauri-apps/plugin-fs";
|
||||
import { useState } from "react";
|
||||
|
||||
export function ArticleCoverUploader({ setCover }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const uploadToNostrBuild = async () => {
|
||||
try {
|
||||
// start loading
|
||||
setLoading(true);
|
||||
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: "Media",
|
||||
extensions: [
|
||||
"png",
|
||||
"jpeg",
|
||||
"jpg",
|
||||
"gif",
|
||||
"mp4",
|
||||
"mp3",
|
||||
"webm",
|
||||
"mkv",
|
||||
"avi",
|
||||
"mov",
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!selected) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const file = await readBinaryFile(selected.path);
|
||||
const blob = new Blob([file]);
|
||||
|
||||
const data = new FormData();
|
||||
data.append("fileToUpload", blob);
|
||||
data.append("submit", "Upload Image");
|
||||
|
||||
const res = await fetch("https://nostr.build/api/v2/upload/files", {
|
||||
method: "POST",
|
||||
body: data,
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
const content = json.data[0];
|
||||
setCover(content.url);
|
||||
|
||||
// stop loading
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (e) {
|
||||
// stop loading
|
||||
setLoading(false);
|
||||
await message(`Upload failed, error: ${e}`, {
|
||||
title: "Lume",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={uploadToNostrBuild}
|
||||
className="inline-flex h-9 w-max items-center justify-center gap-2 rounded-lg bg-neutral-100 px-2.5 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-neutral-800"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
Add cover
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
46
apps/desktop/src/routes/new/components/mediaUploader.tsx
Normal file
46
apps/desktop/src/routes/new/components/mediaUploader.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { MediaIcon } from "@lume/icons";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { useState } from "react";
|
||||
|
||||
export function MediaUploader({ editor }: { editor: Editor }) {
|
||||
const ark = useArk();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const uploadToNostrBuild = async () => {
|
||||
try {
|
||||
// start loading
|
||||
setLoading(true);
|
||||
|
||||
const image = await ark.upload({
|
||||
fileExts: ["mp4", "mp3", "webm", "mkv", "avi", "mov"],
|
||||
});
|
||||
|
||||
if (image) {
|
||||
editor.commands.setImage({ src: image });
|
||||
editor.commands.createParagraphNear();
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (e) {
|
||||
// stop loading
|
||||
setLoading(false);
|
||||
await message(`Upload failed, error: ${e}`, {
|
||||
title: "Lume",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => uploadToNostrBuild()}
|
||||
className="inline-flex h-9 w-max items-center justify-center gap-1.5 rounded-lg bg-neutral-100 px-2 text-sm font-medium text-neutral-900 hover:bg-neutral-200 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<MediaIcon className="h-5 w-5" />
|
||||
{loading ? "Uploading..." : "Add media"}
|
||||
</button>
|
||||
);
|
||||
}
|
115
apps/desktop/src/routes/new/components/mentionList.tsx
Normal file
115
apps/desktop/src/routes/new/components/mentionList.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
import { NDKCacheUserProfile } from "@lume/types";
|
||||
import * as Avatar from "@radix-ui/react-avatar";
|
||||
import { minidenticon } from "minidenticons";
|
||||
import {
|
||||
Ref,
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useState,
|
||||
} from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type MentionListRef = {
|
||||
onKeyDown: (props: { event: Event }) => boolean;
|
||||
};
|
||||
|
||||
const List = (
|
||||
props: {
|
||||
items: NDKCacheUserProfile[];
|
||||
command: (arg0: { id: string }) => void;
|
||||
},
|
||||
ref: Ref<unknown>,
|
||||
) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const selectItem = (index) => {
|
||||
const item = props.items[index];
|
||||
if (item) {
|
||||
props.command({ id: item.pubkey });
|
||||
}
|
||||
};
|
||||
|
||||
const upHandler = () => {
|
||||
setSelectedIndex(
|
||||
(selectedIndex + props.items.length - 1) % props.items.length,
|
||||
);
|
||||
};
|
||||
|
||||
const downHandler = () => {
|
||||
setSelectedIndex((selectedIndex + 1) % props.items.length);
|
||||
};
|
||||
|
||||
const enterHandler = () => {
|
||||
selectItem(selectedIndex);
|
||||
};
|
||||
|
||||
useEffect(() => setSelectedIndex(0), [props.items]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }) => {
|
||||
if (event.key === "ArrowUp") {
|
||||
upHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
downHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
enterHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex w-[200px] flex-col overflow-y-auto rounded-lg border border-neutral-200 bg-neutral-50 p-2 shadow-lg shadow-neutral-500/20 dark:border-neutral-800 dark:bg-neutral-950 dark:shadow-neutral-300/50">
|
||||
{props.items.length ? (
|
||||
props.items.map((item, index) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.pubkey}
|
||||
onClick={() => selectItem(index)}
|
||||
className={twMerge(
|
||||
"inline-flex h-11 items-center gap-2 rounded-md px-2",
|
||||
index === selectedIndex
|
||||
? "bg-neutral-100 dark:bg-neutral-900"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<Avatar.Root className="h-8 w-8 shrink-0">
|
||||
<Avatar.Image
|
||||
src={item.image}
|
||||
alt={item.name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-8 w-8 rounded-md"
|
||||
/>
|
||||
<Avatar.Fallback delayMs={150}>
|
||||
<img
|
||||
src={`data:image/svg+xml;utf8,${encodeURIComponent(
|
||||
minidenticon(item.name, 90, 50),
|
||||
)}`}
|
||||
alt={item.name}
|
||||
className="h-8 w-8 rounded-md bg-black dark:bg-white"
|
||||
/>
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<h5 className="max-w-[150px] truncate text-sm font-medium">
|
||||
{item.name}
|
||||
</h5>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center text-sm font-medium">No result</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MentionList = forwardRef<MentionListRef>(List);
|
51
apps/desktop/src/routes/new/components/mentionPopup.tsx
Normal file
51
apps/desktop/src/routes/new/components/mentionPopup.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { useStorage } from "@lume/ark";
|
||||
import { MentionIcon } from "@lume/icons";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { MentionPopupItem } from "./mentionPopupItem";
|
||||
|
||||
export function MentionPopup({ editor }: { editor: Editor }) {
|
||||
const storage = useStorage();
|
||||
|
||||
const insertMention = (pubkey: string) => {
|
||||
editor.commands.insertContent(`nostr:${nip19.npubEncode(pubkey)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-9 w-max items-center justify-center gap-1.5 rounded-lg bg-neutral-100 px-2 text-sm font-medium text-neutral-900 hover:bg-neutral-200 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<MentionIcon className="h-5 w-5" />
|
||||
Mention
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content
|
||||
side="top"
|
||||
sideOffset={5}
|
||||
className="h-full max-h-[200px] w-[250px] overflow-hidden overflow-y-auto rounded-lg border border-neutral-200 bg-neutral-100 focus:outline-none dark:border-neutral-800 dark:bg-neutral-900"
|
||||
>
|
||||
<div className="flex flex-col gap-1 py-1">
|
||||
{storage.account.contacts.length ? (
|
||||
storage.account.contacts.map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
onClick={() => insertMention(item)}
|
||||
>
|
||||
<MentionPopupItem pubkey={item} />
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="flex h-16 items-center justify-center">
|
||||
Contact list is empty
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
57
apps/desktop/src/routes/new/components/mentionPopupItem.tsx
Normal file
57
apps/desktop/src/routes/new/components/mentionPopupItem.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { useProfile } from "@lume/ark";
|
||||
import { displayNpub } from "@lume/utils";
|
||||
import * as Avatar from "@radix-ui/react-avatar";
|
||||
import { minidenticon } from "minidenticons";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function MentionPopupItem({ pubkey }: { pubkey: string }) {
|
||||
const { isLoading, user } = useProfile(pubkey);
|
||||
const svgURI = useMemo(
|
||||
() =>
|
||||
`data:image/svg+xml;utf8,${encodeURIComponent(
|
||||
minidenticon(pubkey, 90, 50),
|
||||
)}`,
|
||||
[pubkey],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 px-2">
|
||||
<div className="relative h-8 w-8 shrink-0 animate-pulse rounded bg-neutral-400 dark:bg-neutral-600" />
|
||||
<div className="flex w-full flex-1 flex-col items-start gap-1 text-start">
|
||||
<span className="h-4 w-1/2 animate-pulse rounded bg-neutral-400 dark:bg-neutral-600" />
|
||||
<span className="h-3 w-1/3 animate-pulse rounded bg-neutral-400 dark:bg-neutral-600" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-11 items-center justify-start gap-2.5 px-2 hover:bg-neutral-200 dark:bg-neutral-800">
|
||||
<Avatar.Root className="shirnk-0 h-8 w-8">
|
||||
<Avatar.Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-8 w-8 rounded-md object-cover"
|
||||
/>
|
||||
<Avatar.Fallback delayMs={300}>
|
||||
<img
|
||||
src={svgURI}
|
||||
alt={pubkey}
|
||||
className="h-8 w-8 rounded-md bg-black dark:bg-white"
|
||||
/>
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div className="flex flex-col items-start gap-px">
|
||||
<h5 className="max-w-[10rem] truncate text-sm font-medium leading-none text-neutral-900 dark:text-neutral-100">
|
||||
{user?.display_name || user?.displayName || user?.name}
|
||||
</h5>
|
||||
<span className="text-sm leading-none text-neutral-600 dark:text-neutral-400">
|
||||
{displayNpub(pubkey, 16)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
184
apps/desktop/src/routes/new/file.tsx
Normal file
184
apps/desktop/src/routes/new/file.tsx
Normal file
@ -0,0 +1,184 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { LoaderIcon } from "@lume/icons";
|
||||
import { message, open } from "@tauri-apps/plugin-dialog";
|
||||
import { readBinaryFile } from "@tauri-apps/plugin-fs";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function NewFileScreen() {
|
||||
const ark = useArk();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isPublish, setIsPublish] = useState(false);
|
||||
const [metadata, setMetadata] = useState<string[][] | null>(null);
|
||||
const [caption, setCaption] = useState("");
|
||||
|
||||
const uploadFile = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: "Media",
|
||||
extensions: [
|
||||
"png",
|
||||
"jpeg",
|
||||
"jpg",
|
||||
"gif",
|
||||
"mp4",
|
||||
"mp3",
|
||||
"webm",
|
||||
"mkv",
|
||||
"avi",
|
||||
"mov",
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!selected) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const file = await readBinaryFile(selected.path);
|
||||
const blob = new Blob([file]);
|
||||
|
||||
const data = new FormData();
|
||||
data.append("fileToUpload", blob);
|
||||
data.append("submit", "Upload Image");
|
||||
|
||||
const res = await fetch("https://nostr.build/api/v2/upload/files", {
|
||||
method: "POST",
|
||||
body: data,
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
const data = json.data[0];
|
||||
|
||||
setMetadata([
|
||||
["url", data.url],
|
||||
["m", data.mime ?? "application/octet-stream"],
|
||||
["x", data.sha256 ?? ""],
|
||||
["size", data.size.toString() ?? "0"],
|
||||
["dim", `${data.dimensions.width}x${data.dimensions.height}` ?? "0"],
|
||||
["blurhash", data.blurhash ?? ""],
|
||||
["thumb", data.thumbnail ?? ""],
|
||||
]);
|
||||
|
||||
// stop loading
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (e) {
|
||||
// stop loading
|
||||
setLoading(false);
|
||||
await message(`Upload failed, error: ${e}`, {
|
||||
title: "Lume",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
if (!ark.ndk.signer) return navigate("/new/privkey");
|
||||
|
||||
setIsPublish(true);
|
||||
|
||||
const publish = await ark.createEvent({
|
||||
kind: 1063,
|
||||
tags: metadata,
|
||||
content: caption,
|
||||
});
|
||||
|
||||
if (publish) {
|
||||
toast.success(
|
||||
`Broadcasted to ${publish.seens.length} relays successfully.`,
|
||||
);
|
||||
setMetadata(null);
|
||||
setIsPublish(false);
|
||||
}
|
||||
} catch (e) {
|
||||
setIsPublish(false);
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<div className="flex h-96 gap-4 rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900">
|
||||
<button
|
||||
type="button"
|
||||
onClick={uploadFile}
|
||||
className="flex h-full flex-1 flex-col items-center justify-center rounded-lg border border-dashed border-neutral-200 bg-neutral-50 p-2 hover:border-blue-500 hover:text-blue-500 dark:border-neutral-800 dark:bg-neutral-950"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
|
||||
) : !metadata ? (
|
||||
<div className="flex flex-col text-center">
|
||||
<h5 className="text-lg font-semibold">
|
||||
Click or drag a file to this area to upload
|
||||
</h5>
|
||||
<p className="text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||
Supports: jpg, png, webp, gif, mov, mp4 or mp3
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<img
|
||||
src={metadata[0][1]}
|
||||
alt={metadata[1][1]}
|
||||
className="aspect-square h-full w-full rounded-lg object-cover shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
{metadata ? (
|
||||
<div className="flex h-full flex-1 flex-col justify-between">
|
||||
<div className="flex flex-col gap-2 py-2">
|
||||
{metadata.map((item, index) => (
|
||||
<div key={item[0] + index} className="flex min-w-0 gap-2">
|
||||
<h5 className="w-24 shrink-0 truncate font-semibold capitalize text-neutral-600 dark:text-neutral-400">
|
||||
{item[0]}
|
||||
</h5>
|
||||
<p className="w-72 truncate">{item[1]}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
name="caption"
|
||||
type="text"
|
||||
value={caption}
|
||||
onChange={(e) => setCaption(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
placeholder="Caption (Optional)..."
|
||||
className="h-11 w-full rounded-lg bg-neutral-200 px-3 placeholder:text-neutral-500 dark:bg-neutral-900 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={!metadata}
|
||||
className="inline-flex h-9 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{isPublish ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Share"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
173
apps/desktop/src/routes/new/post.tsx
Normal file
173
apps/desktop/src/routes/new/post.tsx
Normal file
@ -0,0 +1,173 @@
|
||||
import { MentionNote, useArk, useSuggestion, useWidget } from "@lume/ark";
|
||||
import { CancelIcon, LoaderIcon } from "@lume/icons";
|
||||
import { WIDGET_KIND } from "@lume/utils";
|
||||
import { NDKKind } from "@nostr-dev-kit/ndk";
|
||||
import CharacterCount from "@tiptap/extension-character-count";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import Mention from "@tiptap/extension-mention";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { EditorContent, useEditor } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { convert } from "html-to-text";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { MediaUploader, MentionPopup } from "./components";
|
||||
|
||||
export function NewPostScreen() {
|
||||
const ark = useArk();
|
||||
const { addWidget } = useWidget();
|
||||
const { suggestion } = useSuggestion();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [height, setHeight] = useState(0);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const containerRef = useRef(null);
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure(),
|
||||
Placeholder.configure({ placeholder: "Sharing some thoughts..." }),
|
||||
Image.configure({
|
||||
HTMLAttributes: {
|
||||
class:
|
||||
"rounded-lg w-full object-cover h-auto max-h-[400px] border border-neutral-200 dark:border-neutral-800 outline outline-1 outline-offset-0 outline-neutral-300 dark:outline-neutral-700",
|
||||
},
|
||||
}),
|
||||
CharacterCount.configure(),
|
||||
Mention.configure({
|
||||
suggestion,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
renderLabel({ options, node }) {
|
||||
const npub = nip19.npubEncode(node.attrs.id);
|
||||
return `nostr:${npub}`;
|
||||
},
|
||||
}),
|
||||
],
|
||||
content: JSON.parse(localStorage.getItem("editor-post") || "{}"),
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
"outline-none prose prose-lg prose-neutral max-w-none select-text whitespace-pre-line break-words dark:prose-invert hover:prose-a:text-blue-500",
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
const jsonContent = JSON.stringify(editor.getJSON());
|
||||
localStorage.setItem("editor-post", jsonContent);
|
||||
},
|
||||
});
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
if (!ark.ndk.signer) return navigate("/new/privkey");
|
||||
|
||||
setLoading(true);
|
||||
|
||||
// get plaintext content
|
||||
const html = editor.getHTML();
|
||||
const serializedContent = convert(html, {
|
||||
selectors: [
|
||||
{ selector: "a", options: { linkBrackets: false } },
|
||||
{ selector: "img", options: { linkBrackets: false } },
|
||||
],
|
||||
});
|
||||
|
||||
// add reply to tags if present
|
||||
const replyTo = searchParams.get("replyTo");
|
||||
const rootReplyTo = searchParams.get("rootReplyTo");
|
||||
|
||||
// publish event
|
||||
const publish = await ark.createEvent({
|
||||
kind: NDKKind.Text,
|
||||
tags: [],
|
||||
content: serializedContent,
|
||||
replyTo,
|
||||
rootReplyTo,
|
||||
});
|
||||
|
||||
if (publish) {
|
||||
toast.success(
|
||||
`Broadcasted to ${publish.seens.length} relays successfully.`,
|
||||
);
|
||||
|
||||
// update state
|
||||
setLoading(false);
|
||||
setSearchParams({});
|
||||
|
||||
// open new widget with this event id
|
||||
if (!replyTo) {
|
||||
addWidget.mutate({
|
||||
title: "Thread",
|
||||
content: publish.id,
|
||||
kind: WIDGET_KIND.thread,
|
||||
});
|
||||
}
|
||||
|
||||
// reset editor
|
||||
editor.commands.clearContent();
|
||||
localStorage.setItem("editor-post", "{}");
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setHeight(containerRef.current.clientHeight);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-[500px] flex-1 flex-col gap-4">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div ref={containerRef} style={{ height: `${height}px` }}>
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
spellCheck="false"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
{searchParams.get("replyTo") && (
|
||||
<div className="relative max-w-lg">
|
||||
<MentionNote eventId={searchParams.get("replyTo")} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchParams({})}
|
||||
className="absolute right-3 top-3 inline-flex h-6 w-6 items-center justify-center rounded bg-neutral-200 px-2 dark:bg-neutral-800"
|
||||
>
|
||||
<CancelIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="inline-flex h-16 w-full items-center justify-between border-t border-neutral-100 bg-neutral-50 dark:border-neutral-900 dark:bg-neutral-950">
|
||||
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
|
||||
{editor?.storage?.characterCount.characters()} characters
|
||||
</span>
|
||||
<div className="flex items-center">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<MediaUploader editor={editor} />
|
||||
<MentionPopup editor={editor} />
|
||||
</div>
|
||||
<div className="mx-3 h-6 w-px bg-neutral-200 dark:bg-neutral-800" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={editor?.isEmpty}
|
||||
className="inline-flex h-9 w-20 items-center justify-center rounded-lg bg-blue-500 px-2 font-medium text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading === true ? (
|
||||
<LoaderIcon className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
"Post"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
81
apps/desktop/src/routes/new/privkey.tsx
Normal file
81
apps/desktop/src/routes/new/privkey.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { useArk, useStorage } from "@lume/ark";
|
||||
import { NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
|
||||
import { getPublicKey, nip19 } from "nostr-tools";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function NewPrivkeyScreen() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [nsec, setNsec] = useState("");
|
||||
|
||||
const submit = async (isSave?: boolean) => {
|
||||
try {
|
||||
if (!nsec.startsWith("nsec1"))
|
||||
return toast.info("You must enter a private key starts with nsec");
|
||||
|
||||
const decoded = nip19.decode(nsec);
|
||||
|
||||
if (decoded.type !== "nsec")
|
||||
return toast.info("You must enter a valid nsec");
|
||||
|
||||
const privkey = decoded.data;
|
||||
const pubkey = getPublicKey(privkey);
|
||||
|
||||
if (pubkey !== storage.account.pubkey)
|
||||
return toast.info(
|
||||
"Your nsec is not match your current public key, please make sure you enter right nsec",
|
||||
);
|
||||
|
||||
const signer = new NDKPrivateKeySigner(privkey);
|
||||
ark.updateNostrSigner({ signer });
|
||||
|
||||
if (isSave) await storage.createPrivkey(storage.account.pubkey, privkey);
|
||||
|
||||
navigate(-1);
|
||||
} catch (e) {
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="mb-16 flex flex-col gap-3">
|
||||
<h1 className="text-center font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
You need to provide private key to sign nostr event.
|
||||
</h1>
|
||||
<input
|
||||
name="privkey"
|
||||
placeholder="nsec..."
|
||||
type="password"
|
||||
value={nsec}
|
||||
onChange={(e) => setNsec(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||
/>
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit(true)}
|
||||
className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-neutral-100 font-medium text-neutral-900 hover:bg-neutral-200 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
Submit and Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
60
apps/desktop/src/routes/nwc/components/form.tsx
Normal file
60
apps/desktop/src/routes/nwc/components/form.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { useStorage } from "@lume/ark";
|
||||
import { LoaderIcon } from "@lume/icons";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function NWCForm({ setWalletConnectURL }) {
|
||||
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(`${storage.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(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: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>
|
||||
);
|
||||
}
|
125
apps/desktop/src/routes/nwc/index.tsx
Normal file
125
apps/desktop/src/routes/nwc/index.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
import { useStorage } from "@lume/ark";
|
||||
import { CheckCircleIcon } from "@lume/icons";
|
||||
import { useEffect, useState } from "react";
|
||||
import { NWCForm } from "./components/form";
|
||||
|
||||
export function NWCScreen() {
|
||||
const storage = useStorage();
|
||||
const [walletConnectURL, setWalletConnectURL] = useState<null | string>(null);
|
||||
|
||||
const remove = async () => {
|
||||
await storage.removePrivkey(`${storage.account.pubkey}-nwc`);
|
||||
setWalletConnectURL(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function getNWC() {
|
||||
const nwc = await storage.loadPrivkey(`${storage.account.pubkey}-nwc`);
|
||||
if (nwc) setWalletConnectURL(nwc);
|
||||
}
|
||||
getNWC();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="flex w-full flex-col gap-5">
|
||||
<div className="text-center">
|
||||
<h3 className="text-2xl font-bold leading-tight">
|
||||
Nostr Wallet Connect
|
||||
</h3>
|
||||
<p className="leading-tight text-neutral-600 dark:text-neutral-400">
|
||||
Sending zap easily via Bitcoin Lightning.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mx-auto 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:bg-neutral-800 dark:text-neutral-100 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
Remove connection
|
||||
</button>
|
||||
</div>
|
||||
</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-sm 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-sm 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-sm text-neutral-600 dark:text-neutral-400">
|
||||
Lume doesn't take any commission or platform fees when you
|
||||
zap someone.
|
||||
</p>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
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-sm 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-sm 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>
|
||||
</div>
|
||||
);
|
||||
}
|
90
apps/desktop/src/routes/relays/components/relayEventList.tsx
Normal file
90
apps/desktop/src/routes/relays/components/relayEventList.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
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 { useCallback, useMemo } from "react";
|
||||
import { VList } from "virtua";
|
||||
|
||||
export function RelayEventList({ relayUrl }: { relayUrl: string }) {
|
||||
const ark = useArk();
|
||||
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ["relay-events", relayUrl],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({
|
||||
signal,
|
||||
pageParam,
|
||||
}: {
|
||||
signal: AbortSignal;
|
||||
pageParam: number;
|
||||
}) => {
|
||||
const url = `wss://${relayUrl}`;
|
||||
const events = await ark.getRelayEvents({
|
||||
relayUrl: url,
|
||||
filter: {
|
||||
kinds: [NDKKind.Text, NDKKind.Repost],
|
||||
},
|
||||
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],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
(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} />;
|
||||
}
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<VList className="mx-auto h-full w-full max-w-[500px] pt-10 scrollbar-none">
|
||||
{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 h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="h-5 w-5" />
|
||||
Load more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</VList>
|
||||
);
|
||||
}
|
62
apps/desktop/src/routes/relays/components/relayForm.tsx
Normal file
62
apps/desktop/src/routes/relays/components/relayForm.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { useRelay } 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 } = useRelay();
|
||||
const [relay, setRelay] = useState<{
|
||||
url: NDKRelayUrl;
|
||||
purpose: "read" | "write" | undefined;
|
||||
}>({ url: "", purpose: undefined });
|
||||
|
||||
const create = () => {
|
||||
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:")
|
||||
) {
|
||||
connectRelay.mutate(normalizeRelayUrl(relay.url));
|
||||
setRelay({ url: "", purpose: undefined });
|
||||
} else {
|
||||
return toast.error(
|
||||
"URL is invalid, a relay must use websocket protocol (start with wss:// or ws://). Please check again",
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return toast.error("Relay URL is not valid. Please check again");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="h-11 flex-1 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||
placeholder="wss://"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
value={relay.url}
|
||||
onChange={(e) =>
|
||||
setRelay((prev) => ({ ...prev, url: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => create()}
|
||||
className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-blue-500 text-white hover:bg-blue-600"
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
91
apps/desktop/src/routes/relays/components/relayList.tsx
Normal file
91
apps/desktop/src/routes/relays/components/relayList.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
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 border-r border-neutral-100 dark:border-neutral-900">
|
||||
{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>
|
||||
);
|
||||
}
|
97
apps/desktop/src/routes/relays/components/userRelayList.tsx
Normal file
97
apps/desktop/src/routes/relays/components/userRelayList.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
import { useArk, useStorage } from "@lume/ark";
|
||||
import { CancelIcon, RefreshIcon } from "@lume/icons";
|
||||
import { useRelay } from "@lume/utils";
|
||||
import { NDKKind } from "@nostr-dev-kit/ndk";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { RelayForm } from "./relayForm";
|
||||
|
||||
export function UserRelayList() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
|
||||
const { removeRelay } = useRelay();
|
||||
const { status, data, refetch } = useQuery({
|
||||
queryKey: ["relays", storage.account.pubkey],
|
||||
queryFn: async () => {
|
||||
const event = await ark.getEventByFilter({
|
||||
filter: {
|
||||
kinds: [NDKKind.RelayList],
|
||||
authors: [storage.account.pubkey],
|
||||
},
|
||||
});
|
||||
|
||||
if (!event) return [];
|
||||
return event.tags;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const currentRelays = new Set(
|
||||
ark.ndk.pool.connectedRelays().map((item) => item.url),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="col-span-1">
|
||||
<div className="inline-flex h-16 w-full items-center justify-between border-b border-neutral-100 px-3 dark:border-neutral-900">
|
||||
<h3 className="font-semibold">Connected relays</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-900"
|
||||
>
|
||||
<RefreshIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-2 px-3">
|
||||
{status === "pending" ? (
|
||||
<p>Loading...</p>
|
||||
) : !data.length ? (
|
||||
<div className="flex h-20 w-full items-center justify-center rounded-xl bg-neutral-50 dark:bg-neutral-950">
|
||||
<p className="text-sm font-medium">
|
||||
You not have personal relay list yet
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
data.map((item) => (
|
||||
<div
|
||||
key={item[1]}
|
||||
className="group flex h-11 items-center justify-between rounded-lg bg-neutral-100 px-3 dark:bg-neutral-900"
|
||||
>
|
||||
<div className="inline-flex items-baseline gap-2">
|
||||
{currentRelays.has(item[1]) ? (
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-teal-500" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-red-500" />
|
||||
</span>
|
||||
)}
|
||||
<p className="max-w-[20rem] truncate text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{item[1]}
|
||||
</p>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
{item[2]?.length ? (
|
||||
<div className="inline-flex h-6 w-max items-center justify-center rounded bg-neutral-200 px-2 text-xs font-medium capitalize dark:bg-neutral-800">
|
||||
{item[2]}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRelay.mutate(item[1])}
|
||||
className="hidden h-6 w-6 items-center justify-center rounded group-hover:inline-flex hover:bg-neutral-300 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<CancelIcon className="h-4 w-4 text-neutral-900 dark:text-neutral-100" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<RelayForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
11
apps/desktop/src/routes/relays/index.tsx
Normal file
11
apps/desktop/src/routes/relays/index.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { RelayList } from "./components/relayList";
|
||||
import { UserRelayList } from "./components/userRelayList";
|
||||
|
||||
export function RelaysScreen() {
|
||||
return (
|
||||
<div className="grid h-full w-full grid-cols-3">
|
||||
<RelayList />
|
||||
<UserRelayList />
|
||||
</div>
|
||||
);
|
||||
}
|
178
apps/desktop/src/routes/relays/relay.tsx
Normal file
178
apps/desktop/src/routes/relays/relay.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
import { ArrowLeftIcon, 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 { RelayEventList } from "./components/relayEventList";
|
||||
|
||||
export function RelayScreen() {
|
||||
const { url } = useParams();
|
||||
|
||||
const data: { relay?: { [key: string]: string } } = useLoaderData();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const getSoftwareName = (url: string) => {
|
||||
const filename = url.substring(url.lastIndexOf("/") + 1);
|
||||
return filename.replace(".git", "");
|
||||
};
|
||||
|
||||
const titleCase = (s: string) => {
|
||||
return s
|
||||
.replace(/^[-_]*(.)/, (_, c) => c.toUpperCase())
|
||||
.replace(/[-_]+(.)/g, (_, c) => ` ${c.toUpperCase()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid h-full w-full grid-cols-3">
|
||||
<div className="col-span-2 border-r border-neutral-100 dark:border-neutral-900">
|
||||
<div className="inline-flex h-16 w-full items-center gap-2.5 border-b border-neutral-100 px-3 dark:border-neutral-900">
|
||||
<button type="button" onClick={() => navigate(-1)}>
|
||||
<ArrowLeftIcon className="h-5 w-5 text-neutral-500 hover:text-neutral-600 dark:text-neutral-600 dark:hover:text-neutral-500" />
|
||||
</button>
|
||||
<h3 className="font-semibold text-neutral-950 dark:text-neutral-50">
|
||||
Global events
|
||||
</h3>
|
||||
</div>
|
||||
<RelayEventList relayUrl={url} />
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<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 text-neutral-900 dark:text-neutral-100">
|
||||
Information
|
||||
</h3>
|
||||
</div>
|
||||
<div className="mt-4 px-3">
|
||||
<Suspense
|
||||
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...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Await
|
||||
resolve={data.relay}
|
||||
errorElement={
|
||||
<div className="text-sm font-medium">
|
||||
<p>Could not load relay information 😬</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(resolvedRelay: NIP11) => (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div>
|
||||
<h3 className="font-semibold leading-tight text-neutral-900 dark:text-neutral-100">
|
||||
{resolvedRelay.name}
|
||||
</h3>
|
||||
<p className="text-sm font-medium text-neutral-600 dark:text-neutral-500">
|
||||
{resolvedRelay.description}
|
||||
</p>
|
||||
</div>
|
||||
{resolvedRelay.pubkey ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||
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" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{resolvedRelay.contact ? (
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||
Contact:
|
||||
</h5>
|
||||
<a
|
||||
href={`mailto:${resolvedRelay.contact}`}
|
||||
target="_blank"
|
||||
className="underline after:content-['_↗'] hover:text-blue-600"
|
||||
rel="noreferrer"
|
||||
>
|
||||
mailto:{resolvedRelay.contact}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||
Software:
|
||||
</h5>
|
||||
<a
|
||||
href={resolvedRelay.software}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline after:content-['_↗'] hover:text-blue-600"
|
||||
>
|
||||
{`${getSoftwareName(resolvedRelay.software)} - ${
|
||||
resolvedRelay.version
|
||||
}`}
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||
Supported NIPs:
|
||||
</h5>
|
||||
<div className="mt-2 grid grid-cols-7 gap-2">
|
||||
{resolvedRelay.supported_nips.map((item) => (
|
||||
<a
|
||||
key={item}
|
||||
href={`https://nips.be/${item}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex aspect-square h-auto w-full items-center justify-center rounded-lg bg-neutral-100 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-900"
|
||||
>
|
||||
{item}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{resolvedRelay.limitation ? (
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||
Limitation
|
||||
</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>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{resolvedRelay.payments_url ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<a
|
||||
href={resolvedRelay.payments_url}
|
||||
target="_blank"
|
||||
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
|
||||
</a>
|
||||
<span className="text-center text-xs text-neutral-600 dark:text-neutral-400">
|
||||
You need to make a payment to connect this relay
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
31
apps/desktop/src/routes/settings/advanced.tsx
Normal file
31
apps/desktop/src/routes/settings/advanced.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { useStorage } from "@lume/ark";
|
||||
|
||||
export function AdvancedSettingScreen() {
|
||||
const storage = useStorage();
|
||||
|
||||
const clearCache = async () => {
|
||||
await storage.clearCache();
|
||||
};
|
||||
|
||||
return (
|
||||
<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 items-center gap-8">
|
||||
<div className="w-24 shrink-0 text-end text-sm font-semibold">
|
||||
Caches
|
||||
</div>
|
||||
<div className="text-sm">Use for boost up NDK</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => clearCache()}
|
||||
className="h-8 w-max rounded-lg bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
62
apps/desktop/src/routes/settings/backup.tsx
Normal file
62
apps/desktop/src/routes/settings/backup.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { useStorage } from "@lume/ark";
|
||||
import { EyeOffIcon } from "@lume/icons";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function BackupSettingScreen() {
|
||||
const storage = useStorage();
|
||||
|
||||
const [privkey, setPrivkey] = useState(null);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const removePrivkey = async () => {
|
||||
await storage.removePrivkey(storage.account.pubkey);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function loadPrivkey() {
|
||||
const key = await storage.loadPrivkey(storage.account.pubkey);
|
||||
if (key) setPrivkey(key);
|
||||
}
|
||||
|
||||
loadPrivkey();
|
||||
}, []);
|
||||
|
||||
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>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative">
|
||||
<input
|
||||
readOnly
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={nip19.nsecEncode(privkey)}
|
||||
className="relative h-11 w-full resize-none rounded-lg border-none bg-neutral-200 py-1 pl-3 pr-11 text-neutral-900 !outline-none placeholder:text-neutral-600 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-1.5 top-1/2 inline-flex h-8 w-8 -translate-y-1/2 transform items-center justify-center rounded-lg bg-neutral-50 dark:bg-neutral-950"
|
||||
>
|
||||
<EyeOffIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
Remove private key
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
45
apps/desktop/src/routes/settings/components/contactCard.tsx
Normal file
45
apps/desktop/src/routes/settings/components/contactCard.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
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>
|
||||
);
|
||||
}
|
60
apps/desktop/src/routes/settings/components/postCard.tsx
Normal file
60
apps/desktop/src/routes/settings/components/postCard.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { useStorage } 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 storage = useStorage();
|
||||
const { status, data } = useQuery({
|
||||
queryKey: ["user-stats", storage.account.pubkey],
|
||||
queryFn: async ({ signal }: { signal: AbortSignal }) => {
|
||||
const res = await fetch(
|
||||
`https://api.nostr.band/v0/stats/profile/${storage.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[storage.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/${storage.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>
|
||||
);
|
||||
}
|
77
apps/desktop/src/routes/settings/components/profileCard.tsx
Normal file
77
apps/desktop/src/routes/settings/components/profileCard.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import { useProfile, useStorage } 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 storage = useStorage();
|
||||
const svgURI = `data:image/svg+xml;utf8,${encodeURIComponent(
|
||||
minidenticon(storage.account.pubkey, 90, 50),
|
||||
)}`;
|
||||
|
||||
const { isLoading, user } = useProfile(storage.account.pubkey);
|
||||
|
||||
const copyNpub = async () => {
|
||||
return await writeText(nip19.npubEncode(storage.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={storage.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={storage.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(storage.account.pubkey, 16)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
47
apps/desktop/src/routes/settings/components/relayCard.tsx
Normal file
47
apps/desktop/src/routes/settings/components/relayCard.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import { useArk, useStorage } 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 storage = useStorage();
|
||||
|
||||
const { status, data } = useQuery({
|
||||
queryKey: ["relays", storage.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>
|
||||
);
|
||||
}
|
52
apps/desktop/src/routes/settings/components/zapCard.tsx
Normal file
52
apps/desktop/src/routes/settings/components/zapCard.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { useStorage } 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 storage = useStorage();
|
||||
const { status, data } = useQuery({
|
||||
queryKey: ["user-stats", storage.account.pubkey],
|
||||
queryFn: async ({ signal }: { signal: AbortSignal }) => {
|
||||
const res = await fetch(
|
||||
`https://api.nostr.band/v0/stats/profile/${storage.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[storage.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>
|
||||
);
|
||||
}
|
34
apps/desktop/src/routes/settings/editContact.tsx
Normal file
34
apps/desktop/src/routes/settings/editContact.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
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>
|
||||
);
|
||||
}
|
306
apps/desktop/src/routes/settings/editProfile.tsx
Normal file
306
apps/desktop/src/routes/settings/editProfile.tsx
Normal file
@ -0,0 +1,306 @@
|
||||
import { useArk, useStorage } from "@lume/ark";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
LoaderIcon,
|
||||
PlusIcon,
|
||||
UnverifiedIcon,
|
||||
} from "@lume/icons";
|
||||
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";
|
||||
|
||||
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 {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setError,
|
||||
formState: { isValid, errors },
|
||||
} = useForm({
|
||||
defaultValues: async () => {
|
||||
const res: NDKUserProfile = queryClient.getQueryData([
|
||||
"user",
|
||||
storage.account.pubkey,
|
||||
]);
|
||||
if (res.image) {
|
||||
setPicture(res.image);
|
||||
}
|
||||
if (res.banner) {
|
||||
setBanner(res.banner);
|
||||
}
|
||||
if (res.nip05) {
|
||||
setNIP05((prev) => ({ ...prev, text: res.nip05 }));
|
||||
}
|
||||
return res;
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
if (data.nip05) {
|
||||
const verify = ark.validateNIP05({
|
||||
pubkey: storage.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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const publish = await ark.createEvent({
|
||||
kind: NDKKind.Metadata,
|
||||
tags: [],
|
||||
content: JSON.stringify(content),
|
||||
});
|
||||
|
||||
if (publish) {
|
||||
// invalid cache
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["user", storage.account.pubkey],
|
||||
});
|
||||
// reset form
|
||||
reset();
|
||||
// reset state
|
||||
setLoading(false);
|
||||
setPicture(null);
|
||||
setBanner(null);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<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">
|
||||
{banner ? (
|
||||
<img
|
||||
src={banner}
|
||||
alt="user's banner"
|
||||
className="h-full w-full rounded-xl object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full rounded-xl bg-neutral-200 dark:bg-neutral-900" />
|
||||
)}
|
||||
<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>
|
||||
</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">
|
||||
<img
|
||||
src={picture}
|
||||
alt="user's avatar"
|
||||
className="h-14 w-14 rounded-xl object-cover"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="display_name"
|
||||
className="text-sm font-semibold uppercase tracking-wider"
|
||||
>
|
||||
Display Name
|
||||
</label>
|
||||
<input
|
||||
type={"text"}
|
||||
{...register("display_name")}
|
||||
spellCheck={false}
|
||||
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 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 className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="text-sm font-semibold uppercase tracking-wider"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type={"text"}
|
||||
{...register("name")}
|
||||
spellCheck={false}
|
||||
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 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 className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="nip05"
|
||||
className="text-sm font-semibold uppercase tracking-wider"
|
||||
>
|
||||
NIP-05
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
{...register("nip05")}
|
||||
spellCheck={false}
|
||||
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 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 className="absolute right-2 top-1/2 -translate-y-1/2 transform">
|
||||
{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
|
||||
</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
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{errors.nip05 && (
|
||||
<p className="mt-1 text-sm text-red-400">
|
||||
{errors.nip05.message.toString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="website"
|
||||
className="text-sm font-semibold uppercase tracking-wider"
|
||||
>
|
||||
Website
|
||||
</label>
|
||||
<input
|
||||
type={"text"}
|
||||
{...register("website", { required: false })}
|
||||
spellCheck={false}
|
||||
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 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 className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="website"
|
||||
className="text-sm font-semibold uppercase tracking-wider"
|
||||
>
|
||||
Lightning address
|
||||
</label>
|
||||
<input
|
||||
type={"text"}
|
||||
{...register("lud16", { required: false })}
|
||||
spellCheck={false}
|
||||
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 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 className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="about"
|
||||
className="text-sm font-semibold uppercase tracking-wider"
|
||||
>
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<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"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-white" />
|
||||
) : (
|
||||
"Update"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
285
apps/desktop/src/routes/settings/general.tsx
Normal file
285
apps/desktop/src/routes/settings/general.tsx
Normal file
@ -0,0 +1,285 @@
|
||||
import { useStorage } from "@lume/ark";
|
||||
import { DarkIcon, LightIcon, SystemModeIcon } from "@lume/icons";
|
||||
import * as Switch from "@radix-ui/react-switch";
|
||||
import { invoke } from "@tauri-apps/api/primitives";
|
||||
import { getCurrent } from "@tauri-apps/api/window";
|
||||
import { disable, enable, isEnabled } from "@tauri-apps/plugin-autostart";
|
||||
import {
|
||||
isPermissionGranted,
|
||||
requestPermission,
|
||||
} from "@tauri-apps/plugin-notification";
|
||||
import { useEffect, useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function GeneralSettingScreen() {
|
||||
const storage = useStorage();
|
||||
|
||||
const [settings, setSettings] = useState({
|
||||
autoupdate: false,
|
||||
autolaunch: false,
|
||||
outbox: false,
|
||||
media: true,
|
||||
hashtag: true,
|
||||
notification: true,
|
||||
appearance: "system",
|
||||
});
|
||||
|
||||
const changeTheme = async (theme: "light" | "dark" | "auto") => {
|
||||
await invoke("plugin:theme|set_theme", { theme });
|
||||
// update state
|
||||
setSettings((prev) => ({ ...prev, appearance: theme }));
|
||||
};
|
||||
|
||||
const toggleAutolaunch = async () => {
|
||||
if (!settings.autolaunch) {
|
||||
await enable();
|
||||
// update state
|
||||
setSettings((prev) => ({ ...prev, autolaunch: true }));
|
||||
} else {
|
||||
await disable();
|
||||
// update state
|
||||
setSettings((prev) => ({ ...prev, autolaunch: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleOutbox = async () => {
|
||||
await storage.createSetting("outbox", String(+!settings.outbox));
|
||||
// update state
|
||||
setSettings((prev) => ({ ...prev, outbox: !settings.outbox }));
|
||||
};
|
||||
|
||||
const toggleMedia = async () => {
|
||||
await storage.createSetting("media", String(+!settings.media));
|
||||
storage.settings.media = !settings.media;
|
||||
// update state
|
||||
setSettings((prev) => ({ ...prev, media: !settings.media }));
|
||||
};
|
||||
|
||||
const toggleHashtag = async () => {
|
||||
await storage.createSetting("hashtag", String(+!settings.hashtag));
|
||||
storage.settings.hashtag = !settings.hashtag;
|
||||
// update state
|
||||
setSettings((prev) => ({ ...prev, hashtag: !settings.hashtag }));
|
||||
};
|
||||
|
||||
const toggleAutoupdate = async () => {
|
||||
await storage.createSetting("autoupdate", String(+!settings.autoupdate));
|
||||
storage.settings.autoupdate = !settings.autoupdate;
|
||||
// update state
|
||||
setSettings((prev) => ({ ...prev, autoupdate: !settings.autoupdate }));
|
||||
};
|
||||
|
||||
const toggleNofitication = async () => {
|
||||
if (settings.notification) return;
|
||||
|
||||
await requestPermission();
|
||||
// update state
|
||||
setSettings((prev) => ({ ...prev, notification: !settings.notification }));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function loadSettings() {
|
||||
const theme = await getCurrent().theme();
|
||||
setSettings((prev) => ({ ...prev, appearance: theme }));
|
||||
|
||||
const autostart = await isEnabled();
|
||||
setSettings((prev) => ({ ...prev, autolaunch: autostart }));
|
||||
|
||||
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 === "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),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<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 items-center gap-8">
|
||||
<div className="w-24 shrink-0 text-end text-sm font-semibold">
|
||||
Updater
|
||||
</div>
|
||||
<div className="text-sm">Auto download new update at Login</div>
|
||||
</div>
|
||||
<Switch.Root
|
||||
checked={settings.autoupdate}
|
||||
onClick={() => toggleAutoupdate()}
|
||||
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 items-center gap-8">
|
||||
<div className="w-24 shrink-0 text-end text-sm font-semibold">
|
||||
Startup
|
||||
</div>
|
||||
<div className="text-sm">Launch Lume at Login</div>
|
||||
</div>
|
||||
<Switch.Root
|
||||
checked={settings.autolaunch}
|
||||
onClick={() => toggleAutolaunch()}
|
||||
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 items-center gap-8">
|
||||
<div className="w-24 shrink-0 text-end text-sm font-semibold">
|
||||
Gossip
|
||||
</div>
|
||||
<div className="text-sm">Use Outbox model</div>
|
||||
</div>
|
||||
<Switch.Root
|
||||
checked={settings.outbox}
|
||||
onClick={() => toggleOutbox()}
|
||||
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 items-center gap-8">
|
||||
<div className="w-24 shrink-0 text-end text-sm font-semibold">
|
||||
Media
|
||||
</div>
|
||||
<div className="text-sm">Automatically load media</div>
|
||||
</div>
|
||||
<Switch.Root
|
||||
checked={settings.media}
|
||||
onClick={() => toggleMedia()}
|
||||
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 items-center gap-8">
|
||||
<div className="w-24 shrink-0 text-end text-sm font-semibold">
|
||||
Hashtag
|
||||
</div>
|
||||
<div className="text-sm">Hide all hashtags in content</div>
|
||||
</div>
|
||||
<Switch.Root
|
||||
checked={settings.hashtag}
|
||||
onClick={() => toggleHashtag()}
|
||||
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 items-center gap-8">
|
||||
<div className="w-24 shrink-0 text-end text-sm font-semibold">
|
||||
Notification
|
||||
</div>
|
||||
<div className="text-sm">Automatically send notification</div>
|
||||
</div>
|
||||
<Switch.Root
|
||||
checked={settings.notification}
|
||||
disabled={settings.notification}
|
||||
onClick={() => toggleNofitication()}
|
||||
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-start gap-8">
|
||||
<div className="w-24 shrink-0 text-end text-sm font-semibold">
|
||||
Appearance
|
||||
</div>
|
||||
<div className="flex flex-1 gap-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeTheme("light")}
|
||||
className="flex flex-col items-center justify-center gap-0.5"
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
"inline-flex h-11 w-11 items-center justify-center rounded-lg",
|
||||
settings.appearance === "light"
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-neutral-100 dark:bg-neutral-900",
|
||||
)}
|
||||
>
|
||||
<LightIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
Light
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeTheme("dark")}
|
||||
className="flex flex-col items-center justify-center gap-0.5"
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
"inline-flex h-11 w-11 items-center justify-center rounded-lg",
|
||||
settings.appearance === "dark"
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-neutral-100 dark:bg-neutral-900",
|
||||
)}
|
||||
>
|
||||
<DarkIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
Dark
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeTheme("auto")}
|
||||
className="flex flex-col items-center justify-center gap-0.5"
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
"inline-flex h-11 w-11 items-center justify-center rounded-lg",
|
||||
settings.appearance === "auto"
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-neutral-100 dark:bg-neutral-900",
|
||||
)}
|
||||
>
|
||||
<SystemModeIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
System
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
19
apps/desktop/src/routes/settings/index.tsx
Normal file
19
apps/desktop/src/routes/settings/index.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
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>
|
||||
);
|
||||
}
|
152
apps/desktop/src/routes/users/components/profile.tsx
Normal file
152
apps/desktop/src/routes/users/components/profile.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
import { useArk, useProfile, useStorage } from "@lume/ark";
|
||||
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 (storage.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>
|
||||
</>
|
||||
);
|
||||
}
|
84
apps/desktop/src/routes/users/components/stats.tsx
Normal file
84
apps/desktop/src/routes/users/components/stats.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
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>
|
||||
);
|
||||
}
|
97
apps/desktop/src/routes/users/index.tsx
Normal file
97
apps/desktop/src/routes/users/index.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
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 h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="h-5 w-5" />
|
||||
Load more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
13
apps/desktop/tailwind.config.js
Normal file
13
apps/desktop/tailwind.config.js
Normal file
@ -0,0 +1,13 @@
|
||||
import sharedConfig from "@lume/tailwindcss";
|
||||
|
||||
const config = {
|
||||
content: [
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
"../../packages/ark/**/*{.js,.ts,.jsx,.tsx}",
|
||||
"../../packages/ui/**/*{.js,.ts,.jsx,.tsx}",
|
||||
"index.html",
|
||||
],
|
||||
presets: [sharedConfig],
|
||||
};
|
||||
|
||||
export default config;
|
8
apps/desktop/tsconfig.json
Normal file
8
apps/desktop/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@lume/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
0
apps/web/.keep
Normal file
0
apps/web/.keep
Normal file
21
biome.json
Normal file
21
biome.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.4.1/schema.json",
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"style": {
|
||||
"noNonNullAssertion": "warn"
|
||||
},
|
||||
"correctness": {
|
||||
"useExhaustiveDependencies": "warn"
|
||||
},
|
||||
"a11y": {
|
||||
"noSvgWithoutTitle": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
139
package.json
139
package.json
@ -1,124 +1,19 @@
|
||||
{
|
||||
"name": "lume",
|
||||
"description": "the communication app",
|
||||
"private": true,
|
||||
"version": "2.2.3",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"tauri": "tauri",
|
||||
"add-migrate": "cd src-tauri/ && sqlx migrate add",
|
||||
"prepare": "husky install",
|
||||
"lint": "eslint ./src --fix",
|
||||
"format": "prettier ./src --write",
|
||||
"dep-update": "pnpm update && cd src-tauri/ && cargo update"
|
||||
},
|
||||
"lint-staged": {
|
||||
"**/*.{ts, tsx}": "eslint --fix",
|
||||
"**/*.{ts, tsx, css, md, html, json}": "prettier --cache --write"
|
||||
},
|
||||
"dependencies": {
|
||||
"@evilmartians/harmony": "^1.2.0",
|
||||
"@getalby/sdk": "^3.2.1",
|
||||
"@nostr-dev-kit/ndk": "^2.3.1",
|
||||
"@nostr-fetch/adapter-ndk": "^0.14.1",
|
||||
"@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-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-hover-card": "^1.0.7",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-toolbar": "^1.0.4",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@tanstack/react-query": "^5.14.2",
|
||||
"@tauri-apps/api": "2.0.0-alpha.11",
|
||||
"@tauri-apps/cli": "2.0.0-alpha.17",
|
||||
"@tauri-apps/plugin-autostart": "2.0.0-alpha.3",
|
||||
"@tauri-apps/plugin-clipboard-manager": "2.0.0-alpha.3",
|
||||
"@tauri-apps/plugin-dialog": "2.0.0-alpha.3",
|
||||
"@tauri-apps/plugin-fs": "2.0.0-alpha.3",
|
||||
"@tauri-apps/plugin-http": "2.0.0-alpha.3",
|
||||
"@tauri-apps/plugin-notification": "2.0.0-alpha.3",
|
||||
"@tauri-apps/plugin-os": "2.0.0-alpha.4",
|
||||
"@tauri-apps/plugin-process": "2.0.0-alpha.3",
|
||||
"@tauri-apps/plugin-shell": "2.0.0-alpha.3",
|
||||
"@tauri-apps/plugin-sql": "2.0.0-alpha.3",
|
||||
"@tauri-apps/plugin-updater": "2.0.0-alpha.3",
|
||||
"@tauri-apps/plugin-upload": "2.0.0-alpha.3",
|
||||
"@tiptap/extension-character-count": "^2.1.13",
|
||||
"@tiptap/extension-document": "^2.1.13",
|
||||
"@tiptap/extension-image": "^2.1.13",
|
||||
"@tiptap/extension-mention": "^2.1.13",
|
||||
"@tiptap/extension-paragraph": "^2.1.13",
|
||||
"@tiptap/extension-placeholder": "^2.1.13",
|
||||
"@tiptap/extension-text": "^2.1.13",
|
||||
"@tiptap/pm": "^2.1.13",
|
||||
"@tiptap/react": "^2.1.13",
|
||||
"@tiptap/starter-kit": "^2.1.13",
|
||||
"@tiptap/suggestion": "^2.1.13",
|
||||
"@vidstack/react": "^1.9.8",
|
||||
"clsx": "^2.0.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"framer-motion": "^10.16.16",
|
||||
"html-to-text": "^9.0.5",
|
||||
"light-bolt11-decoder": "^3.0.0",
|
||||
"lru-cache": "^10.1.0",
|
||||
"markdown-to-jsx": "^7.3.2",
|
||||
"minidenticons": "^4.2.0",
|
||||
"nanoid": "^5.0.4",
|
||||
"nostr-fetch": "^0.14.1",
|
||||
"nostr-tools": "^1.17.0",
|
||||
"qrcode.react": "^3.1.0",
|
||||
"re-resizable": "^6.9.11",
|
||||
"react": "^18.2.0",
|
||||
"react-currency-input-field": "^3.6.12",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.49.2",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"react-router-dom": "^6.21.0",
|
||||
"react-string-replace": "^1.1.1",
|
||||
"smol-toml": "^1.1.3",
|
||||
"sonner": "^1.2.4",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-markdown": "^0.8.8",
|
||||
"use-context-selector": "^1.4.1",
|
||||
"use-react-workers": "^0.3.0",
|
||||
"virtua": "^0.18.0",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
||||
"@types/html-to-text": "^9.0.4",
|
||||
"@types/node": "^20.10.5",
|
||||
"@types/react": "^18.2.45",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@typescript-eslint/eslint-plugin": "^6.15.0",
|
||||
"@typescript-eslint/parser": "^6.15.0",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"cross-env": "^7.0.3",
|
||||
"encoding": "^0.1.13",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.8.0",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||
"husky": "^8.0.3",
|
||||
"lint-staged": "^15.2.0",
|
||||
"postcss": "^8.4.32",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-tailwindcss": "^0.5.9",
|
||||
"prop-types": "^15.8.1",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwind-scrollbar": "^3.0.5",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^4.5.1",
|
||||
"vite-tsconfig-paths": "^4.2.2"
|
||||
}
|
||||
"name": "lume",
|
||||
"private": true,
|
||||
"version": "3.0.0",
|
||||
"scripts": {
|
||||
"build": "turbo build",
|
||||
"dev": "turbo dev",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.4.1",
|
||||
"@tauri-apps/cli": "2.0.0-alpha.20",
|
||||
"turbo": "^1.11.2"
|
||||
},
|
||||
"packageManager": "pnpm@8.9.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
|
22
packages/@columns/notification/package.json
Normal file
22
packages/@columns/notification/package.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@columns/notification",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"dependencies": {
|
||||
"@lume/ark": "workspace:^",
|
||||
"@lume/icons": "workspace:^",
|
||||
"@lume/utils": "workspace:^",
|
||||
"@nostr-dev-kit/ndk": "^2.3.1",
|
||||
"@tanstack/react-query": "^5.14.2",
|
||||
"react": "^18.2.0",
|
||||
"virtua": "^0.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lume/tailwindcss": "workspace:^",
|
||||
"@lume/tsconfig": "workspace:^",
|
||||
"@types/react": "^18.2.45",
|
||||
"tailwind": "^4.0.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
1
packages/@columns/notification/src/index.ts
Normal file
1
packages/@columns/notification/src/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./notification";
|
173
packages/@columns/notification/src/notification.tsx
Normal file
173
packages/@columns/notification/src/notification.tsx
Normal file
@ -0,0 +1,173 @@
|
||||
import { NoteSkeleton, TextNote, Widget, useArk, useStorage } from "@lume/ark";
|
||||
import {
|
||||
AnnouncementIcon,
|
||||
ArrowRightCircleIcon,
|
||||
LoaderIcon,
|
||||
} from "@lume/icons";
|
||||
import { FETCH_LIMIT, sendNativeNotification } from "@lume/utils";
|
||||
import { NDKEvent, NDKKind, NDKSubscription } from "@nostr-dev-kit/ndk";
|
||||
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { VList } from "virtua";
|
||||
|
||||
export function NotificationColumn() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ["notification"],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({
|
||||
signal,
|
||||
pageParam,
|
||||
}: {
|
||||
signal: AbortSignal;
|
||||
pageParam: number;
|
||||
}) => {
|
||||
const events = await ark.getInfiniteEvents({
|
||||
filter: {
|
||||
kinds: [
|
||||
NDKKind.Text,
|
||||
NDKKind.Repost,
|
||||
NDKKind.Reaction,
|
||||
NDKKind.Zap,
|
||||
],
|
||||
"#p": [storage.account.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,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const allEvents = useMemo(
|
||||
() => (data ? data.pages.flatMap((page) => page) : []),
|
||||
[data],
|
||||
);
|
||||
|
||||
const renderEvent = (event: NDKEvent) => {
|
||||
if (event.pubkey === storage.account.pubkey) return null;
|
||||
return <TextNote key={event.id} event={event} />;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let sub: NDKSubscription = undefined;
|
||||
|
||||
if (status === "success" && storage.account) {
|
||||
const filter = {
|
||||
kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Reaction, NDKKind.Zap],
|
||||
"#p": [storage.account.pubkey],
|
||||
since: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
sub = ark.subscribe({
|
||||
filter,
|
||||
closeOnEose: false,
|
||||
cb: async (event) => {
|
||||
queryClient.setQueryData(
|
||||
["notification"],
|
||||
(prev: { pageParams: number; pages: Array<NDKEvent[]> }) => ({
|
||||
...prev,
|
||||
pages: [[event], ...prev.pages],
|
||||
}),
|
||||
);
|
||||
|
||||
const profile = await ark.getUserProfile({ pubkey: event.pubkey });
|
||||
|
||||
switch (event.kind) {
|
||||
case NDKKind.Text:
|
||||
return await sendNativeNotification(
|
||||
`${
|
||||
profile.displayName || profile.name
|
||||
} has replied to your note`,
|
||||
);
|
||||
case NDKKind.Repost:
|
||||
return await sendNativeNotification(
|
||||
`${
|
||||
profile.displayName || profile.name
|
||||
} has reposted to your note`,
|
||||
);
|
||||
case NDKKind.Reaction:
|
||||
return await sendNativeNotification(
|
||||
`${profile.displayName || profile.name} has reacted ${
|
||||
event.content
|
||||
} to your note`,
|
||||
);
|
||||
case NDKKind.Zap:
|
||||
return await sendNativeNotification(
|
||||
`${
|
||||
profile.displayName || profile.name
|
||||
} has zapped to your note`,
|
||||
);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (sub) sub.stop();
|
||||
};
|
||||
}, [status]);
|
||||
|
||||
return (
|
||||
<Widget.Root>
|
||||
<Widget.Header
|
||||
id="9998"
|
||||
queryKey={["notification"]}
|
||||
title="Notification"
|
||||
icon={<AnnouncementIcon className="h-5 w-5" />}
|
||||
/>
|
||||
<Widget.Content>
|
||||
<VList className="flex-1" overscan={2}>
|
||||
{status === "pending" ? (
|
||||
<NoteSkeleton />
|
||||
) : allEvents.length < 1 ? (
|
||||
<div className="my-3 flex w-full items-center justify-center gap-2">
|
||||
<div>🎉</div>
|
||||
<p className="text-center font-medium text-neutral-900 dark:text-neutral-100">
|
||||
Hmm! Nothing new yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
allEvents.map((event) => renderEvent(event))
|
||||
)}
|
||||
<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 h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="h-5 w-5" />
|
||||
Load more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</VList>
|
||||
</Widget.Content>
|
||||
</Widget.Root>
|
||||
);
|
||||
}
|
8
packages/@columns/notification/tailwind.config.js
Normal file
8
packages/@columns/notification/tailwind.config.js
Normal file
@ -0,0 +1,8 @@
|
||||
import sharedConfig from "@lume/tailwindcss";
|
||||
|
||||
const config = {
|
||||
content: ["./src/**/*.{js,ts,jsx,tsx}"],
|
||||
presets: [sharedConfig],
|
||||
};
|
||||
|
||||
export default config;
|
8
packages/@columns/notification/tsconfig.json
Normal file
8
packages/@columns/notification/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@lume/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
22
packages/@columns/timeline/package.json
Normal file
22
packages/@columns/timeline/package.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@columns/timeline",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"dependencies": {
|
||||
"@lume/ark": "workspace:^",
|
||||
"@lume/icons": "workspace:^",
|
||||
"@lume/utils": "workspace:^",
|
||||
"@nostr-dev-kit/ndk": "^2.3.1",
|
||||
"@tanstack/react-query": "^5.14.2",
|
||||
"react": "^18.2.0",
|
||||
"virtua": "^0.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lume/tailwindcss": "workspace:^",
|
||||
"@lume/tsconfig": "workspace:^",
|
||||
"@types/react": "^18.2.45",
|
||||
"tailwind": "^4.0.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
1
packages/@columns/timeline/src/index.ts
Normal file
1
packages/@columns/timeline/src/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./timeline";
|
104
packages/@columns/timeline/src/timeline.tsx
Normal file
104
packages/@columns/timeline/src/timeline.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import { RepostNote, TextNote, Widget, useArk, useStorage } from "@lume/ark";
|
||||
import { ArrowRightCircleIcon, LoaderIcon, TimelineIcon } 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, useRef } from "react";
|
||||
import { VList, VListHandle } from "virtua";
|
||||
|
||||
export function TimelineColumn() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const ref = useRef<VListHandle>();
|
||||
|
||||
const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ["newsfeed"],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({
|
||||
signal,
|
||||
pageParam,
|
||||
}: {
|
||||
signal: AbortSignal;
|
||||
pageParam: number;
|
||||
}) => {
|
||||
const events = await ark.getInfiniteEvents({
|
||||
filter: {
|
||||
kinds: [NDKKind.Text, NDKKind.Repost],
|
||||
authors: !storage.account.contacts.length
|
||||
? [storage.account.pubkey]
|
||||
: storage.account.contacts,
|
||||
},
|
||||
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],
|
||||
);
|
||||
|
||||
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 (
|
||||
<Widget.Root>
|
||||
<Widget.Header
|
||||
id="9999"
|
||||
queryKey={["newsfeed"]}
|
||||
title="Timeline"
|
||||
icon={<TimelineIcon className="h-5 w-5" />}
|
||||
/>
|
||||
<Widget.Content>
|
||||
<VList ref={ref} overscan={2} className="flex-1">
|
||||
{isLoading ? (
|
||||
<div className="inline-flex h-16 items-center justify-center gap-2 px-3 py-1.5">
|
||||
<LoaderIcon className="size-5" />
|
||||
Loading
|
||||
</div>
|
||||
) : (
|
||||
allEvents.map((item) => renderItem(item))
|
||||
)}
|
||||
<div className="flex h-16 items-center justify-center px-3 py-3">
|
||||
{hasNextPage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={!hasNextPage || isFetchingNextPage}
|
||||
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<LoaderIcon className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="h-5 w-5" />
|
||||
Load more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</VList>
|
||||
</Widget.Content>
|
||||
</Widget.Root>
|
||||
);
|
||||
}
|
8
packages/@columns/timeline/tailwind.config.js
Normal file
8
packages/@columns/timeline/tailwind.config.js
Normal file
@ -0,0 +1,8 @@
|
||||
import sharedConfig from "@lume/tailwindcss";
|
||||
|
||||
const config = {
|
||||
content: ["./src/**/*.{js,ts,jsx,tsx}"],
|
||||
presets: [sharedConfig],
|
||||
};
|
||||
|
||||
export default config;
|
8
packages/@columns/timeline/tsconfig.json
Normal file
8
packages/@columns/timeline/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@lume/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
58
packages/ark/package.json
Normal file
58
packages/ark/package.json
Normal file
@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "@lume/ark",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"dependencies": {
|
||||
"@getalby/sdk": "^3.2.1",
|
||||
"@lume/icons": "workspace:^",
|
||||
"@lume/ndk-cache-tauri": "workspace:^",
|
||||
"@lume/storage": "workspace:^",
|
||||
"@lume/utils": "workspace:^",
|
||||
"@nostr-dev-kit/ndk": "^2.3.1",
|
||||
"@nostr-fetch/adapter-ndk": "^0.14.1",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@tanstack/react-query": "^5.14.2",
|
||||
"@tauri-apps/api": "2.0.0-alpha.11",
|
||||
"@tauri-apps/plugin-clipboard-manager": "2.0.0-alpha.3",
|
||||
"@tauri-apps/plugin-dialog": "2.0.0-alpha.3",
|
||||
"@tauri-apps/plugin-fs": "2.0.0-alpha.3",
|
||||
"@tauri-apps/plugin-http": "2.0.0-alpha.3",
|
||||
"@tauri-apps/plugin-os": "2.0.0-alpha.4",
|
||||
"@tauri-apps/plugin-process": "2.0.0-alpha.3",
|
||||
"@tauri-apps/plugin-sql": "2.0.0-alpha.3",
|
||||
"@tauri-apps/plugin-updater": "2.0.0-alpha.3",
|
||||
"@tauri-apps/plugin-upload": "2.0.0-alpha.3",
|
||||
"@tiptap/extension-mention": "^2.1.13",
|
||||
"@tiptap/react": "^2.1.13",
|
||||
"@vidstack/react": "^1.9.8",
|
||||
"markdown-to-jsx": "^7.3.2",
|
||||
"minidenticons": "^4.2.0",
|
||||
"nanoid": "^5.0.4",
|
||||
"nostr-fetch": "^0.14.1",
|
||||
"nostr-tools": "1.17.0",
|
||||
"qrcode.react": "^3.1.0",
|
||||
"re-resizable": "^6.9.11",
|
||||
"react": "^18.2.0",
|
||||
"react-currency-input-field": "^3.6.12",
|
||||
"react-router-dom": "^6.21.0",
|
||||
"react-string-replace": "^1.1.1",
|
||||
"sonner": "^1.2.4",
|
||||
"tippy.js": "^6.3.7",
|
||||
"use-context-selector": "^1.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lume/tailwindcss": "workspace:^",
|
||||
"@lume/tsconfig": "workspace:^",
|
||||
"@lume/types": "workspace:^",
|
||||
"@types/react": "^18.2.45",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
543
packages/ark/src/ark.ts
Normal file
543
packages/ark/src/ark.ts
Normal file
@ -0,0 +1,543 @@
|
||||
import { LumeStorage } from "@lume/storage";
|
||||
import {
|
||||
type Account,
|
||||
type NDKEventWithReplies,
|
||||
type NIP05,
|
||||
} from "@lume/types";
|
||||
import NDK, {
|
||||
NDKEvent,
|
||||
NDKFilter,
|
||||
NDKKind,
|
||||
NDKNip46Signer,
|
||||
NDKPrivateKeySigner,
|
||||
NDKRelay,
|
||||
NDKSubscriptionCacheUsage,
|
||||
NDKTag,
|
||||
NDKUser,
|
||||
NostrEvent,
|
||||
} from "@nostr-dev-kit/ndk";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { readBinaryFile } from "@tauri-apps/plugin-fs";
|
||||
import { fetch } from "@tauri-apps/plugin-http";
|
||||
import { NostrFetcher, normalizeRelayUrl } from "nostr-fetch";
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
export class Ark {
|
||||
#storage: LumeStorage;
|
||||
#fetcher: NostrFetcher;
|
||||
public ndk: NDK;
|
||||
public account: Account;
|
||||
|
||||
constructor({
|
||||
ndk,
|
||||
storage,
|
||||
|
||||
fetcher,
|
||||
}: {
|
||||
ndk: NDK;
|
||||
storage: LumeStorage;
|
||||
|
||||
fetcher: NostrFetcher;
|
||||
}) {
|
||||
this.ndk = ndk;
|
||||
this.#storage = storage;
|
||||
this.#fetcher = fetcher;
|
||||
}
|
||||
|
||||
public async connectDepot() {
|
||||
return this.ndk.addExplicitRelay(
|
||||
new NDKRelay(normalizeRelayUrl("ws://localhost:6090")),
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
public updateNostrSigner({
|
||||
signer,
|
||||
}: { signer: NDKNip46Signer | NDKPrivateKeySigner }) {
|
||||
this.ndk.signer = signer;
|
||||
return this.ndk.signer;
|
||||
}
|
||||
|
||||
public subscribe({
|
||||
filter,
|
||||
closeOnEose = false,
|
||||
cb,
|
||||
}: {
|
||||
filter: NDKFilter;
|
||||
closeOnEose: boolean;
|
||||
cb: (event: NDKEvent) => void;
|
||||
}) {
|
||||
const sub = this.ndk.subscribe(filter, { closeOnEose });
|
||||
sub.addListener("event", (event: NDKEvent) => cb(event));
|
||||
return sub;
|
||||
}
|
||||
|
||||
public async createEvent({
|
||||
kind,
|
||||
tags,
|
||||
content,
|
||||
rootReplyTo = undefined,
|
||||
replyTo = undefined,
|
||||
}: {
|
||||
kind: NDKKind | number;
|
||||
tags: NDKTag[];
|
||||
content?: string;
|
||||
rootReplyTo?: string;
|
||||
replyTo?: string;
|
||||
}) {
|
||||
try {
|
||||
const event = new NDKEvent(this.ndk);
|
||||
if (content) event.content = content;
|
||||
event.kind = kind;
|
||||
event.tags = tags;
|
||||
|
||||
if (rootReplyTo) {
|
||||
const rootEvent = await this.ndk.fetchEvent(rootReplyTo);
|
||||
if (rootEvent) event.tag(rootEvent, "root");
|
||||
}
|
||||
|
||||
if (replyTo) {
|
||||
const replyEvent = await this.ndk.fetchEvent(replyTo);
|
||||
if (replyEvent) event.tag(replyEvent, "reply");
|
||||
}
|
||||
|
||||
const publish = await event.publish();
|
||||
|
||||
if (!publish) throw new Error("Failed to publish event");
|
||||
return {
|
||||
id: event.id,
|
||||
seens: [...publish.values()].map((item) => item.url),
|
||||
};
|
||||
} catch (e) {
|
||||
throw new Error(e);
|
||||
}
|
||||
}
|
||||
|
||||
public async getUserProfile({ pubkey }: { pubkey: string }) {
|
||||
try {
|
||||
// get clean pubkey without any special characters
|
||||
let hexstring = pubkey.replace(/[^a-zA-Z0-9]/g, "");
|
||||
|
||||
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 });
|
||||
|
||||
const profile = await user.fetchProfile({
|
||||
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST,
|
||||
});
|
||||
|
||||
if (!profile) return null;
|
||||
return profile;
|
||||
} catch (e) {
|
||||
throw new Error(e);
|
||||
}
|
||||
}
|
||||
|
||||
public async getUserContacts({
|
||||
pubkey = undefined,
|
||||
outbox = undefined,
|
||||
}: {
|
||||
pubkey?: string;
|
||||
outbox?: boolean;
|
||||
}) {
|
||||
try {
|
||||
const user = this.ndk.getUser({
|
||||
pubkey: pubkey ? pubkey : this.#storage.account.pubkey,
|
||||
});
|
||||
const contacts = [...(await user.follows(undefined, outbox))].map(
|
||||
(user) => user.pubkey,
|
||||
);
|
||||
|
||||
if (pubkey === this.#storage.account.pubkey)
|
||||
this.#storage.account.contacts = contacts;
|
||||
return contacts;
|
||||
} catch (e) {
|
||||
throw new Error(e);
|
||||
}
|
||||
}
|
||||
|
||||
public async getUserRelays({ pubkey }: { pubkey?: string }) {
|
||||
try {
|
||||
const user = this.ndk.getUser({
|
||||
pubkey: pubkey ? pubkey : this.#storage.account.pubkey,
|
||||
});
|
||||
return await user.relayList();
|
||||
} catch (e) {
|
||||
throw new Error(e);
|
||||
}
|
||||
}
|
||||
|
||||
public async newContactList({ tags }: { tags: NDKTag[] }) {
|
||||
const publish = await this.createEvent({
|
||||
kind: NDKKind.Contacts,
|
||||
tags: tags,
|
||||
});
|
||||
|
||||
if (publish) {
|
||||
this.#storage.account.contacts = tags.map((item) => item[1]);
|
||||
return publish;
|
||||
}
|
||||
}
|
||||
|
||||
public async createContact({ pubkey }: { pubkey: string }) {
|
||||
const user = this.ndk.getUser({ pubkey: this.#storage.account.pubkey });
|
||||
const contacts = await user.follows();
|
||||
return await user.follow(new NDKUser({ pubkey: pubkey }), contacts);
|
||||
}
|
||||
|
||||
public async deleteContact({ pubkey }: { pubkey: string }) {
|
||||
const user = this.ndk.getUser({ pubkey: this.#storage.account.pubkey });
|
||||
const contacts = await user.follows();
|
||||
contacts.delete(new NDKUser({ pubkey: pubkey }));
|
||||
|
||||
const event = new NDKEvent(this.ndk);
|
||||
event.content = "";
|
||||
event.kind = NDKKind.Contacts;
|
||||
event.tags = [...contacts].map((item) => [
|
||||
"p",
|
||||
item.pubkey,
|
||||
item.relayUrls?.[0] || "",
|
||||
"",
|
||||
]);
|
||||
|
||||
return await event.publish();
|
||||
}
|
||||
|
||||
public async getAllEvents({ filter }: { filter: NDKFilter }) {
|
||||
const events = await this.ndk.fetchEvents(filter);
|
||||
if (!events) return [];
|
||||
return [...events];
|
||||
}
|
||||
|
||||
public async getEventById({ id }: { id: string }) {
|
||||
let eventId: string = id;
|
||||
|
||||
if (
|
||||
eventId.startsWith("nevent1") ||
|
||||
eventId.startsWith("note1") ||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
public async getEventByFilter({ filter }: { filter: NDKFilter }) {
|
||||
const event = await this.ndk.fetchEvent(filter, {
|
||||
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST,
|
||||
});
|
||||
|
||||
if (!event) return null;
|
||||
return event;
|
||||
}
|
||||
|
||||
public getEventThread({ tags }: { tags: NDKTag[] }) {
|
||||
let rootEventId: string = null;
|
||||
let replyEventId: string = null;
|
||||
|
||||
const events = tags.filter((el) => el[0] === "e");
|
||||
|
||||
if (!events.length) return null;
|
||||
|
||||
if (events.length === 1)
|
||||
return {
|
||||
rootEventId: events[0][1],
|
||||
replyEventId: null,
|
||||
};
|
||||
|
||||
if (events.length > 1) {
|
||||
rootEventId = events.find((el) => el[3] === "root")?.[1];
|
||||
replyEventId = events.find((el) => el[3] === "reply")?.[1];
|
||||
|
||||
if (!rootEventId && !replyEventId) {
|
||||
rootEventId = events[0][1];
|
||||
replyEventId = events[1][1];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rootEventId,
|
||||
replyEventId,
|
||||
};
|
||||
}
|
||||
|
||||
public async getThreads({
|
||||
id,
|
||||
data,
|
||||
}: { id: string; data?: NDKEventWithReplies[] }) {
|
||||
let events = data || null;
|
||||
|
||||
if (!data) {
|
||||
const relayUrls = [...this.ndk.pool.relays.values()].map(
|
||||
(item) => item.url,
|
||||
);
|
||||
const rawEvents = (await this.#fetcher.fetchAllEvents(
|
||||
relayUrls,
|
||||
{
|
||||
kinds: [NDKKind.Text],
|
||||
"#e": [id],
|
||||
},
|
||||
{ since: 0 },
|
||||
{ sort: true },
|
||||
)) as unknown as NostrEvent[];
|
||||
events = rawEvents.map(
|
||||
(event) => new NDKEvent(this.ndk, event),
|
||||
) as NDKEvent[] as NDKEventWithReplies[];
|
||||
}
|
||||
|
||||
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);
|
||||
if (tags.length > 0) {
|
||||
for (const tag of tags) {
|
||||
const rootIndex = events.findIndex((el) => el.id === tag[1]);
|
||||
if (rootIndex !== -1) {
|
||||
const rootEvent = events[rootIndex];
|
||||
if (rootEvent?.replies) {
|
||||
rootEvent.replies.push(event);
|
||||
} else {
|
||||
rootEvent.replies = [event];
|
||||
}
|
||||
replies.add(event.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const cleanEvents = events.filter((ev) => !replies.has(ev.id));
|
||||
return cleanEvents;
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
public async getAllRelaysFromContacts() {
|
||||
const LIMIT = 1;
|
||||
const connectedRelays = this.ndk.pool
|
||||
.connectedRelays()
|
||||
.map((item) => item.url);
|
||||
const relayMap = new Map<string, string[]>();
|
||||
const relayEvents = this.#fetcher.fetchLatestEventsPerAuthor(
|
||||
{
|
||||
authors: this.#storage.account.contacts,
|
||||
relayUrls: connectedRelays,
|
||||
},
|
||||
{ kinds: [NDKKind.RelayList] },
|
||||
LIMIT,
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return relayMap;
|
||||
}
|
||||
|
||||
public async getInfiniteEvents({
|
||||
filter,
|
||||
limit,
|
||||
pageParam = 0,
|
||||
signal = undefined,
|
||||
dedup = true,
|
||||
}: {
|
||||
filter: NDKFilter;
|
||||
limit: number;
|
||||
pageParam?: number;
|
||||
signal?: AbortSignal;
|
||||
dedup?: boolean;
|
||||
}) {
|
||||
const rootIds = new Set();
|
||||
const dedupQueue = new Set();
|
||||
const connectedRelays = this.ndk.pool
|
||||
.connectedRelays()
|
||||
.map((item) => item.url);
|
||||
|
||||
const events = await this.#fetcher.fetchLatestEvents(
|
||||
connectedRelays,
|
||||
filter,
|
||||
limit,
|
||||
{
|
||||
asOf: pageParam === 0 ? undefined : pageParam,
|
||||
abortSignal: signal,
|
||||
},
|
||||
);
|
||||
|
||||
const ndkEvents = events.map((event) => {
|
||||
return new NDKEvent(this.ndk, event);
|
||||
});
|
||||
|
||||
if (dedup) {
|
||||
for (const event of ndkEvents) {
|
||||
const tags = event.tags.filter((el) => el[0] === "e");
|
||||
|
||||
if (tags && tags.length > 0) {
|
||||
const rootId = tags.filter((el) => el[3] === "root")[1] ?? tags[0][1];
|
||||
|
||||
if (rootIds.has(rootId)) {
|
||||
dedupQueue.add(event.id);
|
||||
break;
|
||||
}
|
||||
|
||||
rootIds.add(rootId);
|
||||
}
|
||||
}
|
||||
|
||||
return ndkEvents
|
||||
.filter((event) => !dedupQueue.has(event.id))
|
||||
.sort((a, b) => b.created_at - a.created_at);
|
||||
}
|
||||
|
||||
return ndkEvents.sort((a, b) => b.created_at - a.created_at);
|
||||
}
|
||||
|
||||
public async getRelayEvents({
|
||||
relayUrl,
|
||||
filter,
|
||||
limit,
|
||||
pageParam = 0,
|
||||
signal = undefined,
|
||||
}: {
|
||||
relayUrl: string;
|
||||
filter: NDKFilter;
|
||||
limit: number;
|
||||
pageParam?: number;
|
||||
signal?: AbortSignal;
|
||||
dedup?: boolean;
|
||||
}) {
|
||||
const events = await this.#fetcher.fetchLatestEvents(
|
||||
[normalizeRelayUrl(relayUrl)],
|
||||
filter,
|
||||
limit,
|
||||
{
|
||||
asOf: pageParam === 0 ? undefined : pageParam,
|
||||
abortSignal: signal,
|
||||
},
|
||||
);
|
||||
|
||||
const ndkEvents = events.map((event) => {
|
||||
return new NDKEvent(this.ndk, event);
|
||||
});
|
||||
|
||||
return ndkEvents.sort((a, b) => b.created_at - a.created_at);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload media file to nostr.build
|
||||
* @todo support multiple backends
|
||||
*/
|
||||
public async upload({ fileExts }: { fileExts?: string[] }) {
|
||||
const defaultExts = ["png", "jpeg", "jpg", "gif"].concat(fileExts);
|
||||
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: "Image",
|
||||
extensions: defaultExts,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!selected) return null;
|
||||
|
||||
const file = await readBinaryFile(selected.path);
|
||||
const blob = new Blob([file]);
|
||||
|
||||
const data = new FormData();
|
||||
data.append("fileToUpload", blob);
|
||||
data.append("submit", "Upload Image");
|
||||
|
||||
const res = await fetch("https://nostr.build/api/v2/upload/files", {
|
||||
method: "POST",
|
||||
body: data,
|
||||
});
|
||||
|
||||
if (!res.ok) return null;
|
||||
|
||||
const json = await res.json();
|
||||
const content = json.data[0];
|
||||
|
||||
return content.url as string;
|
||||
}
|
||||
|
||||
public async validateNIP05({
|
||||
pubkey,
|
||||
nip05,
|
||||
signal,
|
||||
}: {
|
||||
pubkey: string;
|
||||
nip05: string;
|
||||
signal?: AbortSignal;
|
||||
}) {
|
||||
const localPath = nip05.split("@")[0];
|
||||
const service = nip05.split("@")[1];
|
||||
const verifyURL = `https://${service}/.well-known/nostr.json?name=${localPath}`;
|
||||
|
||||
const res = await fetch(verifyURL, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Failed to fetch NIP-05 service: ${nip05}`);
|
||||
|
||||
const data: NIP05 = await res.json();
|
||||
|
||||
if (!data.names) return false;
|
||||
|
||||
if (data.names[localPath.toLowerCase()] === pubkey) return true;
|
||||
if (data.names[localPath] === pubkey) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async replyTo({
|
||||
content,
|
||||
event,
|
||||
}: { content: string; event: NDKEvent }) {
|
||||
try {
|
||||
const replyEvent = new NDKEvent(this.ndk);
|
||||
replyEvent.content = content;
|
||||
replyEvent.kind = NDKKind.Text;
|
||||
replyEvent.tag(event, "reply");
|
||||
|
||||
return await replyEvent.publish();
|
||||
} catch (e) {
|
||||
throw new Error(e);
|
||||
}
|
||||
}
|
||||
}
|
116
packages/ark/src/components/mentions.tsx
Normal file
116
packages/ark/src/components/mentions.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
import * as Avatar from "@radix-ui/react-avatar";
|
||||
import { minidenticon } from "minidenticons";
|
||||
import {
|
||||
Ref,
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useState,
|
||||
} from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { NDKCacheUserProfile } from "@lume/types";
|
||||
|
||||
type MentionListRef = {
|
||||
onKeyDown: (props: { event: Event }) => boolean;
|
||||
};
|
||||
|
||||
const List = (
|
||||
props: {
|
||||
items: NDKCacheUserProfile[];
|
||||
command: (arg0: { id: string }) => void;
|
||||
},
|
||||
ref: Ref<unknown>,
|
||||
) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const selectItem = (index) => {
|
||||
const item = props.items[index];
|
||||
if (item) {
|
||||
props.command({ id: item.pubkey });
|
||||
}
|
||||
};
|
||||
|
||||
const upHandler = () => {
|
||||
setSelectedIndex(
|
||||
(selectedIndex + props.items.length - 1) % props.items.length,
|
||||
);
|
||||
};
|
||||
|
||||
const downHandler = () => {
|
||||
setSelectedIndex((selectedIndex + 1) % props.items.length);
|
||||
};
|
||||
|
||||
const enterHandler = () => {
|
||||
selectItem(selectedIndex);
|
||||
};
|
||||
|
||||
useEffect(() => setSelectedIndex(0), [props.items]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }) => {
|
||||
if (event.key === "ArrowUp") {
|
||||
upHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
downHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
enterHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex w-[200px] flex-col overflow-y-auto rounded-lg border border-neutral-200 bg-neutral-50 p-2 shadow-lg shadow-neutral-500/20 dark:border-neutral-800 dark:bg-neutral-950 dark:shadow-neutral-300/50">
|
||||
{props.items.length ? (
|
||||
props.items.map((item, index) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.pubkey}
|
||||
onClick={() => selectItem(index)}
|
||||
className={twMerge(
|
||||
"inline-flex h-11 items-center gap-2 rounded-md px-2",
|
||||
index === selectedIndex
|
||||
? "bg-neutral-100 dark:bg-neutral-900"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<Avatar.Root className="h-8 w-8 shrink-0">
|
||||
<Avatar.Image
|
||||
src={item.image}
|
||||
alt={item.name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-8 w-8 rounded-md"
|
||||
/>
|
||||
<Avatar.Fallback delayMs={150}>
|
||||
<img
|
||||
src={`data:image/svg+xml;utf8,${encodeURIComponent(
|
||||
minidenticon(item.name, 90, 50),
|
||||
)}`}
|
||||
alt={item.name}
|
||||
className="h-8 w-8 rounded-md bg-black dark:bg-white"
|
||||
/>
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<h5 className="max-w-[150px] truncate text-sm font-medium">
|
||||
{item.name}
|
||||
</h5>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center text-sm font-medium">No result</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MentionList = forwardRef<MentionListRef>(List);
|
76
packages/ark/src/components/note/builds/reply.tsx
Normal file
76
packages/ark/src/components/note/builds/reply.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import { NavArrowDownIcon } from "@lume/icons";
|
||||
import { NDKEventWithReplies } from "@lume/types";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Note } from "..";
|
||||
|
||||
export function Reply({
|
||||
event,
|
||||
rootEvent,
|
||||
}: {
|
||||
event: NDKEventWithReplies;
|
||||
rootEvent: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Collapsible.Root open={open} onOpenChange={setOpen}>
|
||||
<Note.Root>
|
||||
<Note.User
|
||||
pubkey={event.pubkey}
|
||||
time={event.created_at}
|
||||
className="h-14 px-3"
|
||||
/>
|
||||
<Note.TextContent content={event.content} className="min-w-0 px-3" />
|
||||
<div className="-ml-1 flex items-center justify-between">
|
||||
{event.replies?.length > 0 ? (
|
||||
<Collapsible.Trigger asChild>
|
||||
<div className="ml-4 inline-flex h-14 items-center gap-1 font-semibold text-blue-500">
|
||||
<NavArrowDownIcon
|
||||
className={twMerge(
|
||||
"h-3 w-3",
|
||||
open ? "rotate-180 transform" : "",
|
||||
)}
|
||||
/>
|
||||
{`${event.replies?.length} ${
|
||||
event.replies?.length === 1 ? "reply" : "replies"
|
||||
}`}
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
) : null}
|
||||
<div className="inline-flex items-center gap-10">
|
||||
<Note.Reply eventId={event.id} rootEventId={rootEvent} />
|
||||
<Note.Reaction event={event} />
|
||||
<Note.Repost event={event} />
|
||||
<Note.Zap event={event} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={twMerge("px-3", open ? "pb-3" : "")}>
|
||||
{event.replies?.length > 0 ? (
|
||||
<Collapsible.Content>
|
||||
{event.replies?.map((childEvent) => (
|
||||
<Note.Root key={childEvent.id}>
|
||||
<Note.User pubkey={event.pubkey} time={event.created_at} />
|
||||
<Note.TextContent
|
||||
content={event.content}
|
||||
className="min-w-0 px-3"
|
||||
/>
|
||||
<div className="-ml-1 flex h-14 items-center justify-between px-3">
|
||||
<Note.Pin eventId={event.id} />
|
||||
<div className="inline-flex items-center gap-10">
|
||||
<Note.Reply eventId={event.id} rootEventId={rootEvent} />
|
||||
<Note.Reaction event={event} />
|
||||
<Note.Repost event={event} />
|
||||
<Note.Zap event={event} />
|
||||
</div>
|
||||
</div>
|
||||
</Note.Root>
|
||||
))}
|
||||
</Collapsible.Content>
|
||||
) : null}
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Collapsible.Root>
|
||||
);
|
||||
}
|
82
packages/ark/src/components/note/builds/repost.tsx
Normal file
82
packages/ark/src/components/note/builds/repost.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import { NDKEvent, NDKKind, NostrEvent } from "@nostr-dev-kit/ndk";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Note } from "..";
|
||||
import { useArk } from "../../../provider";
|
||||
|
||||
export function RepostNote({ event }: { event: NDKEvent }) {
|
||||
const ark = useArk();
|
||||
const {
|
||||
isLoading,
|
||||
isError,
|
||||
data: repostEvent,
|
||||
} = useQuery({
|
||||
queryKey: ["repost", event.id],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
if (event.content.length > 50) {
|
||||
const embed = JSON.parse(event.content) as NostrEvent;
|
||||
return new NDKEvent(ark.ndk, embed);
|
||||
}
|
||||
const id = event.tags.find((el) => el[0] === "e")[1];
|
||||
return await ark.getEventById({ id });
|
||||
} catch {
|
||||
throw new Error("Failed to get repost event");
|
||||
}
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const renderContentByKind = () => {
|
||||
if (!repostEvent) return null;
|
||||
switch (repostEvent.kind) {
|
||||
case NDKKind.Text:
|
||||
return <Note.TextContent content={repostEvent.content} />;
|
||||
case 1063:
|
||||
return <Note.MediaContent tags={repostEvent.tags} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="w-full px-3 pb-3" />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="my-3 h-min w-full px-3">
|
||||
<div className="relative flex flex-col gap-2 overflow-hidden rounded-xl bg-neutral-50 pt-3 dark:bg-neutral-950">
|
||||
<div className="relative flex flex-col gap-2">
|
||||
<div className="px-3">
|
||||
<p>Failed to load event</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Note.Root>
|
||||
<Note.User
|
||||
pubkey={event.pubkey}
|
||||
time={event.created_at}
|
||||
variant="repost"
|
||||
className="h-14"
|
||||
/>
|
||||
<div className="relative flex flex-col gap-2 px-3">
|
||||
<Note.User pubkey={repostEvent.pubkey} time={repostEvent.created_at} />
|
||||
{renderContentByKind()}
|
||||
<div className="flex h-14 items-center justify-between">
|
||||
<Note.Pin eventId={event.id} />
|
||||
<div className="inline-flex items-center gap-10">
|
||||
<Note.Reply eventId={repostEvent.id} />
|
||||
<Note.Reaction event={repostEvent} />
|
||||
<Note.Repost event={repostEvent} />
|
||||
<Note.Zap event={repostEvent} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Note.Root>
|
||||
);
|
||||
}
|
29
packages/ark/src/components/note/builds/text.tsx
Normal file
29
packages/ark/src/components/note/builds/text.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { Note } from "..";
|
||||
import { useArk } from "../../../provider";
|
||||
|
||||
export function TextNote({ event }: { event: NDKEvent }) {
|
||||
const ark = useArk();
|
||||
const thread = ark.getEventThread({ tags: event.tags });
|
||||
|
||||
return (
|
||||
<Note.Root>
|
||||
<Note.User
|
||||
pubkey={event.pubkey}
|
||||
time={event.created_at}
|
||||
className="h-14 px-3"
|
||||
/>
|
||||
<Note.Thread thread={thread} className="mb-2" />
|
||||
<Note.TextContent content={event.content} className="min-w-0 px-3" />
|
||||
<div className="flex h-14 items-center justify-between px-3">
|
||||
<Note.Pin eventId={event.id} />
|
||||
<div className="inline-flex items-center gap-10">
|
||||
<Note.Reply eventId={event.id} rootEventId={thread?.rootEventId} />
|
||||
<Note.Reaction event={event} />
|
||||
<Note.Repost event={event} />
|
||||
<Note.Zap event={event} />
|
||||
</div>
|
||||
</div>
|
||||
</Note.Root>
|
||||
);
|
||||
}
|
37
packages/ark/src/components/note/buttons/pin.tsx
Normal file
37
packages/ark/src/components/note/buttons/pin.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { PinIcon } from "@lume/icons";
|
||||
import { WIDGET_KIND } from "@lume/utils";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { useWidget } from "../../../hooks/useWidget";
|
||||
|
||||
export function NotePin({ eventId }: { eventId: string }) {
|
||||
const { addWidget } = useWidget();
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
addWidget.mutate({
|
||||
kind: WIDGET_KIND.thread,
|
||||
title: "Thread",
|
||||
content: eventId,
|
||||
})
|
||||
}
|
||||
className="inline-flex h-7 w-max items-center justify-center gap-2 rounded-full bg-neutral-100 px-2 text-sm font-medium dark:bg-neutral-900"
|
||||
>
|
||||
<PinIcon className="size-4" />
|
||||
Pin
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="-left-10 inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-200 px-3.5 text-sm text-neutral-900 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-800 dark:text-neutral-100">
|
||||
Pin note
|
||||
<Tooltip.Arrow className="fill-neutral-200 dark:fill-neutral-800" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
}
|
136
packages/ark/src/components/note/buttons/reaction.tsx
Normal file
136
packages/ark/src/components/note/buttons/reaction.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import { ReactionIcon } from "@lume/icons";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const REACTIONS = [
|
||||
{
|
||||
content: "👏",
|
||||
img: "/clapping_hands.png",
|
||||
},
|
||||
{
|
||||
content: "🤪",
|
||||
img: "/face_with_tongue.png",
|
||||
},
|
||||
{
|
||||
content: "😮",
|
||||
img: "/face_with_open_mouth.png",
|
||||
},
|
||||
{
|
||||
content: "😢",
|
||||
img: "/crying_face.png",
|
||||
},
|
||||
{
|
||||
content: "🤡",
|
||||
img: "/clown_face.png",
|
||||
},
|
||||
];
|
||||
|
||||
export function NoteReaction({ event }: { event: NDKEvent }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reaction, setReaction] = useState<string | null>(null);
|
||||
|
||||
const getReactionImage = (content: string) => {
|
||||
const reaction: { img: string } = REACTIONS.find(
|
||||
(el) => el.content === content,
|
||||
);
|
||||
return reaction.img;
|
||||
};
|
||||
|
||||
const react = async (content: string) => {
|
||||
try {
|
||||
setReaction(content);
|
||||
|
||||
// react
|
||||
await event.react(content);
|
||||
|
||||
setOpen(false);
|
||||
} catch (e) {
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
|
||||
>
|
||||
{reaction ? (
|
||||
<img
|
||||
src={getReactionImage(reaction)}
|
||||
alt={reaction}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
) : (
|
||||
<ReactionIcon className="h-5 w-5 group-hover:text-blue-500" />
|
||||
)}
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
className="select-none rounded-md bg-neutral-200 px-1 py-1 text-sm will-change-[transform,opacity] data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-800"
|
||||
sideOffset={0}
|
||||
side="top"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => react("👏")}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded backdrop-blur-xl hover:bg-white/10"
|
||||
>
|
||||
<img
|
||||
src="/clapping_hands.png"
|
||||
alt="Clapping Hands"
|
||||
className="h-6 w-6"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => react("🤪")}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded backdrop-blur-xl hover:bg-white/10"
|
||||
>
|
||||
<img
|
||||
src="/face_with_tongue.png"
|
||||
alt="Face with Tongue"
|
||||
className="h-6 w-6"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => react("😮")}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded backdrop-blur-xl hover:bg-white/10"
|
||||
>
|
||||
<img
|
||||
src="/face_with_open_mouth.png"
|
||||
alt="Face with Open Mouth"
|
||||
className="h-6 w-6"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => react("😢")}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded backdrop-blur-xl hover:bg-white/10"
|
||||
>
|
||||
<img
|
||||
src="/crying_face.png"
|
||||
alt="Crying Face"
|
||||
className="h-6 w-6"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => react("🤡")}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded backdrop-blur-xl hover:bg-white/10"
|
||||
>
|
||||
<img src="/clown_face.png" alt="Clown Face" className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
<Popover.Arrow className="fill-neutral-200 dark:fill-neutral-800" />
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
43
packages/ark/src/components/note/buttons/reply.tsx
Normal file
43
packages/ark/src/components/note/buttons/reply.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { ReplyIcon } from "@lume/icons";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { createSearchParams, useNavigate } from "react-router-dom";
|
||||
|
||||
export function NoteReply({
|
||||
eventId,
|
||||
rootEventId,
|
||||
}: {
|
||||
eventId: string;
|
||||
rootEventId?: string;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
navigate({
|
||||
pathname: "/new/",
|
||||
search: createSearchParams({
|
||||
replyTo: eventId,
|
||||
rootReplyTo: rootEventId,
|
||||
}).toString(),
|
||||
})
|
||||
}
|
||||
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
|
||||
>
|
||||
<ReplyIcon className="h-5 w-5 group-hover:text-blue-500" />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="-left-10 inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-200 px-3.5 text-sm text-neutral-900 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-800 dark:text-neutral-100">
|
||||
Quick reply
|
||||
<Tooltip.Arrow className="fill-neutral-200 dark:fill-neutral-800" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
}
|
50
packages/ark/src/components/note/buttons/repost.tsx
Normal file
50
packages/ark/src/components/note/buttons/repost.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { RepostIcon } from "@lume/icons";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function NoteRepost({ event }: { event: NDKEvent }) {
|
||||
const [isRepost, setIsRepost] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
// repost
|
||||
await event.repost(true);
|
||||
|
||||
// update state
|
||||
setIsRepost(true);
|
||||
toast.success("You've reposted this post successfully");
|
||||
} catch (e) {
|
||||
toast.error("Repost failed, try again later");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
|
||||
>
|
||||
<RepostIcon
|
||||
className={twMerge(
|
||||
"h-5 w-5 group-hover:text-blue-600",
|
||||
isRepost ? "text-blue-500" : "",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="-left-10 inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-200 px-3.5 text-sm text-neutral-900 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-800 dark:text-neutral-100">
|
||||
Repost
|
||||
<Tooltip.Arrow className="fill-neutral-200 dark:fill-neutral-800" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
}
|
260
packages/ark/src/components/note/buttons/zap.tsx
Normal file
260
packages/ark/src/components/note/buttons/zap.tsx
Normal file
@ -0,0 +1,260 @@
|
||||
import { webln } from "@getalby/sdk";
|
||||
import { SendPaymentResponse } from "@getalby/sdk/dist/types";
|
||||
import { CancelIcon, ZapIcon } from "@lume/icons";
|
||||
import {
|
||||
compactNumber,
|
||||
displayNpub,
|
||||
sendNativeNotification,
|
||||
} from "@lume/utils";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { invoke } from "@tauri-apps/api/primitives";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import CurrencyInput from "react-currency-input-field";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useProfile } from "../../../hooks/useProfile";
|
||||
import { useArk, useStorage } from "../../../provider";
|
||||
|
||||
export function NoteZap({ event }: { event: NDKEvent }) {
|
||||
const [walletConnectURL, setWalletConnectURL] = useState<string>(null);
|
||||
const [amount, setAmount] = useState<string>("21");
|
||||
const [zapMessage, setZapMessage] = useState<string>("");
|
||||
const [invoice, setInvoice] = useState<null | string>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isCompleted, setIsCompleted] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { user } = useProfile(event.pubkey);
|
||||
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const nwc = useRef(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const createZapRequest = async () => {
|
||||
try {
|
||||
if (!ark.ndk.signer) return navigate("/new/privkey");
|
||||
|
||||
const zapAmount = parseInt(amount) * 1000;
|
||||
const res = await event.zap(zapAmount, zapMessage);
|
||||
|
||||
if (!res)
|
||||
return await message("Cannot create zap request", {
|
||||
title: "Zap",
|
||||
type: "error",
|
||||
});
|
||||
|
||||
// user don't connect nwc, create QR Code for invoice
|
||||
if (!walletConnectURL) return setInvoice(res);
|
||||
|
||||
// user connect nwc
|
||||
nwc.current = new webln.NostrWebLNProvider({
|
||||
nostrWalletConnectUrl: walletConnectURL,
|
||||
});
|
||||
await nwc.current.enable();
|
||||
|
||||
// start loading
|
||||
setIsLoading(true);
|
||||
// send payment via nwc
|
||||
const send: SendPaymentResponse = await nwc.current.sendPayment(res);
|
||||
|
||||
if (send) {
|
||||
await sendNativeNotification(
|
||||
`You've tipped ${compactNumber.format(send.amount)} sats to ${
|
||||
user?.name || user?.display_name || user?.displayName
|
||||
}`,
|
||||
);
|
||||
|
||||
// eose
|
||||
nwc.current.close();
|
||||
setIsCompleted(true);
|
||||
setIsLoading(false);
|
||||
|
||||
// reset after 3 secs
|
||||
const timeout = setTimeout(() => setIsCompleted(false), 3000);
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
} catch (e) {
|
||||
nwc.current.close();
|
||||
setIsLoading(false);
|
||||
await message(JSON.stringify(e), { title: "Zap", type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function getWalletConnectURL() {
|
||||
const uri: string = await invoke("secure_load", {
|
||||
key: `${storage.account.pubkey}-nwc`,
|
||||
});
|
||||
if (uri) setWalletConnectURL(uri);
|
||||
}
|
||||
|
||||
if (isOpen) getWalletConnectURL();
|
||||
|
||||
return () => {
|
||||
setAmount("21");
|
||||
setZapMessage("");
|
||||
setIsCompleted(false);
|
||||
setIsLoading(false);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
|
||||
>
|
||||
<ZapIcon className="h-5 w-5 group-hover:text-blue-500" />
|
||||
</button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/20 backdrop-blur-sm dark:bg-black/20" />
|
||||
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
||||
<div className="relative h-min w-full max-w-xl rounded-xl bg-white dark:bg-black">
|
||||
<div className="inline-flex w-full shrink-0 items-center justify-between px-5 py-3">
|
||||
<div className="w-6" />
|
||||
<Dialog.Title className="text-center font-semibold">
|
||||
Send tip to{" "}
|
||||
{user?.name ||
|
||||
user?.displayName ||
|
||||
displayNpub(event.pubkey, 16)}
|
||||
</Dialog.Title>
|
||||
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md bg-neutral-100 dark:bg-neutral-900">
|
||||
<CancelIcon className="h-4 w-4" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
<div className="overflow-y-auto overflow-x-hidden px-5 pb-5">
|
||||
{!invoice ? (
|
||||
<>
|
||||
<div className="relative flex h-40 flex-col">
|
||||
<div className="inline-flex h-full flex-1 items-center justify-center gap-1">
|
||||
<CurrencyInput
|
||||
placeholder="0"
|
||||
defaultValue={"21"}
|
||||
value={amount}
|
||||
decimalsLimit={2}
|
||||
min={0} // 0 sats
|
||||
max={10000} // 1M sats
|
||||
maxLength={10000} // 1M sats
|
||||
onValueChange={(value) => setAmount(value)}
|
||||
className="w-full flex-1 border-none bg-transparent text-right text-4xl font-semibold placeholder:text-neutral-600 focus:outline-none focus:ring-0 dark:text-neutral-400"
|
||||
/>
|
||||
<span className="w-full flex-1 text-left text-4xl font-semibold text-neutral-600 dark:text-neutral-400">
|
||||
sats
|
||||
</span>
|
||||
</div>
|
||||
<div className="inline-flex items-center justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAmount("69")}
|
||||
className="w-max rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
>
|
||||
69 sats
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAmount("100")}
|
||||
className="w-max rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
>
|
||||
100 sats
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAmount("200")}
|
||||
className="w-max rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
>
|
||||
200 sats
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAmount("500")}
|
||||
className="w-max rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
>
|
||||
500 sats
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAmount("1000")}
|
||||
className="w-max rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
>
|
||||
1K sats
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex w-full flex-col gap-2">
|
||||
<input
|
||||
name="zapMessage"
|
||||
value={zapMessage}
|
||||
onChange={(e) => setZapMessage(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
placeholder="Enter message (optional)"
|
||||
className="w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-3 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-400"
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
{walletConnectURL ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => createZapRequest()}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 px-4 font-medium text-white hover:bg-blue-600"
|
||||
>
|
||||
{isCompleted ? (
|
||||
<p className="leading-tight">Successfully zapped</p>
|
||||
) : isLoading ? (
|
||||
<span className="flex flex-col">
|
||||
<p className="leading-tight">
|
||||
Waiting for approval
|
||||
</p>
|
||||
<p className="text-xs leading-tight text-neutral-100">
|
||||
Go to your wallet and approve payment request
|
||||
</p>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex flex-col">
|
||||
<p className="leading-tight">Send zap</p>
|
||||
<p className="text-xs leading-tight text-neutral-100">
|
||||
You're using nostr wallet connect
|
||||
</p>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => createZapRequest()}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 px-4 font-medium text-white hover:bg-blue-600"
|
||||
>
|
||||
Create Lightning invoice
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="mt-3 flex flex-col items-center justify-center gap-4">
|
||||
<div className="rounded-md bg-neutral-100 p-3 dark:bg-neutral-900">
|
||||
<QRCodeSVG value={invoice} size={256} />
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<h3 className="text-lg font-medium">Scan to zap</h3>
|
||||
<span className="text-center text-sm text-neutral-600 dark:text-neutral-400">
|
||||
You must use Bitcoin wallet which support Lightning
|
||||
<br />
|
||||
such as: Blue Wallet, Bitkit, Phoenix,...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { useEvent } from '@libs/ark';
|
||||
import { useEvent } from '../../hooks/useEvent';
|
||||
import { NoteChildUser } from './childUser';
|
||||
|
||||
export function NoteChild({ eventId, isRoot }: { eventId: string; isRoot?: boolean }) {
|
||||
@ -27,7 +27,7 @@ export function NoteChild({ eventId, isRoot }: { eventId: string; isRoot?: boole
|
||||
return (
|
||||
<div className="relative flex gap-3">
|
||||
<div className="relative flex-1 rounded-md bg-neutral-200 px-2 py-2 dark:bg-neutral-800">
|
||||
<div className="absolute right-0 top-[18px] h-3 w-3 -translate-y-1/2 translate-x-1/2 rotate-45 transform bg-neutral-200 dark:bg-neutral-800"></div>
|
||||
<div className="absolute right-0 top-[18px] h-3 w-3 -translate-y-1/2 translate-x-1/2 rotate-45 transform bg-neutral-200 dark:bg-neutral-800" />
|
||||
<div className="break-p mt-6 line-clamp-3 select-text leading-normal text-neutral-900 dark:text-neutral-100">
|
||||
{data.content}
|
||||
</div>
|
@ -1,13 +1,13 @@
|
||||
import { displayNpub } from '@lume/utils';
|
||||
import * as Avatar from '@radix-ui/react-avatar';
|
||||
import { minidenticon } from 'minidenticons';
|
||||
import { useMemo } from 'react';
|
||||
import { useProfile } from '@libs/ark';
|
||||
import { displayNpub } from '@utils/formater';
|
||||
import { useProfile } from '../../hooks/useProfile';
|
||||
|
||||
export function NoteChildUser({ pubkey, subtext }: { pubkey: string; subtext: string }) {
|
||||
const fallbackName = useMemo(() => displayNpub(pubkey, 16), [pubkey]);
|
||||
const fallbackAvatar = useMemo(
|
||||
() => 'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon(pubkey, 90, 50)),
|
||||
() => `data:image/svg+xml;utf8,${encodeURIComponent(minidenticon(pubkey, 90, 50))}`,
|
||||
[pubkey]
|
||||
);
|
||||
|
42
packages/ark/src/components/note/index.ts
Normal file
42
packages/ark/src/components/note/index.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { NotePin } from "./buttons/pin";
|
||||
import { NoteReaction } from "./buttons/reaction";
|
||||
import { NoteReply } from "./buttons/reply";
|
||||
import { NoteRepost } from "./buttons/repost";
|
||||
import { NoteZap } from "./buttons/zap";
|
||||
import { NoteChild } from "./child";
|
||||
import { NoteArticleContent } from "./kinds/article";
|
||||
import { NoteMediaContent } from "./kinds/media";
|
||||
import { NoteTextContent } from "./kinds/text";
|
||||
import { NoteMenu } from "./menu";
|
||||
import { NoteReplies } from "./reply";
|
||||
import { NoteRoot } from "./root";
|
||||
import { NoteThread } from "./thread";
|
||||
import { NoteUser } from "./user";
|
||||
|
||||
export const Note = {
|
||||
Root: NoteRoot,
|
||||
User: NoteUser,
|
||||
Menu: NoteMenu,
|
||||
Reply: NoteReply,
|
||||
Repost: NoteRepost,
|
||||
Reaction: NoteReaction,
|
||||
Zap: NoteZap,
|
||||
Pin: NotePin,
|
||||
Child: NoteChild,
|
||||
Thread: NoteThread,
|
||||
TextContent: NoteTextContent,
|
||||
MediaContent: NoteMediaContent,
|
||||
ArticleContent: NoteArticleContent,
|
||||
Replies: NoteReplies,
|
||||
};
|
||||
|
||||
export * from "./builds/text";
|
||||
export * from "./builds/repost";
|
||||
export * from "./builds/skeleton";
|
||||
export * from "./preview/image";
|
||||
export * from "./preview/link";
|
||||
export * from "./preview/video";
|
||||
export * from "./mentions/note";
|
||||
export * from "./mentions/user";
|
||||
export * from "./mentions/hashtag";
|
||||
export * from "./mentions/invoice";
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user