rome -> eslint + prettier

This commit is contained in:
Ren Amamiya
2023-07-04 13:24:42 +07:00
parent 744fbd5683
commit a30cf66c2e
187 changed files with 10179 additions and 10066 deletions

49
.eslintrc.js Normal file
View File

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

9
.prettierignore Normal file
View File

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

22
.prettierrc Normal file
View File

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

View File

@@ -1,71 +1,83 @@
{ {
"name": "lume", "name": "lume",
"private": true, "private": true,
"version": "1.0.0", "version": "1.0.0",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"tauri": "tauri", "tauri": "tauri",
"add-migrate": "cd src-tauri/ && sqlx migrate add", "add-migrate": "cd src-tauri/ && sqlx migrate add",
"prepare": "husky install" "prepare": "husky install",
}, "lint": "eslint ./src --fix",
"lint-staged": { "format": "prettier ./src --write"
"**/*.{js,ts,jsx,tsx}": "rome check --apply" },
}, "lint-staged": {
"dependencies": { "src/*.{ts, tsx}": "eslint --fix",
"@floating-ui/react": "^0.23.1", "src/*.{ts, tsx, css, md, html, json}": "prettier --cache --write"
"@headlessui/react": "^1.7.15", },
"@nostr-dev-kit/ndk": "0.6.0", "dependencies": {
"@radix-ui/react-popover": "^1.0.6", "@floating-ui/react": "^0.23.1",
"@radix-ui/react-tooltip": "^1.0.6", "@headlessui/react": "^1.7.15",
"@tanstack/react-query": "^4.29.19", "@nostr-dev-kit/ndk": "0.6.0",
"@tanstack/react-virtual": "3.0.0-beta.54", "@radix-ui/react-popover": "^1.0.6",
"@tauri-apps/api": "^1.4.0", "@radix-ui/react-tooltip": "^1.0.6",
"cheerio": "1.0.0-rc.12", "@tanstack/react-query": "^4.29.19",
"dayjs": "^1.11.8", "@tanstack/react-virtual": "3.0.0-beta.54",
"destr": "^1.2.2", "@tauri-apps/api": "^1.4.0",
"framer-motion": "^10.12.17", "cheerio": "1.0.0-rc.12",
"get-urls": "^11.0.0", "dayjs": "^1.11.8",
"immer": "^10.0.2", "destr": "^1.2.2",
"light-bolt11-decoder": "^3.0.0", "framer-motion": "^10.12.17",
"nostr-tools": "^1.12.1", "get-urls": "^11.0.0",
"react": "^18.2.0", "immer": "^10.0.2",
"react-dom": "^18.2.0", "light-bolt11-decoder": "^3.0.0",
"react-hook-form": "^7.45.1", "nostr-tools": "^1.12.1",
"react-hotkeys-hook": "^4.4.0", "react": "^18.2.0",
"react-player": "^2.12.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.14.0", "react-hook-form": "^7.45.1",
"react-string-replace": "^1.1.1", "react-hotkeys-hook": "^4.4.0",
"react-virtuoso": "^4.3.11", "react-player": "^2.12.0",
"slate": "^0.94.1", "react-router-dom": "^6.14.0",
"slate-history": "^0.93.0", "react-string-replace": "^1.1.1",
"slate-react": "^0.94.2", "react-virtuoso": "^4.3.11",
"tailwind-merge": "^1.13.2", "slate": "^0.94.1",
"tauri-plugin-autostart-api": "github:tauri-apps/tauri-plugin-autostart#v1", "slate-history": "^0.93.0",
"tauri-plugin-sql-api": "github:tauri-apps/tauri-plugin-sql", "slate-react": "^0.94.2",
"zustand": "^4.3.8" "tailwind-merge": "^1.13.2",
}, "tauri-plugin-autostart-api": "github:tauri-apps/tauri-plugin-autostart#v1",
"devDependencies": { "tauri-plugin-sql-api": "github:tauri-apps/tauri-plugin-sql",
"@tailwindcss/typography": "^0.5.9", "zustand": "^4.3.8"
"@tauri-apps/cli": "^1.4.0", },
"@types/node": "^18.16.18", "devDependencies": {
"@types/react": "^18.2.14", "@tailwindcss/typography": "^0.5.9",
"@types/react-dom": "^18.2.6", "@tauri-apps/cli": "^1.4.0",
"@types/youtube-player": "^5.5.7", "@trivago/prettier-plugin-sort-imports": "^4.1.1",
"@vitejs/plugin-react-swc": "^3.3.2", "@types/node": "^18.16.18",
"autoprefixer": "^10.4.14", "@types/react": "^18.2.14",
"cross-env": "^7.0.3", "@types/react-dom": "^18.2.6",
"csstype": "^3.1.2", "@types/youtube-player": "^5.5.7",
"encoding": "^0.1.13", "@typescript-eslint/eslint-plugin": "^5.61.0",
"husky": "^8.0.3", "@typescript-eslint/parser": "^5.61.0",
"lint-staged": "^13.2.3", "@vitejs/plugin-react-swc": "^3.3.2",
"postcss": "^8.4.24", "autoprefixer": "^10.4.14",
"prop-types": "^15.8.1", "cross-env": "^7.0.3",
"rome": "12.1.0", "csstype": "^3.1.2",
"tailwindcss": "^3.3.2", "encoding": "^0.1.13",
"typescript": "^4.9.5", "eslint": "^8.44.0",
"vite": "^4.3.9", "eslint-config-prettier": "^8.8.0",
"vite-plugin-top-level-await": "^1.3.1", "eslint-plugin-jsx-a11y": "^6.7.1",
"vite-tsconfig-paths": "^4.2.0" "eslint-plugin-react": "^7.32.2",
} "eslint-plugin-simple-import-sort": "^10.0.0",
"husky": "^8.0.3",
"lint-staged": "^13.2.3",
"postcss": "^8.4.24",
"prettier": "^2.8.8",
"prettier-plugin-tailwindcss": "^0.3.0",
"prop-types": "^15.8.1",
"tailwindcss": "^3.3.2",
"typescript": "^4.9.5",
"vite": "^4.3.9",
"vite-plugin-top-level-await": "^1.3.1",
"vite-tsconfig-paths": "^4.2.0"
}
} }

698
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +0,0 @@
{
"$schema": "https://docs.rome.tools/schemas/12.1.0/schema.json",
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"a11y": {
"noSvgWithoutTitle": "off"
},
"suspicious": {
"noExplicitAny": "off"
}
}
}
}

View File

@@ -1,102 +1,105 @@
import "./index.css"; import { RouterProvider, createBrowserRouter } from 'react-router-dom';
import { AuthCreateScreen } from "@app/auth/create";
import { CreateStep1Screen } from "@app/auth/create/step-1"; import { AuthCreateScreen } from '@app/auth/create';
import { CreateStep2Screen } from "@app/auth/create/step-2"; import { CreateStep1Screen } from '@app/auth/create/step-1';
import { CreateStep3Screen } from "@app/auth/create/step-3"; import { CreateStep2Screen } from '@app/auth/create/step-2';
import { CreateStep4Screen } from "@app/auth/create/step-4"; import { CreateStep3Screen } from '@app/auth/create/step-3';
import { AuthImportScreen } from "@app/auth/import"; import { CreateStep4Screen } from '@app/auth/create/step-4';
import { ImportStep1Screen } from "@app/auth/import/step-1"; import { AuthImportScreen } from '@app/auth/import';
import { ImportStep2Screen } from "@app/auth/import/step-2"; import { ImportStep1Screen } from '@app/auth/import/step-1';
import { OnboardingScreen } from "@app/auth/onboarding"; import { ImportStep2Screen } from '@app/auth/import/step-2';
import { WelcomeScreen } from "@app/auth/welcome"; import { OnboardingScreen } from '@app/auth/onboarding';
import { ChannelScreen } from "@app/channel"; import { WelcomeScreen } from '@app/auth/welcome';
import { ChatScreen } from "@app/chat"; import { ChannelScreen } from '@app/channel';
import { ErrorScreen } from "@app/error"; import { ChatScreen } from '@app/chat';
import { Root } from "@app/root"; import { ErrorScreen } from '@app/error';
import { AccountSettingsScreen } from "@app/settings/account"; import { Root } from '@app/root';
import { GeneralSettingsScreen } from "@app/settings/general"; import { AccountSettingsScreen } from '@app/settings/account';
import { ShortcutsSettingsScreen } from "@app/settings/shortcuts"; import { GeneralSettingsScreen } from '@app/settings/general';
import { SpaceScreen } from "@app/space"; import { ShortcutsSettingsScreen } from '@app/settings/shortcuts';
import { TrendingScreen } from "@app/trending"; import { SpaceScreen } from '@app/space';
import { UserScreen } from "@app/user"; import { TrendingScreen } from '@app/trending';
import { AppLayout } from "@shared/appLayout"; import { UserScreen } from '@app/user';
import { AuthLayout } from "@shared/authLayout";
import { Protected } from "@shared/protected"; import { AppLayout } from '@shared/appLayout';
import { SettingsLayout } from "@shared/settingsLayout"; import { AuthLayout } from '@shared/authLayout';
import { RouterProvider, createBrowserRouter } from "react-router-dom"; import { Protected } from '@shared/protected';
import { SettingsLayout } from '@shared/settingsLayout';
import './index.css';
const router = createBrowserRouter([ const router = createBrowserRouter([
{ {
path: "/", path: '/',
element: ( element: (
<Protected> <Protected>
<Root /> <Root />
</Protected> </Protected>
), ),
errorElement: <ErrorScreen />, errorElement: <ErrorScreen />,
}, },
{ {
path: "/auth", path: '/auth',
element: <AuthLayout />, element: <AuthLayout />,
children: [ children: [
{ path: "welcome", element: <WelcomeScreen /> }, { path: 'welcome', element: <WelcomeScreen /> },
{ path: "onboarding", element: <OnboardingScreen /> }, { path: 'onboarding', element: <OnboardingScreen /> },
{ {
path: "import", path: 'import',
element: <AuthImportScreen />, element: <AuthImportScreen />,
children: [ children: [
{ path: "", element: <ImportStep1Screen /> }, { path: '', element: <ImportStep1Screen /> },
{ path: "step-2", element: <ImportStep2Screen /> }, { path: 'step-2', element: <ImportStep2Screen /> },
], ],
}, },
{ {
path: "create", path: 'create',
element: <AuthCreateScreen />, element: <AuthCreateScreen />,
children: [ children: [
{ path: "", element: <CreateStep1Screen /> }, { path: '', element: <CreateStep1Screen /> },
{ path: "step-2", element: <CreateStep2Screen /> }, { path: 'step-2', element: <CreateStep2Screen /> },
{ path: "step-3", element: <CreateStep3Screen /> }, { path: 'step-3', element: <CreateStep3Screen /> },
{ path: "step-4", element: <CreateStep4Screen /> }, { path: 'step-4', element: <CreateStep4Screen /> },
], ],
}, },
], ],
}, },
{ {
path: "/app", path: '/app',
element: ( element: (
<Protected> <Protected>
<AppLayout /> <AppLayout />
</Protected> </Protected>
), ),
children: [ children: [
{ path: "space", element: <SpaceScreen /> }, { path: 'space', element: <SpaceScreen /> },
{ path: "trending", element: <TrendingScreen /> }, { path: 'trending', element: <TrendingScreen /> },
{ path: "user/:pubkey", element: <UserScreen /> }, { path: 'user/:pubkey', element: <UserScreen /> },
{ path: "chat/:pubkey", element: <ChatScreen /> }, { path: 'chat/:pubkey', element: <ChatScreen /> },
{ path: "channel/:id", element: <ChannelScreen /> }, { path: 'channel/:id', element: <ChannelScreen /> },
], ],
}, },
{ {
path: "/settings", path: '/settings',
element: ( element: (
<Protected> <Protected>
<SettingsLayout /> <SettingsLayout />
</Protected> </Protected>
), ),
children: [ children: [
{ path: "general", element: <GeneralSettingsScreen /> }, { path: 'general', element: <GeneralSettingsScreen /> },
{ path: "shortcuts", element: <ShortcutsSettingsScreen /> }, { path: 'shortcuts', element: <ShortcutsSettingsScreen /> },
{ path: "account", element: <AccountSettingsScreen /> }, { path: 'account', element: <AccountSettingsScreen /> },
], ],
}, },
]); ]);
export default function App() { export default function App() {
return ( return (
<RouterProvider <RouterProvider
router={router} router={router}
fallbackElement={<p>Loading..</p>} fallbackElement={<p>Loading..</p>}
future={{ v7_startTransition: true }} future={{ v7_startTransition: true }}
/> />
); );
} }

View File

@@ -1,44 +1,43 @@
import { Image } from "@shared/image"; import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from "@stores/constants";
import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey";
export function User({ import { DEFAULT_AVATAR } from '@stores/constants';
pubkey,
fallback,
}: { pubkey: string; fallback?: string }) {
const { status, user } = useProfile(pubkey, fallback);
if (status === "loading") { import { useProfile } from '@utils/hooks/useProfile';
return ( import { shortenKey } from '@utils/shortenKey';
<div className="flex items-center gap-2">
<div className="relative h-10 w-10 shrink-0 rounded-md bg-zinc-800 animate-pulse" />
<div className="flex w-full flex-1 flex-col items-start gap-1 text-start">
<span className="w-1/2 h-4 rounded bg-zinc-800 animate-pulse" />
<span className="w-1/3 h-3 rounded bg-zinc-800 animate-pulse" />
</div>
</div>
);
}
return ( export function User({ pubkey, fallback }: { pubkey: string; fallback?: string }) {
<div className="flex items-center gap-2"> const { status, user } = useProfile(pubkey, fallback);
<div className="relative h-10 w-10 shrink rounded-md">
<Image if (status === 'loading') {
src={user.picture || user.image} return (
fallback={DEFAULT_AVATAR} <div className="flex items-center gap-2">
alt={pubkey} <div className="relative h-10 w-10 shrink-0 animate-pulse rounded-md bg-zinc-800" />
className="h-10 w-10 rounded-md object-cover" <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-zinc-800" />
</div> <span className="h-3 w-1/3 animate-pulse rounded bg-zinc-800" />
<div className="flex w-full flex-1 flex-col items-start text-start"> </div>
<span className="truncate font-medium leading-tight text-zinc-100"> </div>
{user.name || user.displayName || user.display_name} );
</span> }
<span className="text-base leading-tight text-zinc-400">
{user.nip05?.toLowerCase() || shortenKey(pubkey)} return (
</span> <div className="flex items-center gap-2">
</div> <div className="relative h-10 w-10 shrink rounded-md">
</div> <Image
); src={user.picture || user.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className="h-10 w-10 rounded-md object-cover"
/>
</div>
<div className="flex w-full flex-1 flex-col items-start text-start">
<span className="truncate font-medium leading-tight text-zinc-100">
{user.name || user.displayName || user.display_name}
</span>
<span className="text-base leading-tight text-zinc-400">
{user.nip05?.toLowerCase() || shortenKey(pubkey)}
</span>
</div>
</div>
);
} }

View File

@@ -1,9 +1,9 @@
import { Outlet } from "react-router-dom"; import { Outlet } from 'react-router-dom';
export function AuthCreateScreen() { export function AuthCreateScreen() {
return ( return (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
<Outlet /> <Outlet />
</div> </div>
); );
} }

View File

@@ -1,114 +1,118 @@
import { createAccount, createBlock } from "@libs/storage"; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from "@shared/button"; import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from "@shared/icons"; import { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useNavigate } from 'react-router-dom';
import { generatePrivateKey, getPublicKey, nip19 } from "nostr-tools";
import { useMemo, useState } from "react"; import { createAccount } from '@libs/storage';
import { useNavigate } from "react-router-dom";
import { Button } from '@shared/button';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
export function CreateStep1Screen() { export function CreateStep1Screen() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [type, setType] = useState("password"); const [type, setType] = useState('password');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const privkey = useMemo(() => generatePrivateKey(), []); const privkey = useMemo(() => generatePrivateKey(), []);
const pubkey = getPublicKey(privkey); const pubkey = getPublicKey(privkey);
const npub = nip19.npubEncode(pubkey); const npub = nip19.npubEncode(pubkey);
const nsec = nip19.nsecEncode(privkey); const nsec = nip19.nsecEncode(privkey);
// toggle private key // toggle private key
const showPrivateKey = () => { const showPrivateKey = () => {
if (type === "password") { if (type === 'password') {
setType("text"); setType('text');
} else { } else {
setType("password"); setType('password');
} }
}; };
const account = useMutation({ const account = useMutation({
mutationFn: (data: any) => { mutationFn: (data: {
return createAccount(data.npub, data.pubkey, data.privkey, null, 1); npub: string;
}, pubkey: string;
onSuccess: (data: any) => { privkey: string;
queryClient.setQueryData(["currentAccount"], data); follows: null | string[][];
}, is_active: number;
}); }) => {
return createAccount(data.npub, data.pubkey, data.privkey, null, 1);
},
onSuccess: (data) => {
queryClient.setQueryData(['currentAccount'], data);
},
});
const submit = () => { const submit = () => {
setLoading(true); setLoading(true);
account.mutate({ account.mutate({
npub, npub,
pubkey, pubkey,
privkey, privkey,
follows: null, follows: null,
is_active: 1, is_active: 1,
}); });
// redirect to next step // redirect to next step
setTimeout(() => navigate("/auth/create/step-2", { replace: true }), 1200); setTimeout(() => navigate('/auth/create/step-2', { replace: true }), 1200);
}; };
return ( return (
<div className="mx-auto w-full max-w-md"> <div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center"> <div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100"> <h1 className="text-xl font-semibold text-zinc-100">
Lume is auto-generated key for you Lume is auto-generated key for you
</h1> </h1>
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-base font-semibold text-zinc-400"> <span className="text-base font-semibold text-zinc-400">Public Key</span>
Public Key <input
</label> readOnly
<input value={npub}
readOnly className="relative w-full rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-zinc-100 !outline-none placeholder:text-zinc-400"
value={npub} />
className="relative w-full rounded-lg py-3 pl-3.5 pr-11 !outline-none placeholder:text-zinc-400 bg-zinc-800 text-zinc-100" </div>
/> <div className="flex flex-col gap-1">
</div> <span className="text-base font-semibold text-zinc-400">Private Key</span>
<div className="flex flex-col gap-1"> <div className="relative">
<label className="text-base font-semibold text-zinc-400"> <input
Private Key readOnly
</label> type={type}
<div className="relative"> value={nsec}
<input className="relative w-full rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-zinc-100 !outline-none placeholder:text-zinc-400"
readOnly />
type={type} <button
value={nsec} type="button"
className="relative w-full rounded-lg py-3 pl-3.5 pr-11 !outline-none placeholder:text-zinc-400 bg-zinc-800 text-zinc-100" onClick={() => showPrivateKey()}
/> className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
<button >
type="button" {type === 'password' ? (
onClick={() => showPrivateKey()} <EyeOffIcon
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700" width={20}
> height={20}
{type === "password" ? ( className="text-zinc-500 group-hover:text-zinc-100"
<EyeOffIcon />
width={20} ) : (
height={20} <EyeOnIcon
className="text-zinc-500 group-hover:text-zinc-100" width={20}
/> height={20}
) : ( className="text-zinc-500 group-hover:text-zinc-100"
<EyeOnIcon />
width={20} )}
height={20} </button>
className="text-zinc-500 group-hover:text-zinc-100" </div>
/> </div>
)} <Button preset="large" onClick={() => submit()}>
</button> {loading ? (
</div> <LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
</div> ) : (
<Button preset="large" onClick={() => submit()}> 'Continue →'
{loading ? ( )}
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" /> </Button>
) : ( </div>
"Continue →" </div>
)} );
</Button>
</div>
</div>
);
} }

View File

@@ -1,146 +1,152 @@
import { AvatarUploader } from "@shared/avatarUploader"; import { useState } from 'react';
import { BannerUploader } from "@shared/bannerUploader"; import { useForm } from 'react-hook-form';
import { LoaderIcon } from "@shared/icons"; import { useNavigate } from 'react-router-dom';
import { Image } from "@shared/image";
import { DEFAULT_AVATAR } from "@stores/constants"; import { AvatarUploader } from '@shared/avatarUploader';
import { useOnboarding } from "@stores/onboarding"; import { BannerUploader } from '@shared/bannerUploader';
import { useState } from "react"; import { LoaderIcon } from '@shared/icons';
import { useForm } from "react-hook-form"; import { Image } from '@shared/image';
import { useNavigate } from "react-router-dom";
import { DEFAULT_AVATAR } from '@stores/constants';
import { useOnboarding } from '@stores/onboarding';
export function CreateStep2Screen() { export function CreateStep2Screen() {
const navigate = useNavigate(); const navigate = useNavigate();
const createProfile = useOnboarding((state: any) => state.createProfile); const createProfile = useOnboarding((state: any) => state.createProfile);
const [picture, setPicture] = useState(DEFAULT_AVATAR); const [picture, setPicture] = useState(DEFAULT_AVATAR);
const [banner, setBanner] = useState(""); const [banner, setBanner] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { const {
register, register,
handleSubmit, handleSubmit,
formState: { isDirty, isValid }, formState: { isDirty, isValid },
} = useForm(); } = useForm();
const onSubmit = (data: any) => { const onSubmit = (data: any) => {
setLoading(true); setLoading(true);
try { try {
const profile = { const profile = {
...data, ...data,
username: data.name, username: data.name,
display_name: data.name, display_name: data.name,
bio: data.about, bio: data.about,
}; };
createProfile(profile); createProfile(profile);
// redirect to next step // redirect to next step
setTimeout( setTimeout(() => navigate('/auth/create/step-3', { replace: true }), 1200);
() => navigate("/auth/create/step-3", { replace: true }), } catch {
1200, console.log('error');
); }
} catch { };
console.log("error");
}
};
return ( return (
<div className="mx-auto w-full max-w-md"> <div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center"> <div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100"> <h1 className="text-xl font-semibold text-zinc-100">Create your profile</h1>
Create your profile </div>
</h1> <div className="w-full overflow-hidden rounded-xl border-t border-zinc-800/50 bg-zinc-900">
</div> <form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col">
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900 overflow-hidden"> <input
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col mb-0"> type={'hidden'}
<input {...register('picture')}
type={"hidden"} value={picture}
{...register("picture")} className="shadow-input relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500"
value={picture} />
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500" <input
/> type={'hidden'}
<input {...register('banner')}
type={"hidden"} value={banner}
{...register("banner")} className="shadow-input relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500"
value={banner} />
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500" <div className="relative">
/> <div className="relative h-44 w-full bg-zinc-800">
<div className="relative"> <Image
<div className="relative w-full h-44 bg-zinc-800"> src={banner}
<Image fallback="https://void.cat/d/QY1myro5tkHVs2nY7dy74b.jpg"
src={banner} alt="user's banner"
fallback="https://void.cat/d/QY1myro5tkHVs2nY7dy74b.jpg" className="h-full w-full object-cover"
alt="user's banner" />
className="h-full w-full 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">
/> <BannerUploader setBanner={setBanner} />
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-10 w-full h-full"> </div>
<BannerUploader setBanner={setBanner} /> </div>
</div> <div className="mb-5 px-4">
</div> <div className="relative z-10 -mt-7 h-14 w-14">
<div className="px-4 mb-5"> <Image
<div className="z-10 relative h-14 w-14 -mt-7"> src={picture}
<Image fallback={DEFAULT_AVATAR}
src={picture} alt="user's avatar"
fallback={DEFAULT_AVATAR} className="h-14 w-14 rounded-lg object-cover ring-2 ring-zinc-900"
alt="user's avatar" />
className="h-14 w-14 object-cover ring-2 ring-zinc-900 rounded-lg" <div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
/> <AvatarUploader setPicture={setPicture} />
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-10 w-full h-full"> </div>
<AvatarUploader setPicture={setPicture} /> </div>
</div> </div>
</div> </div>
</div> <div className="flex flex-col gap-4 px-4 pb-4">
</div> <div className="flex flex-col gap-1">
<div className="flex flex-col gap-4 px-4 pb-4"> <label
<div className="flex flex-col gap-1"> htmlFor="name"
<label className="text-sm font-semibold uppercase tracking-wider text-zinc-400"> className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
Name * >
</label> Name *
<input </label>
type={"text"} <input
{...register("name", { type={'text'}
required: true, {...register('name', {
minLength: 4, required: true,
})} minLength: 4,
spellCheck={false} })}
className="relative h-10 w-full rounded-lg px-3 py-2 !outline-none bg-zinc-800 text-zinc-100 placeholder:text-zinc-500" spellCheck={false}
/> className="relative h-10 w-full rounded-lg bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
</div> />
<div className="flex flex-col gap-1"> </div>
<label className="text-sm font-semibold uppercase tracking-wider text-zinc-400"> <div className="flex flex-col gap-1">
Bio <label
</label> htmlFor="about"
<textarea className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
{...register("about")} >
spellCheck={false} Bio
className="resize-none relative h-20 w-full rounded-lg px-3 py-2 !outline-none bg-zinc-800 text-zinc-100 placeholder:text-zinc-500" </label>
/> <textarea
</div> {...register('about')}
<div className="flex flex-col gap-1"> spellCheck={false}
<label className="text-sm font-semibold uppercase tracking-wider text-zinc-400"> className="relative h-20 w-full resize-none rounded-lg bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
Website />
</label> </div>
<input <div className="flex flex-col gap-1">
type={"text"} <label
{...register("website", { htmlFor="website"
required: false, className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
})} >
spellCheck={false} Website
className="relative h-10 w-full rounded-lg px-3 py-2 !outline-none bg-zinc-800 text-zinc-100 placeholder:text-zinc-500" </label>
/> <input
</div> type={'text'}
<button {...register('website', {
type="submit" required: false,
disabled={!isDirty || !isValid} })}
className="inline-flex items-center justify-center h-11 w-full bg-fuchsia-500 rounded-md font-medium text-zinc-100 hover:bg-fuchsia-600" spellCheck={false}
> className="relative h-10 w-full rounded-lg bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
{loading ? ( />
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" /> </div>
) : ( <button
"Continue →" type="submit"
)} disabled={!isDirty || !isValid}
</button> className="inline-flex h-11 w-full items-center justify-center rounded-md bg-fuchsia-500 font-medium text-zinc-100 hover:bg-fuchsia-600"
</div> >
</form> {loading ? (
</div> <LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
</div> ) : (
); 'Continue →'
)}
</button>
</div>
</form>
</div>
</div>
);
} }

View File

@@ -1,99 +1,98 @@
import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk"; import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { Button } from "@shared/button"; import { Body, fetch } from '@tauri-apps/api/http';
import { LoaderIcon } from "@shared/icons"; import { useContext, useState } from 'react';
import { RelayContext } from "@shared/relayProvider"; import { useNavigate } from 'react-router-dom';
import { useOnboarding } from "@stores/onboarding";
import { Body, fetch } from "@tauri-apps/api/http"; import { Button } from '@shared/button';
import { useAccount } from "@utils/hooks/useAccount"; import { LoaderIcon } from '@shared/icons';
import { useContext, useState } from "react"; import { RelayContext } from '@shared/relayProvider';
import { useNavigate } from "react-router-dom";
import { useOnboarding } from '@stores/onboarding';
import { useAccount } from '@utils/hooks/useAccount';
export function CreateStep3Screen() { export function CreateStep3Screen() {
const ndk = useContext(RelayContext); const ndk = useContext(RelayContext);
const profile = useOnboarding((state: any) => state.profile); const profile = useOnboarding((state: any) => state.profile);
const navigate = useNavigate(); const navigate = useNavigate();
const { account } = useAccount(); const { account } = useAccount();
const [username, setUsername] = useState(""); const [username, setUsername] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const createNIP05 = async () => { const createNIP05 = async () => {
try { try {
setLoading(true); setLoading(true);
const response = await fetch("https://lume.nu/api/user-create", { const response = await fetch('https://lume.nu/api/user-create', {
method: "POST", method: 'POST',
timeout: 30, timeout: 30,
headers: { headers: {
"Content-Type": "application/json; charset=utf-8", 'Content-Type': 'application/json; charset=utf-8',
}, },
body: Body.json({ body: Body.json({
username: username, username: username,
pubkey: account.pubkey, pubkey: account.pubkey,
lightningAddress: "", lightningAddress: '',
}), }),
}); });
if (response.ok) { if (response.ok) {
const data = { ...profile, nip05: `${username}@lume.nu` }; const data = { ...profile, nip05: `${username}@lume.nu` };
const signer = new NDKPrivateKeySigner(account.privkey); const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer; ndk.signer = signer;
const event = new NDKEvent(ndk); const event = new NDKEvent(ndk);
// build event // build event
event.content = JSON.stringify(data); event.content = JSON.stringify(data);
event.kind = 0; event.kind = 0;
event.pubkey = account.pubkey; event.pubkey = account.pubkey;
event.tags = []; event.tags = [];
// publish event // publish event
event.publish(); event.publish();
// redirect to step 4 // redirect to step 4
navigate("/auth/create/step-4", { replace: true }); navigate('/auth/create/step-4', { replace: true });
} }
} catch (error) { } catch (error) {
setLoading(false); setLoading(false);
console.error("Error:", error); console.error('Error:', error);
} }
}; };
return ( return (
<div className="mx-auto w-full max-w-md"> <div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center"> <div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100"> <h1 className="text-xl font-semibold text-zinc-100">Create your Lume ID</h1>
Create your Lume ID </div>
</h1> <div className="flex w-full flex-col items-center justify-center gap-4">
</div> <div className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-zinc-800">
<div className="w-full flex flex-col justify-center items-center gap-4"> <input
<div className="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-zinc-800"> type="text"
<input value={username}
type="text" onChange={(e) => setUsername(e.target.value)}
value={username} autoCapitalize="false"
onChange={(e) => setUsername(e.target.value)} autoCorrect="none"
autoCapitalize="false" spellCheck="false"
autoCorrect="none" placeholder="satoshi"
spellCheck="false" className="relative w-full bg-transparent py-3 pl-3.5 text-zinc-100 !outline-none placeholder:text-zinc-500"
placeholder="satoshi" />
className="relative w-full py-3 pl-3.5 !outline-none placeholder:text-zinc-500 bg-transparent text-zinc-100" <span className="pr-3.5 font-semibold text-fuchsia-500">@lume.nu</span>
/> </div>
<span className="text-fuchsia-500 font-semibold pr-3.5"> <Button
@lume.nu preset="large"
</span> onClick={() => createNIP05()}
</div> disabled={username.length === 0}
<Button >
preset="large" {loading ? (
onClick={() => createNIP05()} <LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
disabled={username.length === 0} ) : (
> 'Continue →'
{loading ? ( )}
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" /> </Button>
) : ( </div>
"Continue →" </div>
)} );
</Button>
</div>
</div>
);
} }

View File

@@ -1,236 +1,235 @@
import { User } from "@app/auth/components/user"; import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { updateAccount } from "@libs/storage"; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk"; import { useContext, useState } from 'react';
import { CheckCircleIcon, LoaderIcon } from "@shared/icons"; import { useNavigate } from 'react-router-dom';
import { RelayContext } from "@shared/relayProvider";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { User } from '@app/auth/components/user';
import { useAccount } from "@utils/hooks/useAccount";
import { arrayToNIP02 } from "@utils/transform"; import { updateAccount } from '@libs/storage';
import { useContext, useState } from "react";
import { useNavigate } from "react-router-dom"; import { CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { RelayContext } from '@shared/relayProvider';
import { useAccount } from '@utils/hooks/useAccount';
import { arrayToNIP02 } from '@utils/transform';
const INITIAL_LIST = [ const INITIAL_LIST = [
{ {
pubkey: "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2", pubkey: '82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2',
}, },
{ {
pubkey: "a341f45ff9758f570a21b000c17d4e53a3a497c8397f26c0e6d61e5acffc7a98", pubkey: 'a341f45ff9758f570a21b000c17d4e53a3a497c8397f26c0e6d61e5acffc7a98',
}, },
{ {
pubkey: "04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9", pubkey: '04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9',
}, },
{ {
pubkey: "c4eabae1be3cf657bc1855ee05e69de9f059cb7a059227168b80b89761cbc4e0", pubkey: 'c4eabae1be3cf657bc1855ee05e69de9f059cb7a059227168b80b89761cbc4e0',
}, },
{ {
pubkey: "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93", pubkey: '6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93',
}, },
{ {
pubkey: "e88a691e98d9987c964521dff60025f60700378a4879180dcbbb4a5027850411", pubkey: 'e88a691e98d9987c964521dff60025f60700378a4879180dcbbb4a5027850411',
}, },
{ {
pubkey: "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", pubkey: '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d',
}, },
{ {
pubkey: "c49d52a573366792b9a6e4851587c28042fb24fa5625c6d67b8c95c8751aca15", pubkey: 'c49d52a573366792b9a6e4851587c28042fb24fa5625c6d67b8c95c8751aca15',
}, },
{ {
pubkey: "e33fe65f1fde44c6dc17eeb38fdad0fceaf1cae8722084332ed1e32496291d42", pubkey: 'e33fe65f1fde44c6dc17eeb38fdad0fceaf1cae8722084332ed1e32496291d42',
}, },
{ {
pubkey: "84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240", pubkey: '84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240',
}, },
{ {
pubkey: "703e26b4f8bc0fa57f99d815dbb75b086012acc24fc557befa310f5aa08d1898", pubkey: '703e26b4f8bc0fa57f99d815dbb75b086012acc24fc557befa310f5aa08d1898',
}, },
{ {
pubkey: "bf2376e17ba4ec269d10fcc996a4746b451152be9031fa48e74553dde5526bce", pubkey: 'bf2376e17ba4ec269d10fcc996a4746b451152be9031fa48e74553dde5526bce',
}, },
{ {
pubkey: "4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0", pubkey: '4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0',
}, },
{ {
pubkey: "c9b19ffcd43e6a5f23b3d27106ce19e4ad2df89ba1031dd4617f1b591e108965", pubkey: 'c9b19ffcd43e6a5f23b3d27106ce19e4ad2df89ba1031dd4617f1b591e108965',
}, },
{ {
pubkey: "c7dccba4fe4426a7b1ea239a5637ba40fab9862c8c86b3330fe65e9f667435f6", pubkey: 'c7dccba4fe4426a7b1ea239a5637ba40fab9862c8c86b3330fe65e9f667435f6',
}, },
{ {
pubkey: "6e1534f56fc9e937e06237c8ba4b5662bcacc4e1a3cfab9c16d89390bec4fca3", pubkey: '6e1534f56fc9e937e06237c8ba4b5662bcacc4e1a3cfab9c16d89390bec4fca3',
}, },
{ {
pubkey: "50d94fc2d8580c682b071a542f8b1e31a200b0508bab95a33bef0855df281d63", pubkey: '50d94fc2d8580c682b071a542f8b1e31a200b0508bab95a33bef0855df281d63',
}, },
{ {
pubkey: "3d2e51508699f98f0f2bdbe7a45b673c687fe6420f466dc296d90b908d51d594", pubkey: '3d2e51508699f98f0f2bdbe7a45b673c687fe6420f466dc296d90b908d51d594',
}, },
{ {
pubkey: "6e3f51664e19e082df5217fd4492bb96907405a0b27028671dd7f297b688608c", pubkey: '6e3f51664e19e082df5217fd4492bb96907405a0b27028671dd7f297b688608c',
}, },
{ {
pubkey: "2edbcea694d164629854a52583458fd6d965b161e3c48b57d3aff01940558884", pubkey: '2edbcea694d164629854a52583458fd6d965b161e3c48b57d3aff01940558884',
}, },
{ {
pubkey: "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24", pubkey: '3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24',
}, },
{ {
pubkey: "eab0e756d32b80bcd464f3d844b8040303075a13eabc3599a762c9ac7ab91f4f", pubkey: 'eab0e756d32b80bcd464f3d844b8040303075a13eabc3599a762c9ac7ab91f4f',
}, },
{ {
pubkey: "be1d89794bf92de5dd64c1e60f6a2c70c140abac9932418fee30c5c637fe9479", pubkey: 'be1d89794bf92de5dd64c1e60f6a2c70c140abac9932418fee30c5c637fe9479',
}, },
{ {
pubkey: "a5e93aef8e820cbc7ab7b6205f854b87aed4b48c5f6b30fbbeba5c99e40dcf3f", pubkey: 'a5e93aef8e820cbc7ab7b6205f854b87aed4b48c5f6b30fbbeba5c99e40dcf3f',
}, },
{ {
pubkey: "1989034e56b8f606c724f45a12ce84a11841621aaf7182a1f6564380b9c4276b", pubkey: '1989034e56b8f606c724f45a12ce84a11841621aaf7182a1f6564380b9c4276b',
}, },
{ {
pubkey: "c48b5cced5ada74db078df6b00fa53fc1139d73bf0ed16de325d52220211dbd5", pubkey: 'c48b5cced5ada74db078df6b00fa53fc1139d73bf0ed16de325d52220211dbd5',
}, },
{ {
pubkey: "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", pubkey: '460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c',
}, },
{ {
pubkey: "7f3b464b9ff3623630485060cbda3a7790131c5339a7803bde8feb79a5e1b06a", pubkey: '7f3b464b9ff3623630485060cbda3a7790131c5339a7803bde8feb79a5e1b06a',
}, },
{ {
pubkey: "b99dbca0184a32ce55904cb267b22e434823c97f418f36daf5d2dff0dd7b5c27", pubkey: 'b99dbca0184a32ce55904cb267b22e434823c97f418f36daf5d2dff0dd7b5c27',
}, },
{ {
pubkey: "e9e4276490374a0daf7759fd5f475deff6ffb9b0fc5fa98c902b5f4b2fe3bba2", pubkey: 'e9e4276490374a0daf7759fd5f475deff6ffb9b0fc5fa98c902b5f4b2fe3bba2',
}, },
{ {
pubkey: "ea2e3c814d08a378f8a5b8faecb2884d05855975c5ca4b5c25e2d6f936286f14", pubkey: 'ea2e3c814d08a378f8a5b8faecb2884d05855975c5ca4b5c25e2d6f936286f14',
}, },
{ {
pubkey: "ff04a0e6cd80c141b0b55825fed127d4532a6eecdb7e743a38a3c28bf9f44609", pubkey: 'ff04a0e6cd80c141b0b55825fed127d4532a6eecdb7e743a38a3c28bf9f44609',
}, },
]; ];
export function CreateStep4Screen() { export function CreateStep4Screen() {
const ndk = useContext(RelayContext); const ndk = useContext(RelayContext);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [follows, setFollows] = useState([]); const [follows, setFollows] = useState([]);
const { account } = useAccount(); const { account } = useAccount();
const { status, data } = useQuery(["trending-profiles"], async () => { const { status, data } = useQuery(['trending-profiles'], async () => {
const res = await fetch("https://api.nostr.band/v0/trending/profiles"); const res = await fetch('https://api.nostr.band/v0/trending/profiles');
if (!res.ok) { if (!res.ok) {
throw new Error("Error"); throw new Error('Error');
} }
return res.json(); return res.json();
}); });
// toggle follow state // toggle follow state
const toggleFollow = (pubkey: string) => { const toggleFollow = (pubkey: string) => {
const arr = follows.includes(pubkey) const arr = follows.includes(pubkey)
? follows.filter((i) => i !== pubkey) ? follows.filter((i) => i !== pubkey)
: [...follows, pubkey]; : [...follows, pubkey];
setFollows(arr); setFollows(arr);
}; };
const update = useMutation({ const update = useMutation({
mutationFn: (follows: any) => { mutationFn: (follows: any) => {
return updateAccount("follows", follows, account.pubkey); return updateAccount('follows', follows, account.pubkey);
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["currentAccount"] }); queryClient.invalidateQueries({ queryKey: ['currentAccount'] });
}, },
}); });
// save follows to database then broadcast // save follows to database then broadcast
const submit = async () => { const submit = async () => {
try { try {
setLoading(true); setLoading(true);
const tags = arrayToNIP02([...follows, account.pubkey]); const tags = arrayToNIP02([...follows, account.pubkey]);
const signer = new NDKPrivateKeySigner(account.privkey); const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer; ndk.signer = signer;
const event = new NDKEvent(ndk); const event = new NDKEvent(ndk);
// build event // build event
event.content = ""; event.content = '';
event.kind = 3; event.kind = 3;
event.pubkey = account.pubkey; event.pubkey = account.pubkey;
event.tags = tags; event.tags = tags;
// publish event // publish event
event.publish(); event.publish();
// update // update
update.mutate([...follows, account.pubkey]); update.mutate([...follows, account.pubkey]);
// redirect to next step // redirect to next step
setTimeout(() => navigate("/auth/onboarding", { replace: true }), 1200); setTimeout(() => navigate('/auth/onboarding', { replace: true }), 1200);
} catch { } catch {
console.log("error"); console.log('error');
} }
}; };
const list = data ? data.profiles.concat(INITIAL_LIST) : []; const list = data ? data.profiles.concat(INITIAL_LIST) : [];
return ( return (
<div className="mx-auto w-full max-w-md"> <div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center"> <div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100"> <h1 className="text-xl font-semibold text-zinc-100">
Personalized your newsfeed Personalized your newsfeed
</h1> </h1>
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900 overflow-hidden"> <div className="w-full overflow-hidden rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="inline-flex h-10 w-full items-center gap-1 border-b border-zinc-800 px-4 text-base font-medium text-zinc-400"> <div className="inline-flex h-10 w-full items-center gap-1 border-b border-zinc-800 px-4 text-base font-medium text-zinc-400">
Follow at least Follow at least
<span className="text-fuchsia-500 font-semibold"> <span className="font-semibold text-fuchsia-500">
{follows.length}/10 {follows.length}/10
</span>{" "} </span>{' '}
plebs plebs
</div> </div>
{status === "loading" ? ( {status === 'loading' ? (
<div className="py-2 px-4 w-full h-11 inline-flex items-center justify-center"> <div className="inline-flex h-11 w-full items-center justify-center px-4 py-2">
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" /> <LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
</div> </div>
) : ( ) : (
<div className="scrollbar-hide flex h-96 flex-col overflow-y-auto py-2"> <div className="scrollbar-hide flex h-96 flex-col overflow-y-auto py-2">
{list.map( {list.map((item: { pubkey: string; profile: { content: string } }) => (
(item: { pubkey: string; profile: { content: string } }) => ( <button
<button key={item.pubkey}
key={item.pubkey} type="button"
type="button" onClick={() => toggleFollow(item.pubkey)}
onClick={() => toggleFollow(item.pubkey)} className="inline-flex transform items-center justify-between bg-zinc-900 px-4 py-2 hover:bg-zinc-800 active:translate-y-1"
className="inline-flex transform items-center justify-between bg-zinc-900 px-4 py-2 hover:bg-zinc-800 active:translate-y-1" >
> <User pubkey={item.pubkey} fallback={item.profile?.content} />
<User {follows.includes(item.pubkey) && (
pubkey={item.pubkey} <div>
fallback={item.profile?.content} <CheckCircleIcon className="h-4 w-4 text-green-400" />
/> </div>
{follows.includes(item.pubkey) && ( )}
<div> </button>
<CheckCircleIcon className="w-4 h-4 text-green-400" /> ))}
</div> </div>
)} )}
</button> </div>
), {follows.length >= 10 && (
)} <button
</div> type="button"
)} onClick={() => submit()}
</div> className="inline-flex h-11 w-full items-center justify-center rounded-md bg-fuchsia-500 font-medium text-zinc-100 hover:bg-fuchsia-600"
{follows.length >= 10 && ( >
<button {loading ? (
type="button" <LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
onClick={() => submit()} ) : (
className="inline-flex items-center justify-center h-11 w-full bg-fuchsia-500 rounded-md font-medium text-zinc-100 hover:bg-fuchsia-600" 'Finish →'
> )}
{loading ? ( </button>
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" /> )}
) : ( </div>
"Finish →" </div>
)} );
</button>
)}
</div>
</div>
);
} }

View File

@@ -1,9 +1,9 @@
import { Outlet } from "react-router-dom"; import { Outlet } from 'react-router-dom';
export function AuthImportScreen() { export function AuthImportScreen() {
return ( return (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
<Outlet /> <Outlet />
</div> </div>
); );
} }

View File

@@ -1,120 +1,119 @@
import { createAccount, createBlock } from "@libs/storage"; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { LoaderIcon } from "@shared/icons"; import { getPublicKey, nip19 } from 'nostr-tools';
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useState } from 'react';
import { getPublicKey, nip19 } from "nostr-tools"; import { Resolver, useForm } from 'react-hook-form';
import { useState } from "react"; import { useNavigate } from 'react-router-dom';
import { Resolver, useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom"; import { createAccount } from '@libs/storage';
import { LoaderIcon } from '@shared/icons';
type FormValues = { type FormValues = {
key: string; key: string;
}; };
const resolver: Resolver<FormValues> = async (values) => { const resolver: Resolver<FormValues> = async (values) => {
return { return {
values: values.key ? values : {}, values: values.key ? values : {},
errors: !values.key errors: !values.key
? { ? {
key: { key: {
type: "required", type: 'required',
message: "This is required.", message: 'This is required.',
}, },
} }
: {}, : {},
}; };
}; };
export function ImportStep1Screen() { export function ImportStep1Screen() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const account = useMutation({ const account = useMutation({
mutationFn: (data: any) => { mutationFn: (data: any) => {
return createAccount(data.npub, data.pubkey, data.privkey, null, 1); return createAccount(data.npub, data.pubkey, data.privkey, null, 1);
}, },
onSuccess: (data: any) => { onSuccess: (data: any) => {
queryClient.setQueryData(["currentAccount"], data); queryClient.setQueryData(['currentAccount'], data);
}, },
}); });
const { const {
register, register,
setError, setError,
handleSubmit, handleSubmit,
formState: { errors, isDirty, isValid }, formState: { errors, isDirty, isValid },
} = useForm<FormValues>({ resolver }); } = useForm<FormValues>({ resolver });
const onSubmit = async (data: any) => { const onSubmit = async (data: any) => {
try { try {
setLoading(true); setLoading(true);
let privkey = data["key"]; let privkey = data['key'];
if (privkey.substring(0, 4) === "nsec") { if (privkey.substring(0, 4) === 'nsec') {
privkey = nip19.decode(privkey).data; privkey = nip19.decode(privkey).data;
} }
if (typeof getPublicKey(privkey) === "string") { if (typeof getPublicKey(privkey) === 'string') {
const pubkey = getPublicKey(privkey); const pubkey = getPublicKey(privkey);
const npub = nip19.npubEncode(pubkey); const npub = nip19.npubEncode(pubkey);
// update // update
account.mutate({ account.mutate({
npub, npub,
pubkey, pubkey,
privkey, privkey,
follows: null, follows: null,
is_active: 1, is_active: 1,
}); });
// redirect to step 2 // redirect to step 2
setTimeout( setTimeout(() => navigate('/auth/import/step-2', { replace: true }), 1200);
() => navigate("/auth/import/step-2", { replace: true }), }
1200, } catch (error) {
); setError('key', {
} type: 'custom',
} catch (error) { message: 'Private Key is invalid, please check again',
setError("key", { });
type: "custom", }
message: "Private Key is invalid, please check again", };
});
}
};
return ( return (
<div className="mx-auto w-full max-w-md"> <div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center"> <div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100">Import your key</h1> <h1 className="text-xl font-semibold text-zinc-100">Import your key</h1>
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3"> <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3">
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<input <input
{...register("key", { required: true, minLength: 32 })} {...register('key', { required: true, minLength: 32 })}
type={"password"} type={'password'}
placeholder="Paste private key here..." placeholder="Paste private key here..."
className="relative w-full rounded-lg px-3 py-3 !outline-none bg-zinc-800 text-zinc-100 placeholder:text-zinc-500" className="relative w-full rounded-lg bg-zinc-800 px-3 py-3 text-zinc-100 !outline-none placeholder:text-zinc-500"
/> />
<span className="text-base text-red-400"> <span className="text-base text-red-400">
{errors.key && <p>{errors.key.message}</p>} {errors.key && <p>{errors.key.message}</p>}
</span> </span>
</div> </div>
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
<button <button
type="submit" type="submit"
disabled={!isDirty || !isValid} disabled={!isDirty || !isValid}
className="inline-flex items-center justify-center h-11 w-full bg-fuchsia-500 rounded-md font-medium text-zinc-100 hover:bg-fuchsia-600" className="inline-flex h-11 w-full items-center justify-center rounded-md bg-fuchsia-500 font-medium text-zinc-100 hover:bg-fuchsia-600"
> >
{loading ? ( {loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" /> <LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : ( ) : (
"Continue →" 'Continue →'
)} )}
</button> </button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,83 +1,87 @@
import { User } from "@app/auth/components/user"; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { updateAccount } from "@libs/storage"; import { useContext, useState } from 'react';
import { Button } from "@shared/button"; import { useNavigate } from 'react-router-dom';
import { LoaderIcon } from "@shared/icons";
import { RelayContext } from "@shared/relayProvider"; import { User } from '@app/auth/components/user';
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useAccount } from "@utils/hooks/useAccount"; import { updateAccount } from '@libs/storage';
import { setToArray } from "@utils/transform";
import { useContext, useState } from "react"; import { Button } from '@shared/button';
import { useNavigate } from "react-router-dom"; import { LoaderIcon } from '@shared/icons';
import { RelayContext } from '@shared/relayProvider';
import { useAccount } from '@utils/hooks/useAccount';
import { setToArray } from '@utils/transform';
export function ImportStep2Screen() { export function ImportStep2Screen() {
const ndk = useContext(RelayContext); const ndk = useContext(RelayContext);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { status, account } = useAccount(); const { status, account } = useAccount();
const update = useMutation({ const update = useMutation({
mutationFn: (follows: any) => { mutationFn: (follows: any) => {
return updateAccount("follows", follows, account.pubkey); return updateAccount('follows', follows, account.pubkey);
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["currentAccount"] }); queryClient.invalidateQueries({ queryKey: ['currentAccount'] });
}, },
}); });
const submit = async () => { const submit = async () => {
try { try {
// show loading indicator // show loading indicator
setLoading(true); setLoading(true);
const user = ndk.getUser({ hexpubkey: account.pubkey }); const user = ndk.getUser({ hexpubkey: account.pubkey });
const follows = await user.follows(); const follows = await user.follows();
// follows as list // follows as list
const followsList = setToArray(follows); const followsList = setToArray(follows);
// update // update
update.mutate([...followsList, account.pubkey]); update.mutate([...followsList, account.pubkey]);
// redirect to next step // redirect to next step
setTimeout(() => navigate("/auth/onboarding", { replace: true }), 1200); setTimeout(() => navigate('/auth/onboarding', { replace: true }), 1200);
} catch { } catch {
console.log("error"); console.log('error');
} }
}; };
return ( return (
<div className="mx-auto w-full max-w-md"> <div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center"> <div className="mb-8 text-center">
<h1 className="text-xl font-semibold"> <h1 className="text-xl font-semibold">
{loading ? "Creating..." : "Continue with"} {loading ? 'Creating...' : 'Continue with'}
</h1> </h1>
</div> </div>
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900 p-4"> <div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900 p-4">
{status === "loading" ? ( {status === 'loading' ? (
<div className="w-full"> <div className="w-full">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-11 w-11 animate-pulse rounded-lg bg-zinc-800" /> <div className="h-11 w-11 animate-pulse rounded-lg bg-zinc-800" />
<div> <div>
<h3 className="mb-1 h-4 w-16 animate-pulse rounded bg-zinc-800" /> <div className="mb-1 h-4 w-16 animate-pulse rounded bg-zinc-800" />
<p className="h-3 w-36 animate-pulse rounded bg-zinc-800" /> <div className="h-3 w-36 animate-pulse rounded bg-zinc-800" />
</div> </div>
</div> </div>
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<User pubkey={account.pubkey} /> <User pubkey={account.pubkey} />
<Button preset="large" onClick={() => submit()}> <Button preset="large" onClick={() => submit()}>
{loading ? ( {loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" /> <LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : ( ) : (
"Continue →" 'Continue →'
)} )}
</Button> </Button>
</div> </div>
)} )}
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,103 +1,103 @@
import { usePublish } from "@libs/ndk"; import { useState } from 'react';
import { LoaderIcon } from "@shared/icons"; import { Link, useNavigate } from 'react-router-dom';
import { ArrowRightCircleIcon } from "@shared/icons/arrowRightCircle";
import { User } from "@shared/user"; import { usePublish } from '@libs/ndk';
import { useAccount } from "@utils/hooks/useAccount";
import { useState } from "react"; import { LoaderIcon } from '@shared/icons';
import { Link, useNavigate } from "react-router-dom"; import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
import { User } from '@shared/user';
import { useAccount } from '@utils/hooks/useAccount';
export function OnboardingScreen() { export function OnboardingScreen() {
const publish = usePublish(); const publish = usePublish();
const navigate = useNavigate(); const navigate = useNavigate();
const { status, account } = useAccount(); const { status, account } = useAccount();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const submit = async () => { const submit = async () => {
try { try {
setLoading(true); setLoading(true);
// publish event // publish event
publish({ publish({
content: content:
"Running Lume, fighting for better future, join us here: https://lume.nu", 'Running Lume, fighting for better future, join us here: https://lume.nu',
kind: 1, kind: 1,
tags: [], tags: [],
}); });
// redirect to home // redirect to home
setTimeout(() => navigate("/", { replace: true }), 1200); setTimeout(() => navigate('/', { replace: true }), 1200);
} catch (error) { } catch (error) {
console.log(error); console.log(error);
} }
}; };
return ( return (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md"> <div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center"> <div className="mb-8 text-center">
<h1 className="mb-2 text-xl font-semibold text-zinc-100"> <h1 className="mb-2 text-xl font-semibold text-zinc-100">
👋 Hello, welcome you to Lume 👋 Hello, welcome you to Lume
</h1> </h1>
<p className="text-sm text-zinc-300"> <p className="text-sm text-zinc-300">
You're a part of better future that we're fighting You&apos;re a part of better future that we&apos;re fighting
</p> </p>
<p className="text-sm text-zinc-300"> <p className="text-sm text-zinc-300">
If Lume gets your attention, please help us spread via button below If Lume gets your attention, please help us spread via button below
</p> </p>
</div> </div>
<div className="w-full border-t border-zinc-800/50 bg-zinc-900 rounded-xl"> <div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="h-min w-full px-5 py-3"> <div className="h-min w-full px-5 py-3">
{status === "success" && ( {status === 'success' && (
<User <User pubkey={account.pubkey} time={Math.floor(Date.now() / 1000)} />
pubkey={account.pubkey} )}
time={Math.floor(Date.now() / 1000)} <div className="-mt-6 select-text whitespace-pre-line break-words pl-[49px] text-base text-zinc-100">
/> <p>Running Lume, fighting for better future</p>
)} <p>
<div className="-mt-6 pl-[49px] select-text whitespace-pre-line break-words text-base text-zinc-100"> join us here:{' '}
<p>Running Lume, fighting for better future</p> <a
<p> href="https://lume.nu"
join us here:{" "} className="font-normal text-fuchsia-500 no-underline hover:text-fuchsia-600"
<a target="_blank"
href="https://lume.nu" rel="noreferrer"
className="text-fuchsia-500 hover:text-fuchsia-600 no-underline font-normal" >
target="_blank" https://lume.nu
rel="noreferrer" </a>
> </p>
https://lume.nu </div>
</a> </div>
</p> </div>
</div> <div className="mt-4 flex w-full flex-col gap-2">
</div> <button
</div> type="button"
<div className="mt-4 w-full flex flex-col gap-2"> onClick={() => submit()}
<button className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium text-zinc-100 hover:bg-fuchsia-600"
type="button" >
onClick={() => submit()} {loading ? (
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg px-6 font-medium text-zinc-100 bg-fuchsia-500 hover:bg-fuchsia-600" <>
> <span className="w-5" />
{loading ? ( <LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
<> <span className="w-5" />
<span className="w-5" /> </>
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" /> ) : (
<span className="w-5" /> <>
</> <span className="w-5" />
) : ( <span>Publish</span>
<> <ArrowRightCircleIcon className="h-5 w-5" />
<span className="w-5" /> </>
<span>Publish</span> )}
<ArrowRightCircleIcon className="w-5 h-5" /> </button>
</> <Link
)} to="/"
</button> className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-lg px-6 text-sm font-medium text-zinc-200"
<Link >
to="/" Skip for now
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-lg px-6 text-sm font-medium text-zinc-200" </Link>
> </div>
Skip for now </div>
</Link> </div>
</div> );
</div>
</div>
);
} }

View File

@@ -1,53 +1,54 @@
import { ArrowRightCircleIcon } from "@shared/icons/arrowRightCircle"; import { Link } from 'react-router-dom';
import { Link } from "react-router-dom";
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
export function WelcomeScreen() { export function WelcomeScreen() {
return ( return (
<div className="w-full h-full grid grid-cols-12 gap-4 px-4 py-4"> <div className="grid h-full w-full grid-cols-12 gap-4 px-4 py-4">
<div className="col-span-5 border-t border-zinc-800/50 bg-zinc-900 rounded-xl flex flex-col"> <div className="col-span-5 flex flex-col rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="w-full h-full flex flex-col justify-center px-4 py-4 gap-2"> <div className="flex h-full w-full flex-col justify-center gap-2 px-4 py-4">
<h1 className="text-zinc-700 text-4xl font-bold leading-none text-transparent"> <h1 className="text-4xl font-bold leading-none text-transparent text-zinc-700">
Preserve your <span className="text-fuchsia-300">freedom</span> Preserve your <span className="text-fuchsia-300">freedom</span>
</h1> </h1>
<h2 className="text-zinc-700 text-4xl font-bold leading-none text-transparent"> <h2 className="text-4xl font-bold leading-none text-transparent text-zinc-700">
Protect your <span className="text-red-300">future</span> Protect your <span className="text-red-300">future</span>
</h2> </h2>
<h3 className="text-zinc-700 text-4xl font-bold leading-none text-transparent"> <h3 className="text-4xl font-bold leading-none text-transparent text-zinc-700">
Stack <span className="text-orange-300">bitcoin</span> Stack <span className="text-orange-300">bitcoin</span>
</h3> </h3>
<h3 className="text-zinc-700 text-4xl font-bold leading-none text-transparent"> <h3 className="text-4xl font-bold leading-none text-transparent text-zinc-700">
Use <span className="text-purple-300">nostr</span> Use <span className="text-purple-300">nostr</span>
</h3> </h3>
</div> </div>
<div className="mt-auto w-full flex flex-col gap-2 px-4 py-4"> <div className="mt-auto flex w-full flex-col gap-2 px-4 py-4">
<Link <Link
to="/auth/import" to="/auth/import"
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg px-6 font-medium text-zinc-100 bg-fuchsia-500 hover:bg-fuchsia-600" className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium text-zinc-100 hover:bg-fuchsia-600"
> >
<span className="w-5" /> <span className="w-5" />
<span>Login with private key</span> <span>Login with private key</span>
<ArrowRightCircleIcon className="w-5 h-5" /> <ArrowRightCircleIcon className="h-5 w-5" />
</Link> </Link>
<Link <Link
to="/auth/create" to="/auth/create"
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-lg px-6 font-medium text-zinc-200 bg-zinc-800 hover:bg-zinc-700" className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-lg bg-zinc-800 px-6 font-medium text-zinc-200 hover:bg-zinc-700"
> >
Create new key Create new key
</Link> </Link>
</div> </div>
</div> </div>
<div <div
className="col-span-5 bg-zinc-900 rounded-xl bg-cover bg-center" className="col-span-5 rounded-xl bg-zinc-900 bg-cover bg-center"
style={{ style={{
backgroundImage: `url("https://void.cat/d/Ps1b36vu5pdkEA2w75usuB")`, backgroundImage: `url("https://void.cat/d/Ps1b36vu5pdkEA2w75usuB")`,
}} }}
/> />
<div <div
className="col-span-2 bg-zinc-900 rounded-xl bg-cover bg-center" className="col-span-2 rounded-xl bg-zinc-900 bg-cover bg-center"
style={{ style={{
backgroundImage: `url("https://void.cat/d/5FdJcBP5ZXKAjYqV8hpcp3")`, backgroundImage: `url("https://void.cat/d/5FdJcBP5ZXKAjYqV8hpcp3")`,
}} }}
/> />
</div> </div>
); );
} }

View File

@@ -1,58 +1,58 @@
import { MutedItem } from "@app/channel/components/mutedItem"; import { Popover, Transition } from '@headlessui/react';
import { Popover, Transition } from "@headlessui/react"; import { Fragment } from 'react';
import { MuteIcon } from "@shared/icons";
import { Fragment } from "react"; import { MutedItem } from '@app/channel/components/mutedItem';
import { MuteIcon } from '@shared/icons';
export function ChannelBlackList({ blacklist }: { blacklist: any }) { export function ChannelBlackList({ blacklist }: { blacklist: any }) {
return ( return (
<Popover className="relative"> <Popover className="relative">
{({ open }) => ( {({ open }) => (
<> <>
<Popover.Button <Popover.Button
className={`group inline-flex h-8 w-8 items-center justify-center rounded-md ring-2 ring-zinc-950 focus:outline-none ${ className={`group inline-flex h-8 w-8 items-center justify-center rounded-md ring-2 ring-zinc-950 focus:outline-none ${
open open ? 'bg-zinc-800 hover:bg-zinc-700' : 'bg-zinc-900 hover:bg-zinc-800'
? "bg-zinc-800 hover:bg-zinc-700" }`}
: "bg-zinc-900 hover:bg-zinc-800" >
}`} <MuteIcon
> width={16}
<MuteIcon height={16}
width={16} className="text-zinc-400 group-hover:text-zinc-100"
height={16} />
className="text-zinc-400 group-hover:text-zinc-100" </Popover.Button>
/> <Transition
</Popover.Button> as={Fragment}
<Transition enter="transition ease-out duration-200"
as={Fragment} enterFrom="opacity-0 translate-y-1"
enter="transition ease-out duration-200" enterTo="opacity-100 translate-y-0"
enterFrom="opacity-0 translate-y-1" leave="transition ease-in duration-150"
enterTo="opacity-100 translate-y-0" leaveFrom="opacity-100 translate-y-0"
leave="transition ease-in duration-150" leaveTo="opacity-0 translate-y-1"
leaveFrom="opacity-100 translate-y-0" >
leaveTo="opacity-0 translate-y-1" <Popover.Panel className="absolute right-0 z-10 mt-1 w-screen max-w-xs transform px-4 sm:px-0">
> <div className="shadow-popover flex flex-col gap-2 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900">
<Popover.Panel className="absolute right-0 z-10 mt-1 w-screen max-w-xs transform px-4 sm:px-0"> <div className="h-min w-full shrink-0 border-b border-zinc-800 p-3">
<div className="flex flex-col gap-2 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900 shadow-popover"> <div className="flex flex-col gap-0.5">
<div className="h-min w-full shrink-0 border-b border-zinc-800 p-3"> <h3 className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text font-semibold leading-none text-transparent">
<div className="flex flex-col gap-0.5"> Your muted list
<h3 className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text font-semibold leading-none text-transparent"> </h3>
Your muted list <p className="text-base leading-tight text-zinc-400">
</h3> Currently, unmute only affect locally, when you move to new client,
<p className="text-base leading-tight text-zinc-400"> muted list will loaded again
Currently, unmute only affect locally, when you move to </p>
new client, muted list will loaded again </div>
</p> </div>
</div> <div className="flex flex-col gap-2 px-3 pb-3 pt-1">
</div> {blacklist.map((item: any) => (
<div className="flex flex-col gap-2 px-3 pb-3 pt-1"> <MutedItem key={item.id} data={item} />
{blacklist.map((item: any) => ( ))}
<MutedItem key={item.id} data={item} /> </div>
))} </div>
</div> </Popover.Panel>
</div> </Transition>
</Popover.Panel> </>
</Transition> )}
</> </Popover>
)} );
</Popover>
);
} }

View File

@@ -1,263 +1,269 @@
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from '@headlessui/react';
import { createChannel } from "@libs/storage"; import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk"; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AvatarUploader } from "@shared/avatarUploader"; import { Fragment, useContext, useEffect, useState } from 'react';
import { CancelIcon, LoaderIcon, PlusIcon } from "@shared/icons"; import { useForm } from 'react-hook-form';
import { Image } from "@shared/image"; import { useNavigate } from 'react-router-dom';
import { RelayContext } from "@shared/relayProvider";
import { DEFAULT_AVATAR } from "@stores/constants"; import { createChannel } from '@libs/storage';
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { dateToUnix } from "@utils/date"; import { AvatarUploader } from '@shared/avatarUploader';
import { useAccount } from "@utils/hooks/useAccount"; import { CancelIcon, LoaderIcon, PlusIcon } from '@shared/icons';
import { Fragment, useContext, useEffect, useState } from "react"; import { Image } from '@shared/image';
import { useForm } from "react-hook-form"; import { RelayContext } from '@shared/relayProvider';
import { useNavigate } from "react-router-dom";
import { DEFAULT_AVATAR } from '@stores/constants';
import { dateToUnix } from '@utils/date';
import { useAccount } from '@utils/hooks/useAccount';
export function ChannelCreateModal() { export function ChannelCreateModal() {
const ndk = useContext(RelayContext); const ndk = useContext(RelayContext);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [image, setImage] = useState(DEFAULT_AVATAR); const [image, setImage] = useState(DEFAULT_AVATAR);
const { account } = useAccount(); const { account } = useAccount();
const closeModal = () => { const closeModal = () => {
setIsOpen(false); setIsOpen(false);
}; };
const openModal = () => { const openModal = () => {
setIsOpen(true); setIsOpen(true);
}; };
const { const {
register, register,
handleSubmit, handleSubmit,
reset, reset,
setValue, setValue,
formState: { isDirty, isValid }, formState: { isDirty, isValid },
} = useForm(); } = useForm();
const addChannel = useMutation({ const addChannel = useMutation({
mutationFn: (event: any) => { mutationFn: (event: any) => {
return createChannel( return createChannel(
event.id, event.id,
event.pubkey, event.pubkey,
event.name, event.name,
event.picture, event.picture,
event.about, event.about,
event.created_at, event.created_at
); );
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["channels"] }); queryClient.invalidateQueries({ queryKey: ['channels'] });
}, },
}); });
const onSubmit = (data: any) => { const onSubmit = (data: any) => {
setLoading(true); setLoading(true);
try { try {
const signer = new NDKPrivateKeySigner(account.privkey); const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer; ndk.signer = signer;
const event = new NDKEvent(ndk); const event = new NDKEvent(ndk);
// build event // build event
event.content = JSON.stringify(data); event.content = JSON.stringify(data);
event.kind = 40; event.kind = 40;
event.created_at = dateToUnix(); event.created_at = dateToUnix();
event.pubkey = account.pubkey; event.pubkey = account.pubkey;
event.tags = []; event.tags = [];
// publish event // publish event
event.publish(); event.publish();
// insert to database // insert to database
addChannel.mutate({ addChannel.mutate({
...event, ...event,
name: data.name, name: data.name,
picture: data.picture, picture: data.picture,
about: data.about, about: data.about,
}); });
// reset form // reset form
reset(); reset();
setTimeout(() => { setTimeout(() => {
// close modal // close modal
setIsOpen(false); setIsOpen(false);
// redirect to channel page // redirect to channel page
navigate(`/app/channel/${event.id}`); navigate(`/app/channel/${event.id}`);
}, 1000); }, 1000);
} catch (e) { } catch (e) {
console.log("error: ", e); console.log('error: ', e);
} }
}; };
useEffect(() => { useEffect(() => {
setValue("picture", image); setValue('picture', image);
}, [setValue, image]); }, [setValue, image]);
return ( return (
<> <>
<button <button
type="button" type="button"
onClick={() => openModal()} onClick={() => openModal()}
className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5" className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5"
> >
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900"> <div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<PlusIcon width={12} height={12} className="text-zinc-500" /> <PlusIcon width={12} height={12} className="text-zinc-500" />
</div> </div>
<div> <div>
<h5 className="font-medium text-zinc-400">Create channel</h5> <h5 className="font-medium text-zinc-400">Create channel</h5>
</div> </div>
</button> </button>
<Transition appear show={isOpen} as={Fragment}> <Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}> <Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" /> <div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child> </Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center"> <div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0 scale-95" enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100" enterTo="opacity-100 scale-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-lg border-t border-zinc-800/50 bg-zinc-900"> <Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-lg border-t border-zinc-800/50 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-5"> <div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-5">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Dialog.Title <Dialog.Title
as="h3" as="h3"
className="text-lg font-semibold leading-none text-zinc-100" className="text-lg font-semibold leading-none text-zinc-100"
> >
Create channel Create channel
</Dialog.Title> </Dialog.Title>
<button <button
type="button" type="button"
onClick={closeModal} onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900" className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
> >
<CancelIcon <CancelIcon width={20} height={20} className="text-zinc-300" />
width={20} </button>
height={20} </div>
className="text-zinc-300" <Dialog.Description className="text-sm leading-tight text-zinc-400">
/> Channels are freedom square, everyone can speech freely, no one can
</button> stop you or deceive what to speech
</div> </Dialog.Description>
<Dialog.Description className="text-sm leading-tight text-zinc-400"> </div>
Channels are freedom square, everyone can speech freely, </div>
no one can stop you or deceive what to speech <div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
</Dialog.Description> <form
</div> onSubmit={handleSubmit(onSubmit)}
</div> className="mb-0 flex h-full w-full flex-col gap-4"
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3"> >
<form <input
onSubmit={handleSubmit(onSubmit)} type={'hidden'}
className="flex h-full w-full flex-col gap-4 mb-0" {...register('picture')}
> value={image}
<input className="shadow-input relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500"
type={"hidden"} />
{...register("picture")} <div className="flex flex-col gap-1">
value={image} <span className="text-sm font-medium uppercase tracking-wider text-zinc-400">
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500" Picture
/> </span>
<div className="flex flex-col gap-1"> <div className="relative inline-flex h-36 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950">
<label className="text-sm font-medium uppercase tracking-wider text-zinc-400"> <Image
Picture src={image}
</label> fallback={DEFAULT_AVATAR}
<div className="relative inline-flex h-36 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950"> alt="channel picture"
<Image className="relative z-10 h-11 w-11 rounded-md"
src={image} />
fallback={DEFAULT_AVATAR} <div className="absolute bottom-3 right-3 z-10">
alt="channel picture" <AvatarUploader setPicture={setImage} />
className="relative z-10 h-11 w-11 rounded-md" </div>
/> </div>
<div className="absolute bottom-3 right-3 z-10"> </div>
<AvatarUploader valueState={setImage} /> <div className="flex flex-col gap-1">
</div> <label
</div> htmlFor="name"
</div> className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
<div className="flex flex-col gap-1"> >
<label className="text-sm font-semibold uppercase tracking-wider text-zinc-400"> Channel name *
Channel name * </label>
</label> <input
<input type={'text'}
type={"text"} {...register('name', {
{...register("name", { required: true,
required: true, minLength: 4,
minLength: 4, })}
})} spellCheck={false}
spellCheck={false} className="relative h-10 w-full rounded-lg bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
className="relative h-10 w-full rounded-lg px-3 py-2 !outline-none bg-zinc-800 text-zinc-100 placeholder:text-zinc-500" />
/> </div>
</div> <div className="flex flex-col gap-1">
<div className="flex flex-col gap-1"> <label
<label className="text-sm font-semibold uppercase tracking-wider text-zinc-400"> htmlFor="about"
Description className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
</label> >
<textarea Description
{...register("about")} </label>
spellCheck={false} <textarea
className="relative resize-none h-20 w-full rounded-lg px-3 py-2 !outline-none bg-zinc-800 text-zinc-100 placeholder:text-zinc-500" {...register('about')}
/> spellCheck={false}
</div> className="relative h-20 w-full resize-none rounded-lg bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
<div className="flex h-20 items-center justify-between gap-1 rounded-lg bg-zinc-800 px-4 py-2"> />
<div className="flex flex-col gap-1"> </div>
<span className="font-semibold leading-none text-zinc-100"> <div className="flex h-20 items-center justify-between gap-1 rounded-lg bg-zinc-800 px-4 py-2">
Encrypted <div className="flex flex-col gap-1">
</span> <span className="font-semibold leading-none text-zinc-100">
<p className="w-4/5 text-sm leading-none text-zinc-400"> Encrypted
All messages are encrypted and only invited members </span>
can view and send message <p className="w-4/5 text-sm leading-none text-zinc-400">
</p> All messages are encrypted and only invited members can view and
</div> send message
<div> </p>
<button </div>
type="button" <div>
disabled <button
className="relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent bg-zinc-900 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-fuchsia-600 focus:ring-offset-2" type="button"
role="switch" disabled
aria-checked="false" className="relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent bg-zinc-900 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-fuchsia-600 focus:ring-offset-2"
> role="switch"
<span className="pointer-events-none inline-block h-5 w-5 translate-x-0 transform rounded-full bg-zinc-600 shadow ring-0 transition duration-200 ease-in-out" /> aria-checked="false"
</button> >
</div> <span className="pointer-events-none inline-block h-5 w-5 translate-x-0 transform rounded-full bg-zinc-600 shadow ring-0 transition duration-200 ease-in-out" />
</div> </button>
<div> </div>
<button </div>
type="submit" <div>
disabled={!isDirty || !isValid} <button
className="inline-flex items-center justify-center gap-1 transform active:translate-y-1 disabled:pointer-events-none disabled:opacity-50 focus:outline-none h-11 w-full bg-fuchsia-500 rounded-md font-medium text-zinc-100 hover:bg-fuchsia-600" type="submit"
> disabled={!isDirty || !isValid}
{loading ? ( className="inline-flex h-11 w-full transform items-center justify-center gap-1 rounded-md bg-fuchsia-500 font-medium text-zinc-100 hover:bg-fuchsia-600 focus:outline-none active:translate-y-1 disabled:pointer-events-none disabled:opacity-50"
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" /> >
) : ( {loading ? (
"Create channel →" <LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
)} ) : (
</button> 'Create channel →'
</div> )}
</form> </button>
</div> </div>
</Dialog.Panel> </form>
</Transition.Child> </div>
</div> </Dialog.Panel>
</Dialog> </Transition.Child>
</Transition> </div>
</> </Dialog>
); </Transition>
</>
);
} }

View File

@@ -1,33 +1,34 @@
import { useChannelProfile } from "@app/channel/hooks/useChannelProfile"; import { NavLink } from 'react-router-dom';
import { NavLink } from "react-router-dom"; import { twMerge } from 'tailwind-merge';
import { twMerge } from "tailwind-merge";
import { useChannelProfile } from '@app/channel/hooks/useChannelProfile';
export function ChannelsListItem({ data }: { data: any }) { export function ChannelsListItem({ data }: { data: any }) {
const channel = useChannelProfile(data.event_id); const channel = useChannelProfile(data.event_id);
return ( return (
<NavLink <NavLink
to={`/app/channel/${data.event_id}`} to={`/app/channel/${data.event_id}`}
preventScrollReset={true} preventScrollReset={true}
className={({ isActive }) => className={({ isActive }) =>
twMerge( twMerge(
"inline-flex h-9 items-center gap-2.5 rounded-md px-2.5", 'inline-flex h-9 items-center gap-2.5 rounded-md px-2.5',
isActive ? "bg-zinc-900/50 text-zinc-100" : "", isActive ? 'bg-zinc-900/50 text-zinc-100' : ''
) )
} }
> >
<div className="inline-flex shrink-0 h-6 w-6 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900"> <div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<span className="text-xs text-zinc-100">#</span> <span className="text-xs text-zinc-100">#</span>
</div> </div>
<div className="w-full inline-flex items-center justify-between"> <div className="inline-flex w-full items-center justify-between">
<h5 className="truncate font-medium text-zinc-200">{channel?.name}</h5> <h5 className="truncate font-medium text-zinc-200">{channel?.name}</h5>
<div className="flex items-center"> <div className="flex items-center">
{data.new_messages && ( {data.new_messages && (
<span className="inline-flex items-center justify-center rounded bg-fuchsia-400/10 w-8 px-1 py-1 text-xs font-medium text-fuchsia-500 ring-1 ring-inset ring-fuchsia-400/20"> <span className="inline-flex w-8 items-center justify-center rounded bg-fuchsia-400/10 px-1 py-1 text-xs font-medium text-fuchsia-500 ring-1 ring-inset ring-fuchsia-400/20">
{data.new_messages} {data.new_messages}
</span> </span>
)} )}
</div> </div>
</div> </div>
</NavLink> </NavLink>
); );
} }

View File

@@ -1,50 +1,52 @@
import { ChannelCreateModal } from "@app/channel/components/createModal"; import { useQuery } from '@tanstack/react-query';
import { ChannelsListItem } from "@app/channel/components/item";
import { getChannels } from "@libs/storage"; import { ChannelCreateModal } from '@app/channel/components/createModal';
import { useQuery } from "@tanstack/react-query"; import { ChannelsListItem } from '@app/channel/components/item';
import { getChannels } from '@libs/storage';
export function ChannelsList() { export function ChannelsList() {
const { const {
status, status,
data: channels, data: channels,
isFetching, isFetching,
} = useQuery( } = useQuery(
["channels"], ['channels'],
async () => { async () => {
return await getChannels(); return await getChannels();
}, },
{ {
refetchOnMount: false, refetchOnMount: false,
refetchOnReconnect: false, refetchOnReconnect: false,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}, }
); );
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
{status === "loading" ? ( {status === 'loading' ? (
<> <>
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5"> <div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" /> <div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3.5 w-full animate-pulse rounded-sm bg-zinc-800" /> <div className="h-3.5 w-full animate-pulse rounded-sm bg-zinc-800" />
</div> </div>
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5"> <div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" /> <div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3.5 w-full animate-pulse rounded-sm bg-zinc-800" /> <div className="h-3.5 w-full animate-pulse rounded-sm bg-zinc-800" />
</div> </div>
</> </>
) : ( ) : (
channels.map((item: { event_id: string }) => ( channels.map((item: { event_id: string }) => (
<ChannelsListItem key={item.event_id} data={item} /> <ChannelsListItem key={item.event_id} data={item} />
)) ))
)} )}
{isFetching && ( {isFetching && (
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5"> <div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" /> <div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3.5 w-full animate-pulse rounded-sm bg-zinc-800" /> <div className="h-3.5 w-full animate-pulse rounded-sm bg-zinc-800" />
</div> </div>
)} )}
<ChannelCreateModal /> <ChannelCreateModal />
</div> </div>
); );
} }

View File

@@ -1,22 +1,24 @@
import { Image } from "@shared/image"; import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from "@stores/constants";
import { useProfile } from "@utils/hooks/useProfile"; import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
export function Member({ pubkey }: { pubkey: string }) { export function Member({ pubkey }: { pubkey: string }) {
const { user, isError, isLoading } = useProfile(pubkey); const { user, isError, isLoading } = useProfile(pubkey);
return ( return (
<> <>
{isError || isLoading ? ( {isError || isLoading ? (
<div className="h-7 w-7 animate-pulse rounded bg-zinc-800" /> <div className="h-7 w-7 animate-pulse rounded bg-zinc-800" />
) : ( ) : (
<Image <Image
className="inline-block h-7 w-7 rounded" className="inline-block h-7 w-7 rounded"
src={user?.image} src={user?.image}
fallback={DEFAULT_AVATAR} fallback={DEFAULT_AVATAR}
alt={pubkey} alt={pubkey}
/> />
)} )}
</> </>
); );
} }

View File

@@ -1,29 +1,28 @@
import { Member } from "@app/channel/components/member"; import { useQuery } from '@tanstack/react-query';
import { getChannelUsers } from "@libs/storage";
import { useQuery } from "@tanstack/react-query"; import { Member } from '@app/channel/components/member';
import { getChannelUsers } from '@libs/storage';
export function ChannelMembers({ id }: { id: string }) { export function ChannelMembers({ id }: { id: string }) {
const { status, data, isFetching } = useQuery( const { status, data, isFetching } = useQuery(['channel-members', id], async () => {
["channel-members", id], return await getChannelUsers(id);
async () => { });
return await getChannelUsers(id);
},
);
return ( return (
<div className="mt-3"> <div className="mt-3">
<h5 className="border-b border-zinc-900 pb-1 font-semibold text-zinc-200"> <h5 className="border-b border-zinc-900 pb-1 font-semibold text-zinc-200">
Members Members
</h5> </h5>
<div className="mt-3 w-full flex flex-wrap gap-1.5"> <div className="mt-3 flex w-full flex-wrap gap-1.5">
{status === "loading" || isFetching ? ( {status === 'loading' || isFetching ? (
<p>Loading...</p> <p>Loading...</p>
) : ( ) : (
data.map((member: { pubkey: string }) => ( data.map((member: { pubkey: string }) => (
<Member key={member.pubkey} pubkey={member.pubkey} /> <Member key={member.pubkey} pubkey={member.pubkey} />
)) ))
)} )}
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,110 +1,114 @@
import { UserReply } from "@app/channel/components/messages/userReply"; import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk"; import { useContext, useState } from 'react';
import { CancelIcon, EnterIcon } from "@shared/icons";
import { MediaUploader } from "@shared/mediaUploader"; import { UserReply } from '@app/channel/components/messages/userReply';
import { RelayContext } from "@shared/relayProvider";
import { useChannelMessages } from "@stores/channels"; import { CancelIcon, EnterIcon } from '@shared/icons';
import { dateToUnix } from "@utils/date"; import { MediaUploader } from '@shared/mediaUploader';
import { useAccount } from "@utils/hooks/useAccount"; import { RelayContext } from '@shared/relayProvider';
import { useContext, useState } from "react";
import { useChannelMessages } from '@stores/channels';
import { dateToUnix } from '@utils/date';
import { useAccount } from '@utils/hooks/useAccount';
export function ChannelMessageForm({ channelID }: { channelID: string }) { export function ChannelMessageForm({ channelID }: { channelID: string }) {
const ndk = useContext(RelayContext); const ndk = useContext(RelayContext);
const [value, setValue] = useState(""); const [value, setValue] = useState('');
const [replyTo, closeReply] = useChannelMessages((state: any) => [ const [replyTo, closeReply] = useChannelMessages((state: any) => [
state.replyTo, state.replyTo,
state.closeReply, state.closeReply,
]); ]);
const { account } = useAccount(); const { account } = useAccount();
const submit = () => { const submit = () => {
let tags: string[][]; let tags: string[][];
if (replyTo.id !== null) { if (replyTo.id !== null) {
tags = [ tags = [
["e", channelID, "", "root"], ['e', channelID, '', 'root'],
["e", replyTo.id, "", "reply"], ['e', replyTo.id, '', 'reply'],
["p", replyTo.pubkey, ""], ['p', replyTo.pubkey, ''],
]; ];
} else { } else {
tags = [["e", channelID, "", "root"]]; tags = [['e', channelID, '', 'root']];
} }
const signer = new NDKPrivateKeySigner(account.privkey); const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer; ndk.signer = signer;
const event = new NDKEvent(ndk); const event = new NDKEvent(ndk);
// build event // build event
event.content = value; event.content = value;
event.kind = 42; event.kind = 42;
event.created_at = dateToUnix(); event.created_at = dateToUnix();
event.pubkey = account.pubkey; event.pubkey = account.pubkey;
event.tags = tags; event.tags = tags;
// publish event // publish event
event.publish(); event.publish();
// reset state // reset state
setValue(""); setValue('');
}; };
const handleEnterPress = (e) => { const handleEnterPress = (e) => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
submit(); submit();
} }
}; };
const stopReply = () => { const stopReply = () => {
closeReply(); closeReply();
}; };
return ( return (
<div className={`relative w-full ${replyTo.id ? "h-36" : "h-24"}`}> <div className={`relative w-full ${replyTo.id ? 'h-36' : 'h-24'}`}>
{replyTo.id && ( {replyTo.id && (
<div className="absolute left-0 top-0 z-10 h-16 w-full p-[2px]"> <div className="absolute left-0 top-0 z-10 h-16 w-full p-[2px]">
<div className="flex h-full w-full items-center justify-between rounded-t-md border-b border-zinc-700/70 bg-zinc-900 px-3"> <div className="flex h-full w-full items-center justify-between rounded-t-md border-b border-zinc-700/70 bg-zinc-900 px-3">
<div className="flex w-full flex-col"> <div className="flex w-full flex-col">
<UserReply pubkey={replyTo.pubkey} /> <UserReply pubkey={replyTo.pubkey} />
<div className="-mt-5 pl-[38px]"> <div className="-mt-5 pl-[38px]">
<div className="text-base text-zinc-100">{replyTo.content}</div> <div className="text-base text-zinc-100">{replyTo.content}</div>
</div> </div>
</div> </div>
<button <button
type="button" type="button"
onClick={() => stopReply()} onClick={() => stopReply()}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-800" className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-800"
> >
<CancelIcon width={12} height={12} className="text-zinc-100" /> <CancelIcon width={12} height={12} className="text-zinc-100" />
</button> </button>
</div> </div>
</div> </div>
)} )}
<textarea <textarea
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
onKeyDown={handleEnterPress} onKeyDown={handleEnterPress}
spellCheck={false} spellCheck={false}
placeholder="Message" placeholder="Message"
className={`relative ${ className={`relative ${
replyTo.id ? "h-36 pt-16" : "h-24 pt-3" replyTo.id ? 'h-36 pt-16' : 'h-24 pt-3'
} w-full resize-none rounded-md px-5 !outline-none bg-zinc-800 placeholder:text-zinc-500`} } w-full resize-none rounded-md bg-zinc-800 px-5 !outline-none placeholder:text-zinc-500`}
/> />
<div className="absolute right-2 bottom-0 h-11"> <div className="absolute bottom-0 right-2 h-11">
<div className="h-full flex gap-3 items-center justify-end text-zinc-500"> <div className="flex h-full items-center justify-end gap-3 text-zinc-500">
<MediaUploader setState={setValue} /> <MediaUploader setState={setValue} />
<button <button
type="button" type="button"
onClick={submit} onClick={submit}
className="inline-flex items-center gap-1 text-sm leading-none" className="inline-flex items-center gap-1 text-sm leading-none"
> >
<EnterIcon width={14} height={14} className="" /> <EnterIcon width={14} height={14} className="" />
Send Send
</button> </button>
</div> </div>
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,135 +1,134 @@
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from '@headlessui/react';
import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk"; import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { CancelIcon, HideIcon } from "@shared/icons"; import { Fragment, useContext, useState } from 'react';
import { RelayContext } from "@shared/relayProvider";
import { Tooltip } from "@shared/tooltip_dep"; import { CancelIcon, HideIcon } from '@shared/icons';
import { useChannelMessages } from "@stores/channels"; import { RelayContext } from '@shared/relayProvider';
import { dateToUnix } from "@utils/date"; import { Tooltip } from '@shared/tooltip_dep';
import { useAccount } from "@utils/hooks/useAccount";
import { Fragment, useContext, useState } from "react"; import { useChannelMessages } from '@stores/channels';
import { dateToUnix } from '@utils/date';
import { useAccount } from '@utils/hooks/useAccount';
export function MessageHideButton({ id }: { id: string }) { export function MessageHideButton({ id }: { id: string }) {
const ndk = useContext(RelayContext); const ndk = useContext(RelayContext);
const hide = useChannelMessages((state: any) => state.hideMessage); const hide = useChannelMessages((state: any) => state.hideMessage);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { account } = useAccount(); const { account } = useAccount();
const closeModal = () => { const closeModal = () => {
setIsOpen(false); setIsOpen(false);
}; };
const openModal = () => { const openModal = () => {
setIsOpen(true); setIsOpen(true);
}; };
const hideMessage = () => { const hideMessage = () => {
const signer = new NDKPrivateKeySigner(account.privkey); const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer; ndk.signer = signer;
const event = new NDKEvent(ndk); const event = new NDKEvent(ndk);
// build event // build event
event.content = ""; event.content = '';
event.kind = 43; event.kind = 43;
event.created_at = dateToUnix(); event.created_at = dateToUnix();
event.pubkey = account.pubkey; event.pubkey = account.pubkey;
event.tags = [["e", id]]; event.tags = [['e', id]];
// publish event // publish event
event.publish(); event.publish();
// update state // update state
hide(id); hide(id);
// close modal // close modal
closeModal(); closeModal();
}; };
return ( return (
<> <>
<Tooltip message="Hide this message"> <Tooltip message="Hide this message">
<button <button
type="button" type="button"
onClick={openModal} onClick={openModal}
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800" className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800"
> >
<HideIcon width={16} height={16} className="text-zinc-200" /> <HideIcon width={16} height={16} className="text-zinc-200" />
</button> </button>
</Tooltip> </Tooltip>
<Transition appear show={isOpen} as={Fragment}> <Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}> <Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" /> <div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child> </Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center"> <div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0 scale-95" enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100" enterTo="opacity-100 scale-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col rounded-lg border border-zinc-800 bg-zinc-900"> <Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col rounded-lg border border-zinc-800 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6"> <div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Dialog.Title <Dialog.Title
as="h3" as="h3"
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-xl font-semibold leading-none text-transparent" className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-xl font-semibold leading-none text-transparent"
> >
Are you sure! Are you sure!
</Dialog.Title> </Dialog.Title>
<button <button
type="button" type="button"
onClick={closeModal} onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900" className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
> >
<CancelIcon <CancelIcon width={20} height={20} className="text-zinc-300" />
width={20} </button>
height={20} </div>
className="text-zinc-300" <Dialog.Description className="leading-tight text-zinc-400">
/> This message will be hidden from your feed.
</button> </Dialog.Description>
</div> </div>
<Dialog.Description className="leading-tight text-zinc-400"> </div>
This message will be hidden from your feed. <div className="flex h-full w-full flex-col items-end justify-center overflow-y-auto px-5 py-2.5">
</Dialog.Description> <div className="flex items-center gap-2">
</div> <button
</div> type="button"
<div className="flex h-full w-full flex-col items-end justify-center overflow-y-auto px-5 py-2.5"> onClick={closeModal}
<div className="flex items-center gap-2"> className="inline-flex h-9 items-center justify-center rounded-md px-2 text-base font-medium text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
<button >
type="button" Cancel
onClick={closeModal} </button>
className="inline-flex h-9 items-center justify-center rounded-md px-2 text-base font-medium text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100" <button
> type="button"
Cancel onClick={() => hideMessage()}
</button> className="inline-flex h-9 items-center justify-center rounded-md bg-red-500 px-2 text-base font-medium text-zinc-100 hover:bg-red-600"
<button >
type="button" Confirm
onClick={() => hideMessage()} </button>
className="inline-flex h-9 items-center justify-center rounded-md bg-red-500 px-2 text-base font-medium text-zinc-100 hover:bg-red-600" </div>
> </div>
Confirm </Dialog.Panel>
</button> </Transition.Child>
</div> </div>
</div> </Dialog>
</Dialog.Panel> </Transition>
</Transition.Child> </>
</div> );
</Dialog>
</Transition>
</>
);
} }

View File

@@ -1,60 +1,56 @@
import { MessageHideButton } from "@app/channel/components/messages/hideButton"; import { MessageHideButton } from '@app/channel/components/messages/hideButton';
import { MessageMuteButton } from "@app/channel/components/messages/muteButton"; import { MessageMuteButton } from '@app/channel/components/messages/muteButton';
import { MessageReplyButton } from "@app/channel/components/messages/replyButton"; import { MessageReplyButton } from '@app/channel/components/messages/replyButton';
import { MentionNote } from "@shared/notes/mentions/note";
import { ImagePreview } from "@shared/notes/preview/image"; import { MentionNote } from '@shared/notes/mentions/note';
import { LinkPreview } from "@shared/notes/preview/link"; import { ImagePreview } from '@shared/notes/preview/image';
import { VideoPreview } from "@shared/notes/preview/video"; import { LinkPreview } from '@shared/notes/preview/link';
import { User } from "@shared/user"; import { VideoPreview } from '@shared/notes/preview/video';
import { parser } from "@utils/parser"; import { User } from '@shared/user';
import { LumeEvent } from "@utils/types";
import { parser } from '@utils/parser';
import { LumeEvent } from '@utils/types';
export function ChannelMessageItem({ data }: { data: LumeEvent }) { export function ChannelMessageItem({ data }: { data: LumeEvent }) {
const content = parser(data); const content = parser(data);
return ( return (
<div className="group relative flex h-min min-h-min w-full select-text flex-col px-5 py-3 hover:bg-black/20"> <div className="group relative flex h-min min-h-min w-full select-text flex-col px-5 py-3 hover:bg-black/20">
<div className="flex flex-col"> <div className="flex flex-col">
<User pubkey={data.pubkey} time={data.created_at} isChat={true} /> <User pubkey={data.pubkey} time={data.created_at} isChat={true} />
<div className="-mt-[20px] pl-[49px]"> <div className="-mt-[20px] pl-[49px]">
<p className="select-text whitespace-pre-line break-words text-base text-zinc-100"> <p className="select-text whitespace-pre-line break-words text-base text-zinc-100">
{content.parsed} {content.parsed}
</p> </p>
{Array.isArray(content.images) && content.images.length ? ( {Array.isArray(content.images) && content.images.length ? (
<ImagePreview urls={content.images} /> <ImagePreview urls={content.images} />
) : ( ) : (
<></> <></>
)} )}
{Array.isArray(content.videos) && content.videos.length ? ( {Array.isArray(content.videos) && content.videos.length ? (
<VideoPreview urls={content.videos} /> <VideoPreview urls={content.videos} />
) : ( ) : (
<></> <></>
)} )}
{Array.isArray(content.links) && content.links.length ? ( {Array.isArray(content.links) && content.links.length ? (
<LinkPreview urls={content.links} /> <LinkPreview urls={content.links} />
) : ( ) : (
<></> <></>
)} )}
{Array.isArray(content.notes) && content.notes.length ? ( {Array.isArray(content.notes) && content.notes.length ? (
content.notes.map((note: string) => ( content.notes.map((note: string) => <MentionNote key={note} id={note} />)
<MentionNote key={note} id={note} /> ) : (
)) <></>
) : ( )}
<></> </div>
)} </div>
</div> <div className="absolute -top-4 right-4 z-10 hidden group-hover:inline-flex">
</div> <div className="inline-flex h-8 items-center justify-center gap-1.5 rounded bg-zinc-900 px-0.5 shadow-md shadow-black/20 ring-1 ring-zinc-800">
<div className="absolute -top-4 right-4 z-10 hidden group-hover:inline-flex"> <MessageReplyButton id={data.id} pubkey={data.pubkey} content={data.content} />
<div className="inline-flex h-8 items-center justify-center gap-1.5 rounded bg-zinc-900 px-0.5 shadow-md shadow-black/20 ring-1 ring-zinc-800"> <MessageHideButton id={data.id} />
<MessageReplyButton <MessageMuteButton pubkey={data.pubkey} />
id={data.id} </div>
pubkey={data.pubkey} </div>
content={data.content} </div>
/> );
<MessageHideButton id={data.id} />
<MessageMuteButton pubkey={data.pubkey} />
</div>
</div>
</div>
);
} }

View File

@@ -1,135 +1,134 @@
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from '@headlessui/react';
import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk"; import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { CancelIcon, MuteIcon } from "@shared/icons"; import { Fragment, useContext, useState } from 'react';
import { RelayContext } from "@shared/relayProvider";
import { Tooltip } from "@shared/tooltip_dep"; import { CancelIcon, MuteIcon } from '@shared/icons';
import { useChannelMessages } from "@stores/channels"; import { RelayContext } from '@shared/relayProvider';
import { dateToUnix } from "@utils/date"; import { Tooltip } from '@shared/tooltip_dep';
import { useAccount } from "@utils/hooks/useAccount";
import { Fragment, useContext, useState } from "react"; import { useChannelMessages } from '@stores/channels';
import { dateToUnix } from '@utils/date';
import { useAccount } from '@utils/hooks/useAccount';
export function MessageMuteButton({ pubkey }: { pubkey: string }) { export function MessageMuteButton({ pubkey }: { pubkey: string }) {
const ndk = useContext(RelayContext); const ndk = useContext(RelayContext);
const mute = useChannelMessages((state: any) => state.muteUser); const mute = useChannelMessages((state: any) => state.muteUser);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { account } = useAccount(); const { account } = useAccount();
const closeModal = () => { const closeModal = () => {
setIsOpen(false); setIsOpen(false);
}; };
const openModal = () => { const openModal = () => {
setIsOpen(true); setIsOpen(true);
}; };
const muteUser = () => { const muteUser = () => {
const signer = new NDKPrivateKeySigner(account.privkey); const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer; ndk.signer = signer;
const event = new NDKEvent(ndk); const event = new NDKEvent(ndk);
// build event // build event
event.content = ""; event.content = '';
event.kind = 44; event.kind = 44;
event.created_at = dateToUnix(); event.created_at = dateToUnix();
event.pubkey = account.pubkey; event.pubkey = account.pubkey;
event.tags = [["p", pubkey]]; event.tags = [['p', pubkey]];
// publish event // publish event
event.publish(); event.publish();
// update state // update state
mute(pubkey); mute(pubkey);
// close modal // close modal
closeModal(); closeModal();
}; };
return ( return (
<> <>
<Tooltip message="Mute this user"> <Tooltip message="Mute this user">
<button <button
type="button" type="button"
onClick={() => openModal()} onClick={() => openModal()}
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800" className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800"
> >
<MuteIcon width={16} height={16} className="text-zinc-200" /> <MuteIcon width={16} height={16} className="text-zinc-200" />
</button> </button>
</Tooltip> </Tooltip>
<Transition appear show={isOpen} as={Fragment}> <Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}> <Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" /> <div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child> </Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center"> <div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0 scale-95" enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100" enterTo="opacity-100 scale-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col rounded-lg border border-zinc-800 bg-zinc-900"> <Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col rounded-lg border border-zinc-800 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6"> <div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Dialog.Title <Dialog.Title
as="h3" as="h3"
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-xl font-semibold leading-none text-transparent" className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-xl font-semibold leading-none text-transparent"
> >
Are you sure! Are you sure!
</Dialog.Title> </Dialog.Title>
<button <button
type="button" type="button"
onClick={closeModal} onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900" className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
> >
<CancelIcon <CancelIcon width={20} height={20} className="text-zinc-300" />
width={20} </button>
height={20} </div>
className="text-zinc-300" <Dialog.Description className="leading-tight text-zinc-400">
/> You will no longer see messages from this user.
</button> </Dialog.Description>
</div> </div>
<Dialog.Description className="leading-tight text-zinc-400"> </div>
You will no longer see messages from this user. <div className="flex h-full w-full flex-col items-end justify-center overflow-y-auto px-5 py-2.5">
</Dialog.Description> <div className="flex items-center gap-2">
</div> <button
</div> type="button"
<div className="flex h-full w-full flex-col items-end justify-center overflow-y-auto px-5 py-2.5"> onClick={closeModal}
<div className="flex items-center gap-2"> className="inline-flex h-9 items-center justify-center rounded-md px-2 text-base font-medium text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
<button >
type="button" Cancel
onClick={closeModal} </button>
className="inline-flex h-9 items-center justify-center rounded-md px-2 text-base font-medium text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100" <button
> type="button"
Cancel onClick={() => muteUser()}
</button> className="inline-flex h-9 items-center justify-center rounded-md bg-red-500 px-2 text-base font-medium text-zinc-100 hover:bg-red-600"
<button >
type="button" Confirm
onClick={() => muteUser()} </button>
className="inline-flex h-9 items-center justify-center rounded-md bg-red-500 px-2 text-base font-medium text-zinc-100 hover:bg-red-600" </div>
> </div>
Confirm </Dialog.Panel>
</button> </Transition.Child>
</div> </div>
</div> </Dialog>
</Dialog.Panel> </Transition>
</Transition.Child> </>
</div> );
</Dialog>
</Transition>
</>
);
} }

View File

@@ -1,27 +1,32 @@
import { ReplyMessageIcon } from "@shared/icons"; import { ReplyMessageIcon } from '@shared/icons';
import { Tooltip } from "@shared/tooltip_dep"; import { Tooltip } from '@shared/tooltip_dep';
import { useChannelMessages } from "@stores/channels";
import { useChannelMessages } from '@stores/channels';
export function MessageReplyButton({ export function MessageReplyButton({
id, id,
pubkey, pubkey,
content, content,
}: { id: string; pubkey: string; content: string }) { }: {
const openReply = useChannelMessages((state: any) => state.openReply); id: string;
pubkey: string;
content: string;
}) {
const openReply = useChannelMessages((state: any) => state.openReply);
const createReply = () => { const createReply = () => {
openReply(id, pubkey, content); openReply(id, pubkey, content);
}; };
return ( return (
<Tooltip message="Reply to message"> <Tooltip message="Reply to message">
<button <button
type="button" type="button"
onClick={() => createReply()} onClick={() => createReply()}
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800" className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800"
> >
<ReplyMessageIcon width={16} height={16} className="text-zinc-200" /> <ReplyMessageIcon width={16} height={16} className="text-zinc-200" />
</button> </button>
</Tooltip> </Tooltip>
); );
} }

View File

@@ -1,42 +1,40 @@
import { Image } from "@shared/image"; import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from "@stores/constants";
import { useProfile } from "@utils/hooks/useProfile";
export function ChannelMessageUserMute({ import { DEFAULT_AVATAR } from '@stores/constants';
pubkey,
}: {
pubkey: string;
}) {
const { user, isError, isLoading } = useProfile(pubkey);
return ( import { useProfile } from '@utils/hooks/useProfile';
<div className="flex items-center gap-3">
{isError || isLoading ? ( export function ChannelMessageUserMute({ pubkey }: { pubkey: string }) {
<> const { user, isError, isLoading } = useProfile(pubkey);
<div className="relative h-11 w-11 shrink animate-pulse rounded-md bg-zinc-800" />
<div className="flex w-full flex-1 items-center justify-between"> return (
<div className="flex items-baseline gap-2 text-base"> <div className="flex items-center gap-3">
<div className="h-4 w-20 animate-pulse rounded bg-zinc-800" /> {isError || isLoading ? (
</div> <>
</div> <div className="relative h-11 w-11 shrink animate-pulse rounded-md bg-zinc-800" />
</> <div className="flex w-full flex-1 items-center justify-between">
) : ( <div className="flex items-baseline gap-2 text-base">
<> <div className="h-4 w-20 animate-pulse rounded bg-zinc-800" />
<div className="relative h-11 w-11 shrink-0 rounded-md"> </div>
<Image </div>
src={user?.image} </>
fallback={DEFAULT_AVATAR} ) : (
alt={pubkey} <>
className="h-11 w-11 rounded-md object-cover" <div className="relative h-11 w-11 shrink-0 rounded-md">
/> <Image
</div> src={user?.image}
<div className="flex w-full flex-1 items-center justify-between"> fallback={DEFAULT_AVATAR}
<span className="leading-none text-zinc-300"> alt={pubkey}
You has been muted this user className="h-11 w-11 rounded-md object-cover"
</span> />
</div> </div>
</> <div className="flex w-full flex-1 items-center justify-between">
)} <span className="leading-none text-zinc-300">
</div> You has been muted this user
); </span>
</div>
</>
)}
</div>
);
} }

View File

@@ -1,33 +1,35 @@
import { Image } from "@shared/image"; import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from "@stores/constants";
import { useProfile } from "@utils/hooks/useProfile"; import { DEFAULT_AVATAR } from '@stores/constants';
import { shortenKey } from "@utils/shortenKey";
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
export function UserReply({ pubkey }: { pubkey: string }) { export function UserReply({ pubkey }: { pubkey: string }) {
const { user, isError, isLoading } = useProfile(pubkey); const { user, isError, isLoading } = useProfile(pubkey);
return ( return (
<div className="group flex items-start gap-2"> <div className="group flex items-start gap-2">
{isError || isLoading ? ( {isError || isLoading ? (
<> <>
<div className="relative h-9 w-9 shrink animate-pulse overflow-hidden rounded bg-zinc-800" /> <div className="relative h-9 w-9 shrink animate-pulse overflow-hidden rounded bg-zinc-800" />
<span className="h-2 w-10 animate-pulse rounded bg-zinc-800 text-base font-medium leading-none text-zinc-500" /> <span className="h-2 w-10 animate-pulse rounded bg-zinc-800 text-base font-medium leading-none text-zinc-500" />
</> </>
) : ( ) : (
<> <>
<div className="relative h-9 w-9 shrink overflow-hidden rounded"> <div className="relative h-9 w-9 shrink overflow-hidden rounded">
<Image <Image
src={user?.image} src={user?.image}
fallback={DEFAULT_AVATAR} fallback={DEFAULT_AVATAR}
alt={pubkey} alt={pubkey}
className="h-9 w-9 rounded object-cover" className="h-9 w-9 rounded object-cover"
/> />
</div> </div>
<span className="max-w-[10rem] truncate text-sm font-medium leading-none text-zinc-500"> <span className="max-w-[10rem] truncate text-sm font-medium leading-none text-zinc-500">
Replying to {user?.name || shortenKey(pubkey)} Replying to {user?.name || shortenKey(pubkey)}
</span> </span>
</> </>
)} )}
</div> </div>
); );
} }

View File

@@ -1,43 +1,44 @@
import { useChannelProfile } from "@app/channel/hooks/useChannelProfile"; import { nip19 } from 'nostr-tools';
import { CopyIcon } from "@shared/icons";
import { Image } from "@shared/image"; import { useChannelProfile } from '@app/channel/hooks/useChannelProfile';
import { DEFAULT_AVATAR } from "@stores/constants";
import { nip19 } from "nostr-tools"; import { CopyIcon } from '@shared/icons';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
export function ChannelMetadata({ id }: { id: string }) { export function ChannelMetadata({ id }: { id: string }) {
const metadata = useChannelProfile(id); const metadata = useChannelProfile(id);
const noteID = id ? nip19.noteEncode(id) : null; const noteID = id ? nip19.noteEncode(id) : null;
const copyNoteID = async () => { const copyNoteID = async () => {
const { writeText } = await import("@tauri-apps/api/clipboard"); const { writeText } = await import('@tauri-apps/api/clipboard');
if (noteID) { if (noteID) {
await writeText(noteID); await writeText(noteID);
} }
}; };
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="relative shrink-0 rounded-md h-11 w-11"> <div className="relative h-11 w-11 shrink-0 rounded-md">
<Image <Image
src={metadata?.picture} src={metadata?.picture}
fallback={DEFAULT_AVATAR} fallback={DEFAULT_AVATAR}
alt={id} alt={id}
className="h-11 w-11 rounded-md object-contain bg-zinc-900" className="h-11 w-11 rounded-md bg-zinc-900 object-contain"
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="inline-flex items-center gap-1"> <div className="inline-flex items-center gap-1">
<h5 className="leading-none text-lg font-semibold"> <h5 className="text-lg font-semibold leading-none">{metadata?.name}</h5>
{metadata?.name} <button type="button" onClick={() => copyNoteID()}>
</h5> <CopyIcon width={14} height={14} className="text-zinc-400" />
<button type="button" onClick={() => copyNoteID()}> </button>
<CopyIcon width={14} height={14} className="text-zinc-400" /> </div>
</button> <p className="leading-tight text-zinc-400">
</div> {metadata?.about || (noteID && `${noteID.substring(0, 24)}...`)}
<p className="leading-tight text-zinc-400"> </p>
{metadata?.about || (noteID && `${noteID.substring(0, 24)}...`)} </div>
</p> </div>
</div> );
</div>
);
} }

View File

@@ -1,82 +1,85 @@
import { Image } from "@shared/image"; import { useState } from 'react';
import { DEFAULT_AVATAR } from "@stores/constants";
import { useProfile } from "@utils/hooks/useProfile"; import { Image } from '@shared/image';
import { shortenKey } from "@utils/shortenKey";
import { useState } from "react"; import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
export function MutedItem({ data }: { data: any }) { export function MutedItem({ data }: { data: any }) {
const { user, isError, isLoading } = useProfile(data.content); const { user, isError, isLoading } = useProfile(data.content);
const [status, setStatus] = useState(data.status); const [status, setStatus] = useState(data.status);
const unmute = async () => { const unmute = async () => {
const { updateItemInBlacklist } = await import("@libs/storage"); const { updateItemInBlacklist } = await import('@libs/storage');
const res = await updateItemInBlacklist(data.content, 0); const res = await updateItemInBlacklist(data.content, 0);
if (res) { if (res) {
setStatus(0); setStatus(0);
} }
}; };
const mute = async () => { const mute = async () => {
const { updateItemInBlacklist } = await import("@libs/storage"); const { updateItemInBlacklist } = await import('@libs/storage');
const res = await updateItemInBlacklist(data.content, 1); const res = await updateItemInBlacklist(data.content, 1);
if (res) { if (res) {
setStatus(1); setStatus(1);
} }
}; };
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
{isError || isLoading ? ( {isError || isLoading ? (
<> <>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="relative h-9 w-9 shrink animate-pulse rounded-md bg-zinc-800" /> <div className="relative h-9 w-9 shrink animate-pulse rounded-md bg-zinc-800" />
<div className="flex w-full flex-1 flex-col items-start gap-0.5 text-start"> <div className="flex w-full flex-1 flex-col items-start gap-0.5 text-start">
<div className="h-3 w-16 animate-pulse bg-zinc-800" /> <div className="h-3 w-16 animate-pulse bg-zinc-800" />
<div className="h-2 w-10 animate-pulse bg-zinc-800" /> <div className="h-2 w-10 animate-pulse bg-zinc-800" />
</div> </div>
</div> </div>
</> </>
) : ( ) : (
<> <>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="relative h-9 w-9 shrink rounded-md"> <div className="relative h-9 w-9 shrink rounded-md">
<Image <Image
src={user?.image} src={user?.image}
fallback={DEFAULT_AVATAR} fallback={DEFAULT_AVATAR}
alt={data.content} alt={data.content}
className="h-9 w-9 rounded-md object-cover" className="h-9 w-9 rounded-md object-cover"
/> />
</div> </div>
<div className="flex w-full flex-1 flex-col items-start gap-0.5 text-start"> <div className="flex w-full flex-1 flex-col items-start gap-0.5 text-start">
<span className="truncate text-base font-medium leading-none text-zinc-100"> <span className="truncate text-base font-medium leading-none text-zinc-100">
{user?.displayName || user?.name || "Pleb"} {user?.displayName || user?.name || 'Pleb'}
</span> </span>
<span className="text-base leading-none text-zinc-400"> <span className="text-base leading-none text-zinc-400">
{shortenKey(data.content)} {shortenKey(data.content)}
</span> </span>
</div> </div>
</div> </div>
<div> <div>
{status === 1 ? ( {status === 1 ? (
<button <button
type="button" type="button"
onClick={() => unmute()} onClick={() => unmute()}
className="inline-flex h-6 w-min items-center justify-center rounded px-1.5 text-base font-medium leading-none text-zinc-400 hover:bg-zinc-800 hover:text-fuchsia-500" className="inline-flex h-6 w-min items-center justify-center rounded px-1.5 text-base font-medium leading-none text-zinc-400 hover:bg-zinc-800 hover:text-fuchsia-500"
> >
Unmute Unmute
</button> </button>
) : ( ) : (
<button <button
type="button" type="button"
onClick={() => mute()} onClick={() => mute()}
className="inline-flex h-6 w-min items-center justify-center rounded px-1.5 text-base font-medium leading-none text-zinc-400 hover:bg-zinc-800 hover:text-fuchsia-500" className="inline-flex h-6 w-min items-center justify-center rounded px-1.5 text-base font-medium leading-none text-zinc-400 hover:bg-zinc-800 hover:text-fuchsia-500"
> >
Mute Mute
</button> </button>
)} )}
</div> </div>
</> </>
)} )}
</div> </div>
); );
} }

View File

@@ -1,35 +1,37 @@
import { getChannel, updateChannelMetadata } from "@libs/storage"; import { useQuery } from '@tanstack/react-query';
import { RelayContext } from "@shared/relayProvider"; import { useContext, useEffect } from 'react';
import { useQuery } from "@tanstack/react-query";
import { useContext, useEffect } from "react"; import { getChannel, updateChannelMetadata } from '@libs/storage';
import { RelayContext } from '@shared/relayProvider';
export function useChannelProfile(id: string) { export function useChannelProfile(id: string) {
const ndk = useContext(RelayContext); const ndk = useContext(RelayContext);
const { data } = useQuery(["channel-metadata", id], async () => { const { data } = useQuery(['channel-metadata', id], async () => {
return await getChannel(id); return await getChannel(id);
}); });
useEffect(() => { useEffect(() => {
// subscribe to channel // subscribe to channel
const sub = ndk.subscribe( const sub = ndk.subscribe(
{ {
"#e": [id], '#e': [id],
kinds: [41], kinds: [41],
}, },
{ {
closeOnEose: true, closeOnEose: true,
}, }
); );
sub.addListener("event", (event: { content: string }) => { sub.addListener('event', (event: { content: string }) => {
// update in local database // update in local database
updateChannelMetadata(id, event.content); updateChannelMetadata(id, event.content);
}); });
return () => { return () => {
sub.stop(); sub.stop();
}; };
}, []); }, []);
return data; return data;
} }

View File

@@ -1,154 +1,149 @@
import { ChannelMessageItem } from "./components/messages/item"; import { useCallback, useContext, useEffect, useLayoutEffect, useRef } from 'react';
import { ChannelMembers } from "@app/channel/components/members"; import { useParams } from 'react-router-dom';
import { ChannelMessageForm } from "@app/channel/components/messages/form"; import { Virtuoso } from 'react-virtuoso';
import { ChannelMetadata } from "@app/channel/components/metadata";
import { RelayContext } from "@shared/relayProvider"; import { ChannelMembers } from '@app/channel/components/members';
import { useChannelMessages } from "@stores/channels"; import { ChannelMessageForm } from '@app/channel/components/messages/form';
import { dateToUnix, getHourAgo } from "@utils/date"; import { ChannelMetadata } from '@app/channel/components/metadata';
import { LumeEvent } from "@utils/types";
import { import { RelayContext } from '@shared/relayProvider';
useCallback,
useContext, import { useChannelMessages } from '@stores/channels';
useEffect,
useLayoutEffect, import { dateToUnix, getHourAgo } from '@utils/date';
useRef, import { LumeEvent } from '@utils/types';
} from "react";
import { useParams } from "react-router-dom"; import { ChannelMessageItem } from './components/messages/item';
import { Virtuoso } from "react-virtuoso";
const now = new Date(); const now = new Date();
const Header = ( const Header = (
<div className="relative py-4"> <div className="relative py-4">
<div className="absolute inset-0 flex items-center"> <div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-zinc-800" /> <div className="w-full border-t border-zinc-800" />
</div> </div>
<div className="relative flex justify-center"> <div className="relative flex justify-center">
<div className="inline-flex items-center gap-x-1.5 rounded-full bg-zinc-900 px-3 py-1.5 text-sm font-medium text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-800"> <div className="inline-flex items-center gap-x-1.5 rounded-full bg-zinc-900 px-3 py-1.5 text-sm font-medium text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-800">
{getHourAgo(24, now).toLocaleDateString("en-US", { {getHourAgo(24, now).toLocaleDateString('en-US', {
weekday: "long", weekday: 'long',
year: "numeric", year: 'numeric',
month: "long", month: 'long',
day: "numeric", day: 'numeric',
})} })}
</div> </div>
</div> </div>
</div> </div>
); );
const Empty = ( const Empty = (
<div className="flex flex-col gap-1 text-center"> <div className="flex flex-col gap-1 text-center">
<h3 className="text-base font-semibold leading-none text-white"> <h3 className="text-base font-semibold leading-none text-white">
Nothing to see here yet Nothing to see here yet
</h3> </h3>
<p className="text-base leading-none text-zinc-400"> <p className="text-base leading-none text-zinc-400">
Be the first to share a message in this channel. Be the first to share a message in this channel.
</p> </p>
</div> </div>
); );
export function ChannelScreen() { export function ChannelScreen() {
const ndk = useContext(RelayContext); const ndk = useContext(RelayContext);
const virtuosoRef = useRef(null); const virtuosoRef = useRef(null);
const { id } = useParams(); const { id } = useParams();
const [messages, fetchMessages, addMessage, clearMessages] = const [messages, fetchMessages, addMessage, clearMessages] = useChannelMessages(
useChannelMessages((state: any) => [ (state: any) => [state.messages, state.fetch, state.add, state.clear]
state.messages, );
state.fetch,
state.add,
state.clear,
]);
useLayoutEffect(() => { useLayoutEffect(() => {
fetchMessages(id); fetchMessages(id);
}, [fetchMessages]); }, [fetchMessages]);
useEffect(() => { useEffect(() => {
// subscribe to channel // subscribe to channel
const sub = ndk.subscribe( const sub = ndk.subscribe(
{ {
"#e": [id], '#e': [id],
kinds: [42], kinds: [42],
since: dateToUnix(), since: dateToUnix(),
}, },
{ closeOnEose: false }, { closeOnEose: false }
); );
sub.addListener("event", (event: LumeEvent) => { sub.addListener('event', (event: LumeEvent) => {
addMessage(id, event); addMessage(id, event);
}); });
return () => { return () => {
clearMessages(); clearMessages();
sub.stop(); sub.stop();
}; };
}, []); }, []);
const itemContent: any = useCallback( const itemContent: any = useCallback(
(index: string | number) => { (index: string | number) => {
return <ChannelMessageItem data={messages[index]} />; return <ChannelMessageItem data={messages[index]} />;
}, },
[messages], [messages]
); );
const computeItemKey = useCallback( const computeItemKey = useCallback(
(index: string | number) => { (index: string | number) => {
return messages[index].event_id; return messages[index].event_id;
}, },
[messages], [messages]
); );
return ( return (
<div className="h-full w-full grid grid-cols-3"> <div className="grid h-full w-full grid-cols-3">
<div className="col-span-2 flex flex-col justify-between border-r border-zinc-900"> <div className="col-span-2 flex flex-col justify-between border-r border-zinc-900">
<div <div
data-tauri-drag-region data-tauri-drag-region
className="h-11 w-full shrink-0 inline-flex items-center justify-center border-b border-zinc-900" className="inline-flex h-11 w-full shrink-0 items-center justify-center border-b border-zinc-900"
> >
<h3 className="font-semibold text-zinc-100">Public Channel</h3> <h3 className="font-semibold text-zinc-100">Public Channel</h3>
</div> </div>
<div className="w-full h-full flex-1 p-3"> <div className="h-full w-full flex-1 p-3">
<div className="h-full flex flex-col justify-between rounded-xl border-t border-zinc-800/50 bg-zinc-900 overflow-hidden"> <div className="flex h-full flex-col justify-between overflow-hidden rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="flex-1 w-full h-full"> <div className="h-full w-full flex-1">
{!messages ? ( {!messages ? (
<p>Loading...</p> <p>Loading...</p>
) : ( ) : (
<Virtuoso <Virtuoso
ref={virtuosoRef} ref={virtuosoRef}
data={messages} data={messages}
itemContent={itemContent} itemContent={itemContent}
computeItemKey={computeItemKey} computeItemKey={computeItemKey}
initialTopMostItemIndex={messages.length - 1} initialTopMostItemIndex={messages.length - 1}
alignToBottom={true} alignToBottom={true}
followOutput={true} followOutput={true}
overscan={50} overscan={50}
increaseViewportBy={{ top: 200, bottom: 200 }} increaseViewportBy={{ top: 200, bottom: 200 }}
className="scrollbar-hide overflow-y-auto" className="scrollbar-hide overflow-y-auto"
components={{ components={{
Header: () => Header, Header: () => Header,
EmptyPlaceholder: () => Empty, EmptyPlaceholder: () => Empty,
}} }}
/> />
)} )}
</div> </div>
<div className="shrink-0 px-5 p-3 rounded-b-xl border-t border-zinc-800 bg-zinc-900 z-50"> <div className="z-50 shrink-0 rounded-b-xl border-t border-zinc-800 bg-zinc-900 p-3 px-5">
<ChannelMessageForm channelID={id} /> <ChannelMessageForm channelID={id} />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div className="col-span-1 flex flex-col"> <div className="col-span-1 flex flex-col">
<div <div
data-tauri-drag-region data-tauri-drag-region
className="h-11 w-full shrink-0 inline-flex items-center justify-center border-b border-zinc-900" className="inline-flex h-11 w-full shrink-0 items-center justify-center border-b border-zinc-900"
/> />
<div className="p-3 flex flex-col gap-3"> <div className="flex flex-col gap-3 p-3">
<ChannelMetadata id={id} /> <ChannelMetadata id={id} />
<ChannelMembers id={id} /> <ChannelMembers id={id} />
</div> </div>
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,58 +1,61 @@
import { Image } from "@shared/image"; import { NavLink } from 'react-router-dom';
import { DEFAULT_AVATAR } from "@stores/constants"; import { twMerge } from 'tailwind-merge';
import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey"; import { Image } from '@shared/image';
import { NavLink } from "react-router-dom";
import { twMerge } from "tailwind-merge"; import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
export function ChatsListItem({ data }: { data: any }) { export function ChatsListItem({ data }: { data: any }) {
const { status, user } = useProfile(data.sender_pubkey); const { status, user } = useProfile(data.sender_pubkey);
if (status === "loading") { if (status === 'loading') {
return ( return (
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5"> <div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" /> <div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-2.5 w-2/3 animate-pulse rounded bg-zinc-800" /> <div className="h-2.5 w-2/3 animate-pulse rounded bg-zinc-800" />
</div> </div>
); );
} }
return ( return (
<NavLink <NavLink
to={`/app/chat/${data.sender_pubkey}`} to={`/app/chat/${data.sender_pubkey}`}
preventScrollReset={true} preventScrollReset={true}
className={({ isActive }) => className={({ isActive }) =>
twMerge( twMerge(
"inline-flex h-9 items-center gap-2.5 rounded-md px-2.5", 'inline-flex h-9 items-center gap-2.5 rounded-md px-2.5',
isActive ? "bg-zinc-900/50 text-zinc-100" : "", isActive ? 'bg-zinc-900/50 text-zinc-100' : ''
) )
} }
> >
<div className="inline-flex shrink-0 h-6 w-6 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900"> <div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<Image <Image
src={user?.image} src={user?.image}
fallback={DEFAULT_AVATAR} fallback={DEFAULT_AVATAR}
alt={data.sender_pubkey} alt={data.sender_pubkey}
className="h-6 w-6 rounded object-cover" className="h-6 w-6 rounded object-cover"
/> />
</div> </div>
<div className="w-full inline-flex items-center justify-between"> <div className="inline-flex w-full items-center justify-between">
<div className="inline-flex items-baseline gap-1"> <div className="inline-flex items-baseline gap-1">
<h5 className="max-w-[10rem] truncate font-medium text-zinc-200"> <h5 className="max-w-[10rem] truncate font-medium text-zinc-200">
{user?.nip05 || {user?.nip05 ||
user?.name || user?.name ||
user?.displayName || user?.displayName ||
shortenKey(data.sender_pubkey)} shortenKey(data.sender_pubkey)}
</h5> </h5>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
{data.new_messages > 0 && ( {data.new_messages > 0 && (
<span className="inline-flex items-center justify-center rounded bg-fuchsia-400/10 w-8 px-1 py-1 text-xs font-medium text-fuchsia-500 ring-1 ring-inset ring-fuchsia-400/20"> <span className="inline-flex w-8 items-center justify-center rounded bg-fuchsia-400/10 px-1 py-1 text-xs font-medium text-fuchsia-500 ring-1 ring-inset ring-fuchsia-400/20">
{data.new_messages} {data.new_messages}
</span> </span>
)} )}
</div> </div>
</div> </div>
</NavLink> </NavLink>
); );
} }

View File

@@ -1,68 +1,71 @@
import { ChatsListItem } from "@app/chat/components/item"; import { useQuery } from '@tanstack/react-query';
import { NewMessageModal } from "@app/chat/components/modal";
import { ChatsListSelfItem } from "@app/chat/components/self"; import { ChatsListItem } from '@app/chat/components/item';
import { getChatsByPubkey } from "@libs/storage"; import { NewMessageModal } from '@app/chat/components/modal';
import { useQuery } from "@tanstack/react-query"; import { ChatsListSelfItem } from '@app/chat/components/self';
import { useAccount } from "@utils/hooks/useAccount";
import { getChatsByPubkey } from '@libs/storage';
import { useAccount } from '@utils/hooks/useAccount';
export function ChatsList() { export function ChatsList() {
const { account } = useAccount(); const { account } = useAccount();
const { const {
status, status,
data: chats, data: chats,
isFetching, isFetching,
} = useQuery( } = useQuery(
["chats"], ['chats'],
async () => { async () => {
const chats = await getChatsByPubkey(account.pubkey); const chats = await getChatsByPubkey(account.pubkey);
const sorted = chats.sort( const sorted = chats.sort(
(a, b) => parseInt(a.new_messages) - parseInt(b.new_messages), (a, b) => parseInt(a.new_messages) - parseInt(b.new_messages)
); );
return sorted; return sorted;
}, },
{ {
enabled: account ? true : false, enabled: account ? true : false,
}, }
); );
if (status === "loading") { if (status === 'loading') {
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5"> <div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" /> <div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3 w-full rounded-sm animate-pulse bg-zinc-800" /> <div className="h-3 w-full animate-pulse rounded-sm bg-zinc-800" />
</div> </div>
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5"> <div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" /> <div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3 w-full rounded-sm animate-pulse bg-zinc-800" /> <div className="h-3 w-full animate-pulse rounded-sm bg-zinc-800" />
</div> </div>
</div> </div>
); );
} }
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<NewMessageModal /> <NewMessageModal />
{account ? ( {account ? (
<ChatsListSelfItem data={account} /> <ChatsListSelfItem data={account} />
) : ( ) : (
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5"> <div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" /> <div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3 w-full rounded-sm animate-pulse bg-zinc-800" /> <div className="h-3 w-full animate-pulse rounded-sm bg-zinc-800" />
</div> </div>
)} )}
{chats.map((item) => { {chats.map((item) => {
if (account.pubkey !== item.sender_pubkey) { if (account.pubkey !== item.sender_pubkey) {
return <ChatsListItem key={item.sender_pubkey} data={item} />; return <ChatsListItem key={item.sender_pubkey} data={item} />;
} }
})} })}
{isFetching && ( {isFetching && (
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5"> <div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" /> <div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3 w-full rounded-sm animate-pulse bg-zinc-800" /> <div className="h-3 w-full animate-pulse rounded-sm bg-zinc-800" />
</div> </div>
)} )}
</div> </div>
); );
} }

View File

@@ -1,65 +1,71 @@
import { usePublish } from "@libs/ndk"; import { nip04 } from 'nostr-tools';
import { EnterIcon } from "@shared/icons"; import { useCallback, useState } from 'react';
import { MediaUploader } from "@shared/mediaUploader";
import { nip04 } from "nostr-tools"; import { usePublish } from '@libs/ndk';
import { useCallback, useState } from "react";
import { EnterIcon } from '@shared/icons';
import { MediaUploader } from '@shared/mediaUploader';
export function ChatMessageForm({ export function ChatMessageForm({
receiverPubkey, receiverPubkey,
userPrivkey, userPrivkey,
}: { receiverPubkey: string; userPubkey: string; userPrivkey: string }) { }: {
const publish = usePublish(); receiverPubkey: string;
const [value, setValue] = useState(""); userPubkey: string;
userPrivkey: string;
}) {
const publish = usePublish();
const [value, setValue] = useState('');
const encryptMessage = useCallback(async () => { const encryptMessage = useCallback(async () => {
return await nip04.encrypt(userPrivkey, receiverPubkey, value); return await nip04.encrypt(userPrivkey, receiverPubkey, value);
}, [receiverPubkey, value]); }, [receiverPubkey, value]);
const submit = async () => { const submit = async () => {
const message = await encryptMessage(); const message = await encryptMessage();
const tags = [["p", receiverPubkey]]; const tags = [['p', receiverPubkey]];
// publish message // publish message
await publish({ content: message, kind: 4, tags }); await publish({ content: message, kind: 4, tags });
// reset state // reset state
setValue(""); setValue('');
}; };
const handleEnterPress = (e: { const handleEnterPress = (e: {
key: string; key: string;
shiftKey: any; shiftKey: any;
preventDefault: () => void; preventDefault: () => void;
}) => { }) => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
submit(); submit();
} }
}; };
return ( return (
<div className="relative h-11 w-full"> <div className="relative h-11 w-full">
<input <input
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
onKeyDown={handleEnterPress} onKeyDown={handleEnterPress}
spellCheck={false} spellCheck={false}
placeholder="Message" placeholder="Message"
className="relative h-11 w-full resize-none rounded-md px-5 !outline-none bg-zinc-800 placeholder:text-zinc-500" className="relative h-11 w-full resize-none rounded-md bg-zinc-800 px-5 !outline-none placeholder:text-zinc-500"
/> />
<div className="absolute right-2 top-0 h-11"> <div className="absolute right-2 top-0 h-11">
<div className="h-full flex gap-3 items-center justify-end text-zinc-500"> <div className="flex h-full items-center justify-end gap-3 text-zinc-500">
<MediaUploader setState={setValue} /> <MediaUploader setState={setValue} />
<button <button
type="button" type="button"
onClick={submit} onClick={submit}
className="inline-flex items-center gap-1 text-sm leading-none" className="inline-flex items-center gap-1 text-sm leading-none"
> >
<EnterIcon width={14} height={14} className="" /> <EnterIcon width={14} height={14} className="" />
Send Send
</button> </button>
</div> </div>
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,49 +1,45 @@
import { useDecryptMessage } from "@app/chat/hooks/useDecryptMessage"; import { useDecryptMessage } from '@app/chat/hooks/useDecryptMessage';
import { MentionNote } from "@shared/notes/mentions/note";
import { ImagePreview } from "@shared/notes/preview/image"; import { MentionNote } from '@shared/notes/mentions/note';
import { LinkPreview } from "@shared/notes/preview/link"; import { ImagePreview } from '@shared/notes/preview/image';
import { VideoPreview } from "@shared/notes/preview/video"; import { LinkPreview } from '@shared/notes/preview/link';
import { User } from "@shared/user"; import { VideoPreview } from '@shared/notes/preview/video';
import { parser } from "@utils/parser"; import { User } from '@shared/user';
import { parser } from '@utils/parser';
export function ChatMessageItem({ export function ChatMessageItem({
data, data,
userPubkey, userPubkey,
userPrivkey, userPrivkey,
}: { }: {
data: any; data: any;
userPubkey: string; userPubkey: string;
userPrivkey: string; userPrivkey: string;
}) { }) {
const decryptedContent = useDecryptMessage(data, userPubkey, userPrivkey); const decryptedContent = useDecryptMessage(data, userPubkey, userPrivkey);
// if we have decrypted content, use it instead of the encrypted content // if we have decrypted content, use it instead of the encrypted content
if (decryptedContent) { if (decryptedContent) {
data["content"] = decryptedContent; data['content'] = decryptedContent;
} }
// parse the note content // parse the note content
const content = parser(data); const content = parser(data);
return ( return (
<div className="flex h-min min-h-min w-full select-text flex-col px-5 py-3 hover:bg-black/20"> <div className="flex h-min min-h-min w-full select-text flex-col px-5 py-3 hover:bg-black/20">
<div className="flex flex-col"> <div className="flex flex-col">
<User <User pubkey={data.sender_pubkey} time={data.created_at} isChat={true} />
pubkey={data.sender_pubkey} <div className="-mt-[20px] pl-[49px]">
time={data.created_at} <p className="select-text whitespace-pre-line break-words text-base text-zinc-100">
isChat={true} {content.parsed}
/> </p>
<div className="-mt-[20px] pl-[49px]"> {content.images.length > 0 && <ImagePreview urls={content.images} />}
<p className="select-text whitespace-pre-line break-words text-base text-zinc-100"> {content.videos.length > 0 && <VideoPreview urls={content.videos} />}
{content.parsed} {content.links.length > 0 && <LinkPreview urls={content.links} />}
</p> {content.notes.length > 0 &&
{content.images.length > 0 && <ImagePreview urls={content.images} />} content.notes.map((note: string) => <MentionNote key={note} id={note} />)}
{content.videos.length > 0 && <VideoPreview urls={content.videos} />} </div>
{content.links.length > 0 && <LinkPreview urls={content.links} />} </div>
{content.notes.length > 0 && </div>
content.notes.map((note: string) => ( );
<MentionNote key={note} id={note} />
))}
</div>
</div>
</div>
);
} }

View File

@@ -1,125 +1,123 @@
import { User } from "@app/auth/components/user"; import { Dialog, Transition } from '@headlessui/react';
import { Dialog, Transition } from "@headlessui/react"; import { Fragment, useState } from 'react';
import { CancelIcon, LoaderIcon, PlusIcon } from "@shared/icons"; import { useNavigate } from 'react-router-dom';
import { useAccount } from "@utils/hooks/useAccount";
import { Fragment, useState } from "react"; import { User } from '@app/auth/components/user';
import { useNavigate } from "react-router-dom";
import { CancelIcon, LoaderIcon, PlusIcon } from '@shared/icons';
import { useAccount } from '@utils/hooks/useAccount';
export function NewMessageModal() { export function NewMessageModal() {
const navigate = useNavigate(); const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { status, account } = useAccount(); const { status, account } = useAccount();
const follows = account ? JSON.parse(account.follows) : []; const follows = account ? JSON.parse(account.follows) : [];
const closeModal = () => { const closeModal = () => {
setIsOpen(false); setIsOpen(false);
}; };
const openModal = () => { const openModal = () => {
setIsOpen(true); setIsOpen(true);
}; };
const openChat = (pubkey: string) => { const openChat = (pubkey: string) => {
closeModal(); closeModal();
navigate(`/app/chat/${pubkey}`); navigate(`/app/chat/${pubkey}`);
}; };
return ( return (
<> <>
<button <button
type="button" type="button"
onClick={() => openModal()} onClick={() => openModal()}
className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5" className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5"
> >
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900"> <div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<PlusIcon width={12} height={12} className="text-zinc-500" /> <PlusIcon width={12} height={12} className="text-zinc-500" />
</div> </div>
<div> <div>
<h5 className="font-medium text-zinc-400">New chat</h5> <h5 className="font-medium text-zinc-400">New chat</h5>
</div> </div>
</button> </button>
<Transition appear show={isOpen} as={Fragment}> <Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}> <Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" /> <div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child> </Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center"> <div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0 scale-95" enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100" enterTo="opacity-100 scale-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-lg border-t border-zinc-800/50 bg-zinc-900"> <Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-lg border-t border-zinc-800/50 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-5"> <div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-5">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Dialog.Title <Dialog.Title
as="h3" as="h3"
className="text-lg font-semibold leading-none text-zinc-100" className="text-lg font-semibold leading-none text-zinc-100"
> >
New chat New chat
</Dialog.Title> </Dialog.Title>
<button <button
type="button" type="button"
onClick={closeModal} onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900" className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
> >
<CancelIcon <CancelIcon width={20} height={20} className="text-zinc-300" />
width={20} </button>
height={20} </div>
className="text-zinc-300" <Dialog.Description className="text-sm leading-tight text-zinc-400">
/> All messages will be encrypted, but anyone can see who you chat
</button> </Dialog.Description>
</div> </div>
<Dialog.Description className="text-sm leading-tight text-zinc-400"> </div>
All messages will be encrypted, but anyone can see who you <div className="flex h-[500px] flex-col overflow-y-auto overflow-x-hidden pb-5">
chat {status === 'loading' ? (
</Dialog.Description> <div className="inline-flex items-center justify-center px-4 py-3">
</div> <LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-zinc-100" />
</div> </div>
<div className="h-[500px] flex flex-col pb-5 overflow-x-hidden overflow-y-auto"> ) : (
{status === "loading" ? ( follows.map((follow) => (
<div className="px-4 py-3 inline-flex items-center justify-center"> <div
<LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-zinc-100" /> key={follow}
</div> className="group flex items-center justify-between px-4 py-3 hover:bg-zinc-800"
) : ( >
follows.map((follow) => ( <User pubkey={follow} />
<div <div>
key={follow} <button
className="group flex items-center justify-between px-4 py-3 hover:bg-zinc-800" type="button"
> onClick={() => openChat(follow)}
<User pubkey={follow} /> className="inline-flex w-max translate-x-20 transform rounded border-t border-zinc-600/50 bg-zinc-700 px-3 py-1.5 text-sm transition-transform duration-150 ease-in-out hover:bg-fuchsia-500 group-hover:translate-x-0"
<div> >
<button Chat
type="button" </button>
onClick={() => openChat(follow)} </div>
className="inline-flex text-sm w-max px-3 py-1.5 rounded border-t border-zinc-600/50 bg-zinc-700 hover:bg-fuchsia-500 transform translate-x-20 group-hover:translate-x-0 transition-transform ease-in-out duration-150" </div>
> ))
Chat )}
</button> </div>
</div> </Dialog.Panel>
</div> </Transition.Child>
)) </div>
)} </Dialog>
</div> </Transition>
</Dialog.Panel> </>
</Transition.Child> );
</div>
</Dialog>
</Transition>
</>
);
} }

View File

@@ -1,49 +1,52 @@
import { Image } from "@shared/image"; import { NavLink } from 'react-router-dom';
import { DEFAULT_AVATAR } from "@stores/constants"; import { twMerge } from 'tailwind-merge';
import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey"; import { Image } from '@shared/image';
import { NavLink } from "react-router-dom";
import { twMerge } from "tailwind-merge"; import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
export function ChatsListSelfItem({ data }: { data: any }) { export function ChatsListSelfItem({ data }: { data: any }) {
const { status, user } = useProfile(data.pubkey); const { status, user } = useProfile(data.pubkey);
if (status === "loading") { if (status === 'loading') {
return ( return (
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5"> <div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" /> <div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div> <div>
<div className="h-2.5 w-full animate-pulse truncate rounded bg-zinc-800 text-base font-medium" /> <div className="h-2.5 w-full animate-pulse truncate rounded bg-zinc-800 text-base font-medium" />
</div> </div>
</div> </div>
); );
} }
return ( return (
<NavLink <NavLink
to={`/app/chat/${data.pubkey}`} to={`/app/chat/${data.pubkey}`}
preventScrollReset={true} preventScrollReset={true}
className={({ isActive }) => className={({ isActive }) =>
twMerge( twMerge(
"inline-flex h-9 items-center gap-2.5 rounded-md px-2.5", 'inline-flex h-9 items-center gap-2.5 rounded-md px-2.5',
isActive ? "bg-zinc-900/50 text-zinc-100" : "", isActive ? 'bg-zinc-900/50 text-zinc-100' : ''
) )
} }
> >
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900"> <div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<Image <Image
src={user?.image} src={user?.image}
fallback={DEFAULT_AVATAR} fallback={DEFAULT_AVATAR}
alt={data.pubkey} alt={data.pubkey}
className="h-6 w-6 rounded bg-white object-cover" className="h-6 w-6 rounded bg-white object-cover"
/> />
</div> </div>
<div className="inline-flex items-baseline gap-1"> <div className="inline-flex items-baseline gap-1">
<h5 className="max-w-[9rem] truncate font-medium text-zinc-200"> <h5 className="max-w-[9rem] truncate font-medium text-zinc-200">
{user?.nip05 || user?.name || shortenKey(data.pubkey)} {user?.nip05 || user?.name || shortenKey(data.pubkey)}
</h5> </h5>
<span className="text-zinc-500">(you)</span> <span className="text-zinc-500">(you)</span>
</div> </div>
</NavLink> </NavLink>
); );
} }

View File

@@ -1,43 +1,46 @@
import { Image } from "@shared/image"; import { Link } from 'react-router-dom';
import { DEFAULT_AVATAR } from "@stores/constants";
import { useProfile } from "@utils/hooks/useProfile"; import { Image } from '@shared/image';
import { shortenKey } from "@utils/shortenKey";
import { Link } from "react-router-dom"; import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
export function ChatSidebar({ pubkey }: { pubkey: string }) { export function ChatSidebar({ pubkey }: { pubkey: string }) {
const { user } = useProfile(pubkey); const { user } = useProfile(pubkey);
return ( return (
<div className="px-3 py-2"> <div className="px-3 py-2">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="relative h-11 w-11 shrink rounded-md"> <div className="relative h-11 w-11 shrink rounded-md">
<Image <Image
src={user?.image} src={user?.image}
fallback={DEFAULT_AVATAR} fallback={DEFAULT_AVATAR}
alt={pubkey} alt={pubkey}
className="h-11 w-11 rounded-md object-cover" className="h-11 w-11 rounded-md object-cover"
/> />
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h3 className="leading-none text-lg font-semibold"> <h3 className="text-lg font-semibold leading-none">
{user?.displayName || user?.name} {user?.displayName || user?.name}
</h3> </h3>
<h5 className="leading-none text-zinc-400"> <h5 className="leading-none text-zinc-400">
{user?.nip05 || shortenKey(pubkey)} {user?.nip05 || shortenKey(pubkey)}
</h5> </h5>
</div> </div>
<div> <div>
<p className="leading-tight">{user?.bio || user?.about}</p> <p className="leading-tight">{user?.bio || user?.about}</p>
<Link <Link
to={`/app/user/${pubkey}`} to={`/app/user/${pubkey}`}
className="mt-3 inline-flex w-full h-10 items-center justify-center rounded-md bg-zinc-900 hover:bg-zinc-800 text-sm text-zinc-300 hover:text-zinc-100 font-medium" className="mt-3 inline-flex h-10 w-full items-center justify-center rounded-md bg-zinc-900 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100"
> >
View full profile View full profile
</Link> </Link>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,25 +1,19 @@
import { nip04 } from "nostr-tools"; import { nip04 } from 'nostr-tools';
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react';
export function useDecryptMessage( export function useDecryptMessage(data: any, userPubkey: string, userPriv: string) {
data: any, const [content, setContent] = useState(data.content);
userPubkey: string,
userPriv: string,
) {
const [content, setContent] = useState(data.content);
useEffect(() => { useEffect(() => {
async function decrypt() { async function decrypt() {
const pubkey = const pubkey =
userPubkey === data.sender_pubkey userPubkey === data.sender_pubkey ? data.receiver_pubkey : data.sender_pubkey;
? data.receiver_pubkey const result = await nip04.decrypt(userPriv, pubkey, data.content);
: data.sender_pubkey; setContent(result);
const result = await nip04.decrypt(userPriv, pubkey, data.content); }
setContent(result);
}
decrypt().catch(console.error); decrypt().catch(console.error);
}, []); }, []);
return content; return content;
} }

View File

@@ -1,155 +1,159 @@
import { ChatMessageForm } from "@app/chat/components/messages/form"; import { NDKSubscription } from '@nostr-dev-kit/ndk';
import { ChatMessageItem } from "@app/chat/components/messages/item"; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ChatSidebar } from "@app/chat/components/sidebar"; import { useCallback, useContext, useEffect, useRef } from 'react';
import { createChat, getChatMessages } from "@libs/storage"; import { useParams } from 'react-router-dom';
import { NDKSubscription } from "@nostr-dev-kit/ndk"; import { Virtuoso } from 'react-virtuoso';
import { RelayContext } from "@shared/relayProvider";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ChatMessageForm } from '@app/chat/components/messages/form';
import { useAccount } from "@utils/hooks/useAccount"; import { ChatMessageItem } from '@app/chat/components/messages/item';
import { useCallback, useContext, useEffect, useRef } from "react"; import { ChatSidebar } from '@app/chat/components/sidebar';
import { useParams } from "react-router-dom";
import { Virtuoso } from "react-virtuoso"; import { createChat, getChatMessages } from '@libs/storage';
import { RelayContext } from '@shared/relayProvider';
import { useAccount } from '@utils/hooks/useAccount';
export function ChatScreen() { export function ChatScreen() {
const ndk = useContext(RelayContext); const ndk = useContext(RelayContext);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const virtuosoRef = useRef(null); const virtuosoRef = useRef(null);
const { pubkey } = useParams(); const { pubkey } = useParams();
const { account } = useAccount(); const { account } = useAccount();
const { status, data } = useQuery( const { status, data } = useQuery(
["chat", pubkey], ['chat', pubkey],
async () => { async () => {
return await getChatMessages(account.pubkey, pubkey); return await getChatMessages(account.pubkey, pubkey);
}, },
{ {
enabled: account ? true : false, enabled: account ? true : false,
}, }
); );
const itemContent: any = useCallback( const itemContent: any = useCallback(
(index: string | number) => { (index: string | number) => {
return ( return (
<ChatMessageItem <ChatMessageItem
data={data[index]} data={data[index]}
userPubkey={account.pubkey} userPubkey={account.pubkey}
userPrivkey={account.privkey} userPrivkey={account.privkey}
/> />
); );
}, },
[data], [data]
); );
const computeItemKey = useCallback( const computeItemKey = useCallback(
(index: string | number) => { (index: string | number) => {
return data[index].id; return data[index].id;
}, },
[data], [data]
); );
const chat = useMutation({ const chat = useMutation({
mutationFn: (data: any) => { mutationFn: (data: any) => {
return createChat( return createChat(
data.id, data.id,
data.receiver_pubkey, data.receiver_pubkey,
data.sender_pubkey, data.sender_pubkey,
data.content, data.content,
data.tags, data.tags,
data.created_at, data.created_at
); );
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["chat", pubkey] }); queryClient.invalidateQueries({ queryKey: ['chat', pubkey] });
}, },
}); });
useEffect(() => { useEffect(() => {
const sub: NDKSubscription = ndk.subscribe( const sub: NDKSubscription = ndk.subscribe(
{ {
kinds: [4], kinds: [4],
authors: [account.pubkey], authors: [account.pubkey],
"#p": [pubkey], '#p': [pubkey],
since: Math.floor(Date.now() / 1000), since: Math.floor(Date.now() / 1000),
}, },
{ {
closeOnEose: false, closeOnEose: false,
}, }
); );
sub.addListener("event", (event) => { sub.addListener('event', (event) => {
chat.mutate({ chat.mutate({
id: event.id, id: event.id,
receiver_pubkey: pubkey, receiver_pubkey: pubkey,
sender_pubkey: event.pubkey, sender_pubkey: event.pubkey,
content: event.content, content: event.content,
tags: event.tags, tags: event.tags,
created_at: event.created_at, created_at: event.created_at,
}); });
}); });
return () => { return () => {
sub.stop(); sub.stop();
}; };
}, [pubkey]); }, [pubkey]);
return ( return (
<div className="h-full w-full grid grid-cols-3"> <div className="grid h-full w-full grid-cols-3">
<div className="col-span-2 flex flex-col justify-between border-r border-zinc-900"> <div className="col-span-2 flex flex-col justify-between border-r border-zinc-900">
<div <div
data-tauri-drag-region data-tauri-drag-region
className="h-11 w-full shrink-0 inline-flex items-center justify-center border-b border-zinc-900" className="inline-flex h-11 w-full shrink-0 items-center justify-center border-b border-zinc-900"
> >
<h3 className="font-semibold text-zinc-100">Encrypted Chat</h3> <h3 className="font-semibold text-zinc-100">Encrypted Chat</h3>
</div> </div>
<div className="w-full h-full flex-1 p-3"> <div className="h-full w-full flex-1 p-3">
<div className="h-full flex flex-col justify-between rounded-xl border-t border-zinc-800/50 bg-zinc-900 overflow-hidden"> <div className="flex h-full flex-col justify-between overflow-hidden rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="flex-1 w-full h-full"> <div className="h-full w-full flex-1">
{status === "loading" ? ( {status === 'loading' ? (
<p>Loading...</p> <p>Loading...</p>
) : ( ) : (
<Virtuoso <Virtuoso
ref={virtuosoRef} ref={virtuosoRef}
data={data} data={data}
itemContent={itemContent} itemContent={itemContent}
computeItemKey={computeItemKey} computeItemKey={computeItemKey}
initialTopMostItemIndex={data.length - 1} initialTopMostItemIndex={data.length - 1}
alignToBottom={true} alignToBottom={true}
followOutput={true} followOutput={true}
overscan={50} overscan={50}
increaseViewportBy={{ top: 200, bottom: 200 }} increaseViewportBy={{ top: 200, bottom: 200 }}
className="relative scrollbar-hide overflow-y-auto" className="scrollbar-hide relative overflow-y-auto"
components={{ components={{
EmptyPlaceholder: () => Empty, EmptyPlaceholder: () => Empty,
}} }}
/> />
)} )}
</div> </div>
<div className="shrink-0 px-5 p-3 rounded-b-xl border-t border-zinc-800 bg-zinc-900 z-50"> <div className="z-50 shrink-0 rounded-b-xl border-t border-zinc-800 bg-zinc-900 p-3 px-5">
<ChatMessageForm <ChatMessageForm
receiverPubkey={pubkey} receiverPubkey={pubkey}
userPubkey={account.pubkey} userPubkey={account.pubkey}
userPrivkey={account.privkey} userPrivkey={account.privkey}
/> />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div className="col-span-1"> <div className="col-span-1">
<div <div
data-tauri-drag-region data-tauri-drag-region
className="h-11 w-full shrink-0 inline-flex items-center justify-center border-b border-zinc-900" className="inline-flex h-11 w-full shrink-0 items-center justify-center border-b border-zinc-900"
/> />
<ChatSidebar pubkey={pubkey} /> <ChatSidebar pubkey={pubkey} />
</div> </div>
</div> </div>
); );
} }
const Empty = ( const Empty = (
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-full flex flex-col gap-1 text-center"> <div className="absolute left-1/2 top-1/2 flex w-full -translate-x-1/2 -translate-y-1/2 transform flex-col gap-1 text-center">
<h3 className="mb-2 text-4xl">🙌</h3> <h3 className="mb-2 text-4xl">🙌</h3>
<p className="leading-none text-zinc-400"> <p className="leading-none text-zinc-400">
You two didn't talk yet, let's send first message You two didn&apos;t talk yet, let&apos;s send first message
</p> </p>
</div> </div>
); );

View File

@@ -1,17 +1,17 @@
import { useRouteError } from "react-router-dom"; import { useRouteError } from 'react-router-dom';
export function ErrorScreen() { export function ErrorScreen() {
const error: any = useRouteError(); const error: any = useRouteError();
return ( return (
<div className="w-full h-full flex items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
<div> <div>
<h1>Oops!</h1> <h1>Oops!</h1>
<p>Sorry, an unexpected error has occurred.</p> <p>Sorry, an unexpected error has occurred.</p>
<p> <p>
<i>{error.statusText || error.message}</i> <i>{error.statusText || error.message}</i>
</p> </p>
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,185 +1,187 @@
import { prefetchEvents } from "@libs/ndk"; import { NDKFilter } from '@nostr-dev-kit/ndk';
import { useContext, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { prefetchEvents } from '@libs/ndk';
import { import {
countTotalNotes, countTotalNotes,
createChannelMessage, createChannelMessage,
createChat, createChat,
createNote, createNote,
getChannels, getChannels,
getLastLogin, getLastLogin,
updateLastLogin, updateLastLogin,
} from "@libs/storage"; } from '@libs/storage';
import { NDKFilter } from "@nostr-dev-kit/ndk";
import { LoaderIcon, LumeIcon } from "@shared/icons"; import { LoaderIcon, LumeIcon } from '@shared/icons';
import { RelayContext } from "@shared/relayProvider"; import { RelayContext } from '@shared/relayProvider';
import { dateToUnix, getHourAgo } from "@utils/date";
import { useAccount } from "@utils/hooks/useAccount"; import { dateToUnix, getHourAgo } from '@utils/date';
import { useContext, useEffect, useRef } from "react"; import { useAccount } from '@utils/hooks/useAccount';
import { useNavigate } from "react-router-dom";
const totalNotes = await countTotalNotes(); const totalNotes = await countTotalNotes();
const lastLogin = await getLastLogin(); const lastLogin = await getLastLogin();
export function Root() { export function Root() {
const ndk = useContext(RelayContext); const ndk = useContext(RelayContext);
const now = useRef(new Date()); const now = useRef(new Date());
const navigate = useNavigate(); const navigate = useNavigate();
const { status, account } = useAccount(); const { status, account } = useAccount();
async function fetchNotes() { async function fetchNotes() {
try { try {
const follows = JSON.parse(account.follows); const follows = JSON.parse(account.follows);
let since: number; let since: number;
if (totalNotes === 0 || lastLogin === 0) { if (totalNotes === 0 || lastLogin === 0) {
since = dateToUnix(getHourAgo(48, now.current)); since = dateToUnix(getHourAgo(48, now.current));
} else { } else {
since = lastLogin; since = lastLogin;
} }
const filter: NDKFilter = { const filter: NDKFilter = {
kinds: [1, 6], kinds: [1, 6],
authors: follows, authors: follows,
since: since, since: since,
}; };
const events = await prefetchEvents(ndk, filter); const events = await prefetchEvents(ndk, filter);
events.forEach((event) => { events.forEach((event) => {
createNote( createNote(
event.id, event.id,
event.pubkey, event.pubkey,
event.kind, event.kind,
event.tags, event.tags,
event.content, event.content,
event.created_at, event.created_at
); );
}); });
return true; return true;
} catch (e) { } catch (e) {
console.log("error: ", e); console.log('error: ', e);
} }
} }
async function fetchChats() { async function fetchChats() {
try { try {
const sendFilter: NDKFilter = { const sendFilter: NDKFilter = {
kinds: [4], kinds: [4],
authors: [account.pubkey], authors: [account.pubkey],
since: lastLogin, since: lastLogin,
}; };
const receiveFilter: NDKFilter = { const receiveFilter: NDKFilter = {
kinds: [4], kinds: [4],
"#p": [account.pubkey], '#p': [account.pubkey],
since: lastLogin, since: lastLogin,
}; };
const sendMessages = await prefetchEvents(ndk, sendFilter); const sendMessages = await prefetchEvents(ndk, sendFilter);
const receiveMessages = await prefetchEvents(ndk, receiveFilter); const receiveMessages = await prefetchEvents(ndk, receiveFilter);
const events = [...sendMessages, ...receiveMessages]; const events = [...sendMessages, ...receiveMessages];
events.forEach((event) => { events.forEach((event) => {
const receiverPubkey = const receiverPubkey = event.tags.find((t) => t[0] === 'p')[1] || account.pubkey;
event.tags.find((t) => t[0] === "p")[1] || account.pubkey; createChat(
createChat( event.id,
event.id, receiverPubkey,
receiverPubkey, event.pubkey,
event.pubkey, event.content,
event.content, event.tags,
event.tags, event.created_at
event.created_at, );
); });
});
return true; return true;
} catch (e) { } catch (e) {
console.log("error: ", e); console.log('error: ', e);
} }
} }
async function fetchChannelMessages() { /*
try { async function fetchChannelMessages() {
const ids = []; try {
const channels: any = await getChannels(); const ids = [];
channels.forEach((channel) => { const channels: any = await getChannels();
ids.push(channel.event_id); channels.forEach((channel) => {
}); ids.push(channel.event_id);
});
const since = const since = lastLogin === 0 ? dateToUnix(getHourAgo(48, now.current)) : lastLogin;
lastLogin === 0 ? dateToUnix(getHourAgo(48, now.current)) : lastLogin;
const filter: NDKFilter = { const filter: NDKFilter = {
"#e": ids, '#e': ids,
kinds: [42], kinds: [42],
since: since, since: since,
}; };
const events = await prefetchEvents(ndk, filter); const events = await prefetchEvents(ndk, filter);
events.forEach((event) => { events.forEach((event) => {
const channel_id = event.tags[0][1]; const channel_id = event.tags[0][1];
if (channel_id) { if (channel_id) {
createChannelMessage( createChannelMessage(
channel_id, channel_id,
event.id, event.id,
event.pubkey, event.pubkey,
event.kind, event.kind,
event.content, event.content,
event.tags, event.tags,
event.created_at, event.created_at
); );
} }
}); });
return true; return true;
} catch (e) { } catch (e) {
console.log("error: ", e); console.log('error: ', e);
} }
} }
*/
useEffect(() => { useEffect(() => {
async function prefetch() { async function prefetch() {
const notes = await fetchNotes(); const notes = await fetchNotes();
if (notes) { if (notes) {
const chats = await fetchChats(); const chats = await fetchChats();
// const channels = await fetchChannelMessages(); // const channels = await fetchChannelMessages();
if (chats) { if (chats) {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
await updateLastLogin(now); await updateLastLogin(now);
navigate("/app/space", { replace: true }); navigate('/app/space', { replace: true });
} }
} }
} }
if (status === "success" && account) { if (status === 'success' && account) {
prefetch(); prefetch();
} }
}, [status]); }, [status]);
return ( return (
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-zinc-100"> <div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-zinc-100">
<div className="relative h-full overflow-hidden"> <div className="relative h-full overflow-hidden">
<div <div
data-tauri-drag-region data-tauri-drag-region
className="absolute left-0 top-0 z-20 h-16 w-full bg-transparent" className="absolute left-0 top-0 z-20 h-16 w-full bg-transparent"
/> />
<div className="relative flex h-full flex-col items-center justify-center"> <div className="relative flex h-full flex-col items-center justify-center">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<LumeIcon className="h-16 w-16 text-black dark:text-zinc-100" /> <LumeIcon className="h-16 w-16 text-black dark:text-zinc-100" />
<div className="text-center"> <div className="text-center">
<h3 className="text-lg font-semibold leading-tight text-zinc-900 dark:text-zinc-100"> <h3 className="text-lg font-semibold leading-tight text-zinc-900 dark:text-zinc-100">
Here&apos;s an interesting fact: Here&apos;s an interesting fact:
</h3> </h3>
<p className="font-medium text-zinc-300 dark:text-zinc-600"> <p className="font-medium text-zinc-300 dark:text-zinc-600">
Bitcoin and Nostr can be used by anyone, and no one can stop Bitcoin and Nostr can be used by anyone, and no one can stop you!
you! </p>
</p> </div>
</div> </div>
</div> <div className="absolute bottom-16 left-1/2 -translate-x-1/2 transform">
<div className="absolute bottom-16 left-1/2 -translate-x-1/2 transform"> <LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-zinc-100" />
<LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-zinc-100" /> </div>
</div> </div>
</div> </div>
</div> </div>
</div> );
);
} }

View File

@@ -1,84 +1,89 @@
import { EyeOffIcon, EyeOnIcon } from "@shared/icons"; import { useState } from 'react';
import { useAccount } from "@utils/hooks/useAccount";
import { useState } from "react"; import { EyeOffIcon, EyeOnIcon } from '@shared/icons';
import { useAccount } from '@utils/hooks/useAccount';
export function AccountSettingsScreen() { export function AccountSettingsScreen() {
const { status, account } = useAccount(); const { status, account } = useAccount();
const [type, setType] = useState("password"); const [type, setType] = useState('password');
const showPrivateKey = () => { const showPrivateKey = () => {
if (type === "password") { if (type === 'password') {
setType("text"); setType('text');
} else { } else {
setType("password"); setType('password');
} }
}; };
return ( return (
<div className="w-full h-full px-3 pt-12"> <div className="h-full w-full px-3 pt-12">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h1 className="text-lg font-semibold text-zinc-100">Account</h1> <h1 className="text-lg font-semibold text-zinc-100">Account</h1>
<div className=""> <div className="">
{status === "loading" ? ( {status === 'loading' ? (
<p>Loading...</p> <p>Loading...</p>
) : ( ) : (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-base font-semibold text-zinc-400"> <label htmlFor="pubkey" className="text-base font-semibold text-zinc-400">
Public Key Public Key
</label> </label>
<input <input
readOnly readOnly
value={account.pubkey} value={account.pubkey}
className="relative w-2/3 rounded-lg py-3 pl-3.5 pr-11 !outline-none placeholder:text-zinc-400 bg-zinc-800 text-zinc-100" className="relative w-2/3 rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-zinc-100 !outline-none placeholder:text-zinc-400"
/> />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-base font-semibold text-zinc-400"> <label htmlFor="npub" className="text-base font-semibold text-zinc-400">
Npub Npub
</label> </label>
<input <input
readOnly readOnly
value={account.npub} value={account.npub}
className="relative w-2/3 rounded-lg py-3 pl-3.5 pr-11 !outline-none placeholder:text-zinc-400 bg-zinc-800 text-zinc-100" className="relative w-2/3 rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-zinc-100 !outline-none placeholder:text-zinc-400"
/> />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-base font-semibold text-zinc-400"> <label
Private Key htmlFor="privkey"
</label> className="text-base font-semibold text-zinc-400"
<div className="relative w-2/3"> >
<input Private Key
readOnly </label>
type={type} <div className="relative w-2/3">
value={account.privkey} <input
className="relative w-full rounded-lg py-3 pl-3.5 pr-11 !outline-none placeholder:text-zinc-400 bg-zinc-800 text-zinc-100" readOnly
/> type={type}
<button value={account.privkey}
type="button" className="relative w-full rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-zinc-100 !outline-none placeholder:text-zinc-400"
onClick={() => showPrivateKey()} />
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700" <button
> type="button"
{type === "password" ? ( onClick={() => showPrivateKey()}
<EyeOffIcon className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
width={20} >
height={20} {type === 'password' ? (
className="text-zinc-500 group-hover:text-zinc-100" <EyeOffIcon
/> width={20}
) : ( height={20}
<EyeOnIcon className="text-zinc-500 group-hover:text-zinc-100"
width={20} />
height={20} ) : (
className="text-zinc-500 group-hover:text-zinc-100" <EyeOnIcon
/> width={20}
)} height={20}
</button> className="text-zinc-500 group-hover:text-zinc-100"
</div> />
</div> )}
</div> </button>
)} </div>
</div> </div>
</div> </div>
</div> )}
); </div>
</div>
</div>
);
} }

View File

@@ -1,61 +1,58 @@
import { Switch } from "@headlessui/react"; import { Switch } from '@headlessui/react';
import { getSetting, updateSetting } from "@libs/storage"; import { useEffect, useState } from 'react';
import { useEffect, useState } from "react"; import { twMerge } from 'tailwind-merge';
import { twMerge } from "tailwind-merge"; import { disable, enable, isEnabled } from 'tauri-plugin-autostart-api';
import { disable, enable, isEnabled } from "tauri-plugin-autostart-api";
import { getSetting, updateSetting } from '@libs/storage';
export function AutoStartSetting() { export function AutoStartSetting() {
const [enabled, setEnabled] = useState(false); const [enabled, setEnabled] = useState(false);
const toggle = async () => { const toggle = async () => {
if (!enabled) { if (!enabled) {
await enable(); await enable();
await updateSetting("auto_start", 1); await updateSetting('auto_start', 1);
console.log(`registered for autostart? ${await isEnabled()}`); console.log(`registered for autostart? ${await isEnabled()}`);
} else { } else {
await disable(); await disable();
await updateSetting("auto_start", 0); await updateSetting('auto_start', 0);
} }
setEnabled(!enabled); setEnabled(!enabled);
}; };
useEffect(() => { useEffect(() => {
async function getAppSetting() { async function getAppSetting() {
const setting = await getSetting("auto_start"); const setting = await getSetting('auto_start');
if (parseInt(setting) === 0) { if (parseInt(setting) === 0) {
setEnabled(false); setEnabled(false);
} else { } else {
setEnabled(true); setEnabled(true);
} }
} }
getAppSetting(); getAppSetting();
}, []); }, []);
return ( return (
<div className="px-5 py-4 inline-flex items-center justify-between"> <div className="inline-flex items-center justify-between px-5 py-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="leading-none font-medium text-zinc-200"> <span className="font-medium leading-none text-zinc-200">Auto start</span>
Auto start <span className="text-sm leading-none text-zinc-400">Auto start at login</span>
</span> </div>
<span className="leading-none text-sm text-zinc-400"> <Switch
Auto start at login checked={enabled}
</span> onChange={toggle}
</div> className={twMerge(
<Switch 'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-fuchsia-500 focus:ring-offset-2',
checked={enabled} enabled ? 'bg-fuchsia-500' : 'bg-zinc-700'
onChange={toggle} )}
className={twMerge( >
"relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-fuchsia-500 focus:ring-offset-2", <span
enabled ? "bg-fuchsia-500" : "bg-zinc-700", className={twMerge(
)} 'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-zinc-900 shadow ring-0 transition duration-200 ease-in-out',
> enabled ? 'translate-x-5' : 'translate-x-0'
<span )}
className={twMerge( />
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-zinc-900 shadow ring-0 transition duration-200 ease-in-out", </Switch>
enabled ? "translate-x-5" : "translate-x-0", </div>
)} );
/>
</Switch>
</div>
);
} }

View File

@@ -1,43 +1,43 @@
import { getSetting, updateSetting } from "@libs/storage"; import { useState } from 'react';
import { CheckCircleIcon } from "@shared/icons";
import { useState } from "react";
const setting = await getSetting("cache_time"); import { getSetting, updateSetting } from '@libs/storage';
import { CheckCircleIcon } from '@shared/icons';
const setting = await getSetting('cache_time');
const cacheTime = setting; const cacheTime = setting;
export function CacheTimeSetting() { export function CacheTimeSetting() {
const [time, setTime] = useState(cacheTime); const [time, setTime] = useState(cacheTime);
const update = async () => { const update = async () => {
await updateSetting("cache_time", time); await updateSetting('cache_time', time);
}; };
return ( return (
<div className="px-5 py-4 inline-flex items-center justify-between"> <div className="inline-flex items-center justify-between px-5 py-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="leading-none font-medium text-zinc-200"> <span className="font-medium leading-none text-zinc-200">Cache time</span>
Cache time <span className="text-sm leading-none text-zinc-400">
</span> The length of time before inactive data gets removed from the cache
<span className="leading-none text-sm text-zinc-400"> </span>
The length of time before inactive data gets removed from the cache </div>
</span> <div className="inline-flex items-center gap-2">
</div> <input
<div className="inline-flex items-center gap-2"> value={time}
<input onChange={(e) => setTime(e.currentTarget.value)}
value={time} autoCapitalize="none"
onChange={(e) => setTime(e.currentTarget.value)} autoCorrect="none"
autoCapitalize="none" className="h-8 w-24 rounded-md bg-zinc-800 px-2 text-right font-medium text-zinc-300 focus:outline-none"
autoCorrect="none" />
className="w-24 h-8 rounded-md px-2 bg-zinc-800 text-zinc-300 text-right font-medium focus:outline-none" <button
/> type="button"
<button onClick={() => update()}
type="button" className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-zinc-800 font-medium hover:bg-fuchsia-500"
onClick={() => update()} >
className="w-8 h-8 inline-flex items-center justify-center font-medium bg-zinc-800 hover:bg-fuchsia-500 rounded-md" <CheckCircleIcon className="h-4 w-4 text-zinc-100" />
> </button>
<CheckCircleIcon className="w-4 h-4 text-zinc-100" /> </div>
</button> </div>
</div> );
</div>
);
} }

View File

@@ -1,26 +1,27 @@
import { RefreshIcon } from "@shared/icons"; import { getVersion } from '@tauri-apps/api/app';
import { getVersion } from "@tauri-apps/api/app";
import { RefreshIcon } from '@shared/icons';
const appVersion = await getVersion(); const appVersion = await getVersion();
export function VersionSetting() { export function VersionSetting() {
return ( return (
<div className="px-5 py-4 inline-flex items-center justify-between"> <div className="inline-flex items-center justify-between px-5 py-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="leading-none font-medium text-zinc-200">Version</span> <span className="font-medium leading-none text-zinc-200">Version</span>
<span className="leading-none text-sm text-zinc-400"> <span className="text-sm leading-none text-zinc-400">
You're using latest version You&apos;re using latest version
</span> </span>
</div> </div>
<div className="inline-flex items-center gap-2"> <div className="inline-flex items-center gap-2">
<span className="text-zinc-300 font-medium">{appVersion}</span> <span className="font-medium text-zinc-300">{appVersion}</span>
<button <button
type="button" type="button"
className="w-8 h-8 inline-flex items-center justify-center font-medium bg-zinc-800 hover:bg-fuchsia-500 rounded-md" className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-zinc-800 font-medium hover:bg-fuchsia-500"
> >
<RefreshIcon className="w-4 h-4 text-zinc-100" /> <RefreshIcon className="h-4 w-4 text-zinc-100" />
</button> </button>
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,20 +1,20 @@
import { AutoStartSetting } from "@app/settings/components/autoStart"; import { AutoStartSetting } from '@app/settings/components/autoStart';
import { CacheTimeSetting } from "@app/settings/components/cacheTime"; import { CacheTimeSetting } from '@app/settings/components/cacheTime';
import { VersionSetting } from "@app/settings/components/version"; import { VersionSetting } from '@app/settings/components/version';
export function GeneralSettingsScreen() { export function GeneralSettingsScreen() {
return ( return (
<div className="w-full h-full px-3 pt-12"> <div className="h-full w-full px-3 pt-12">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h1 className="text-lg font-semibold text-zinc-100">General</h1> <h1 className="text-lg font-semibold text-zinc-100">General</h1>
<div className="w-full bg-zinc-900 border-t border-zinc-800/50 rounded-xl"> <div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="w-full h-full flex flex-col divide-y divide-zinc-800"> <div className="flex h-full w-full flex-col divide-y divide-zinc-800">
<AutoStartSetting /> <AutoStartSetting />
<CacheTimeSetting /> <CacheTimeSetting />
<VersionSetting /> <VersionSetting />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,110 +1,90 @@
import { CommandIcon } from "@shared/icons"; import { CommandIcon } from '@shared/icons';
export function ShortcutsSettingsScreen() { export function ShortcutsSettingsScreen() {
return ( return (
<div className="w-full h-full px-3 pt-12"> <div className="h-full w-full px-3 pt-12">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h1 className="text-lg font-semibold text-zinc-100">Shortcuts</h1> <h1 className="text-lg font-semibold text-zinc-100">Shortcuts</h1>
<div className="w-full bg-zinc-900 border-t border-zinc-800/50 rounded-xl"> <div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="w-full h-full flex flex-col divide-y divide-zinc-800"> <div className="flex h-full w-full flex-col divide-y divide-zinc-800">
<div className="px-5 py-4 inline-flex items-center justify-between"> <div className="inline-flex items-center justify-between px-5 py-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="leading-none font-medium text-zinc-200"> <span className="font-medium leading-none text-zinc-200">
Open composer Open composer
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800"> <div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
<CommandIcon <CommandIcon width={12} height={12} className="text-zinc-500" />
width={12} </div>
height={12} <div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
className="text-zinc-500" <span className="text-sm leading-none text-zinc-500">N</span>
/> </div>
</div> </div>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800"> </div>
<span className="text-zinc-500 text-sm leading-none">N</span> <div className="inline-flex items-center justify-between px-5 py-4">
</div> <div className="flex flex-col gap-1">
</div> <span className="font-medium leading-none text-zinc-200">
</div> Add image block
<div className="px-5 py-4 inline-flex items-center justify-between"> </span>
<div className="flex flex-col gap-1"> </div>
<span className="leading-none font-medium text-zinc-200"> <div className="flex items-center gap-2">
Add image block <div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
</span> <CommandIcon width={12} height={12} className="text-zinc-500" />
</div> </div>
<div className="flex items-center gap-2"> <div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800"> <span className="text-sm leading-none text-zinc-500">I</span>
<CommandIcon </div>
width={12} </div>
height={12} </div>
className="text-zinc-500" <div className="inline-flex items-center justify-between px-5 py-4">
/> <div className="flex flex-col gap-1">
</div> <span className="font-medium leading-none text-zinc-200">
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800"> Add newsfeed block
<span className="text-zinc-500 text-sm leading-none">I</span> </span>
</div> </div>
</div> <div className="flex items-center gap-2">
</div> <div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
<div className="px-5 py-4 inline-flex items-center justify-between"> <CommandIcon width={12} height={12} className="text-zinc-500" />
<div className="flex flex-col gap-1"> </div>
<span className="leading-none font-medium text-zinc-200"> <div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
Add newsfeed block <span className="text-sm leading-none text-zinc-500">F</span>
</span> </div>
</div> </div>
<div className="flex items-center gap-2"> </div>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800"> <div className="inline-flex items-center justify-between px-5 py-4">
<CommandIcon <div className="flex flex-col gap-1">
width={12} <span className="font-medium leading-none text-zinc-200">
height={12} Open personal page
className="text-zinc-500" </span>
/> </div>
</div> <div className="flex items-center gap-2">
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800"> <div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
<span className="text-zinc-500 text-sm leading-none">F</span> <CommandIcon width={12} height={12} className="text-zinc-500" />
</div> </div>
</div> <div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
</div> <span className="text-sm leading-none text-zinc-500">P</span>
<div className="px-5 py-4 inline-flex items-center justify-between"> </div>
<div className="flex flex-col gap-1"> </div>
<span className="leading-none font-medium text-zinc-200"> </div>
Open personal page <div className="inline-flex items-center justify-between px-5 py-4">
</span> <div className="flex flex-col gap-1">
</div> <span className="font-medium leading-none text-zinc-200">
<div className="flex items-center gap-2"> Open notification
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800"> </span>
<CommandIcon </div>
width={12} <div className="flex items-center gap-2">
height={12} <div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
className="text-zinc-500" <CommandIcon width={12} height={12} className="text-zinc-500" />
/> </div>
</div> <div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800"> <span className="text-sm leading-none text-zinc-500">B</span>
<span className="text-zinc-500 text-sm leading-none">P</span> </div>
</div> </div>
</div> </div>
</div> </div>
<div className="px-5 py-4 inline-flex items-center justify-between"> </div>
<div className="flex flex-col gap-1"> </div>
<span className="leading-none font-medium text-zinc-200"> </div>
Open notification );
</span>
</div>
<div className="flex items-center gap-2">
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
<CommandIcon
width={12}
height={12}
className="text-zinc-500"
/>
</div>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
<span className="text-zinc-500 text-sm leading-none">B</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
} }

View File

@@ -1,11 +1,11 @@
import { AddFeedBlock } from "@app/space/components/addFeed"; import { AddFeedBlock } from '@app/space/components/addFeed';
import { AddImageBlock } from "@app/space/components/addImage"; import { AddImageBlock } from '@app/space/components/addImage';
export function AddBlock() { export function AddBlock() {
return ( return (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<AddImageBlock /> <AddImageBlock />
<AddFeedBlock /> <AddFeedBlock />
</div> </div>
); );
} }

View File

@@ -1,274 +1,252 @@
import { User } from "@app/auth/components/user"; import { Dialog, Transition } from '@headlessui/react';
import { Dialog, Transition } from "@headlessui/react"; import { Combobox } from '@headlessui/react';
import { Combobox } from "@headlessui/react"; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createBlock } from "@libs/storage"; import { nip19 } from 'nostr-tools';
import { CancelIcon, CheckCircleIcon, CommandIcon } from "@shared/icons"; import { Fragment, useState } from 'react';
import { DEFAULT_AVATAR } from "@stores/constants"; import { useForm } from 'react-hook-form';
import { ADD_FEEDBLOCK_SHORTCUT } from "@stores/shortcuts"; import { useHotkeys } from 'react-hotkeys-hook';
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useAccount } from "@utils/hooks/useAccount"; import { User } from '@app/auth/components/user';
import { nip19 } from "nostr-tools";
import { Fragment, useEffect, useState } from "react"; import { createBlock } from '@libs/storage';
import { useForm } from "react-hook-form";
import { useHotkeys } from "react-hotkeys-hook"; import { CancelIcon, CheckCircleIcon, CommandIcon, LoaderIcon } from '@shared/icons';
import { DEFAULT_AVATAR } from '@stores/constants';
import { ADD_FEEDBLOCK_SHORTCUT } from '@stores/shortcuts';
import { useAccount } from '@utils/hooks/useAccount';
export function AddFeedBlock() { export function AddFeedBlock() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [selected, setSelected] = useState([]); const [selected, setSelected] = useState([]);
const [query, setQuery] = useState(""); const [query, setQuery] = useState('');
const { status, account } = useAccount(); const { status, account } = useAccount();
const openModal = () => { const openModal = () => {
setIsOpen(true); setIsOpen(true);
}; };
const closeModal = () => { const closeModal = () => {
setIsOpen(false); setIsOpen(false);
}; };
useHotkeys(ADD_FEEDBLOCK_SHORTCUT, () => openModal()); useHotkeys(ADD_FEEDBLOCK_SHORTCUT, () => openModal());
const block = useMutation({ const block = useMutation({
mutationFn: (data: any) => { mutationFn: (data: any) => {
return createBlock(data.kind, data.title, data.content); return createBlock(data.kind, data.title, data.content);
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["blocks"] }); queryClient.invalidateQueries({ queryKey: ['blocks'] });
}, },
}); });
const { const {
register, register,
handleSubmit, handleSubmit,
reset, reset,
formState: { isDirty, isValid }, formState: { isDirty, isValid },
} = useForm(); } = useForm();
const onSubmit = (data: any) => { const onSubmit = (data: any) => {
setLoading(true); setLoading(true);
selected.forEach((item, index) => { selected.forEach((item, index) => {
if (item.substring(0, 4) === "npub") { if (item.substring(0, 4) === 'npub') {
selected[index] = nip19.decode(item).data; selected[index] = nip19.decode(item).data;
} }
}); });
// insert to database // insert to database
block.mutate({ block.mutate({
kind: 1, kind: 1,
title: data.title, title: data.title,
content: JSON.stringify(selected), content: JSON.stringify(selected),
}); });
setLoading(false); setLoading(false);
// reset form // reset form
reset(); reset();
// close modal // close modal
closeModal(); closeModal();
}; };
return ( return (
<> <>
<button <button
type="button" type="button"
onClick={() => openModal()} onClick={() => openModal()}
className="inline-flex w-56 h-9 items-center justify-start gap-2.5 rounded-md px-2.5" className="inline-flex h-9 w-56 items-center justify-start gap-2.5 rounded-md px-2.5"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900"> <div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<CommandIcon width={12} height={12} className="text-zinc-500" /> <CommandIcon width={12} height={12} className="text-zinc-500" />
</div> </div>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900"> <div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<span className="text-zinc-500 text-sm leading-none">F</span> <span className="text-sm leading-none text-zinc-500">F</span>
</div> </div>
</div> </div>
<div> <div>
<h5 className="font-medium text-zinc-400">New feed block</h5> <h5 className="font-medium text-zinc-400">New feed block</h5>
</div> </div>
</button> </button>
<Transition appear show={isOpen} as={Fragment}> <Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={closeModal}> <Dialog as="div" className="relative z-50" onClose={closeModal}>
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" /> <div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child> </Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center"> <div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0 scale-95" enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100" enterTo="opacity-100 scale-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-xl border-t border-zinc-800/50 bg-zinc-900"> <Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-5"> <div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-5">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Dialog.Title <Dialog.Title
as="h3" as="h3"
className="text-lg font-semibold leading-none text-zinc-100" className="text-lg font-semibold leading-none text-zinc-100"
> >
Create feed block Create feed block
</Dialog.Title> </Dialog.Title>
<button <button
type="button" type="button"
onClick={closeModal} onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900" className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
> >
<CancelIcon <CancelIcon width={14} height={14} className="text-zinc-300" />
width={14} </button>
height={14} </div>
className="text-zinc-300" <Dialog.Description className="text-sm leading-tight text-zinc-400">
/> Specific newsfeed space for people you want to keep up to date
</button> </Dialog.Description>
</div> </div>
<Dialog.Description className="text-sm leading-tight text-zinc-400"> </div>
Specific newsfeed space for people you want to keep up to <div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
date <form
</Dialog.Description> onSubmit={handleSubmit(onSubmit)}
</div> className="mb-0 flex h-full w-full flex-col gap-4"
</div> >
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3"> <div className="flex flex-col gap-1">
<form <label
onSubmit={handleSubmit(onSubmit)} htmlFor="title"
className="flex h-full w-full flex-col gap-4 mb-0" className="text-sm font-medium uppercase tracking-wider text-zinc-400"
> >
<div className="flex flex-col gap-1"> Title *
<label className="text-sm font-medium uppercase tracking-wider text-zinc-400"> </label>
Title * <input
</label> type={'text'}
<input {...register('title', {
type={"text"} required: true,
{...register("title", { })}
required: true, spellCheck={false}
})} className="relative h-10 w-full rounded-md bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
spellCheck={false} />
className="relative h-10 w-full rounded-md px-3 py-2 !outline-none placeholder:text-zinc-500 bg-zinc-800 text-zinc-100" </div>
/> <div className="flex flex-col gap-1">
</div> <span className="text-sm font-medium uppercase tracking-wider text-zinc-400">
<div className="flex flex-col gap-1"> Choose at least 1 user *
<label className="text-sm font-medium uppercase tracking-wider text-zinc-400"> </span>
Choose at least 1 user * <div className="flex h-[300px] w-full flex-col overflow-y-auto overflow-x-hidden rounded-lg border-t border-zinc-700/50 bg-zinc-800">
</label> <div className="w-full px-3 py-2">
<div className="w-full h-[300px] flex flex-col rounded-lg border-t border-zinc-700/50 bg-zinc-800 overflow-x-hidden overflow-y-auto"> <Combobox value={selected} onChange={setSelected} multiple>
<div className="w-full px-3 py-2"> <Combobox.Input
<Combobox onChange={(event) => setQuery(event.target.value)}
value={selected} spellCheck={false}
onChange={setSelected} placeholder="Enter pubkey or npub..."
multiple className="relative mb-2 h-10 w-full rounded-md bg-zinc-700 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
> />
<Combobox.Input <Combobox.Options static>
onChange={(event) => setQuery(event.target.value)} {query.length > 0 && (
spellCheck={false} <Combobox.Option
autoFocus={false} value={query}
placeholder="Enter pubkey or npub..." className="group flex w-full items-center justify-between rounded-md px-2 py-2 hover:bg-zinc-700"
className="mb-2 relative h-10 w-full rounded-md px-3 py-2 !outline-none placeholder:text-zinc-500 bg-zinc-700 text-zinc-100" >
/> {({ selected }) => (
<Combobox.Options static> <>
{query.length > 0 && ( <div className="flex items-center gap-2">
<Combobox.Option <img
value={query} alt={query}
className="group w-full flex items-center justify-between px-2 py-2 rounded-md hover:bg-zinc-700" src={DEFAULT_AVATAR}
> className="h-11 w-11 shrink-0 rounded object-cover"
{({ selected }) => ( />
<> <div className="inline-flex flex-col gap-1">
<div className="flex items-center gap-2"> <span className="text-base leading-tight text-zinc-400">
<img {query}
alt={query} </span>
src={DEFAULT_AVATAR} </div>
className="w-11 h-11 shrink-0 object-cover rounded" </div>
/> {selected && (
<div className="inline-flex flex-col gap-1"> <CheckCircleIcon className="h-4 w-4 text-green-500" />
<span className="text-base leading-tight text-zinc-400"> )}
{query} </>
</span> )}
</div> </Combobox.Option>
</div> )}
{selected && ( {status === 'loading' ? (
<CheckCircleIcon className="w-4 h-4 text-green-500" /> <p>Loading...</p>
)} ) : (
</> JSON.parse(account.follows).map((follow) => (
)} <Combobox.Option
</Combobox.Option> key={follow}
)} value={follow}
{status === "loading" ? ( className="group flex w-full items-center justify-between rounded-md px-2 py-2 hover:bg-zinc-700"
<p>Loading...</p> >
) : ( {({ selected }) => (
JSON.parse(account.follows).map((follow) => ( <>
<Combobox.Option <User pubkey={follow} />
key={follow} {selected && (
value={follow} <CheckCircleIcon className="h-4 w-4 text-green-500" />
className="group w-full flex items-center justify-between px-2 py-2 rounded-md hover:bg-zinc-700" )}
> </>
{({ selected }) => ( )}
<> </Combobox.Option>
<User pubkey={follow} /> ))
{selected && ( )}
<CheckCircleIcon className="w-4 h-4 text-green-500" /> </Combobox.Options>
)} </Combobox>
</> </div>
)} </div>
</Combobox.Option> </div>
)) <div>
)} <button
</Combobox.Options> type="submit"
</Combobox> disabled={!isDirty || !isValid}
</div> className="shadow-button inline-flex h-11 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 font-medium text-zinc-100 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30"
</div> >
</div> {loading ? (
<div> <LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
<button ) : (
type="submit" 'Confirm'
disabled={!isDirty || !isValid} )}
className="inline-flex h-11 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 font-medium text-zinc-100 shadow-button active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30" </button>
> </div>
{loading ? ( </form>
<svg </div>
className="h-4 w-4 animate-spin text-black dark:text-zinc-100" </Dialog.Panel>
xmlns="http://www.w3.org/2000/svg" </Transition.Child>
fill="none" </div>
viewBox="0 0 24 24" </Dialog>
> </Transition>
<title id="loading">Loading</title> </>
<circle );
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
) : (
"Confirm"
)}
</button>
</div>
</form>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
} }

View File

@@ -1,298 +1,303 @@
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from '@headlessui/react';
import { createBlock } from "@libs/storage"; import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk"; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { CancelIcon, CommandIcon } from "@shared/icons"; import { open } from '@tauri-apps/api/dialog';
import { Image } from "@shared/image"; import { Body, fetch } from '@tauri-apps/api/http';
import { RelayContext } from "@shared/relayProvider"; import { Fragment, useContext, useEffect, useRef, useState } from 'react';
import { DEFAULT_AVATAR } from "@stores/constants"; import { useForm } from 'react-hook-form';
import { ADD_IMAGEBLOCK_SHORTCUT } from "@stores/shortcuts"; import { useHotkeys } from 'react-hotkeys-hook';
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { open } from "@tauri-apps/api/dialog"; import { createBlock } from '@libs/storage';
import { Body, fetch } from "@tauri-apps/api/http";
import { createBlobFromFile } from "@utils/createBlobFromFile"; import { CancelIcon, CommandIcon } from '@shared/icons';
import { dateToUnix } from "@utils/date"; import { Image } from '@shared/image';
import { useAccount } from "@utils/hooks/useAccount"; import { RelayContext } from '@shared/relayProvider';
import { Fragment, useContext, useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { DEFAULT_AVATAR } from '@stores/constants';
import { useHotkeys } from "react-hotkeys-hook"; import { ADD_IMAGEBLOCK_SHORTCUT } from '@stores/shortcuts';
import { createBlobFromFile } from '@utils/createBlobFromFile';
import { dateToUnix } from '@utils/date';
import { useAccount } from '@utils/hooks/useAccount';
export function AddImageBlock() { export function AddImageBlock() {
const ndk = useContext(RelayContext); const ndk = useContext(RelayContext);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [image, setImage] = useState(""); const [image, setImage] = useState('');
const { account } = useAccount(); const { account } = useAccount();
const tags = useRef(null); const tags = useRef(null);
const openModal = () => { const openModal = () => {
setIsOpen(true); setIsOpen(true);
}; };
const closeModal = () => { const closeModal = () => {
setIsOpen(false); setIsOpen(false);
}; };
useHotkeys(ADD_IMAGEBLOCK_SHORTCUT, () => openModal()); useHotkeys(ADD_IMAGEBLOCK_SHORTCUT, () => openModal());
const { const {
register, register,
handleSubmit, handleSubmit,
reset, reset,
setValue, setValue,
formState: { isDirty, isValid }, formState: { isDirty, isValid },
} = useForm(); } = useForm();
const openFileDialog = async () => { const openFileDialog = async () => {
const selected: any = await open({ const selected: any = await open({
multiple: false, multiple: false,
filters: [ filters: [
{ {
name: "Image", name: 'Image',
extensions: ["png", "jpeg", "jpg"], extensions: ['png', 'jpeg', 'jpg'],
}, },
], ],
}); });
if (Array.isArray(selected)) { if (Array.isArray(selected)) {
// user selected multiple files // user selected multiple files
} else if (selected === null) { } else if (selected === null) {
// user cancelled the selection // user cancelled the selection
} else { } else {
const filename = selected.split("/").pop(); const filename = selected.split('/').pop();
const file = await createBlobFromFile(selected); const file = await createBlobFromFile(selected);
const buf = await file.arrayBuffer(); const buf = await file.arrayBuffer();
const res: any = await fetch("https://void.cat/upload?cli=false", { const res: any = await fetch('https://void.cat/upload?cli=false', {
method: "POST", method: 'POST',
timeout: 5, timeout: 5,
headers: { headers: {
accept: "*/*", accept: '*/*',
"Content-Type": "application/octet-stream", 'Content-Type': 'application/octet-stream',
"V-Filename": filename, 'V-Filename': filename,
"V-Description": "Upload from https://lume.nu", 'V-Description': 'Upload from https://lume.nu',
"V-Strip-Metadata": "true", 'V-Strip-Metadata': 'true',
}, },
body: Body.bytes(buf), body: Body.bytes(buf),
}); });
if (res.ok) { if (res.ok) {
const imageURL = `https://void.cat/d/${res.data.file.id}.webp`; const imageURL = `https://void.cat/d/${res.data.file.id}.webp`;
tags.current = [ tags.current = [
["url", imageURL], ['url', imageURL],
["m", res.data.file.metadata.mimeType], ['m', res.data.file.metadata.mimeType],
["x", res.data.file.metadata.digest], ['x', res.data.file.metadata.digest],
["size", res.data.file.metadata.size], ['size', res.data.file.metadata.size],
["magnet", res.data.file.metadata.magnetLink], ['magnet', res.data.file.metadata.magnetLink],
]; ];
setImage(imageURL); setImage(imageURL);
} }
} }
}; };
const block = useMutation({ const block = useMutation({
mutationFn: (data: any) => { mutationFn: (data: any) => {
return createBlock(data.kind, data.title, data.content); return createBlock(data.kind, data.title, data.content);
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["blocks"] }); queryClient.invalidateQueries({ queryKey: ['blocks'] });
}, },
}); });
const onSubmit = (data: any) => { const onSubmit = (data: any) => {
setLoading(true); setLoading(true);
const signer = new NDKPrivateKeySigner(account.privkey); const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer; ndk.signer = signer;
const event = new NDKEvent(ndk); const event = new NDKEvent(ndk);
// build event // build event
event.content = data.title; event.content = data.title;
event.kind = 1063; event.kind = 1063;
event.created_at = dateToUnix(); event.created_at = dateToUnix();
event.pubkey = account.pubkey; event.pubkey = account.pubkey;
event.tags = tags.current; event.tags = tags.current;
// publish event // publish event
event.publish(); event.publish();
// mutate // mutate
block.mutate({ kind: 0, title: data.title, content: data.content }); block.mutate({ kind: 0, title: data.title, content: data.content });
setLoading(false); setLoading(false);
// reset form // reset form
reset(); reset();
// close modal // close modal
closeModal(); closeModal();
}; };
useEffect(() => { useEffect(() => {
setValue("content", image); setValue('content', image);
}, [setValue, image]); }, [setValue, image]);
return ( return (
<> <>
<button <button
type="button" type="button"
onClick={() => openModal()} onClick={() => openModal()}
className="inline-flex w-56 h-9 items-center justify-start gap-2.5 rounded-md px-2.5" className="inline-flex h-9 w-56 items-center justify-start gap-2.5 rounded-md px-2.5"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900"> <div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<CommandIcon width={12} height={12} className="text-zinc-500" /> <CommandIcon width={12} height={12} className="text-zinc-500" />
</div> </div>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900"> <div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<span className="text-zinc-500 text-sm leading-none">I</span> <span className="text-sm leading-none text-zinc-500">I</span>
</div> </div>
</div> </div>
<div> <div>
<h5 className="font-medium text-zinc-400">New image block</h5> <h5 className="font-medium text-zinc-400">New image block</h5>
</div> </div>
</button> </button>
<Transition appear show={isOpen} as={Fragment}> <Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={closeModal}> <Dialog as="div" className="relative z-50" onClose={closeModal}>
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" /> <div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child> </Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center"> <div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0 scale-95" enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100" enterTo="opacity-100 scale-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-xl border-t border-zinc-800/50 bg-zinc-900"> <Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-5"> <div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-5">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Dialog.Title <Dialog.Title
as="h3" as="h3"
className="text-lg font-semibold leading-none text-zinc-100" className="text-lg font-semibold leading-none text-zinc-100"
> >
Create image block Create image block
</Dialog.Title> </Dialog.Title>
<button <button
type="button" type="button"
onClick={closeModal} onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900" className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
> >
<CancelIcon <CancelIcon width={14} height={14} className="text-zinc-300" />
width={14} </button>
height={14} </div>
className="text-zinc-300" <Dialog.Description className="text-sm leading-tight text-zinc-400">
/> Pin your favorite image to Space then you can view every time that
</button> you use Lume, your image will be broadcast to Nostr Relay as well
</div> </Dialog.Description>
<Dialog.Description className="text-sm leading-tight text-zinc-400"> </div>
Pin your favorite image to Space then you can view every </div>
time that you use Lume, your image will be broadcast to <div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
Nostr Relay as well <form
</Dialog.Description> onSubmit={handleSubmit(onSubmit)}
</div> className="mb-0 flex h-full w-full flex-col gap-4"
</div> >
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3"> <input
<form type={'hidden'}
onSubmit={handleSubmit(onSubmit)} {...register('content')}
className="flex h-full w-full flex-col gap-4 mb-0" value={image}
> className="shadow-input relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500"
<input />
type={"hidden"} <div className="flex flex-col gap-1">
{...register("content")} <label
value={image} htmlFor="title"
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500" className="text-sm font-medium uppercase tracking-wider text-zinc-400"
/> >
<div className="flex flex-col gap-1"> Title *
<label className="text-sm font-medium uppercase tracking-wider text-zinc-400"> </label>
Title * <div className="after:shadow-highlight relative w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[6px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[6px] after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
</label> <input
<div className="relative w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[6px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[6px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20"> type={'text'}
<input {...register('title', {
type={"text"} required: true,
{...register("title", { })}
required: true, spellCheck={false}
})} className="shadow-input relative h-10 w-full rounded-md border border-black/5 px-3 py-2 shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500"
spellCheck={false} />
className="relative h-10 w-full rounded-md border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500" </div>
/> </div>
</div> <div className="flex flex-col gap-1">
</div> <label
<div className="flex flex-col gap-1"> htmlFor="picture"
<label className="text-sm font-medium uppercase tracking-wider text-zinc-400"> className="text-sm font-medium uppercase tracking-wider text-zinc-400"
Picture >
</label> Picture
<div className="relative inline-flex h-56 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950"> </label>
<Image <div className="relative inline-flex h-56 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950">
src={image} <Image
fallback={DEFAULT_AVATAR} src={image}
alt="content" fallback={DEFAULT_AVATAR}
className="relative z-10 max-h-[156px] h-auto w-[150px] object-cover rounded-md" alt="content"
/> className="relative z-10 h-auto max-h-[156px] w-[150px] rounded-md object-cover"
<div className="absolute bottom-3 right-3 z-10"> />
<button <div className="absolute bottom-3 right-3 z-10">
onClick={() => openFileDialog()} <button
type="button" onClick={() => openFileDialog()}
className="inline-flex h-6 items-center justify-center rounded bg-zinc-900 px-3 text-sm font-medium text-zinc-300 ring-1 ring-zinc-800 hover:bg-zinc-800" type="button"
> className="inline-flex h-6 items-center justify-center rounded bg-zinc-900 px-3 text-sm font-medium text-zinc-300 ring-1 ring-zinc-800 hover:bg-zinc-800"
Upload >
</button> Upload
</div> </button>
</div> </div>
</div> </div>
<div> </div>
<button <div>
type="submit" <button
disabled={!isDirty || !isValid} type="submit"
className="inline-flex h-11 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 font-medium text-zinc-100 shadow-button active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30" disabled={!isDirty || !isValid}
> className="shadow-button inline-flex h-11 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 font-medium text-zinc-100 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30"
{loading ? ( >
<svg {loading ? (
className="h-4 w-4 animate-spin text-black dark:text-zinc-100" <svg
xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 animate-spin text-black dark:text-zinc-100"
fill="none" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" fill="none"
> viewBox="0 0 24 24"
<title id="loading">Loading</title> >
<circle <title id="loading">Loading</title>
className="opacity-25" <circle
cx="12" className="opacity-25"
cy="12" cx="12"
r="10" cy="12"
stroke="currentColor" r="10"
strokeWidth="4" stroke="currentColor"
/> strokeWidth="4"
<path />
className="opacity-75" <path
fill="currentColor" className="opacity-75"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" fill="currentColor"
/> d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
</svg> />
) : ( </svg>
"Confirm" ) : (
)} 'Confirm'
</button> )}
</div> </button>
</form> </div>
</div> </form>
</Dialog.Panel> </div>
</Transition.Child> </Dialog.Panel>
</div> </Transition.Child>
</Dialog> </div>
</Transition> </Dialog>
</> </Transition>
); </>
);
} }

View File

@@ -1,124 +1,113 @@
import { getNotesByAuthors, removeBlock } from "@libs/storage"; import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Note } from "@shared/notes/note"; import { useVirtualizer } from '@tanstack/react-virtual';
import { NoteSkeleton } from "@shared/notes/skeleton"; import { useEffect, useRef } from 'react';
import { TitleBar } from "@shared/titleBar";
import { import { getNotesByAuthors, removeBlock } from '@libs/storage';
useInfiniteQuery,
useMutation, import { Note } from '@shared/notes/note';
useQueryClient, import { NoteSkeleton } from '@shared/notes/skeleton';
} from "@tanstack/react-query"; import { TitleBar } from '@shared/titleBar';
import { useVirtualizer } from "@tanstack/react-virtual";
import { useEffect, useRef } from "react";
const ITEM_PER_PAGE = 10; const ITEM_PER_PAGE = 10;
export function FeedBlock({ params }: { params: any }) { export function FeedBlock({ params }: { params: any }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { status, data, fetchNextPage, hasNextPage, isFetchingNextPage }: any = const { status, data, fetchNextPage, hasNextPage, isFetchingNextPage }: any =
useInfiniteQuery({ useInfiniteQuery({
queryKey: ["newsfeed", params.content], queryKey: ['newsfeed', params.content],
queryFn: async ({ pageParam = 0 }) => { queryFn: async ({ pageParam = 0 }) => {
return await getNotesByAuthors( return await getNotesByAuthors(params.content, ITEM_PER_PAGE, pageParam);
params.content, },
ITEM_PER_PAGE, getNextPageParam: (lastPage) => lastPage.nextCursor,
pageParam, });
);
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const notes = data ? data.pages.flatMap((d: { data: any }) => d.data) : []; const notes = data ? data.pages.flatMap((d: { data: any }) => d.data) : [];
const parentRef = useRef(); const parentRef = useRef();
const rowVirtualizer = useVirtualizer({ const rowVirtualizer = useVirtualizer({
count: hasNextPage ? notes.length + 1 : notes.length, count: hasNextPage ? notes.length + 1 : notes.length,
getScrollElement: () => parentRef.current, getScrollElement: () => parentRef.current,
estimateSize: () => 500, estimateSize: () => 500,
overscan: 2, overscan: 2,
}); });
const itemsVirtualizer = rowVirtualizer.getVirtualItems(); const itemsVirtualizer = rowVirtualizer.getVirtualItems();
useEffect(() => { useEffect(() => {
const [lastItem] = [...rowVirtualizer.getVirtualItems()].reverse(); const [lastItem] = [...rowVirtualizer.getVirtualItems()].reverse();
if (!lastItem) { if (!lastItem) {
return; return;
} }
if ( if (lastItem.index >= notes.length - 1 && hasNextPage && !isFetchingNextPage) {
lastItem.index >= notes.length - 1 && fetchNextPage();
hasNextPage && }
!isFetchingNextPage }, [notes.length, fetchNextPage, rowVirtualizer.getVirtualItems()]);
) {
fetchNextPage();
}
}, [notes.length, fetchNextPage, rowVirtualizer.getVirtualItems()]);
const block = useMutation({ const block = useMutation({
mutationFn: (id: string) => { mutationFn: (id: string) => {
return removeBlock(id); return removeBlock(id);
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["blocks"] }); queryClient.invalidateQueries({ queryKey: ['blocks'] });
}, },
}); });
const renderItem = (index: string | number) => { const renderItem = (index: string | number) => {
const note = notes[index]; const note = notes[index];
if (!note) return; if (!note) return;
return ( return (
<div key={index} data-index={index} ref={rowVirtualizer.measureElement}> <div key={index} data-index={index} ref={rowVirtualizer.measureElement}>
<Note event={note} block={params.id} /> <Note event={note} block={params.id} />
</div> </div>
); );
}; };
return ( return (
<div className="shrink-0 w-[400px] border-r border-zinc-900"> <div className="w-[400px] shrink-0 border-r border-zinc-900">
<TitleBar title={params.title} onClick={() => block.mutate(params.id)} /> <TitleBar title={params.title} onClick={() => block.mutate(params.id)} />
<div <div
ref={parentRef} ref={parentRef}
className="scrollbar-hide flex w-full h-full flex-col justify-between gap-1.5 pt-1.5 pb-20 overflow-y-auto" className="scrollbar-hide flex h-full w-full flex-col justify-between gap-1.5 overflow-y-auto pb-20 pt-1.5"
style={{ contain: "strict" }} style={{ contain: 'strict' }}
> >
{status === "loading" ? ( {status === 'loading' ? (
<div className="px-3 py-1.5"> <div className="px-3 py-1.5">
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3"> <div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3">
<NoteSkeleton /> <NoteSkeleton />
</div> </div>
</div> </div>
) : ( ) : (
<div <div
className="relative w-full" className="relative w-full"
style={{ style={{
height: `${rowVirtualizer.getTotalSize()}px`, height: `${rowVirtualizer.getTotalSize()}px`,
}} }}
> >
<div <div
className="absolute left-0 top-0 w-full" className="absolute left-0 top-0 w-full"
style={{ style={{
transform: `translateY(${ transform: `translateY(${
itemsVirtualizer[0].start - itemsVirtualizer[0].start - rowVirtualizer.options.scrollMargin
rowVirtualizer.options.scrollMargin }px)`,
}px)`, }}
}} >
> {rowVirtualizer
{rowVirtualizer .getVirtualItems()
.getVirtualItems() .map((virtualRow) => renderItem(virtualRow.index))}
.map((virtualRow) => renderItem(virtualRow.index))} </div>
</div> </div>
</div> )}
)} {isFetchingNextPage && (
{isFetchingNextPage && ( <div className="px-3 py-1.5">
<div className="px-3 py-1.5"> <div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3">
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3"> <NoteSkeleton />
<NoteSkeleton /> </div>
</div> </div>
</div> )}
)} </div>
</div> </div>
</div> );
);
} }

View File

@@ -1,140 +1,133 @@
import { useNewsfeed } from "@app/space/hooks/useNewsfeed"; import { useInfiniteQuery } from '@tanstack/react-query';
import { getNotes } from "@libs/storage"; import { useVirtualizer } from '@tanstack/react-virtual';
import { Note } from "@shared/notes/note"; import { useEffect, useRef } from 'react';
import { NoteSkeleton } from "@shared/notes/skeleton";
import { TitleBar } from "@shared/titleBar"; import { useNewsfeed } from '@app/space/hooks/useNewsfeed';
import { useNote } from "@stores/note";
import { useInfiniteQuery } from "@tanstack/react-query"; import { getNotes } from '@libs/storage';
import { useVirtualizer } from "@tanstack/react-virtual";
import { useEffect, useRef } from "react"; import { Note } from '@shared/notes/note';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { TitleBar } from '@shared/titleBar';
import { useNote } from '@stores/note';
const ITEM_PER_PAGE = 10; const ITEM_PER_PAGE = 10;
export function FollowingBlock({ block }: { block: number }) { export function FollowingBlock({ block }: { block: number }) {
// subscribe for live update // subscribe for live update
useNewsfeed(); useNewsfeed();
const [hasNewNote, toggleHasNewNote] = useNote((state) => [ const [hasNewNote, toggleHasNewNote] = useNote((state) => [
state.hasNewNote, state.hasNewNote,
state.toggleHasNewNote, state.toggleHasNewNote,
]); ]);
const { const { status, data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch }: any =
status, useInfiniteQuery({
data, queryKey: ['newsfeed-circle'],
fetchNextPage, queryFn: async ({ pageParam = 0 }) => {
hasNextPage, return await getNotes(ITEM_PER_PAGE, pageParam);
isFetchingNextPage, },
refetch, getNextPageParam: (lastPage) => lastPage.nextCursor,
}: any = useInfiniteQuery({ });
queryKey: ["newsfeed-circle"],
queryFn: async ({ pageParam = 0 }) => {
return await getNotes(ITEM_PER_PAGE, pageParam);
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const notes = data ? data.pages.flatMap((d: { data: any }) => d.data) : []; const notes = data ? data.pages.flatMap((d: { data: any }) => d.data) : [];
const parentRef = useRef(); const parentRef = useRef();
const rowVirtualizer = useVirtualizer({ const rowVirtualizer = useVirtualizer({
count: hasNextPage ? notes.length + 1 : notes.length, count: hasNextPage ? notes.length + 1 : notes.length,
getScrollElement: () => parentRef.current, getScrollElement: () => parentRef.current,
estimateSize: () => 500, estimateSize: () => 500,
overscan: 2, overscan: 2,
}); });
const itemsVirtualizer = rowVirtualizer.getVirtualItems(); const itemsVirtualizer = rowVirtualizer.getVirtualItems();
useEffect(() => { useEffect(() => {
const [lastItem] = [...rowVirtualizer.getVirtualItems()].reverse(); const [lastItem] = [...rowVirtualizer.getVirtualItems()].reverse();
if (!lastItem) { if (!lastItem) {
return; return;
} }
if ( if (lastItem.index >= notes.length - 1 && hasNextPage && !isFetchingNextPage) {
lastItem.index >= notes.length - 1 && fetchNextPage();
hasNextPage && }
!isFetchingNextPage }, [notes.length, fetchNextPage, rowVirtualizer.getVirtualItems()]);
) {
fetchNextPage();
}
}, [notes.length, fetchNextPage, rowVirtualizer.getVirtualItems()]);
const refreshFirstPage = () => { const refreshFirstPage = () => {
// refetch // refetch
refetch({ refetchPage: (_, index: number) => index === 0 }); refetch({ refetchPage: (_, index: number) => index === 0 });
// scroll to top // scroll to top
rowVirtualizer.scrollToIndex(1); rowVirtualizer.scrollToIndex(1);
// stop notify // stop notify
toggleHasNewNote(false); toggleHasNewNote(false);
}; };
const renderItem = (index: string | number) => { const renderItem = (index: string | number) => {
const note = notes[index]; const note = notes[index];
if (!note) return; if (!note) return;
return ( return (
<div key={index} data-index={index} ref={rowVirtualizer.measureElement}> <div key={index} data-index={index} ref={rowVirtualizer.measureElement}>
<Note event={note} block={block} /> <Note event={note} block={block} />
</div> </div>
); );
}; };
return ( return (
<div className="shrink-0 relative w-[400px] border-r border-zinc-900"> <div className="relative w-[400px] shrink-0 border-r border-zinc-900">
<TitleBar title="Your Circle" /> <TitleBar title="Your Circle" />
{hasNewNote && ( {hasNewNote && (
<div className="z-50 absolute top-12 left-1/2 transform -translate-x-1/2"> <div className="absolute left-1/2 top-12 z-50 -translate-x-1/2 transform">
<button <button
type="button" type="button"
onClick={() => refreshFirstPage()} onClick={() => refreshFirstPage()}
className="inline-flex items-center justify-center w-min px-3.5 py-1.5 rounded-full bg-fuchsia-500 hover:bg-fuchsia-600 border border-fuchsia-800/50 text-sm" className="inline-flex w-min items-center justify-center rounded-full border border-fuchsia-800/50 bg-fuchsia-500 px-3.5 py-1.5 text-sm hover:bg-fuchsia-600"
> >
Newest Newest
</button> </button>
</div> </div>
)} )}
<div <div
ref={parentRef} ref={parentRef}
className="scrollbar-hide flex w-full h-full flex-col justify-between gap-1.5 pt-1.5 pb-20 overflow-y-auto" className="scrollbar-hide flex h-full w-full flex-col justify-between gap-1.5 overflow-y-auto pb-20 pt-1.5"
style={{ contain: "strict" }} style={{ contain: 'strict' }}
> >
{status === "loading" ? ( {status === 'loading' ? (
<div className="px-3 py-1.5"> <div className="px-3 py-1.5">
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3"> <div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3">
<NoteSkeleton /> <NoteSkeleton />
</div> </div>
</div> </div>
) : ( ) : (
<div <div
className="relative w-full" className="relative w-full"
style={{ style={{
height: `${rowVirtualizer.getTotalSize()}px`, height: `${rowVirtualizer.getTotalSize()}px`,
}} }}
> >
<div <div
className="absolute left-0 top-0 w-full" className="absolute left-0 top-0 w-full"
style={{ style={{
transform: `translateY(${ transform: `translateY(${
itemsVirtualizer[0].start - itemsVirtualizer[0].start - rowVirtualizer.options.scrollMargin
rowVirtualizer.options.scrollMargin }px)`,
}px)`, }}
}} >
> {rowVirtualizer
{rowVirtualizer .getVirtualItems()
.getVirtualItems() .map((virtualRow) => renderItem(virtualRow.index))}
.map((virtualRow) => renderItem(virtualRow.index))} </div>
</div> </div>
</div> )}
)} {isFetchingNextPage && (
{isFetchingNextPage && ( <div className="px-3 py-1.5">
<div className="px-3 py-1.5"> <div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3">
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3"> <NoteSkeleton />
<NoteSkeleton /> </div>
</div> </div>
</div> )}
)} </div>
</div> </div>
</div> );
);
} }

View File

@@ -1,45 +1,46 @@
import { removeBlock } from "@libs/storage"; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { CancelIcon } from "@shared/icons";
import { Image } from "@shared/image"; import { removeBlock } from '@libs/storage';
import { DEFAULT_AVATAR } from "@stores/constants";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { CancelIcon } from '@shared/icons';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
export function ImageBlock({ params }: { params: any }) { export function ImageBlock({ params }: { params: any }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const block = useMutation({ const block = useMutation({
mutationFn: (id: string) => { mutationFn: (id: string) => {
return removeBlock(id); return removeBlock(id);
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["blocks"] }); queryClient.invalidateQueries({ queryKey: ['blocks'] });
}, },
}); });
return ( return (
<div className="shrink-0 w-[350px] h-full flex flex-col justify-between border-r border-zinc-900"> <div className="flex h-full w-[350px] shrink-0 flex-col justify-between border-r border-zinc-900">
<div className="relative flex-1 w-full h-full p-3 overflow-hidden"> <div className="relative h-full w-full flex-1 overflow-hidden p-3">
<div className="absolute top-3 left-0 w-full h-16 px-3"> <div className="absolute left-0 top-3 h-16 w-full px-3">
<div className="h-16 rounded-t-xl overflow-hidden flex items-center justify-between px-5"> <div className="flex h-16 items-center justify-between overflow-hidden rounded-t-xl px-5">
<h3 className="text-white font-medium drop-shadow-lg"> <h3 className="font-medium text-white drop-shadow-lg">{params.title}</h3>
{params.title} <button
</h3> type="button"
<button onClick={() => block.mutate(params.id)}
type="button" className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-white/30 backdrop-blur-lg"
onClick={() => block.mutate(params.id)} >
className="inline-flex h-7 w-7 rounded-md items-center justify-center bg-white/30 backdrop-blur-lg" <CancelIcon width={16} height={16} className="text-white" />
> </button>
<CancelIcon width={16} height={16} className="text-white" /> </div>
</button> </div>
</div> <Image
</div> src={params.content}
<Image fallback={DEFAULT_AVATAR}
src={params.content} alt={params.title}
fallback={DEFAULT_AVATAR} className="h-full w-full rounded-xl border-t border-zinc-800/50 object-cover"
alt={params.title} />
className="w-full h-full object-cover rounded-xl border-t border-zinc-800/50" </div>
/> </div>
</div> );
</div>
);
} }

View File

@@ -1,75 +1,76 @@
import { useLiveThread } from "@app/space/hooks/useLiveThread"; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getNoteByID, removeBlock } from "@libs/storage";
import { Kind1 } from "@shared/notes/contents/kind1"; import { useLiveThread } from '@app/space/hooks/useLiveThread';
import { Kind1063 } from "@shared/notes/contents/kind1063";
import { NoteMetadata } from "@shared/notes/metadata"; import { getNoteByID, removeBlock } from '@libs/storage';
import { NoteReplyForm } from "@shared/notes/replies/form";
import { RepliesList } from "@shared/notes/replies/list"; import { Kind1 } from '@shared/notes/contents/kind1';
import { NoteSkeleton } from "@shared/notes/skeleton"; import { Kind1063 } from '@shared/notes/contents/kind1063';
import { TitleBar } from "@shared/titleBar"; import { NoteMetadata } from '@shared/notes/metadata';
import { User } from "@shared/user"; import { NoteReplyForm } from '@shared/notes/replies/form';
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { RepliesList } from '@shared/notes/replies/list';
import { useAccount } from "@utils/hooks/useAccount"; import { NoteSkeleton } from '@shared/notes/skeleton';
import { parser } from "@utils/parser"; import { TitleBar } from '@shared/titleBar';
import { User } from '@shared/user';
import { useAccount } from '@utils/hooks/useAccount';
import { parser } from '@utils/parser';
export function ThreadBlock({ params }: { params: any }) { export function ThreadBlock({ params }: { params: any }) {
useLiveThread(params.content); useLiveThread(params.content);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { account } = useAccount(); const { account } = useAccount();
const { status, data } = useQuery(["thread", params.content], async () => { const { status, data } = useQuery(['thread', params.content], async () => {
const res = await getNoteByID(params.content); const res = await getNoteByID(params.content);
res["content"] = parser(res); res['content'] = parser(res);
return res; return res;
}); });
const block = useMutation({ const block = useMutation({
mutationFn: (id: string) => { mutationFn: (id: string) => {
return removeBlock(id); return removeBlock(id);
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["blocks"] }); queryClient.invalidateQueries({ queryKey: ['blocks'] });
}, },
}); });
return ( return (
<div className="shrink-0 w-[400px] border-r border-zinc-900"> <div className="w-[400px] shrink-0 border-r border-zinc-900">
<TitleBar title={params.title} onClick={() => block.mutate(params.id)} /> <TitleBar title={params.title} onClick={() => block.mutate(params.id)} />
<div className="scrollbar-hide flex w-full h-full flex-col gap-1.5 pt-1.5 pb-20 overflow-y-auto"> <div className="scrollbar-hide flex h-full w-full flex-col gap-1.5 overflow-y-auto pb-20 pt-1.5">
{status === "loading" ? ( {status === 'loading' ? (
<div className="px-3 py-1.5"> <div className="px-3 py-1.5">
<div className="rounded-md bg-zinc-900 px-3 py-3 shadow-input shadow-black/20"> <div className="shadow-input rounded-md bg-zinc-900 px-3 py-3 shadow-black/20">
<NoteSkeleton /> <NoteSkeleton />
</div> </div>
</div> </div>
) : ( ) : (
<div className="h-min w-full px-3 py-1.5"> <div className="h-min w-full px-3 py-1.5">
<div className="rounded-md bg-zinc-900 px-5 pt-5"> <div className="rounded-md bg-zinc-900 px-5 pt-5">
<User pubkey={data.pubkey} time={data.created_at} /> <User pubkey={data.pubkey} time={data.created_at} />
<div className="mt-3"> <div className="mt-3">
{data.kind === 1 && <Kind1 content={data.content} />} {data.kind === 1 && <Kind1 content={data.content} />}
{data.kind === 1063 && <Kind1063 metadata={data.tags} />} {data.kind === 1063 && <Kind1063 metadata={data.tags} />}
<NoteMetadata <NoteMetadata
id={data.event_id || params.content} id={data.event_id || params.content}
eventPubkey={data.pubkey} eventPubkey={data.pubkey}
/> />
</div> </div>
</div> </div>
<div className="mt-3 bg-zinc-900 rounded-md"> <div className="mt-3 rounded-md bg-zinc-900">
{account && ( {account && (
<NoteReplyForm <NoteReplyForm rootID={params.content} userPubkey={account.pubkey} />
rootID={params.content} )}
userPubkey={account.pubkey} </div>
/> </div>
)} )}
</div> <div className="px-3">
</div> <RepliesList parent_id={params.content} />
)} </div>
<div className="px-3"> </div>
<RepliesList parent_id={params.content} /> </div>
</div> );
</div>
</div>
);
} }

View File

@@ -1,46 +1,48 @@
import { createReplyNote } from "@libs/storage"; import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk';
import { NDKEvent, NDKFilter } from "@nostr-dev-kit/ndk"; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { RelayContext } from "@shared/relayProvider"; import { useContext, useEffect, useRef } from 'react';
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useContext, useEffect, useRef } from "react"; import { createReplyNote } from '@libs/storage';
import { RelayContext } from '@shared/relayProvider';
export function useLiveThread(id: string) { export function useLiveThread(id: string) {
const ndk = useContext(RelayContext); const ndk = useContext(RelayContext);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const now = useRef(Math.floor(Date.now() / 1000)); const now = useRef(Math.floor(Date.now() / 1000));
const thread = useMutation({ const thread = useMutation({
mutationFn: (data: NDKEvent) => { mutationFn: (data: NDKEvent) => {
return createReplyNote( return createReplyNote(
id, id,
data.id, data.id,
data.pubkey, data.pubkey,
data.kind, data.kind,
data.tags, data.tags,
data.content, data.content,
data.created_at, data.created_at
); );
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["replies", id] }); queryClient.invalidateQueries({ queryKey: ['replies', id] });
}, },
}); });
useEffect(() => { useEffect(() => {
const filter: NDKFilter = { const filter: NDKFilter = {
kinds: [1], kinds: [1],
"#e": [id], '#e': [id],
since: now.current, since: now.current,
}; };
const sub = ndk.subscribe(filter, { closeOnEose: false }); const sub = ndk.subscribe(filter, { closeOnEose: false });
sub.addListener("event", (event: NDKEvent) => { sub.addListener('event', (event: NDKEvent) => {
thread.mutate(event); thread.mutate(event);
}); });
return () => { return () => {
sub.stop(); sub.stop();
}; };
}, []); }, []);
} }

View File

@@ -1,48 +1,52 @@
import { createNote } from "@libs/storage"; import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk';
import { NDKEvent, NDKFilter } from "@nostr-dev-kit/ndk"; import { useContext, useEffect, useRef } from 'react';
import { RelayContext } from "@shared/relayProvider";
import { useNote } from "@stores/note"; import { createNote } from '@libs/storage';
import { useAccount } from "@utils/hooks/useAccount";
import { useContext, useEffect, useRef } from "react"; import { RelayContext } from '@shared/relayProvider';
import { useNote } from '@stores/note';
import { useAccount } from '@utils/hooks/useAccount';
export function useNewsfeed() { export function useNewsfeed() {
const ndk = useContext(RelayContext); const ndk = useContext(RelayContext);
const sub = useRef(null); const sub = useRef(null);
const now = useRef(Math.floor(Date.now() / 1000)); const now = useRef(Math.floor(Date.now() / 1000));
const toggleHasNewNote = useNote((state) => state.toggleHasNewNote); const toggleHasNewNote = useNote((state) => state.toggleHasNewNote);
const { status, account } = useAccount(); const { status, account } = useAccount();
useEffect(() => { useEffect(() => {
if (status === "success" && account) { if (status === 'success' && account) {
const follows = account ? JSON.parse(account.follows) : []; const follows = account ? JSON.parse(account.follows) : [];
const filter: NDKFilter = { const filter: NDKFilter = {
kinds: [1, 6], kinds: [1, 6],
authors: follows, authors: follows,
since: now.current, since: now.current,
}; };
sub.current = ndk.subscribe(filter, { closeOnEose: false }); sub.current = ndk.subscribe(filter, { closeOnEose: false });
sub.current.addListener("event", (event: NDKEvent) => { sub.current.addListener('event', (event: NDKEvent) => {
console.log("new note: ", event); console.log('new note: ', event);
// add to db // add to db
createNote( createNote(
event.id, event.id,
event.pubkey, event.pubkey,
event.kind, event.kind,
event.tags, event.tags,
event.content, event.content,
event.created_at, event.created_at
); );
// notify user about created note // notify user about created note
toggleHasNewNote(true); toggleHasNewNote(true);
}); });
} }
return () => { return () => {
sub.current.stop(); sub.current.stop();
}; };
}, [status]); }, [status]);
} }

View File

@@ -1,76 +1,79 @@
import { AddBlock } from "@app/space/components/add"; import { useQuery } from '@tanstack/react-query';
import { FeedBlock } from "@app/space/components/blocks/feed";
import { FollowingBlock } from "@app/space/components/blocks/following"; import { AddBlock } from '@app/space/components/add';
import { ImageBlock } from "@app/space/components/blocks/image"; import { FeedBlock } from '@app/space/components/blocks/feed';
import { ThreadBlock } from "@app/space/components/blocks/thread"; import { FollowingBlock } from '@app/space/components/blocks/following';
import { getBlocks } from "@libs/storage"; import { ImageBlock } from '@app/space/components/blocks/image';
import { LoaderIcon } from "@shared/icons"; import { ThreadBlock } from '@app/space/components/blocks/thread';
import { useQuery } from "@tanstack/react-query";
import { getBlocks } from '@libs/storage';
import { LoaderIcon } from '@shared/icons';
export function SpaceScreen() { export function SpaceScreen() {
const { const {
status, status,
data: blocks, data: blocks,
isFetching, isFetching,
} = useQuery( } = useQuery(
["blocks"], ['blocks'],
async () => { async () => {
return await getBlocks(); return await getBlocks();
}, },
{ {
staleTime: Infinity, staleTime: Infinity,
refetchOnMount: false, refetchOnMount: false,
refetchOnReconnect: false, refetchOnReconnect: false,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}, }
); );
return ( return (
<div className="h-full w-full flex flex-nowrap overflow-x-auto overflow-y-hidden scrollbar-hide"> <div className="scrollbar-hide flex h-full w-full flex-nowrap overflow-x-auto overflow-y-hidden">
<FollowingBlock block={1} /> <FollowingBlock block={1} />
{status === "loading" ? ( {status === 'loading' ? (
<div className="shrink-0 w-[350px] flex-col flex border-r border-zinc-900"> <div className="flex w-[350px] shrink-0 flex-col border-r border-zinc-900">
<div <div
data-tauri-drag-region data-tauri-drag-region
className="group overflow-hidden h-11 w-full flex items-center justify-between px-3 border-b border-zinc-900" className="group flex h-11 w-full items-center justify-between overflow-hidden border-b border-zinc-900 px-3"
/> />
<div className="w-full flex-1 flex items-center justify-center p-3"> <div className="flex w-full flex-1 items-center justify-center p-3">
<LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-zinc-100" /> <LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-zinc-100" />
</div> </div>
</div> </div>
) : ( ) : (
blocks.map((block: any) => { blocks.map((block: any) => {
switch (block.kind) { switch (block.kind) {
case 0: case 0:
return <ImageBlock key={block.id} params={block} />; return <ImageBlock key={block.id} params={block} />;
case 1: case 1:
return <FeedBlock key={block.id} params={block} />; return <FeedBlock key={block.id} params={block} />;
case 2: case 2:
return <ThreadBlock key={block.id} params={block} />; return <ThreadBlock key={block.id} params={block} />;
default: default:
break; break;
} }
}) })
)} )}
{isFetching && ( {isFetching && (
<div className="shrink-0 w-[350px] flex-col flex border-r border-zinc-900"> <div className="flex w-[350px] shrink-0 flex-col border-r border-zinc-900">
<div <div
data-tauri-drag-region data-tauri-drag-region
className="group overflow-hidden h-11 w-full flex items-center justify-between px-3 border-b border-zinc-900" className="group flex h-11 w-full items-center justify-between overflow-hidden border-b border-zinc-900 px-3"
/> />
<div className="w-full flex-1 flex items-center justify-center p-3"> <div className="flex w-full flex-1 items-center justify-center p-3">
<LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-zinc-100" /> <LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-zinc-100" />
</div> </div>
</div> </div>
)} )}
<div className="shrink-0 w-[350px] flex-col flex border-r border-zinc-900"> <div className="flex w-[350px] shrink-0 flex-col border-r border-zinc-900">
<div className="w-full h-full inline-flex items-center justify-center"> <div className="inline-flex h-full w-full items-center justify-center">
<AddBlock /> <AddBlock />
</div> </div>
</div> </div>
<div className="shrink-0 w-[350px]" /> <div className="w-[350px] shrink-0" />
</div> </div>
); );
} }

View File

@@ -1,152 +1,144 @@
import { FollowIcon, LoaderIcon, UnfollowIcon } from "@shared/icons"; import { useQuery } from '@tanstack/react-query';
import { Image } from "@shared/image"; import { useEffect, useState } from 'react';
import { DEFAULT_AVATAR } from "@stores/constants";
import { useQuery } from "@tanstack/react-query"; import { FollowIcon, LoaderIcon, UnfollowIcon } from '@shared/icons';
import { useSocial } from "@utils/hooks/useSocial"; import { Image } from '@shared/image';
import { compactNumber } from "@utils/number";
import { shortenKey } from "@utils/shortenKey"; import { DEFAULT_AVATAR } from '@stores/constants';
import { useEffect, useState } from "react";
import { useSocial } from '@utils/hooks/useSocial';
import { compactNumber } from '@utils/number';
import { shortenKey } from '@utils/shortenKey';
export function Profile({ data }: { data: any }) { export function Profile({ data }: { data: any }) {
const { status, data: userStats } = useQuery( const { status, data: userStats } = useQuery(['user-stats', data.pubkey], async () => {
["user-stats", data.pubkey], const res = await fetch(`https://api.nostr.band/v0/stats/profile/${data.pubkey}`);
async () => { return res.json();
const res = await fetch( });
`https://api.nostr.band/v0/stats/profile/${data.pubkey}`,
);
return res.json();
},
);
const embedProfile = data.profile ? JSON.parse(data.profile.content) : null; const embedProfile = data.profile ? JSON.parse(data.profile.content) : null;
const profile = embedProfile; const profile = embedProfile;
const { status: socialStatus, userFollows, follow, unfollow } = useSocial(); const { status: socialStatus, userFollows, follow, unfollow } = useSocial();
const [followed, setFollowed] = useState(false); const [followed, setFollowed] = useState(false);
const followUser = (pubkey: string) => { const followUser = (pubkey: string) => {
try { try {
follow(pubkey); follow(pubkey);
// update state // update state
setFollowed(true); setFollowed(true);
} catch (error) { } catch (error) {
console.log(error); console.log(error);
} }
}; };
const unfollowUser = (pubkey: string) => { const unfollowUser = (pubkey: string) => {
try { try {
unfollow(pubkey); unfollow(pubkey);
// update state // update state
setFollowed(false); setFollowed(false);
} catch (error) { } catch (error) {
console.log(error); console.log(error);
} }
}; };
useEffect(() => { useEffect(() => {
if (status === "success" && userFollows) { if (status === 'success' && userFollows) {
if (userFollows.includes(data.pubkey)) { if (userFollows.includes(data.pubkey)) {
setFollowed(true); setFollowed(true);
} }
} }
}, [status]); }, [status]);
if (!profile) if (!profile)
return ( return (
<div className="rounded-md bg-zinc-900 px-5 py-5"> <div className="rounded-md bg-zinc-900 px-5 py-5">
<p>Can't fetch profile</p> <p>Can&apos;t fetch profile</p>
</div> </div>
); );
return ( return (
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-5 py-5"> <div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-5 py-5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="inline-flex items-center gap-2"> <div className="inline-flex items-center gap-2">
<div className="w-11 h-11 shrink-0"> <div className="h-11 w-11 shrink-0">
<Image <Image
src={profile.picture} src={profile.picture}
fallback={DEFAULT_AVATAR} fallback={DEFAULT_AVATAR}
className="w-11 h-11 object-cover rounded-lg" className="h-11 w-11 rounded-lg object-cover"
/> />
</div> </div>
<div className="inline-flex flex-col gap-1"> <div className="inline-flex flex-col gap-1">
<h3 className="max-w-[15rem] truncate font-semibold text-zinc-100 leading-none"> <h3 className="max-w-[15rem] truncate font-semibold leading-none text-zinc-100">
{profile.display_name || profile.name} {profile.display_name || profile.name}
</h3> </h3>
<p className="max-w-[10rem] truncate text-sm text-zinc-400 leading-none"> <p className="max-w-[10rem] truncate text-sm leading-none text-zinc-400">
{profile.nip05 || shortenKey(data.pubkey)} {profile.nip05 || shortenKey(data.pubkey)}
</p> </p>
</div> </div>
</div> </div>
<div className="inline-flex items-center gap-2"> <div className="inline-flex items-center gap-2">
{socialStatus === "loading" ? ( {socialStatus === 'loading' ? (
<button <button
type="button" type="button"
className="inline-flex w-8 h-8 items-center justify-center rounded-md bg-zinc-900 hover:bg-fuchsia-500" className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-zinc-900 hover:bg-fuchsia-500"
> >
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" /> <LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
</button> </button>
) : followed ? ( ) : followed ? (
<button <button
type="button" type="button"
onClick={() => unfollowUser(data.pubkey)} onClick={() => unfollowUser(data.pubkey)}
className="inline-flex w-8 h-8 items-center justify-center rounded-md text-zinc-400 bg-zinc-800 hover:bg-fuchsia-500 hover:text-white" className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-zinc-800 text-zinc-400 hover:bg-fuchsia-500 hover:text-white"
> >
<UnfollowIcon className="w-4 h-4" /> <UnfollowIcon className="h-4 w-4" />
</button> </button>
) : ( ) : (
<button <button
type="button" type="button"
onClick={() => followUser(data.pubkey)} onClick={() => followUser(data.pubkey)}
className="inline-flex w-8 h-8 items-center justify-center rounded-md text-zinc-400 bg-zinc-800 hover:bg-fuchsia-500 hover:text-white" className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-zinc-800 text-zinc-400 hover:bg-fuchsia-500 hover:text-white"
> >
<FollowIcon className="w-4 h-4" /> <FollowIcon className="h-4 w-4" />
</button> </button>
)} )}
</div> </div>
</div> </div>
<div className="mt-2"> <div className="mt-2">
<p className="whitespace-pre-line break-words text-zinc-100"> <p className="whitespace-pre-line break-words text-zinc-100">
{profile.about || profile.bio} {profile.about || profile.bio}
</p> </p>
</div> </div>
<div className="mt-8"> <div className="mt-8">
{status === "loading" ? ( {status === 'loading' ? (
<p>Loading...</p> <p>Loading...</p>
) : ( ) : (
<div className="w-full flex items-center gap-8"> <div className="flex w-full items-center gap-8">
<div className="inline-flex flex-col gap-1"> <div className="inline-flex flex-col gap-1">
<span className="leading-none font-semibold text-zinc-100"> <span className="font-semibold leading-none text-zinc-100">
{userStats.stats[data.pubkey].followers_pubkey_count ?? 0} {userStats.stats[data.pubkey].followers_pubkey_count ?? 0}
</span> </span>
<span className="leading-none text-sm text-zinc-400"> <span className="text-sm leading-none text-zinc-400">Followers</span>
Followers </div>
</span> <div className="inline-flex flex-col gap-1">
</div> <span className="font-semibold leading-none text-zinc-100">
<div className="inline-flex flex-col gap-1"> {userStats.stats[data.pubkey].pub_following_pubkey_count ?? 0}
<span className="leading-none font-semibold text-zinc-100"> </span>
{userStats.stats[data.pubkey].pub_following_pubkey_count ?? 0} <span className="text-sm leading-none text-zinc-400">Following</span>
</span> </div>
<span className="leading-none text-sm text-zinc-400"> <div className="inline-flex flex-col gap-1">
Following <span className="font-semibold leading-none text-zinc-100">
</span> {userStats.stats[data.pubkey].zaps_received
</div> ? compactNumber.format(
<div className="inline-flex flex-col gap-1"> userStats.stats[data.pubkey].zaps_received.msats / 1000
<span className="leading-none font-semibold text-zinc-100"> )
{userStats.stats[data.pubkey].zaps_received : 0}
? compactNumber.format( </span>
userStats.stats[data.pubkey].zaps_received.msats / 1000, <span className="text-sm leading-none text-zinc-400">Zaps received</span>
) </div>
: 0} </div>
</span> )}
<span className="leading-none text-sm text-zinc-400"> </div>
Zaps received </div>
</span> );
</div>
</div>
)}
</div>
</div>
);
} }

View File

@@ -1,36 +1,37 @@
import { Note } from "@shared/notes/note"; import { useQuery } from '@tanstack/react-query';
import { NoteSkeleton } from "@shared/notes/skeleton";
import { TitleBar } from "@shared/titleBar"; import { Note } from '@shared/notes/note';
import { useQuery } from "@tanstack/react-query"; import { NoteSkeleton } from '@shared/notes/skeleton';
import { TitleBar } from '@shared/titleBar';
export function TrendingNotes() { export function TrendingNotes() {
const { status, data, error } = useQuery(["trending-notes"], async () => { const { status, data, error } = useQuery(['trending-notes'], async () => {
const res = await fetch("https://api.nostr.band/v0/trending/notes"); const res = await fetch('https://api.nostr.band/v0/trending/notes');
if (!res.ok) { if (!res.ok) {
throw new Error("Error"); throw new Error('Error');
} }
return res.json(); return res.json();
}); });
return ( return (
<div className="shrink-0 w-[360px] flex-col flex border-r border-zinc-900"> <div className="flex w-[360px] shrink-0 flex-col border-r border-zinc-900">
<TitleBar title="Trending Posts" /> <TitleBar title="Trending Posts" />
<div className="scrollbar-hide flex w-full h-full flex-col justify-between gap-1.5 pt-1.5 pb-20 overflow-y-auto"> <div className="scrollbar-hide flex h-full w-full flex-col justify-between gap-1.5 overflow-y-auto pb-20 pt-1.5">
{error && <p>Failed to fetch</p>} {error && <p>Failed to fetch</p>}
{status === "loading" ? ( {status === 'loading' ? (
<div className="px-3 py-1.5"> <div className="px-3 py-1.5">
<div className="rounded-md bg-zinc-900 px-3 py-3 shadow-input shadow-black/20"> <div className="shadow-input rounded-md bg-zinc-900 px-3 py-3 shadow-black/20">
<NoteSkeleton /> <NoteSkeleton />
</div> </div>
</div> </div>
) : ( ) : (
<div className="relative w-full flex flex-col pt-1.5"> <div className="relative flex w-full flex-col pt-1.5">
{data.notes.map((item) => ( {data.notes.map((item) => (
<Note key={item.id} event={item.event} /> <Note key={item.id} event={item.event} />
))} ))}
</div> </div>
)} )}
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,36 +1,38 @@
import { Profile } from "@app/trending/components/profile"; import { useQuery } from '@tanstack/react-query';
import { NoteSkeleton } from "@shared/notes/skeleton";
import { TitleBar } from "@shared/titleBar"; import { Profile } from '@app/trending/components/profile';
import { useQuery } from "@tanstack/react-query";
import { NoteSkeleton } from '@shared/notes/skeleton';
import { TitleBar } from '@shared/titleBar';
export function TrendingProfiles() { export function TrendingProfiles() {
const { status, data, error } = useQuery(["trending-profiles"], async () => { const { status, data, error } = useQuery(['trending-profiles'], async () => {
const res = await fetch("https://api.nostr.band/v0/trending/profiles"); const res = await fetch('https://api.nostr.band/v0/trending/profiles');
if (!res.ok) { if (!res.ok) {
throw new Error("Error"); throw new Error('Error');
} }
return res.json(); return res.json();
}); });
return ( return (
<div className="shrink-0 w-[360px] flex-col flex border-r border-zinc-900"> <div className="flex w-[360px] shrink-0 flex-col border-r border-zinc-900">
<TitleBar title="Trending Profiles" /> <TitleBar title="Trending Profiles" />
<div className="scrollbar-hide flex w-full h-full flex-col justify-between gap-1.5 pt-1.5 pb-20 overflow-y-auto"> <div className="scrollbar-hide flex h-full w-full flex-col justify-between gap-1.5 overflow-y-auto pb-20 pt-1.5">
{error && <p>Failed to fetch</p>} {error && <p>Failed to fetch</p>}
{status === "loading" ? ( {status === 'loading' ? (
<div className="px-3 py-1.5"> <div className="px-3 py-1.5">
<div className="rounded-md bg-zinc-900 px-3 py-3 shadow-input shadow-black/20"> <div className="shadow-input rounded-md bg-zinc-900 px-3 py-3 shadow-black/20">
<NoteSkeleton /> <NoteSkeleton />
</div> </div>
</div> </div>
) : ( ) : (
<div className="relative w-full flex flex-col gap-3 px-3 pt-3"> <div className="relative flex w-full flex-col gap-3 px-3 pt-3">
{data.profiles.map((item) => ( {data.profiles.map((item) => (
<Profile key={item.pubkey} data={item} /> <Profile key={item.pubkey} data={item} />
))} ))}
</div> </div>
)} )}
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,11 +1,11 @@
import { TrendingNotes } from "@app/trending/components/trendingNotes"; import { TrendingNotes } from '@app/trending/components/trendingNotes';
import { TrendingProfiles } from "@app/trending/components/trendingProfiles"; import { TrendingProfiles } from '@app/trending/components/trendingProfiles';
export function TrendingScreen() { export function TrendingScreen() {
return ( return (
<div className="h-full w-full flex flex-nowrap overflow-x-auto overflow-y-hidden scrollbar-hide"> <div className="scrollbar-hide flex h-full w-full flex-nowrap overflow-x-auto overflow-y-hidden">
<TrendingProfiles /> <TrendingProfiles />
<TrendingNotes /> <TrendingNotes />
</div> </div>
); );
} }

View File

@@ -1,33 +1,35 @@
import { NDKFilter } from "@nostr-dev-kit/ndk"; import { NDKFilter } from '@nostr-dev-kit/ndk';
import { Note } from "@shared/notes/note"; import { useQuery } from '@tanstack/react-query';
import { RelayContext } from "@shared/relayProvider"; import { useContext } from 'react';
import { useQuery } from "@tanstack/react-query";
import { dateToUnix, getHourAgo } from "@utils/date"; import { Note } from '@shared/notes/note';
import { LumeEvent } from "@utils/types"; import { RelayContext } from '@shared/relayProvider';
import { useContext } from "react";
import { dateToUnix, getHourAgo } from '@utils/date';
import { LumeEvent } from '@utils/types';
export function UserFeed({ pubkey }: { pubkey: string }) { export function UserFeed({ pubkey }: { pubkey: string }) {
const ndk = useContext(RelayContext); const ndk = useContext(RelayContext);
const { status, data } = useQuery(["user-feed", pubkey], async () => { const { status, data } = useQuery(['user-feed', pubkey], async () => {
const now = new Date(); const now = new Date();
const filter: NDKFilter = { const filter: NDKFilter = {
kinds: [1], kinds: [1],
authors: [pubkey], authors: [pubkey],
since: dateToUnix(getHourAgo(48, now)), since: dateToUnix(getHourAgo(48, now)),
}; };
const events = await ndk.fetchEvents(filter); const events = await ndk.fetchEvents(filter);
return [...events]; return [...events];
}); });
return ( return (
<div className="w-full max-w-[400px] px-2 pb-10"> <div className="w-full max-w-[400px] px-2 pb-10">
{status === "loading" ? ( {status === 'loading' ? (
<div className="px-3"> <div className="px-3">
<p>Loading...</p> <p>Loading...</p>
</div> </div>
) : ( ) : (
data.map((note: LumeEvent) => <Note key={note.id} event={note} />) data.map((note: LumeEvent) => <Note key={note.id} event={note} />)
)} )}
</div> </div>
); );
} }

View File

@@ -1,55 +1,50 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from '@tanstack/react-query';
import { compactNumber } from "@utils/number";
import { compactNumber } from '@utils/number';
export function UserMetadata({ pubkey }: { pubkey: string }) { export function UserMetadata({ pubkey }: { pubkey: string }) {
const { status, data } = useQuery(["user-metadata", pubkey], async () => { const { status, data } = useQuery(['user-metadata', pubkey], async () => {
const res = await fetch( const res = await fetch(`https://api.nostr.band/v0/stats/profile/${pubkey}`);
`https://api.nostr.band/v0/stats/profile/${pubkey}`, if (!res.ok) {
); throw new Error('Error');
if (!res.ok) { }
throw new Error("Error"); return await res.json();
} });
return await res.json();
});
if (status === "loading") { if (status === 'loading') {
return <p>Loading...</p>; return <p>Loading...</p>;
} }
return ( return (
<div className="w-full flex items-center gap-10"> <div className="flex w-full items-center gap-10">
<div className="inline-flex flex-col gap-1"> <div className="inline-flex flex-col gap-1">
<span className="leading-none font-semibold text-zinc-100"> <span className="font-semibold leading-none text-zinc-100">
{data.stats[pubkey].followers_pubkey_count ?? 0} {data.stats[pubkey].followers_pubkey_count ?? 0}
</span> </span>
<span className="leading-none text-sm text-zinc-400">Followers</span> <span className="text-sm leading-none text-zinc-400">Followers</span>
</div> </div>
<div className="inline-flex flex-col gap-1"> <div className="inline-flex flex-col gap-1">
<span className="leading-none font-semibold text-zinc-100"> <span className="font-semibold leading-none text-zinc-100">
{data.stats[pubkey].pub_following_pubkey_count ?? 0} {data.stats[pubkey].pub_following_pubkey_count ?? 0}
</span> </span>
<span className="leading-none text-sm text-zinc-400">Following</span> <span className="text-sm leading-none text-zinc-400">Following</span>
</div> </div>
<div className="inline-flex flex-col gap-1"> <div className="inline-flex flex-col gap-1">
<span className="leading-none font-semibold text-zinc-100"> <span className="font-semibold leading-none text-zinc-100">
{data.stats[pubkey].zaps_received {data.stats[pubkey].zaps_received
? compactNumber.format( ? compactNumber.format(data.stats[pubkey].zaps_received.msats / 1000)
data.stats[pubkey].zaps_received.msats / 1000, : 0}
) </span>
: 0} <span className="text-sm leading-none text-zinc-400">Zaps received</span>
</span> </div>
<span className="leading-none text-sm text-zinc-400"> <div className="inline-flex flex-col gap-1">
Zaps received <span className="font-semibold leading-none text-zinc-100">
</span> {data.stats[pubkey].zaps_sent
</div> ? compactNumber.format(data.stats[pubkey].zaps_sent.msats / 1000)
<div className="inline-flex flex-col gap-1"> : 0}
<span className="leading-none font-semibold text-zinc-100"> </span>
{data.stats[pubkey].zaps_sent <span className="text-sm leading-none text-zinc-400">Zaps sent</span>
? compactNumber.format(data.stats[pubkey].zaps_sent.msats / 1000) </div>
: 0} </div>
</span> );
<span className="leading-none text-sm text-zinc-400">Zaps sent</span>
</div>
</div>
);
} }

View File

@@ -1,163 +1,167 @@
import { UserFeed } from "@app/user/components/feed"; import { Tab } from '@headlessui/react';
import { UserMetadata } from "@app/user/components/metadata"; import { Fragment, useEffect, useState } from 'react';
import { Tab } from "@headlessui/react"; import { Link, useParams } from 'react-router-dom';
import { EditProfileModal } from "@shared/editProfileModal";
import { ThreadsIcon, ZapIcon } from "@shared/icons"; import { UserFeed } from '@app/user/components/feed';
import { Image } from "@shared/image"; import { UserMetadata } from '@app/user/components/metadata';
import { DEFAULT_AVATAR } from "@stores/constants";
import { useAccount } from "@utils/hooks/useAccount"; import { EditProfileModal } from '@shared/editProfileModal';
import { useProfile } from "@utils/hooks/useProfile"; import { ThreadsIcon, ZapIcon } from '@shared/icons';
import { useSocial } from "@utils/hooks/useSocial"; import { Image } from '@shared/image';
import { shortenKey } from "@utils/shortenKey";
import { Fragment, useEffect, useState } from "react"; import { DEFAULT_AVATAR } from '@stores/constants';
import { Link, useParams } from "react-router-dom";
import { useAccount } from '@utils/hooks/useAccount';
import { useProfile } from '@utils/hooks/useProfile';
import { useSocial } from '@utils/hooks/useSocial';
import { shortenKey } from '@utils/shortenKey';
export function UserScreen() { export function UserScreen() {
const { pubkey } = useParams(); const { pubkey } = useParams();
const { user } = useProfile(pubkey); const { user } = useProfile(pubkey);
const { account } = useAccount(); const { account } = useAccount();
const { status, userFollows, follow, unfollow } = useSocial(); const { status, userFollows, follow, unfollow } = useSocial();
const [followed, setFollowed] = useState(false); const [followed, setFollowed] = useState(false);
const followUser = (pubkey: string) => { const followUser = (pubkey: string) => {
try { try {
follow(pubkey); follow(pubkey);
// update state // update state
setFollowed(true); setFollowed(true);
} catch (error) { } catch (error) {
console.log(error); console.log(error);
} }
}; };
const unfollowUser = (pubkey: string) => { const unfollowUser = (pubkey: string) => {
try { try {
unfollow(pubkey); unfollow(pubkey);
// update state // update state
setFollowed(false); setFollowed(false);
} catch (error) { } catch (error) {
console.log(error); console.log(error);
} }
}; };
useEffect(() => { useEffect(() => {
if (status === "success" && userFollows) { if (status === 'success' && userFollows) {
if (userFollows.includes(pubkey)) { if (userFollows.includes(pubkey)) {
setFollowed(true); setFollowed(true);
} }
} }
}, [status]); }, [status]);
return ( return (
<div className="h-full w-full overflow-y-auto"> <div className="h-full w-full overflow-y-auto">
<div <div
data-tauri-drag-region data-tauri-drag-region
className="h-11 w-full flex items-center px-3 border-b border-zinc-900" className="flex h-11 w-full items-center border-b border-zinc-900 px-3"
/> />
<div className="w-full h-56 bg-zinc-100"> <div className="h-56 w-full bg-zinc-100">
<Image <Image
src={user?.banner} src={user?.banner}
fallback="https://void.cat/d/QY1myro5tkHVs2nY7dy74b.jpg" fallback="https://void.cat/d/QY1myro5tkHVs2nY7dy74b.jpg"
alt={"banner"} alt={'banner'}
className="w-full h-full object-cover" className="h-full w-full object-cover"
/> />
</div> </div>
<div className="w-full -mt-7"> <div className="-mt-7 w-full">
<div className="px-5"> <div className="px-5">
<Image <Image
src={user?.image} src={user?.image}
fallback={DEFAULT_AVATAR} fallback={DEFAULT_AVATAR}
alt={pubkey} alt={pubkey}
className="w-14 h-14 rounded-md ring-2 ring-black" className="h-14 w-14 rounded-md ring-2 ring-black"
/> />
<div className="flex-1 flex flex-col gap-4 mt-2"> <div className="mt-2 flex flex-1 flex-col gap-4">
<div className="flex items-center gap-16"> <div className="flex items-center gap-16">
<div className="inline-flex flex-col gap-1.5"> <div className="inline-flex flex-col gap-1.5">
<h5 className="font-semibold text-lg leading-none"> <h5 className="text-lg font-semibold leading-none">
{user?.displayName || user?.name || "No name"} {user?.displayName || user?.name || 'No name'}
</h5> </h5>
<span className="max-w-[15rem] text-sm truncate leading-none text-zinc-500"> <span className="max-w-[15rem] truncate text-sm leading-none text-zinc-500">
{user?.nip05 || shortenKey(pubkey)} {user?.nip05 || shortenKey(pubkey)}
</span> </span>
</div> </div>
<div className="inline-flex items-center gap-2"> <div className="inline-flex items-center gap-2">
{status === "loading" ? ( {status === 'loading' ? (
<button <button
type="button" type="button"
className="inline-flex w-36 h-10 items-center justify-center rounded-md bg-zinc-900 hover:bg-fuchsia-500 text-sm font-medium" className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-zinc-900 text-sm font-medium hover:bg-fuchsia-500"
> >
Loading... Loading...
</button> </button>
) : followed ? ( ) : followed ? (
<button <button
type="button" type="button"
onClick={() => unfollowUser(pubkey)} onClick={() => unfollowUser(pubkey)}
className="inline-flex w-36 h-10 items-center justify-center rounded-md bg-zinc-900 hover:bg-fuchsia-500 text-sm font-medium" className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-zinc-900 text-sm font-medium hover:bg-fuchsia-500"
> >
Unfollow Unfollow
</button> </button>
) : ( ) : (
<button <button
type="button" type="button"
onClick={() => followUser(pubkey)} onClick={() => followUser(pubkey)}
className="inline-flex w-36 h-10 items-center justify-center rounded-md bg-zinc-900 hover:bg-fuchsia-500 text-sm font-medium" className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-zinc-900 text-sm font-medium hover:bg-fuchsia-500"
> >
Follow Follow
</button> </button>
)} )}
<Link <Link
to={`/app/chat/${pubkey}`} to={`/app/chat/${pubkey}`}
className="inline-flex w-36 h-10 items-center justify-center rounded-md bg-zinc-900 hover:bg-fuchsia-500 text-sm font-medium" className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-zinc-900 text-sm font-medium hover:bg-fuchsia-500"
> >
Message Message
</Link> </Link>
<button <button
type="button" type="button"
className="inline-flex w-10 h-10 items-center justify-center rounded-md bg-zinc-900 group hover:bg-orange-500 text-sm font-medium" className="group inline-flex h-10 w-10 items-center justify-center rounded-md bg-zinc-900 text-sm font-medium hover:bg-orange-500"
> >
<ZapIcon className="w-5 h-5" /> <ZapIcon className="h-5 w-5" />
</button> </button>
<span className="inline-flex mx-2 w-px h-4 bg-zinc-900" /> <span className="mx-2 inline-flex h-4 w-px bg-zinc-900" />
{account && account.pubkey === pubkey && <EditProfileModal />} {account && account.pubkey === pubkey && <EditProfileModal />}
</div> </div>
</div> </div>
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<p className="mt-2 max-w-[500px] break-words select-text text-zinc-100"> <p className="mt-2 max-w-[500px] select-text break-words text-zinc-100">
{user?.about} {user?.about}
</p> </p>
<UserMetadata pubkey={pubkey} /> <UserMetadata pubkey={pubkey} />
</div> </div>
</div> </div>
</div> </div>
<div className="mt-8 w-full border-t border-zinc-900"> <div className="mt-8 w-full border-t border-zinc-900">
<Tab.Group> <Tab.Group>
<Tab.List className="px-5 mb-2"> <Tab.List className="mb-2 px-5">
<Tab as={Fragment}> <Tab as={Fragment}>
{({ selected }) => ( {({ selected }) => (
<button <button
type="button" type="button"
className={`${ className={`${
selected selected
? "text-fuchsia-500 border-fuchsia-500" ? 'border-fuchsia-500 text-fuchsia-500'
: "text-zinc-200 border-transparent" : 'border-transparent text-zinc-200'
} font-medium inline-flex items-center gap-2 h-10 border-t`} } inline-flex h-10 items-center gap-2 border-t font-medium`}
> >
<ThreadsIcon className="w-4 h-4" /> <ThreadsIcon className="h-4 w-4" />
Activities from 48 hours ago Activities from 48 hours ago
</button> </button>
)} )}
</Tab> </Tab>
</Tab.List> </Tab.List>
<Tab.Panels> <Tab.Panels>
<Tab.Panel> <Tab.Panel>
<UserFeed pubkey={pubkey} /> <UserFeed pubkey={pubkey} />
</Tab.Panel> </Tab.Panel>
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>
</div> </div>
</div> </div>
</div> </div>
); );
} }

View File

@@ -15,7 +15,7 @@ button {
} }
.markdown { .markdown {
@apply prose prose-zinc max-w-none select-text break-words dark:prose-invert prose-p:mt-0 prose-p:mb-2 prose-p:last:mb-0 prose-p:leading-tight prose-a:font-normal prose-a:leading-tight prose-a:text-fuchsia-500 hover:prose-a:text-fuchsia-600 prose-ol:mb-1 prose-ul:mb-1 prose-li:leading-tight prose-blockquote:m-0 prose-ol:m-0 prose-hr:mx-0 prose-hr:my-2; @apply prose prose-zinc max-w-none select-text break-words dark:prose-invert prose-p:mb-2 prose-p:mt-0 prose-p:leading-tight prose-p:last:mb-0 prose-a:font-normal prose-a:leading-tight prose-a:text-fuchsia-500 hover:prose-a:text-fuchsia-600 prose-blockquote:m-0 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-li:leading-tight prose-hr:mx-0 prose-hr:my-2;
} }
/* For Webkit-based browsers (Chrome, Safari and Opera) */ /* For Webkit-based browsers (Chrome, Safari and Opera) */

View File

@@ -1,76 +1,79 @@
import NDK, { import NDK, {
NDKConstructorParams, NDKConstructorParams,
NDKEvent, NDKEvent,
NDKFilter, NDKFilter,
NDKKind, NDKKind,
NDKPrivateKeySigner, NDKPrivateKeySigner,
} from "@nostr-dev-kit/ndk"; } from '@nostr-dev-kit/ndk';
import { RelayContext } from "@shared/relayProvider"; import { useContext } from 'react';
import { FULL_RELAYS } from "@stores/constants";
import { useAccount } from "@utils/hooks/useAccount"; import { RelayContext } from '@shared/relayProvider';
import { useContext } from "react";
import { FULL_RELAYS } from '@stores/constants';
import { useAccount } from '@utils/hooks/useAccount';
export async function initNDK(relays?: string[]): Promise<NDK> { export async function initNDK(relays?: string[]): Promise<NDK> {
const opts: NDKConstructorParams = {}; const opts: NDKConstructorParams = {};
const defaultRelays = new Set(relays || FULL_RELAYS); const defaultRelays = new Set(relays || FULL_RELAYS);
opts.explicitRelayUrls = [...defaultRelays]; opts.explicitRelayUrls = [...defaultRelays];
const ndk = new NDK(opts); const ndk = new NDK(opts);
await ndk.connect(500); await ndk.connect(500);
return ndk; return ndk;
} }
export async function prefetchEvents( export async function prefetchEvents(
ndk: NDK, ndk: NDK,
filter: NDKFilter, filter: NDKFilter
): Promise<Set<NDKEvent>> { ): Promise<Set<NDKEvent>> {
return new Promise((resolve) => { return new Promise((resolve) => {
const events: Map<string, NDKEvent> = new Map(); const events: Map<string, NDKEvent> = new Map();
const relaySetSubscription = ndk.subscribe(filter, { const relaySetSubscription = ndk.subscribe(filter, {
closeOnEose: true, closeOnEose: true,
}); });
relaySetSubscription.on("event", (event: NDKEvent) => { relaySetSubscription.on('event', (event: NDKEvent) => {
event.ndk = ndk; event.ndk = ndk;
events.set(event.tagId(), event); events.set(event.tagId(), event);
}); });
relaySetSubscription.on("eose", () => { relaySetSubscription.on('eose', () => {
setTimeout(() => resolve(new Set(events.values())), 3000); setTimeout(() => resolve(new Set(events.values())), 3000);
}); });
}); });
} }
export function usePublish() { export function usePublish() {
const ndk = useContext(RelayContext); const ndk = useContext(RelayContext);
const { account } = useAccount(); const { account } = useAccount();
const publish = async ({ const publish = async ({
content, content,
kind, kind,
tags, tags,
}: { }: {
content: string; content: string;
kind: NDKKind; kind: NDKKind;
tags: string[][]; tags: string[][];
}): Promise<NDKEvent> => { }): Promise<NDKEvent> => {
const event = new NDKEvent(ndk); const event = new NDKEvent(ndk);
const signer = new NDKPrivateKeySigner(account.privkey); const signer = new NDKPrivateKeySigner(account.privkey);
event.content = content; event.content = content;
event.kind = kind; event.kind = kind;
event.created_at = Math.floor(Date.now() / 1000); event.created_at = Math.floor(Date.now() / 1000);
event.pubkey = account.pubkey; event.pubkey = account.pubkey;
event.tags = tags; event.tags = tags;
await event.sign(signer); await event.sign(signer);
await event.publish(); await event.publish();
return event; return event;
}; };
return publish; return publish;
} }

View File

@@ -1,360 +1,349 @@
import { OPENGRAPH } from "@stores/constants"; import { FetchOptions, ResponseType, fetch } from '@tauri-apps/api/http';
import { FetchOptions, ResponseType, fetch } from "@tauri-apps/api/http"; import * as cheerio from 'cheerio';
import * as cheerio from "cheerio";
import { OPENGRAPH } from '@stores/constants';
interface ILinkPreviewOptions { interface ILinkPreviewOptions {
headers?: Record<string, string>; headers?: Record<string, string>;
imagesPropertyType?: string; imagesPropertyType?: string;
proxyUrl?: string; proxyUrl?: string;
timeout?: number; timeout?: number;
followRedirects?: `follow` | `error` | `manual`; followRedirects?: `follow` | `error` | `manual`;
resolveDNSHost?: (url: string) => Promise<string>; resolveDNSHost?: (url: string) => Promise<string>;
handleRedirects?: (baseURL: string, forwardedURL: string) => boolean; handleRedirects?: (baseURL: string, forwardedURL: string) => boolean;
} }
interface IPreFetchedResource { interface IPreFetchedResource {
headers: Record<string, string>; headers: Record<string, string>;
status?: number; status?: number;
imagesPropertyType?: string; imagesPropertyType?: string;
proxyUrl?: string; proxyUrl?: string;
url: string; url: string;
data: any; data: any;
} }
function metaTag(doc: cheerio.CheerioAPI, type: string, attr: string) { function metaTag(doc: cheerio.CheerioAPI, type: string, attr: string) {
const nodes = doc(`meta[${attr}='${type}']`); const nodes = doc(`meta[${attr}='${type}']`);
return nodes.length ? nodes : null; return nodes.length ? nodes : null;
} }
function metaTagContent(doc: cheerio.CheerioAPI, type: string, attr: string) { function metaTagContent(doc: cheerio.CheerioAPI, type: string, attr: string) {
return doc(`meta[${attr}='${type}']`).attr("content"); return doc(`meta[${attr}='${type}']`).attr('content');
} }
function getTitle(doc: cheerio.CheerioAPI) { function getTitle(doc: cheerio.CheerioAPI) {
let title = let title =
metaTagContent(doc, "og:title", "property") || metaTagContent(doc, 'og:title', 'property') ||
metaTagContent(doc, "og:title", "name"); metaTagContent(doc, 'og:title', 'name');
if (!title) { if (!title) {
title = doc("title").text(); title = doc('title').text();
} }
return title; return title;
} }
function getSiteName(doc: cheerio.CheerioAPI) { function getSiteName(doc: cheerio.CheerioAPI) {
const siteName = const siteName =
metaTagContent(doc, "og:site_name", "property") || metaTagContent(doc, 'og:site_name', 'property') ||
metaTagContent(doc, "og:site_name", "name"); metaTagContent(doc, 'og:site_name', 'name');
return siteName; return siteName;
} }
function getDescription(doc: cheerio.CheerioAPI) { function getDescription(doc: cheerio.CheerioAPI) {
const description = const description =
metaTagContent(doc, "description", "name") || metaTagContent(doc, 'description', 'name') ||
metaTagContent(doc, "Description", "name") || metaTagContent(doc, 'Description', 'name') ||
metaTagContent(doc, "og:description", "property"); metaTagContent(doc, 'og:description', 'property');
return description; return description;
} }
function getMediaType(doc: cheerio.CheerioAPI) { function getMediaType(doc: cheerio.CheerioAPI) {
const node = metaTag(doc, "medium", "name"); const node = metaTag(doc, 'medium', 'name');
if (node) { if (node) {
const content = node.attr("content"); const content = node.attr('content');
return content === "image" ? "photo" : content; return content === 'image' ? 'photo' : content;
} }
return ( return (
metaTagContent(doc, "og:type", "property") || metaTagContent(doc, 'og:type', 'property') || metaTagContent(doc, 'og:type', 'name')
metaTagContent(doc, "og:type", "name") );
);
} }
function getImages( function getImages(
doc: cheerio.CheerioAPI, doc: cheerio.CheerioAPI,
rootUrl: string, rootUrl: string,
imagesPropertyType?: string, imagesPropertyType?: string
) { ) {
let images: string[] = []; let images: string[] = [];
let nodes: cheerio.Cheerio<cheerio.Element> | null; let nodes: cheerio.Cheerio<cheerio.Element> | null;
let src: string | undefined; let src: string | undefined;
let dic: Record<string, boolean> = {}; let dic: Record<string, boolean> = {};
const imagePropertyType = imagesPropertyType ?? "og"; const imagePropertyType = imagesPropertyType ?? 'og';
nodes = nodes =
metaTag(doc, `${imagePropertyType}:image`, "property") || metaTag(doc, `${imagePropertyType}:image`, 'property') ||
metaTag(doc, `${imagePropertyType}:image`, "name"); metaTag(doc, `${imagePropertyType}:image`, 'name');
if (nodes) { if (nodes) {
nodes.each((_: number, node: cheerio.Element) => { nodes.each((_: number, node: cheerio.Element) => {
if (node.type === "tag") { if (node.type === 'tag') {
src = node.attribs.content; src = node.attribs.content;
if (src) { if (src) {
src = new URL(src, rootUrl).href; src = new URL(src, rootUrl).href;
images.push(src); images.push(src);
} }
} }
}); });
} }
if (images.length <= 0 && !imagesPropertyType) { if (images.length <= 0 && !imagesPropertyType) {
src = doc("link[rel=image_src]").attr("href"); src = doc('link[rel=image_src]').attr('href');
if (src) { if (src) {
src = new URL(src, rootUrl).href; src = new URL(src, rootUrl).href;
images = [src]; images = [src];
} else { } else {
nodes = doc("img"); nodes = doc('img');
if (nodes?.length) { if (nodes?.length) {
dic = {}; dic = {};
images = []; images = [];
nodes.each((_: number, node: cheerio.Element) => { nodes.each((_: number, node: cheerio.Element) => {
if (node.type === "tag") src = node.attribs.src; if (node.type === 'tag') src = node.attribs.src;
if (src && !dic[src]) { if (src && !dic[src]) {
dic[src] = true; dic[src] = true;
// width = node.attribs.width; // width = node.attribs.width;
// height = node.attribs.height; // height = node.attribs.height;
images.push(new URL(src, rootUrl).href); images.push(new URL(src, rootUrl).href);
} }
}); });
} }
} }
} }
return images; return images;
} }
function getVideos(doc: cheerio.CheerioAPI) { function getVideos(doc: cheerio.CheerioAPI) {
const videos = []; const videos = [];
let nodeTypes; let nodeTypes;
let nodeSecureUrls; let nodeSecureUrls;
let nodeType; let nodeType;
let nodeSecureUrl; let nodeSecureUrl;
let video; let video;
let videoType; let videoType;
let videoSecureUrl; let videoSecureUrl;
let width; let width;
let height; let height;
let videoObj; let videoObj;
let index; let index;
const nodes = const nodes = metaTag(doc, 'og:video', 'property') || metaTag(doc, 'og:video', 'name');
metaTag(doc, "og:video", "property") || metaTag(doc, "og:video", "name");
if (nodes?.length) { if (nodes?.length) {
nodeTypes = nodeTypes =
metaTag(doc, "og:video:type", "property") || metaTag(doc, 'og:video:type', 'property') || metaTag(doc, 'og:video:type', 'name');
metaTag(doc, "og:video:type", "name"); nodeSecureUrls =
nodeSecureUrls = metaTag(doc, 'og:video:secure_url', 'property') ||
metaTag(doc, "og:video:secure_url", "property") || metaTag(doc, 'og:video:secure_url', 'name');
metaTag(doc, "og:video:secure_url", "name"); width =
width = metaTagContent(doc, 'og:video:width', 'property') ||
metaTagContent(doc, "og:video:width", "property") || metaTagContent(doc, 'og:video:width', 'name');
metaTagContent(doc, "og:video:width", "name"); height =
height = metaTagContent(doc, 'og:video:height', 'property') ||
metaTagContent(doc, "og:video:height", "property") || metaTagContent(doc, 'og:video:height', 'name');
metaTagContent(doc, "og:video:height", "name");
for (index = 0; index < nodes.length; index += 1) { for (index = 0; index < nodes.length; index += 1) {
const node = nodes[index]; const node = nodes[index];
if (node.type === "tag") video = node.attribs.content; if (node.type === 'tag') video = node.attribs.content;
nodeType = nodeTypes?.[index]; nodeType = nodeTypes?.[index];
if (nodeType?.type === "tag") { if (nodeType?.type === 'tag') {
videoType = nodeType ? nodeType.attribs.content : null; videoType = nodeType ? nodeType.attribs.content : null;
} }
nodeSecureUrl = nodeSecureUrls?.[index]; nodeSecureUrl = nodeSecureUrls?.[index];
if (nodeSecureUrl?.type === "tag") { if (nodeSecureUrl?.type === 'tag') {
videoSecureUrl = nodeSecureUrl ? nodeSecureUrl.attribs.content : null; videoSecureUrl = nodeSecureUrl ? nodeSecureUrl.attribs.content : null;
} }
videoObj = { videoObj = {
url: video, url: video,
secureUrl: videoSecureUrl, secureUrl: videoSecureUrl,
type: videoType, type: videoType,
width, width,
height, height,
}; };
if (videoType && videoType.indexOf("video/") === 0) { if (videoType && videoType.indexOf('video/') === 0) {
videos.splice(0, 0, videoObj); videos.splice(0, 0, videoObj);
} else { } else {
videos.push(videoObj); videos.push(videoObj);
} }
} }
} }
return videos; return videos;
} }
// returns default favicon (//hostname/favicon.ico) for a url // returns default favicon (//hostname/favicon.ico) for a url
function getDefaultFavicon(rootUrl: string) { function getDefaultFavicon(rootUrl: string) {
return `${new URL(rootUrl).origin}/favicon.ico`; return `${new URL(rootUrl).origin}/favicon.ico`;
} }
// returns an array of URLs to favicon images // returns an array of URLs to favicon images
function getFavicons(doc: cheerio.CheerioAPI, rootUrl: string) { function getFavicons(doc: cheerio.CheerioAPI, rootUrl: string) {
const images = []; const images = [];
let nodes: cheerio.Cheerio<cheerio.Element> | never[] = []; let nodes: cheerio.Cheerio<cheerio.Element> | never[] = [];
let src: string | undefined; let src: string | undefined;
const relSelectors = [ const relSelectors = ['rel=icon', `rel="shortcut icon"`, 'rel=apple-touch-icon'];
"rel=icon",
`rel="shortcut icon"`,
"rel=apple-touch-icon",
];
relSelectors.forEach((relSelector) => { relSelectors.forEach((relSelector) => {
// look for all icon tags // look for all icon tags
nodes = doc(`link[${relSelector}]`); nodes = doc(`link[${relSelector}]`);
// collect all images from icon tags // collect all images from icon tags
if (nodes.length) { if (nodes.length) {
nodes.each((_: number, node: cheerio.Element) => { nodes.each((_: number, node: cheerio.Element) => {
if (node.type === "tag") src = node.attribs.href; if (node.type === 'tag') src = node.attribs.href;
if (src) { if (src) {
src = new URL(rootUrl).href; src = new URL(rootUrl).href;
images.push(src); images.push(src);
} }
}); });
} }
}); });
// if no icon images, use default favicon location // if no icon images, use default favicon location
if (images.length <= 0) { if (images.length <= 0) {
images.push(getDefaultFavicon(rootUrl)); images.push(getDefaultFavicon(rootUrl));
} }
return images; return images;
} }
function parseImageResponse(url: string, contentType: string) { function parseImageResponse(url: string, contentType: string) {
return { return {
url, url,
mediaType: "image", mediaType: 'image',
contentType, contentType,
favicons: [getDefaultFavicon(url)], favicons: [getDefaultFavicon(url)],
}; };
} }
function parseAudioResponse(url: string, contentType: string) { function parseAudioResponse(url: string, contentType: string) {
return { return {
url, url,
mediaType: "audio", mediaType: 'audio',
contentType, contentType,
favicons: [getDefaultFavicon(url)], favicons: [getDefaultFavicon(url)],
}; };
} }
function parseVideoResponse(url: string, contentType: string) { function parseVideoResponse(url: string, contentType: string) {
return { return {
url, url,
mediaType: "video", mediaType: 'video',
contentType, contentType,
favicons: [getDefaultFavicon(url)], favicons: [getDefaultFavicon(url)],
}; };
} }
function parseApplicationResponse(url: string, contentType: string) { function parseApplicationResponse(url: string, contentType: string) {
return { return {
url, url,
mediaType: "application", mediaType: 'application',
contentType, contentType,
favicons: [getDefaultFavicon(url)], favicons: [getDefaultFavicon(url)],
}; };
} }
function parseTextResponse( function parseTextResponse(
body: string, body: string,
url: string, url: string,
options: ILinkPreviewOptions = {}, options: ILinkPreviewOptions = {},
contentType?: string, contentType?: string
) { ) {
const doc = cheerio.load(body); const doc = cheerio.load(body);
return { return {
url, url,
title: getTitle(doc), title: getTitle(doc),
siteName: getSiteName(doc), siteName: getSiteName(doc),
description: getDescription(doc), description: getDescription(doc),
mediaType: getMediaType(doc) || "website", mediaType: getMediaType(doc) || 'website',
contentType, contentType,
images: getImages(doc, url, options.imagesPropertyType), images: getImages(doc, url, options.imagesPropertyType),
videos: getVideos(doc), videos: getVideos(doc),
favicons: getFavicons(doc, url), favicons: getFavicons(doc, url),
}; };
} }
function parseUnknownResponse( function parseUnknownResponse(
body: string, body: string,
url: string, url: string,
options: ILinkPreviewOptions = {}, options: ILinkPreviewOptions = {},
contentType?: string, contentType?: string
) { ) {
return parseTextResponse(body, url, options, contentType); return parseTextResponse(body, url, options, contentType);
} }
function parseResponse( function parseResponse(response: IPreFetchedResource, options?: ILinkPreviewOptions) {
response: IPreFetchedResource, try {
options?: ILinkPreviewOptions, let contentType = response.headers['content-type'];
) { // console.warn(`original content type`, contentType);
try { if (contentType?.indexOf(';')) {
let contentType = response.headers["content-type"]; // eslint-disable-next-line prefer-destructuring
// console.warn(`original content type`, contentType); contentType = contentType.split(';')[0];
if (contentType?.indexOf(";")) { // console.warn(`splitting content type`, contentType);
// eslint-disable-next-line prefer-destructuring }
contentType = contentType.split(";")[0];
// console.warn(`splitting content type`, contentType);
}
if (!contentType) { if (!contentType) {
return parseUnknownResponse(response.data, response.url, options); return parseUnknownResponse(response.data, response.url, options);
} }
if ((contentType as any) instanceof Array) { if ((contentType as any) instanceof Array) {
// eslint-disable-next-line no-param-reassign, prefer-destructuring // eslint-disable-next-line no-param-reassign, prefer-destructuring
contentType = contentType[0]; contentType = contentType[0];
} }
// parse response depending on content type // parse response depending on content type
if (OPENGRAPH.REGEX_CONTENT_TYPE_IMAGE.test(contentType)) { if (OPENGRAPH.REGEX_CONTENT_TYPE_IMAGE.test(contentType)) {
return parseImageResponse(response.url, contentType); return parseImageResponse(response.url, contentType);
} }
if (OPENGRAPH.REGEX_CONTENT_TYPE_AUDIO.test(contentType)) { if (OPENGRAPH.REGEX_CONTENT_TYPE_AUDIO.test(contentType)) {
return parseAudioResponse(response.url, contentType); return parseAudioResponse(response.url, contentType);
} }
if (OPENGRAPH.REGEX_CONTENT_TYPE_VIDEO.test(contentType)) { if (OPENGRAPH.REGEX_CONTENT_TYPE_VIDEO.test(contentType)) {
return parseVideoResponse(response.url, contentType); return parseVideoResponse(response.url, contentType);
} }
if (OPENGRAPH.REGEX_CONTENT_TYPE_TEXT.test(contentType)) { if (OPENGRAPH.REGEX_CONTENT_TYPE_TEXT.test(contentType)) {
const htmlString = response.data; const htmlString = response.data;
return parseTextResponse(htmlString, response.url, options, contentType); return parseTextResponse(htmlString, response.url, options, contentType);
} }
if (OPENGRAPH.REGEX_CONTENT_TYPE_APPLICATION.test(contentType)) { if (OPENGRAPH.REGEX_CONTENT_TYPE_APPLICATION.test(contentType)) {
return parseApplicationResponse(response.url, contentType); return parseApplicationResponse(response.url, contentType);
} }
const htmlString = response.data; const htmlString = response.data;
return parseUnknownResponse(htmlString, response.url, options); return parseUnknownResponse(htmlString, response.url, options);
} catch (e) { } catch (e) {
throw new Error( throw new Error(
`link-preview-js could not fetch link information ${( `link-preview-js could not fetch link information ${(e as any).toString()}`
e as any );
).toString()}`, }
);
}
} }
export async function getLinkPreview(text: string) { export async function getLinkPreview(text: string) {
const fetchUrl = text; const fetchUrl = text;
const options: FetchOptions = { const options: FetchOptions = {
method: "GET", method: 'GET',
timeout: 5, timeout: 5,
responseType: ResponseType.Text, responseType: ResponseType.Text,
}; };
let response = await fetch(fetchUrl, options); let response = await fetch(fetchUrl, options);
if (response.status > 300 && response.status < 309) { if (response.status > 300 && response.status < 309) {
const forwardedUrl = response.headers.location || ""; const forwardedUrl = response.headers.location || '';
response = await fetch(forwardedUrl, options); response = await fetch(forwardedUrl, options);
} }
return parseResponse(response); return parseResponse(response);
} }

View File

@@ -1,441 +1,418 @@
import { getParentID } from "@utils/transform"; import Database from 'tauri-plugin-sql-api';
import Database from "tauri-plugin-sql-api";
import { getParentID } from '@utils/transform';
let db: null | Database = null; let db: null | Database = null;
// connect database (sqlite) // connect database (sqlite)
// path: tauri::api::path::BaseDirectory::App // path: tauri::api::path::BaseDirectory::App
export async function connect(): Promise<Database> { export async function connect(): Promise<Database> {
if (db) { if (db) {
return db; return db;
} }
db = await Database.load("sqlite:lume.db"); db = await Database.load('sqlite:lume.db');
return db; return db;
} }
// get active account // get active account
export async function getActiveAccount() { export async function getActiveAccount() {
const db = await connect(); const db = await connect();
const result: any = await db.select( const result: any = await db.select('SELECT * FROM accounts WHERE is_active = 1;');
"SELECT * FROM accounts WHERE is_active = 1;", if (result.length > 0) {
); return result[0];
if (result.length > 0) { } else {
return result[0]; return null;
} else { }
return null;
}
} }
// get all accounts // get all accounts
export async function getAccounts() { export async function getAccounts() {
const db = await connect(); const db = await connect();
return await db.select( return await db.select(
"SELECT * FROM accounts WHERE is_active = 0 ORDER BY created_at DESC;", 'SELECT * FROM accounts WHERE is_active = 0 ORDER BY created_at DESC;'
); );
} }
// create account // create account
export async function createAccount( export async function createAccount(
npub: string, npub: string,
pubkey: string, pubkey: string,
privkey: string, privkey: string,
follows?: string[][], follows?: string[][],
is_active?: number, is_active?: number
) { ) {
const db = await connect(); const db = await connect();
const res = await db.execute( const res = await db.execute(
"INSERT OR IGNORE INTO accounts (npub, pubkey, privkey, follows, is_active) VALUES (?, ?, ?, ?, ?);", 'INSERT OR IGNORE INTO accounts (npub, pubkey, privkey, follows, is_active) VALUES (?, ?, ?, ?, ?);',
[npub, pubkey, privkey, follows || "", is_active || 0], [npub, pubkey, privkey, follows || '', is_active || 0]
); );
if (res) { if (res) {
await createBlock( await createBlock(
0, 0,
"Preserve your freedom", 'Preserve your freedom',
"https://void.cat/d/949GNg7ZjSLHm2eTR3jZqv", 'https://void.cat/d/949GNg7ZjSLHm2eTR3jZqv'
); );
} }
const getAccount = await getActiveAccount(); const getAccount = await getActiveAccount();
return getAccount; return getAccount;
} }
// update account // update account
export async function updateAccount( export async function updateAccount(
column: string, column: string,
value: string | string[], value: string | string[],
pubkey: string, pubkey: string
) { ) {
const db = await connect(); const db = await connect();
return await db.execute( return await db.execute(`UPDATE accounts SET ${column} = ? WHERE pubkey = ?;`, [
`UPDATE accounts SET ${column} = ? WHERE pubkey = ?;`, value,
[value, pubkey], pubkey,
); ]);
} }
// count total notes // count total notes
export async function countTotalChannels() { export async function countTotalChannels() {
const db = await connect(); const db = await connect();
const result = await db.select('SELECT COUNT(*) AS "total" FROM channels;'); const result = await db.select('SELECT COUNT(*) AS "total" FROM channels;');
return result[0]; return result[0];
} }
// count total notes // count total notes
export async function countTotalNotes() { export async function countTotalNotes() {
const db = await connect(); const db = await connect();
const result = await db.select( const result = await db.select(
'SELECT COUNT(*) AS "total" FROM notes WHERE kind IN (1, 6);', 'SELECT COUNT(*) AS "total" FROM notes WHERE kind IN (1, 6);'
); );
return parseInt(result[0].total); return parseInt(result[0].total);
} }
// get all notes // get all notes
export async function getNotes(limit: number, offset: number) { export async function getNotes(limit: number, offset: number) {
const db = await connect(); const db = await connect();
const totalNotes = await countTotalNotes(); const totalNotes = await countTotalNotes();
const nextCursor = offset + limit; const nextCursor = offset + limit;
const notes: any = { data: null, nextCursor: 0 }; const notes: any = { data: null, nextCursor: 0 };
const query: any = await db.select( const query: any = await db.select(
`SELECT * FROM notes WHERE kind IN (1, 6, 1063) GROUP BY parent_id ORDER BY created_at DESC LIMIT "${limit}" OFFSET "${offset}";`, `SELECT * FROM notes WHERE kind IN (1, 6, 1063) GROUP BY parent_id ORDER BY created_at DESC LIMIT "${limit}" OFFSET "${offset}";`
); );
notes["data"] = query; notes['data'] = query;
notes["nextCursor"] = notes['nextCursor'] = Math.round(totalNotes / nextCursor) > 1 ? nextCursor : undefined;
Math.round(totalNotes / nextCursor) > 1 ? nextCursor : undefined;
return notes; return notes;
} }
// get all notes by pubkey // get all notes by pubkey
export async function getNotesByPubkey(pubkey: string) { export async function getNotesByPubkey(pubkey: string) {
const db = await connect(); const db = await connect();
const res: any = await db.select( const res: any = await db.select(
`SELECT * FROM notes WHERE pubkey == "${pubkey}" AND kind IN (1, 6, 1063) GROUP BY parent_id ORDER BY created_at DESC;`, `SELECT * FROM notes WHERE pubkey == "${pubkey}" AND kind IN (1, 6, 1063) GROUP BY parent_id ORDER BY created_at DESC;`
); );
return res; return res;
} }
// get all notes by authors // get all notes by authors
export async function getNotesByAuthors( export async function getNotesByAuthors(authors: string, limit: number, offset: number) {
authors: string, const db = await connect();
limit: number, const totalNotes = await countTotalNotes();
offset: number, const nextCursor = offset + limit;
) { const array = JSON.parse(authors);
const db = await connect(); const finalArray = `'${array.join("','")}'`;
const totalNotes = await countTotalNotes();
const nextCursor = offset + limit;
const array = JSON.parse(authors);
const finalArray = `'${array.join("','")}'`;
const notes: any = { data: null, nextCursor: 0 }; const notes: any = { data: null, nextCursor: 0 };
const query: any = await db.select( const query: any = await db.select(
`SELECT * FROM notes WHERE pubkey IN (${finalArray}) AND kind IN (1, 6, 1063) GROUP BY parent_id ORDER BY created_at DESC LIMIT "${limit}" OFFSET "${offset}";`, `SELECT * FROM notes WHERE pubkey IN (${finalArray}) AND kind IN (1, 6, 1063) GROUP BY parent_id ORDER BY created_at DESC LIMIT "${limit}" OFFSET "${offset}";`
); );
notes["data"] = query; notes['data'] = query;
notes["nextCursor"] = notes['nextCursor'] = Math.round(totalNotes / nextCursor) > 1 ? nextCursor : undefined;
Math.round(totalNotes / nextCursor) > 1 ? nextCursor : undefined;
return notes; return notes;
} }
// get note by id // get note by id
export async function getNoteByID(event_id: string) { export async function getNoteByID(event_id: string) {
const db = await connect(); const db = await connect();
const result = await db.select( const result = await db.select(`SELECT * FROM notes WHERE event_id = "${event_id}";`);
`SELECT * FROM notes WHERE event_id = "${event_id}";`, return result[0];
);
return result[0];
} }
// create note // create note
export async function createNote( export async function createNote(
event_id: string, event_id: string,
pubkey: string, pubkey: string,
kind: number, kind: number,
tags: any, tags: any,
content: string, content: string,
created_at: number, created_at: number
) { ) {
const db = await connect(); const db = await connect();
const account = await getActiveAccount(); const account = await getActiveAccount();
const parentID = getParentID(tags, event_id); const parentID = getParentID(tags, event_id);
return await db.execute( return await db.execute(
"INSERT OR IGNORE INTO notes (event_id, account_id, pubkey, kind, tags, content, created_at, parent_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?);", 'INSERT OR IGNORE INTO notes (event_id, account_id, pubkey, kind, tags, content, created_at, parent_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?);',
[event_id, account.id, pubkey, kind, tags, content, created_at, parentID], [event_id, account.id, pubkey, kind, tags, content, created_at, parentID]
); );
} }
// get note replies // get note replies
export async function getReplies(parent_id: string) { export async function getReplies(parent_id: string) {
const db = await connect(); const db = await connect();
const result: any = await db.select( const result: any = await db.select(
`SELECT * FROM replies WHERE parent_id = "${parent_id}" ORDER BY created_at DESC;`, `SELECT * FROM replies WHERE parent_id = "${parent_id}" ORDER BY created_at DESC;`
); );
return result; return result;
} }
// create reply note // create reply note
export async function createReplyNote( export async function createReplyNote(
parent_id: string, parent_id: string,
event_id: string, event_id: string,
pubkey: string, pubkey: string,
kind: number, kind: number,
tags: any, tags: any,
content: string, content: string,
created_at: number, created_at: number
) { ) {
const db = await connect(); const db = await connect();
return await db.execute( return await db.execute(
"INSERT OR IGNORE INTO replies (event_id, parent_id, pubkey, kind, tags, content, created_at) VALUES (?, ?, ?, ?, ?, ?, ?);", 'INSERT OR IGNORE INTO replies (event_id, parent_id, pubkey, kind, tags, content, created_at) VALUES (?, ?, ?, ?, ?, ?, ?);',
[event_id, parent_id, pubkey, kind, tags, content, created_at], [event_id, parent_id, pubkey, kind, tags, content, created_at]
); );
} }
// get all channels // get all channels
export async function getChannels() { export async function getChannels() {
const db = await connect(); const db = await connect();
const result: any = await db.select( const result: any = await db.select('SELECT * FROM channels ORDER BY created_at DESC;');
"SELECT * FROM channels ORDER BY created_at DESC;", return result;
);
return result;
} }
// get channel by id // get channel by id
export async function getChannel(id: string) { export async function getChannel(id: string) {
const db = await connect(); const db = await connect();
const result = await db.select( const result = await db.select(`SELECT * FROM channels WHERE event_id = "${id}";`);
`SELECT * FROM channels WHERE event_id = "${id}";`, return result[0];
);
return result[0];
} }
// create channel // create channel
export async function createChannel( export async function createChannel(
event_id: string, event_id: string,
pubkey: string, pubkey: string,
name: string, name: string,
picture: string, picture: string,
about: string, about: string,
created_at: number, created_at: number
) { ) {
const db = await connect(); const db = await connect();
return await db.execute( return await db.execute(
"INSERT OR IGNORE INTO channels (event_id, pubkey, name, picture, about, created_at) VALUES (?, ?, ?, ?, ?, ?);", 'INSERT OR IGNORE INTO channels (event_id, pubkey, name, picture, about, created_at) VALUES (?, ?, ?, ?, ?, ?);',
[event_id, pubkey, name, picture, about, created_at], [event_id, pubkey, name, picture, about, created_at]
); );
} }
// update channel metadata // update channel metadata
export async function updateChannelMetadata(event_id: string, value: string) { export async function updateChannelMetadata(event_id: string, value: string) {
const db = await connect(); const db = await connect();
const data = JSON.parse(value); const data = JSON.parse(value);
return await db.execute( return await db.execute(
"UPDATE channels SET name = ?, picture = ?, about = ? WHERE event_id = ?;", 'UPDATE channels SET name = ?, picture = ?, about = ? WHERE event_id = ?;',
[data.name, data.picture, data.about, event_id], [data.name, data.picture, data.about, event_id]
); );
} }
// create channel messages // create channel messages
export async function createChannelMessage( export async function createChannelMessage(
channel_id: string, channel_id: string,
event_id: string, event_id: string,
pubkey: string, pubkey: string,
kind: number, kind: number,
content: string, content: string,
tags: string[][], tags: string[][],
created_at: number, created_at: number
) { ) {
const db = await connect(); const db = await connect();
return await db.execute( return await db.execute(
"INSERT OR IGNORE INTO channel_messages (channel_id, event_id, pubkey, kind, content, tags, created_at) VALUES (?, ?, ?, ?, ?, ?, ?);", 'INSERT OR IGNORE INTO channel_messages (channel_id, event_id, pubkey, kind, content, tags, created_at) VALUES (?, ?, ?, ?, ?, ?, ?);',
[channel_id, event_id, pubkey, kind, content, tags, created_at], [channel_id, event_id, pubkey, kind, content, tags, created_at]
); );
} }
// get channel messages by channel id // get channel messages by channel id
export async function getChannelMessages(channel_id: string) { export async function getChannelMessages(channel_id: string) {
const db = await connect(); const db = await connect();
return await db.select( return await db.select(
`SELECT * FROM channel_messages WHERE channel_id = "${channel_id}" ORDER BY created_at ASC;`, `SELECT * FROM channel_messages WHERE channel_id = "${channel_id}" ORDER BY created_at ASC;`
); );
} }
// get channel users // get channel users
export async function getChannelUsers(channel_id: string) { export async function getChannelUsers(channel_id: string) {
const db = await connect(); const db = await connect();
const result: any = await db.select( const result: any = await db.select(
`SELECT DISTINCT pubkey FROM channel_messages WHERE channel_id = "${channel_id}";`, `SELECT DISTINCT pubkey FROM channel_messages WHERE channel_id = "${channel_id}";`
); );
return result; return result;
} }
// get all chats by pubkey // get all chats by pubkey
export async function getChatsByPubkey(pubkey: string) { export async function getChatsByPubkey(pubkey: string) {
const db = await connect(); const db = await connect();
const result: any = await db.select( const result: any = await db.select(
`SELECT DISTINCT sender_pubkey FROM chats WHERE receiver_pubkey = "${pubkey}" ORDER BY created_at DESC;`, `SELECT DISTINCT sender_pubkey FROM chats WHERE receiver_pubkey = "${pubkey}" ORDER BY created_at DESC;`
); );
const newArr: any = result.map((v) => ({ ...v, new_messages: 0 })); const newArr: any = result.map((v) => ({ ...v, new_messages: 0 }));
return newArr; return newArr;
} }
// get chat messages // get chat messages
export async function getChatMessages( export async function getChatMessages(receiver_pubkey: string, sender_pubkey: string) {
receiver_pubkey: string, const db = await connect();
sender_pubkey: string, let receiver = [];
) {
const db = await connect();
let receiver = [];
const sender: any = await db.select( const sender: any = await db.select(
`SELECT * FROM chats WHERE sender_pubkey = "${sender_pubkey}" AND receiver_pubkey = "${receiver_pubkey}";`, `SELECT * FROM chats WHERE sender_pubkey = "${sender_pubkey}" AND receiver_pubkey = "${receiver_pubkey}";`
); );
if (receiver_pubkey !== sender_pubkey) { if (receiver_pubkey !== sender_pubkey) {
receiver = await db.select( receiver = await db.select(
`SELECT * FROM chats WHERE sender_pubkey = "${receiver_pubkey}" AND receiver_pubkey = "${sender_pubkey}";`, `SELECT * FROM chats WHERE sender_pubkey = "${receiver_pubkey}" AND receiver_pubkey = "${sender_pubkey}";`
); );
} }
const result = [...sender, ...receiver].sort( const result = [...sender, ...receiver].sort(
(x: { created_at: number }, y: { created_at: number }) => (x: { created_at: number }, y: { created_at: number }) => x.created_at - y.created_at
x.created_at - y.created_at, );
);
return result; return result;
} }
// create chat // create chat
export async function createChat( export async function createChat(
event_id: string, event_id: string,
receiver_pubkey: string, receiver_pubkey: string,
sender_pubkey: string, sender_pubkey: string,
content: string, content: string,
tags: string[][], tags: string[][],
created_at: number, created_at: number
) { ) {
const db = await connect(); const db = await connect();
await db.execute( await db.execute(
"INSERT OR IGNORE INTO chats (event_id, receiver_pubkey, sender_pubkey, content, tags, created_at) VALUES (?, ?, ?, ?, ?, ?);", 'INSERT OR IGNORE INTO chats (event_id, receiver_pubkey, sender_pubkey, content, tags, created_at) VALUES (?, ?, ?, ?, ?, ?);',
[event_id, receiver_pubkey, sender_pubkey, content, tags, created_at], [event_id, receiver_pubkey, sender_pubkey, content, tags, created_at]
); );
return sender_pubkey; return sender_pubkey;
} }
// get setting // get setting
export async function getSetting(key: string) { export async function getSetting(key: string) {
const db = await connect(); const db = await connect();
const result = await db.select( const result = await db.select(`SELECT value FROM settings WHERE key = "${key}";`);
`SELECT value FROM settings WHERE key = "${key}";`, return result[0]?.value;
);
return result[0]?.value;
} }
// update setting // update setting
export async function updateSetting(key: string, value: string | number) { export async function updateSetting(key: string, value: string | number) {
const db = await connect(); const db = await connect();
return await db.execute( return await db.execute(`UPDATE settings SET value = "${value}" WHERE key = "${key}";`);
`UPDATE settings SET value = "${value}" WHERE key = "${key}";`,
);
} }
// get last login // get last login
export async function getLastLogin() { export async function getLastLogin() {
const db = await connect(); const db = await connect();
const result = await db.select( const result = await db.select(`SELECT value FROM settings WHERE key = "last_login";`);
`SELECT value FROM settings WHERE key = "last_login";`, if (result[0]) {
); return parseInt(result[0].value);
if (result[0]) { } else {
return parseInt(result[0].value); return 0;
} else { }
return 0;
}
} }
// update last login // update last login
export async function updateLastLogin(value: number) { export async function updateLastLogin(value: number) {
const db = await connect(); const db = await connect();
return await db.execute( return await db.execute(
`UPDATE settings SET value = ${value} WHERE key = "last_login";`, `UPDATE settings SET value = ${value} WHERE key = "last_login";`
); );
} }
// get blacklist by kind and account id // get blacklist by kind and account id
export async function getBlacklist(account_id: number, kind: number) { export async function getBlacklist(account_id: number, kind: number) {
const db = await connect(); const db = await connect();
return await db.select( return await db.select(
`SELECT * FROM blacklist WHERE account_id = "${account_id}" AND kind = "${kind}";`, `SELECT * FROM blacklist WHERE account_id = "${account_id}" AND kind = "${kind}";`
); );
} }
// get active blacklist by kind and account id // get active blacklist by kind and account id
export async function getActiveBlacklist(account_id: number, kind: number) { export async function getActiveBlacklist(account_id: number, kind: number) {
const db = await connect(); const db = await connect();
return await db.select( return await db.select(
`SELECT content FROM blacklist WHERE account_id = "${account_id}" AND kind = "${kind}" AND status = 1;`, `SELECT content FROM blacklist WHERE account_id = "${account_id}" AND kind = "${kind}" AND status = 1;`
); );
} }
// add to blacklist // add to blacklist
export async function addToBlacklist( export async function addToBlacklist(
account_id: number, account_id: number,
content: string, content: string,
kind: number, kind: number,
status?: number, status?: number
) { ) {
const db = await connect(); const db = await connect();
return await db.execute( return await db.execute(
"INSERT OR IGNORE INTO blacklist (account_id, content, kind, status) VALUES (?, ?, ?, ?);", 'INSERT OR IGNORE INTO blacklist (account_id, content, kind, status) VALUES (?, ?, ?, ?);',
[account_id, content, kind, status || 1], [account_id, content, kind, status || 1]
); );
} }
// update item in blacklist // update item in blacklist
export async function updateItemInBlacklist(content: string, status: number) { export async function updateItemInBlacklist(content: string, status: number) {
const db = await connect(); const db = await connect();
return await db.execute( return await db.execute(
`UPDATE blacklist SET status = "${status}" WHERE content = "${content}";`, `UPDATE blacklist SET status = "${status}" WHERE content = "${content}";`
); );
} }
// get all blocks // get all blocks
export async function getBlocks() { export async function getBlocks() {
const db = await connect(); const db = await connect();
const activeAccount = await getActiveAccount(); const activeAccount = await getActiveAccount();
const result: any = await db.select( const result: any = await db.select(
`SELECT * FROM blocks WHERE account_id = "${activeAccount.id}" ORDER BY created_at DESC;`, `SELECT * FROM blocks WHERE account_id = "${activeAccount.id}" ORDER BY created_at DESC;`
); );
return result; return result;
} }
// create block // create block
export async function createBlock(kind: number, title: string, content: any) { export async function createBlock(kind: number, title: string, content: any) {
const db = await connect(); const db = await connect();
const activeAccount = await getActiveAccount(); const activeAccount = await getActiveAccount();
return await db.execute( return await db.execute(
"INSERT OR IGNORE INTO blocks (account_id, kind, title, content) VALUES (?, ?, ?, ?);", 'INSERT OR IGNORE INTO blocks (account_id, kind, title, content) VALUES (?, ?, ?, ?);',
[activeAccount.id, kind, title, content], [activeAccount.id, kind, title, content]
); );
} }
// remove block // remove block
export async function removeBlock(id: string) { export async function removeBlock(id: string) {
const db = await connect(); const db = await connect();
return await db.execute(`DELETE FROM blocks WHERE id = "${id}";`); return await db.execute(`DELETE FROM blocks WHERE id = "${id}";`);
} }
// logout // logout
export async function removeAll() { export async function removeAll() {
const db = await connect(); const db = await connect();
await db.execute(`UPDATE settings SET value = "0" WHERE key = "last_login";`); await db.execute(`UPDATE settings SET value = "0" WHERE key = "last_login";`);
await db.execute("DELETE FROM replies;"); await db.execute('DELETE FROM replies;');
await db.execute("DELETE FROM notes;"); await db.execute('DELETE FROM notes;');
await db.execute("DELETE FROM blacklist;"); await db.execute('DELETE FROM blacklist;');
await db.execute("DELETE FROM blocks;"); await db.execute('DELETE FROM blocks;');
await db.execute("DELETE FROM chats;"); await db.execute('DELETE FROM chats;');
await db.execute("DELETE FROM accounts;"); await db.execute('DELETE FROM accounts;');
return true; return true;
} }

View File

@@ -1,26 +1,29 @@
import App from "./app"; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { getSetting } from "@libs/storage"; import { createRoot } from 'react-dom/client';
import { RelayProvider } from "@shared/relayProvider";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRoot } from "react-dom/client";
const cacheTime = await getSetting("cache_time"); import { getSetting } from '@libs/storage';
import { RelayProvider } from '@shared/relayProvider';
import App from './app';
const cacheTime = await getSetting('cache_time');
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
cacheTime: parseInt(cacheTime), cacheTime: parseInt(cacheTime),
}, },
}, },
}); });
const container = document.getElementById("root"); const container = document.getElementById('root');
const root = createRoot(container); const root = createRoot(container);
root.render( root.render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<RelayProvider> <RelayProvider>
<App /> <App />
</RelayProvider> </RelayProvider>
</QueryClientProvider>, </QueryClientProvider>
); );

View File

@@ -1,105 +1,105 @@
import { createChat, getLastLogin } from "@libs/storage"; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Image } from "@shared/image"; import { produce } from 'immer';
import { NetworkStatusIndicator } from "@shared/networkStatusIndicator"; import { useContext, useEffect } from 'react';
import { RelayContext } from "@shared/relayProvider"; import { Link } from 'react-router-dom';
import { DEFAULT_AVATAR } from "@stores/constants";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { createChat, getLastLogin } from '@libs/storage';
import { useProfile } from "@utils/hooks/useProfile";
import { sendNativeNotification } from "@utils/notification"; import { Image } from '@shared/image';
import { produce } from "immer"; import { NetworkStatusIndicator } from '@shared/networkStatusIndicator';
import { useContext, useEffect } from "react"; import { RelayContext } from '@shared/relayProvider';
import { Link } from "react-router-dom";
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { sendNativeNotification } from '@utils/notification';
const lastLogin = await getLastLogin(); const lastLogin = await getLastLogin();
export function ActiveAccount({ data }: { data: any }) { export function ActiveAccount({ data }: { data: any }) {
const ndk = useContext(RelayContext); const ndk = useContext(RelayContext);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { status, user } = useProfile(data.pubkey); const { status, user } = useProfile(data.pubkey);
const chat = useMutation({ const chat = useMutation({
mutationFn: (data: any) => { mutationFn: (data: any) => {
return createChat( return createChat(
data.id, data.id,
data.receiver_pubkey, data.receiver_pubkey,
data.sender_pubkey, data.sender_pubkey,
data.content, data.content,
data.tags, data.tags,
data.created_at, data.created_at
); );
}, },
onSuccess: (data: any) => { onSuccess: (data: any) => {
const prev = queryClient.getQueryData(["chats"]); const prev = queryClient.getQueryData(['chats']);
const next = produce(prev, (draft: any) => { const next = produce(prev, (draft: any) => {
const target = draft.findIndex( const target = draft.findIndex(
(m: { sender_pubkey: string }) => m.sender_pubkey === data, (m: { sender_pubkey: string }) => m.sender_pubkey === data
); );
if (target !== -1) { if (target !== -1) {
draft[target]["new_messages"] = draft[target]['new_messages'] = draft[target]['new_messages'] + 1 || 1;
draft[target]["new_messages"] + 1 || 1; } else {
} else { draft.push({ sender_pubkey: data, new_messages: 1 });
draft.push({ sender_pubkey: data, new_messages: 1 }); }
} });
}); queryClient.setQueryData(['chats'], next);
queryClient.setQueryData(["chats"], next); },
}, });
});
useEffect(() => { useEffect(() => {
const since = lastLogin > 0 ? lastLogin : Math.floor(Date.now() / 1000); const since = lastLogin > 0 ? lastLogin : Math.floor(Date.now() / 1000);
const sub = ndk.subscribe( const sub = ndk.subscribe(
{ {
kinds: [4], kinds: [4],
"#p": [data.pubkey], '#p': [data.pubkey],
since: since, since: since,
}, },
{ {
closeOnEose: false, closeOnEose: false,
}, }
); );
sub.addListener("event", (event) => { sub.addListener('event', (event) => {
switch (event.kind) { switch (event.kind) {
case 4: case 4:
// update state // update state
chat.mutate({ chat.mutate({
id: event.id, id: event.id,
receiver_pubkey: data.pubkey, receiver_pubkey: data.pubkey,
sender_pubkey: event.pubkey, sender_pubkey: event.pubkey,
content: event.content, content: event.content,
tags: event.tags, tags: event.tags,
created_at: event.created_at, created_at: event.created_at,
}); });
// send native notifiation // send native notifiation
sendNativeNotification("You've received new message"); sendNativeNotification("You've received new message");
break; break;
default: default:
break; break;
} }
}); });
return () => { return () => {
sub.stop(); sub.stop();
}; };
}, []); }, []);
if (status === "loading") { if (status === 'loading') {
return <div className="w-9 h-9 rounded-md bg-zinc-800 animate-pulse" />; return <div className="h-9 w-9 animate-pulse rounded-md bg-zinc-800" />;
} }
return ( return (
<Link <Link to={`/app/user/${data.pubkey}`} className="relative inline-block h-9 w-9">
to={`/app/user/${data.pubkey}`} <Image
className="relative inline-block h-9 w-9" src={user.image}
> fallback={DEFAULT_AVATAR}
<Image alt={data.npub}
src={user.image} className="h-9 w-9 rounded-md object-cover"
fallback={DEFAULT_AVATAR} />
alt={data.npub} <NetworkStatusIndicator />
className="h-9 w-9 rounded-md object-cover" </Link>
/> );
<NetworkStatusIndicator />
</Link>
);
} }

View File

@@ -1,18 +1,20 @@
import { Image } from "@shared/image"; import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from "@stores/constants";
import { useProfile } from "@utils/hooks/useProfile"; import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
export function InactiveAccount({ data }: { data: any }) { export function InactiveAccount({ data }: { data: any }) {
const { user } = useProfile(data.npub); const { user } = useProfile(data.npub);
return ( return (
<div className="relative h-9 w-9 shrink-0"> <div className="relative h-9 w-9 shrink-0">
<Image <Image
src={user?.image} src={user?.image}
fallback={DEFAULT_AVATAR} fallback={DEFAULT_AVATAR}
alt={data.npub} alt={data.npub}
className="h-9 w-9 rounded object-cover" className="h-9 w-9 rounded object-cover"
/> />
</div> </div>
); );
} }

View File

@@ -1,48 +1,49 @@
import { ArrowLeftIcon, ArrowRightIcon } from "@shared/icons"; import { useNavigate } from 'react-router-dom';
import { useNavigate } from "react-router-dom";
import { ArrowLeftIcon, ArrowRightIcon } from '@shared/icons';
export function AppHeader({ reverse }: { reverse?: boolean }) { export function AppHeader({ reverse }: { reverse?: boolean }) {
const navigate = useNavigate(); const navigate = useNavigate();
const goBack = () => { const goBack = () => {
navigate(-1); navigate(-1);
}; };
const goForward = () => { const goForward = () => {
navigate(1); navigate(1);
}; };
return ( return (
<div <div
data-tauri-drag-region data-tauri-drag-region
className={`shrink-0 flex h-11 w-full px-3 border-b border-zinc-900 items-center ${ className={`flex h-11 w-full shrink-0 items-center border-b border-zinc-900 px-3 ${
reverse ? "justify-start" : "justify-end" reverse ? 'justify-start' : 'justify-end'
}`} }`}
> >
<div className="flex gap-2.5"> <div className="flex gap-2.5">
<button <button
type="button" type="button"
onClick={() => goBack()} onClick={() => goBack()}
className="group inline-flex h-6 w-6 items-center justify-center rounded hover:bg-zinc-900" className="group inline-flex h-6 w-6 items-center justify-center rounded hover:bg-zinc-900"
> >
<ArrowLeftIcon <ArrowLeftIcon
width={16} width={16}
height={16} height={16}
className="text-zinc-500 group-hover:text-zinc-300" className="text-zinc-500 group-hover:text-zinc-300"
/> />
</button> </button>
<button <button
type="button" type="button"
onClick={() => goForward()} onClick={() => goForward()}
className="group inline-flex h-6 w-6 items-center justify-center rounded hover:bg-zinc-900" className="group inline-flex h-6 w-6 items-center justify-center rounded hover:bg-zinc-900"
> >
<ArrowRightIcon <ArrowRightIcon
width={16} width={16}
height={16} height={16}
className="text-zinc-500 group-hover:text-zinc-300" className="text-zinc-500 group-hover:text-zinc-300"
/> />
</button> </button>
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,16 +1,17 @@
import { Navigation } from "@shared/navigation"; import { Outlet, ScrollRestoration } from 'react-router-dom';
import { Outlet, ScrollRestoration } from "react-router-dom";
import { Navigation } from '@shared/navigation';
export function AppLayout() { export function AppLayout() {
return ( return (
<div className="flex w-screen h-screen"> <div className="flex h-screen w-screen">
<div className="relative flex flex-row shrink-0"> <div className="relative flex shrink-0 flex-row">
<Navigation /> <Navigation />
</div> </div>
<div className="w-full h-full"> <div className="h-full w-full">
<Outlet /> <Outlet />
<ScrollRestoration /> <ScrollRestoration />
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,65 +1,66 @@
import { ArrowLeftIcon, ArrowRightIcon } from "@shared/icons"; import { platform } from '@tauri-apps/api/os';
import { platform } from "@tauri-apps/api/os"; import { Outlet, useNavigate } from 'react-router-dom';
import { Outlet, useNavigate } from "react-router-dom";
import { ArrowLeftIcon, ArrowRightIcon } from '@shared/icons';
const platformName = await platform(); const platformName = await platform();
export function AuthLayout() { export function AuthLayout() {
const navigate = useNavigate(); const navigate = useNavigate();
const goBack = () => { const goBack = () => {
navigate(-1); navigate(-1);
}; };
const goForward = () => { const goForward = () => {
navigate(1); navigate(1);
}; };
return ( return (
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100"> <div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100">
<div className="flex h-screen w-full flex-col"> <div className="flex h-screen w-full flex-col">
<div <div
data-tauri-drag-region data-tauri-drag-region
className="relative h-11 shrink-0 border border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black" className="relative h-11 shrink-0 border border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
> >
<div <div
data-tauri-drag-region data-tauri-drag-region
className="flex h-full w-full flex-1 items-center px-2" className="flex h-full w-full flex-1 items-center px-2"
> >
<div <div
className={`flex h-full items-center gap-2 ${ className={`flex h-full items-center gap-2 ${
platformName === "darwin" ? "pl-[68px]" : "" platformName === 'darwin' ? 'pl-[68px]' : ''
}`} }`}
> >
<button <button
type="button" type="button"
onClick={() => goBack()} onClick={() => goBack()}
className="group inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-900" className="group inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-900"
> >
<ArrowLeftIcon <ArrowLeftIcon
width={16} width={16}
height={16} height={16}
className="text-zinc-500 group-hover:text-zinc-300" className="text-zinc-500 group-hover:text-zinc-300"
/> />
</button> </button>
<button <button
type="button" type="button"
onClick={() => goForward()} onClick={() => goForward()}
className="group inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-900" className="group inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-900"
> >
<ArrowRightIcon <ArrowRightIcon
width={16} width={16}
height={16} height={16}
className="text-zinc-500 group-hover:text-zinc-300" className="text-zinc-500 group-hover:text-zinc-300"
/> />
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<div className="relative flex min-h-0 w-full flex-1"> <div className="relative flex min-h-0 w-full flex-1">
<Outlet /> <Outlet />
</div> </div>
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,69 +1,71 @@
import { LoaderIcon, PlusIcon } from "@shared/icons"; import { open } from '@tauri-apps/api/dialog';
import { open } from "@tauri-apps/api/dialog"; import { Body, fetch } from '@tauri-apps/api/http';
import { Body, fetch } from "@tauri-apps/api/http"; import { useState } from 'react';
import { createBlobFromFile } from "@utils/createBlobFromFile";
import { useState } from "react"; import { LoaderIcon, PlusIcon } from '@shared/icons';
import { createBlobFromFile } from '@utils/createBlobFromFile';
export function AvatarUploader({ setPicture }: { setPicture: any }) { export function AvatarUploader({ setPicture }: { setPicture: any }) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const openFileDialog = async () => { const openFileDialog = async () => {
const selected: any = await open({ const selected: any = await open({
multiple: false, multiple: false,
filters: [ filters: [
{ {
name: "Image", name: 'Image',
extensions: ["png", "jpeg", "jpg", "gif"], extensions: ['png', 'jpeg', 'jpg', 'gif'],
}, },
], ],
}); });
if (Array.isArray(selected)) { if (Array.isArray(selected)) {
// user selected multiple files // user selected multiple files
} else if (selected === null) { } else if (selected === null) {
// user cancelled the selection // user cancelled the selection
} else { } else {
setLoading(true); setLoading(true);
const filename = selected.split("/").pop(); const filename = selected.split('/').pop();
const file = await createBlobFromFile(selected); const file = await createBlobFromFile(selected);
const buf = await file.arrayBuffer(); const buf = await file.arrayBuffer();
const res: { data: { file: { id: string } } } = await fetch( const res: { data: { file: { id: string } } } = await fetch(
"https://void.cat/upload?cli=false", 'https://void.cat/upload?cli=false',
{ {
method: "POST", method: 'POST',
timeout: 5, timeout: 5,
headers: { headers: {
accept: "*/*", accept: '*/*',
"Content-Type": "application/octet-stream", 'Content-Type': 'application/octet-stream',
"V-Filename": filename, 'V-Filename': filename,
"V-Description": "Upload from https://lume.nu", 'V-Description': 'Upload from https://lume.nu',
"V-Strip-Metadata": "true", 'V-Strip-Metadata': 'true',
}, },
body: Body.bytes(buf), body: Body.bytes(buf),
}, }
); );
const image = `https://void.cat/d/${res.data.file.id}.jpg`; const image = `https://void.cat/d/${res.data.file.id}.jpg`;
// update parent state // update parent state
setPicture(image); setPicture(image);
// disable loader // disable loader
setLoading(false); setLoading(false);
} }
}; };
return ( return (
<button <button
type="button" type="button"
onClick={() => openFileDialog()} onClick={() => openFileDialog()}
className="w-full h-full inline-flex items-center justify-center bg-zinc-900/40" className="inline-flex h-full w-full items-center justify-center bg-zinc-900/40"
> >
{loading ? ( {loading ? (
<LoaderIcon className="h-6 w-6 animate-spin text-zinc-100" /> <LoaderIcon className="h-6 w-6 animate-spin text-zinc-100" />
) : ( ) : (
<PlusIcon className="h-6 w-6 text-zinc-100" /> <PlusIcon className="h-6 w-6 text-zinc-100" />
)} )}
</button> </button>
); );
} }

View File

@@ -1,69 +1,71 @@
import { LoaderIcon, PlusIcon } from "@shared/icons"; import { open } from '@tauri-apps/api/dialog';
import { open } from "@tauri-apps/api/dialog"; import { Body, fetch } from '@tauri-apps/api/http';
import { Body, fetch } from "@tauri-apps/api/http"; import { useState } from 'react';
import { createBlobFromFile } from "@utils/createBlobFromFile";
import { useState } from "react"; import { LoaderIcon, PlusIcon } from '@shared/icons';
import { createBlobFromFile } from '@utils/createBlobFromFile';
export function BannerUploader({ setBanner }: { setBanner: any }) { export function BannerUploader({ setBanner }: { setBanner: any }) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const openFileDialog = async () => { const openFileDialog = async () => {
const selected: any = await open({ const selected: any = await open({
multiple: false, multiple: false,
filters: [ filters: [
{ {
name: "Image", name: 'Image',
extensions: ["png", "jpeg", "jpg", "gif"], extensions: ['png', 'jpeg', 'jpg', 'gif'],
}, },
], ],
}); });
if (Array.isArray(selected)) { if (Array.isArray(selected)) {
// user selected multiple files // user selected multiple files
} else if (selected === null) { } else if (selected === null) {
// user cancelled the selection // user cancelled the selection
} else { } else {
setLoading(true); setLoading(true);
const filename = selected.split("/").pop(); const filename = selected.split('/').pop();
const file = await createBlobFromFile(selected); const file = await createBlobFromFile(selected);
const buf = await file.arrayBuffer(); const buf = await file.arrayBuffer();
const res: { data: { file: { id: string } } } = await fetch( const res: { data: { file: { id: string } } } = await fetch(
"https://void.cat/upload?cli=false", 'https://void.cat/upload?cli=false',
{ {
method: "POST", method: 'POST',
timeout: 5, timeout: 5,
headers: { headers: {
accept: "*/*", accept: '*/*',
"Content-Type": "application/octet-stream", 'Content-Type': 'application/octet-stream',
"V-Filename": filename, 'V-Filename': filename,
"V-Description": "Upload from https://lume.nu", 'V-Description': 'Upload from https://lume.nu',
"V-Strip-Metadata": "true", 'V-Strip-Metadata': 'true',
}, },
body: Body.bytes(buf), body: Body.bytes(buf),
}, }
); );
const image = `https://void.cat/d/${res.data.file.id}.jpg`; const image = `https://void.cat/d/${res.data.file.id}.jpg`;
// update parent state // update parent state
setBanner(image); setBanner(image);
// disable loader // disable loader
setLoading(false); setLoading(false);
} }
}; };
return ( return (
<button <button
type="button" type="button"
onClick={() => openFileDialog()} onClick={() => openFileDialog()}
className="w-full h-full inline-flex items-center justify-center bg-zinc-900/40" className="inline-flex h-full w-full items-center justify-center bg-zinc-900/40"
> >
{loading ? ( {loading ? (
<LoaderIcon className="h-8 w-8 animate-spin text-zinc-100" /> <LoaderIcon className="h-8 w-8 animate-spin text-zinc-100" />
) : ( ) : (
<PlusIcon className="h-8 w-8 text-zinc-100" /> <PlusIcon className="h-8 w-8 text-zinc-100" />
)} )}
</button> </button>
); );
} }

View File

@@ -1,46 +1,46 @@
import { ReactNode } from "react"; import { ReactNode } from 'react';
import { twMerge } from "tailwind-merge"; import { twMerge } from 'tailwind-merge';
export function Button({ export function Button({
preset, preset,
children, children,
disabled = false, disabled = false,
onClick = undefined, onClick = undefined,
}: { }: {
preset: "small" | "publish" | "large"; preset: 'small' | 'publish' | 'large';
children: ReactNode; children: ReactNode;
disabled?: boolean; disabled?: boolean;
onClick?: () => void; onClick?: () => void;
}) { }) {
let preClass: string; let preClass: string;
switch (preset) { switch (preset) {
case "small": case 'small':
preClass = preClass =
"w-min h-9 px-4 bg-fuchsia-500 rounded-md text-sm font-medium text-zinc-100 hover:bg-fuchsia-600"; 'w-min h-9 px-4 bg-fuchsia-500 rounded-md text-sm font-medium text-zinc-100 hover:bg-fuchsia-600';
break; break;
case "publish": case 'publish':
preClass = preClass =
"w-min h-9 px-4 bg-fuchsia-500 rounded-md text-sm font-medium text-zinc-100 hover:bg-fuchsia-600"; 'w-min h-9 px-4 bg-fuchsia-500 rounded-md text-sm font-medium text-zinc-100 hover:bg-fuchsia-600';
break; break;
case "large": case 'large':
preClass = preClass =
"h-11 w-full bg-fuchsia-500 rounded-md font-medium text-zinc-100 hover:bg-fuchsia-600"; 'h-11 w-full bg-fuchsia-500 rounded-md font-medium text-zinc-100 hover:bg-fuchsia-600';
break; break;
default: default:
break; break;
} }
return ( return (
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
className={twMerge( className={twMerge(
"inline-flex items-center justify-center gap-1 transform active:translate-y-1 disabled:pointer-events-none disabled:opacity-50 focus:outline-none", 'inline-flex transform items-center justify-center gap-1 focus:outline-none active:translate-y-1 disabled:pointer-events-none disabled:opacity-50',
preClass, preClass
)} )}
> >
{children} {children}
</button> </button>
); );
} }

View File

@@ -1,132 +1,134 @@
import { PlusCircleIcon } from "@shared/icons"; import { open } from '@tauri-apps/api/dialog';
import { open } from "@tauri-apps/api/dialog"; import { listen } from '@tauri-apps/api/event';
import { listen } from "@tauri-apps/api/event"; import { Body, fetch } from '@tauri-apps/api/http';
import { Body, fetch } from "@tauri-apps/api/http"; import { useCallback, useEffect, useState } from 'react';
import { createBlobFromFile } from "@utils/createBlobFromFile"; import { Transforms } from 'slate';
import { useCallback, useEffect, useState } from "react"; import { useSlateStatic } from 'slate-react';
import { Transforms } from "slate";
import { useSlateStatic } from "slate-react"; import { PlusCircleIcon } from '@shared/icons';
import { createBlobFromFile } from '@utils/createBlobFromFile';
export function ImageUploader() { export function ImageUploader() {
const editor = useSlateStatic(); const editor = useSlateStatic();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const insertImage = (editor, url) => { const insertImage = (editor, url) => {
const image = { type: "image", url, children: [{ text: url }] }; const image = { type: 'image', url, children: [{ text: url }] };
Transforms.insertNodes(editor, image); Transforms.insertNodes(editor, image);
}; };
const uploadToVoidCat = useCallback( const uploadToVoidCat = useCallback(
async (filepath) => { async (filepath) => {
const filename = filepath.split("/").pop(); const filename = filepath.split('/').pop();
const file = await createBlobFromFile(filepath); const file = await createBlobFromFile(filepath);
const buf = await file.arrayBuffer(); const buf = await file.arrayBuffer();
try { try {
const res: { data: { file: { id: string } } } = await fetch( const res: { data: { file: { id: string } } } = await fetch(
"https://void.cat/upload?cli=false", 'https://void.cat/upload?cli=false',
{ {
method: "POST", method: 'POST',
timeout: 5, timeout: 5,
headers: { headers: {
accept: "*/*", accept: '*/*',
"Content-Type": "application/octet-stream", 'Content-Type': 'application/octet-stream',
"V-Filename": filename, 'V-Filename': filename,
"V-Description": "Uploaded from https://lume.nu", 'V-Description': 'Uploaded from https://lume.nu',
"V-Strip-Metadata": "true", 'V-Strip-Metadata': 'true',
}, },
body: Body.bytes(buf), body: Body.bytes(buf),
}, }
); );
const image = `https://void.cat/d/${res.data.file.id}.webp`; const image = `https://void.cat/d/${res.data.file.id}.webp`;
// update parent state // update parent state
insertImage(editor, image); insertImage(editor, image);
// reset loading state // reset loading state
setLoading(false); setLoading(false);
} catch (error) { } catch (error) {
// reset loading state // reset loading state
setLoading(false); setLoading(false);
// handle error // handle error
if (error instanceof SyntaxError) { if (error instanceof SyntaxError) {
// Unexpected token < in JSON // Unexpected token < in JSON
console.log("There was a SyntaxError", error); console.log('There was a SyntaxError', error);
} else { } else {
console.log("There was an error", error); console.log('There was an error', error);
} }
} }
}, },
[editor], [editor]
); );
const openFileDialog = async () => { const openFileDialog = async () => {
const selected: any = await open({ const selected: any = await open({
multiple: false, multiple: false,
filters: [ filters: [
{ {
name: "Image", name: 'Image',
extensions: ["png", "jpeg", "jpg", "gif"], extensions: ['png', 'jpeg', 'jpg', 'gif'],
}, },
], ],
}); });
if (Array.isArray(selected)) { if (Array.isArray(selected)) {
// user selected multiple files // user selected multiple files
} else if (selected === null) { } else if (selected === null) {
// user cancelled the selection // user cancelled the selection
} else { } else {
setLoading(true); setLoading(true);
// upload file // upload file
uploadToVoidCat(selected); uploadToVoidCat(selected);
} }
}; };
useEffect(() => { useEffect(() => {
async function initFileDrop() { async function initFileDrop() {
const unlisten = await listen("tauri://file-drop", (event) => { const unlisten = await listen('tauri://file-drop', (event) => {
// set loading state // set loading state
setLoading(true); setLoading(true);
// upload file // upload file
uploadToVoidCat(event.payload[0]); uploadToVoidCat(event.payload[0]);
}); });
return () => { return () => {
unlisten(); unlisten();
}; };
} }
initFileDrop(); initFileDrop();
}, [uploadToVoidCat]); }, [uploadToVoidCat]);
return ( return (
<button <button
type="button" type="button"
onClick={() => openFileDialog()} onClick={() => openFileDialog()}
className="inline-flex h-8 w-8 items-center justify-center rounded hover:bg-zinc-800" className="inline-flex h-8 w-8 items-center justify-center rounded hover:bg-zinc-800"
> >
{loading ? ( {loading ? (
<svg <svg
className="h-4 w-4 animate-spin text-black dark:text-zinc-100" className="h-4 w-4 animate-spin text-black dark:text-zinc-100"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<title id="loading">Loading</title> <title id="loading">Loading</title>
<circle <circle
className="opacity-25" className="opacity-25"
cx="12" cx="12"
cy="12" cy="12"
r="10" r="10"
stroke="currentColor" stroke="currentColor"
strokeWidth="4" strokeWidth="4"
/> />
<path <path
className="opacity-75" className="opacity-75"
fill="currentColor" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/> />
</svg> </svg>
) : ( ) : (
<PlusCircleIcon width={20} height={20} className="text-zinc-500" /> <PlusCircleIcon width={20} height={20} className="text-zinc-500" />
)} )}
</button> </button>
); );
} }

View File

@@ -1,96 +1,94 @@
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from '@headlessui/react';
import { Button } from "@shared/button"; import { Fragment } from 'react';
import { Post } from "@shared/composer/types/post"; import { useHotkeys } from 'react-hotkeys-hook';
import { User } from "@shared/composer/user";
import { Button } from '@shared/button';
import { Post } from '@shared/composer/types/post';
import { User } from '@shared/composer/user';
import { import {
CancelIcon, CancelIcon,
ChevronDownIcon, ChevronDownIcon,
ChevronRightIcon, ChevronRightIcon,
ComposeIcon, ComposeIcon,
} from "@shared/icons"; } from '@shared/icons';
import { useComposer } from "@stores/composer";
import { COMPOSE_SHORTCUT } from "@stores/shortcuts"; import { useComposer } from '@stores/composer';
import { useAccount } from "@utils/hooks/useAccount"; import { COMPOSE_SHORTCUT } from '@stores/shortcuts';
import { Fragment } from "react";
import { useHotkeys } from "react-hotkeys-hook"; import { useAccount } from '@utils/hooks/useAccount';
export function Composer() { export function Composer() {
const { account } = useAccount(); const { account } = useAccount();
const [toggle, open] = useComposer((state) => [ const [toggle, open] = useComposer((state) => [state.toggleModal, state.open]);
state.toggleModal,
state.open,
]);
const closeModal = () => { const closeModal = () => {
toggle(false); toggle(false);
}; };
useHotkeys(COMPOSE_SHORTCUT, () => toggle(true)); useHotkeys(COMPOSE_SHORTCUT, () => toggle(true));
return ( return (
<> <>
<Button onClick={() => toggle(true)} preset="small"> <Button onClick={() => toggle(true)} preset="small">
<ComposeIcon width={14} height={14} /> <ComposeIcon width={14} height={14} />
Compose Compose
</Button> </Button>
<Transition appear show={open} as={Fragment}> <Transition appear show={open} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}> <Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" /> <div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child> </Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center"> <div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0 scale-95" enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100" enterTo="opacity-100 scale-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<Dialog.Panel className="relative h-min w-full max-w-xl rounded-xl border-t border-zinc-800/50 bg-zinc-900"> <Dialog.Panel className="relative h-min w-full max-w-xl rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="flex items-center justify-between px-4 py-4"> <div className="flex items-center justify-between px-4 py-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div>{account && <User pubkey={account.pubkey} />}</div> <div>{account && <User pubkey={account.pubkey} />}</div>
<span> <span>
<ChevronRightIcon <ChevronRightIcon
width={14} width={14}
height={14} height={14}
className="text-zinc-500" className="text-zinc-500"
/> />
</span> </span>
<div className="inline-flex h-7 w-max items-center justify-center gap-0.5 rounded bg-zinc-800 pl-3 pr-1.5 text-sm font-medium text-zinc-400"> <div className="inline-flex h-7 w-max items-center justify-center gap-0.5 rounded bg-zinc-800 pl-3 pr-1.5 text-sm font-medium text-zinc-400">
New Post New Post
<ChevronDownIcon width={14} height={14} /> <ChevronDownIcon width={14} height={14} />
</div> </div>
</div> </div>
<div <div
onClick={closeModal} onClick={closeModal}
onKeyDown={closeModal} onKeyDown={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-800" role="button"
> tabIndex={0}
<CancelIcon className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-800"
width={16} >
height={16} <CancelIcon width={16} height={16} className="text-zinc-500" />
className="text-zinc-500" </div>
/> </div>
</div> {account && <Post />}
</div> </Dialog.Panel>
{account && <Post />} </Transition.Child>
</Dialog.Panel> </div>
</Transition.Child> </Dialog>
</div> </Transition>
</Dialog> </>
</Transition> );
</>
);
} }

View File

@@ -1,179 +1,171 @@
import { usePublish } from "@libs/ndk"; import { useCallback, useMemo, useState } from 'react';
import { Button } from "@shared/button"; import { Node, Transforms, createEditor } from 'slate';
import { ImageUploader } from "@shared/composer/imageUploader"; import { withHistory } from 'slate-history';
import { TrashIcon } from "@shared/icons"; import { Editable, ReactEditor, Slate, useSlateStatic, withReact } from 'slate-react';
import { MentionNote } from "@shared/notes/mentions/note";
import { useComposer } from "@stores/composer"; import { usePublish } from '@libs/ndk';
import { FULL_RELAYS } from "@stores/constants";
import { useCallback, useMemo, useState } from "react"; import { Button } from '@shared/button';
import { Node, Transforms, createEditor } from "slate"; import { ImageUploader } from '@shared/composer/imageUploader';
import { withHistory } from "slate-history"; import { TrashIcon } from '@shared/icons';
import { import { MentionNote } from '@shared/notes/mentions/note';
Editable,
ReactEditor, import { useComposer } from '@stores/composer';
Slate, import { FULL_RELAYS } from '@stores/constants';
useSlateStatic,
withReact,
} from "slate-react";
const withImages = (editor) => { const withImages = (editor) => {
const { isVoid } = editor; const { isVoid } = editor;
editor.isVoid = (element) => { editor.isVoid = (element) => {
return element.type === "image" ? true : isVoid(element); return element.type === 'image' ? true : isVoid(element);
}; };
return editor; return editor;
}; };
const ImagePreview = ({ const ImagePreview = ({
attributes, attributes,
children, children,
element, element,
}: { }: {
attributes: any; attributes: any;
children: any; children: any;
element: any; element: any;
}) => { }) => {
const editor: any = useSlateStatic(); const editor: any = useSlateStatic();
const path = ReactEditor.findPath(editor, element); const path = ReactEditor.findPath(editor, element);
return ( return (
<figure {...attributes} className="m-0 mt-3"> <figure {...attributes} className="m-0 mt-3">
{children} {children}
<div contentEditable={false} className="relative"> <div contentEditable={false} className="relative">
<img <img
alt={element.url} alt={element.url}
src={element.url} src={element.url}
className="m-0 h-auto max-h-[300px] w-full rounded-md object-cover" className="m-0 h-auto max-h-[300px] w-full rounded-md object-cover"
/> />
<button <button
type="button" type="button"
onClick={() => Transforms.removeNodes(editor, { at: path })} onClick={() => Transforms.removeNodes(editor, { at: path })}
className="absolute right-2 top-2 inline-flex h-7 w-7 items-center justify-center gap-0.5 rounded bg-zinc-800 text-base font-medium text-zinc-400 shadow-mini-button hover:bg-zinc-700" className="shadow-mini-button absolute right-2 top-2 inline-flex h-7 w-7 items-center justify-center gap-0.5 rounded bg-zinc-800 text-base font-medium text-zinc-400 hover:bg-zinc-700"
> >
<TrashIcon width={14} height={14} className="text-zinc-100" /> <TrashIcon width={14} height={14} className="text-zinc-100" />
</button> </button>
</div> </div>
</figure> </figure>
); );
}; };
export function Post() { export function Post() {
const publish = usePublish(); const publish = usePublish();
const editor = useMemo( const editor = useMemo(() => withReact(withImages(withHistory(createEditor()))), []);
() => withReact(withImages(withHistory(createEditor()))),
[],
);
const [repost, reply, toggle] = useComposer((state) => [ const [repost, reply, toggle] = useComposer((state) => [
state.repost, state.repost,
state.reply, state.reply,
state.toggleModal, state.toggleModal,
]); ]);
const [content, setContent] = useState<Node[]>([ const [content, setContent] = useState<Node[]>([
{ {
children: [ children: [
{ {
text: "", text: '',
}, },
], ],
}, },
]); ]);
const serialize = useCallback((nodes: Node[]) => { const serialize = useCallback((nodes: Node[]) => {
return nodes.map((n) => Node.string(n)).join("\n"); return nodes.map((n) => Node.string(n)).join('\n');
}, []); }, []);
const getRef = () => { const getRef = () => {
if (repost.id) { if (repost.id) {
return repost.id; return repost.id;
} else if (reply.id) { } else if (reply.id) {
return reply.id; return reply.id;
} else { } else {
return null; return null;
} }
}; };
const refID = getRef(); const refID = getRef();
const submit = async () => { const submit = async () => {
let tags: string[][] = []; let tags: string[][] = [];
let kind: number; let kind: number;
if (repost.id && repost.pubkey) { if (repost.id && repost.pubkey) {
kind = 6; kind = 6;
tags = [ tags = [
["e", repost.id, FULL_RELAYS[0], "root"], ['e', repost.id, FULL_RELAYS[0], 'root'],
["p", repost.pubkey], ['p', repost.pubkey],
]; ];
} else if (reply.id && reply.pubkey) { } else if (reply.id && reply.pubkey) {
kind = 1; kind = 1;
if (reply.root && reply.root !== reply.id) { if (reply.root && reply.root !== reply.id) {
tags = [ tags = [
["e", reply.id, FULL_RELAYS[0], "root"], ['e', reply.id, FULL_RELAYS[0], 'root'],
["e", reply.root, FULL_RELAYS[0], "reply"], ['e', reply.root, FULL_RELAYS[0], 'reply'],
["p", reply.pubkey], ['p', reply.pubkey],
]; ];
} else { } else {
tags = [ tags = [
["e", reply.id, FULL_RELAYS[0], "root"], ['e', reply.id, FULL_RELAYS[0], 'root'],
["p", reply.pubkey], ['p', reply.pubkey],
]; ];
} }
} else { } else {
kind = 1; kind = 1;
tags = []; tags = [];
} }
// serialize content // serialize content
const serializedContent = serialize(content); const serializedContent = serialize(content);
// publish message // publish message
await publish({ content: serializedContent, kind, tags }); await publish({ content: serializedContent, kind, tags });
// close modal // close modal
toggle(false); toggle(false);
}; };
const renderElement = useCallback((props: any) => { const renderElement = useCallback((props: any) => {
switch (props.element.type) { switch (props.element.type) {
case "image": case 'image':
if (props.element.url) { if (props.element.url) {
return <ImagePreview {...props} />; return <ImagePreview {...props} />;
} }
default: break;
return <p {...props.attributes}>{props.children}</p>; default:
} return <p {...props.attributes}>{props.children}</p>;
}, []); }
}, []);
return ( return (
<Slate editor={editor} value={content} onChange={setContent}> <Slate editor={editor} value={content} onChange={setContent}>
<div className="flex h-full flex-col px-4 pb-4"> <div className="flex h-full flex-col px-4 pb-4">
<div className="flex h-full w-full gap-2"> <div className="flex h-full w-full gap-2">
<div className="flex w-8 shrink-0 items-center justify-center"> <div className="flex w-8 shrink-0 items-center justify-center">
<div className="h-full w-[2px] bg-zinc-800" /> <div className="h-full w-[2px] bg-zinc-800" />
</div> </div>
<div className="w-full"> <div className="w-full">
<Editable <Editable
autoFocus placeholder={refID ? 'Share your thoughts on it' : "What's on your mind?"}
placeholder={ spellCheck="false"
refID ? "Share your thoughts on it" : "What's on your mind?" className={`${refID ? '!min-h-42' : '!min-h-[86px]'} markdown`}
} renderElement={renderElement}
spellCheck="false" />
className={`${refID ? "!min-h-42" : "!min-h-[86px]"} markdown`} {refID && <MentionNote id={refID} />}
renderElement={renderElement} </div>
/> </div>
{refID && <MentionNote id={refID} />} <div className="mt-4 flex items-center justify-between">
</div> <ImageUploader />
</div> <Button onClick={() => submit()} preset="publish">
<div className="mt-4 flex items-center justify-between"> Publish
<ImageUploader /> </Button>
<Button onClick={() => submit()} preset="publish"> </div>
Publish </div>
</Button> </Slate>
</div> );
</div>
</Slate>
);
} }

View File

@@ -1,25 +1,27 @@
import { Image } from "@shared/image"; import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from "@stores/constants";
import { useProfile } from "@utils/hooks/useProfile"; import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
export function User({ pubkey }: { pubkey: string }) { export function User({ pubkey }: { pubkey: string }) {
const { user } = useProfile(pubkey); const { user } = useProfile(pubkey);
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-8 w-8 shrink-0 overflow-hidden rounded bg-zinc-900"> <div className="h-8 w-8 shrink-0 overflow-hidden rounded bg-zinc-900">
<Image <Image
src={user?.image} src={user?.image}
fallback={DEFAULT_AVATAR} fallback={DEFAULT_AVATAR}
alt={pubkey} alt={pubkey}
className="h-8 w-8 object-cover" className="h-8 w-8 object-cover"
/> />
</div> </div>
<h5 className="text-base font-semibold leading-none text-zinc-100"> <h5 className="text-base font-semibold leading-none text-zinc-100">
{user?.nip05 || user?.name || ( {user?.nip05 || user?.name || (
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700" /> <div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700" />
)} )}
</h5> </h5>
</div> </div>
); );
} }

View File

@@ -1,328 +1,339 @@
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from '@headlessui/react';
import { usePublish } from "@libs/ndk"; import { NDKEvent } from '@nostr-dev-kit/ndk';
import { NDKEvent } from "@nostr-dev-kit/ndk"; import { useQueryClient } from '@tanstack/react-query';
import { AvatarUploader } from "@shared/avatarUploader"; import { fetch } from '@tauri-apps/api/http';
import { BannerUploader } from "@shared/bannerUploader"; import { Fragment, useEffect, useState } from 'react';
import { import { useForm } from 'react-hook-form';
CancelIcon,
CheckCircleIcon, import { usePublish } from '@libs/ndk';
LoaderIcon,
UnverifiedIcon, import { AvatarUploader } from '@shared/avatarUploader';
} from "@shared/icons"; import { BannerUploader } from '@shared/bannerUploader';
import { Image } from "@shared/image"; import { CancelIcon, CheckCircleIcon, LoaderIcon, UnverifiedIcon } from '@shared/icons';
import { DEFAULT_AVATAR } from "@stores/constants"; import { Image } from '@shared/image';
import { useQueryClient } from "@tanstack/react-query";
import { fetch } from "@tauri-apps/api/http"; import { DEFAULT_AVATAR } from '@stores/constants';
import { useAccount } from "@utils/hooks/useAccount";
import { Fragment, useEffect, useState } from "react"; import { useAccount } from '@utils/hooks/useAccount';
import { useForm } from "react-hook-form";
export function EditProfileModal() { export function EditProfileModal() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const publish = usePublish(); const publish = usePublish();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [picture, setPicture] = useState(DEFAULT_AVATAR); const [picture, setPicture] = useState(DEFAULT_AVATAR);
const [banner, setBanner] = useState(""); const [banner, setBanner] = useState('');
const [nip05, setNIP05] = useState({ verified: false, text: "" }); const [nip05, setNIP05] = useState({ verified: false, text: '' });
const { account } = useAccount(); const { account } = useAccount();
const { const {
register, register,
handleSubmit, handleSubmit,
reset, reset,
setError, setError,
formState: { isValid, errors }, formState: { isValid, errors },
} = useForm({ } = useForm({
defaultValues: async () => { defaultValues: async () => {
const res: any = queryClient.getQueryData(["user", account.pubkey]); const res: any = queryClient.getQueryData(['user', account.pubkey]);
if (res.image) { if (res.image) {
setPicture(res.image); setPicture(res.image);
} }
if (res.banner) { if (res.banner) {
setBanner(res.banner); setBanner(res.banner);
} }
if (res.nip05) { if (res.nip05) {
setNIP05((prev) => ({ ...prev, text: res.nip05 })); setNIP05((prev) => ({ ...prev, text: res.nip05 }));
} }
return res; return res;
}, },
}); });
const closeModal = () => { const closeModal = () => {
setIsOpen(false); setIsOpen(false);
}; };
const openModal = () => { const openModal = () => {
setIsOpen(true); setIsOpen(true);
}; };
const verifyNIP05 = async (data: string) => { const verifyNIP05 = async (data: string) => {
if (data) { if (data) {
const url = data.split("@"); const url = data.split('@');
const username = url[0]; const username = url[0];
const service = url[1]; const service = url[1];
const verifyURL = `https://${service}/.well-known/nostr.json?name=${username}`; const verifyURL = `https://${service}/.well-known/nostr.json?name=${username}`;
const res: any = await fetch(verifyURL, { const res: any = await fetch(verifyURL, {
method: "GET", method: 'GET',
timeout: 30, timeout: 30,
headers: { headers: {
"Content-Type": "application/json; charset=utf-8", 'Content-Type': 'application/json; charset=utf-8',
}, },
}); });
if (!res.ok) return false; if (!res.ok) return false;
if (res.data.names[username] === account.pubkey) { if (res.data.names[username] === account.pubkey) {
setNIP05((prev) => ({ ...prev, verified: true })); setNIP05((prev) => ({ ...prev, verified: true }));
return true; return true;
} else { } else {
return false; return false;
} }
} }
}; };
const onSubmit = async (data: any) => { const onSubmit = async (data: any) => {
// start loading // start loading
setLoading(true); setLoading(true);
let event: NDKEvent; let event: NDKEvent;
const content = { const content = {
...data, ...data,
username: data.name, username: data.name,
display_name: data.name, display_name: data.name,
bio: data.about, bio: data.about,
image: data.picture, image: data.picture,
}; };
if (data.nip05) { if (data.nip05) {
const verify = await verifyNIP05(data.nip05); const verify = await verifyNIP05(data.nip05);
if (verify) { if (verify) {
event = await publish({ event = await publish({
content: JSON.stringify({ ...content, nip05: data.nip05 }), content: JSON.stringify({ ...content, nip05: data.nip05 }),
kind: 0, kind: 0,
tags: [], tags: [],
}); });
} else { } else {
setNIP05((prev) => ({ ...prev, verified: false })); setNIP05((prev) => ({ ...prev, verified: false }));
setError("nip05", { setError('nip05', {
type: "manual", type: 'manual',
message: "Can't verify your Lume ID / NIP-05, please check again", message: "Can't verify your Lume ID / NIP-05, please check again",
}); });
} }
} else { } else {
event = await publish({ event = await publish({
content: JSON.stringify(content), content: JSON.stringify(content),
kind: 0, kind: 0,
tags: [], tags: [],
}); });
} }
if (event.id) { if (event.id) {
setTimeout(() => { setTimeout(() => {
// invalid cache // invalid cache
queryClient.invalidateQueries(["user", account.pubkey]); queryClient.invalidateQueries(['user', account.pubkey]);
// reset form // reset form
reset(); reset();
// reset state // reset state
setLoading(false); setLoading(false);
setIsOpen(false); setIsOpen(false);
setPicture(DEFAULT_AVATAR); setPicture(DEFAULT_AVATAR);
setBanner(null); setBanner(null);
}, 1200); }, 1200);
} else { } else {
setLoading(false); setLoading(false);
} }
}; };
useEffect(() => { useEffect(() => {
if (!nip05.verified && /\S+@\S+\.\S+/.test(nip05.text)) { if (!nip05.verified && /\S+@\S+\.\S+/.test(nip05.text)) {
verifyNIP05(nip05.text); verifyNIP05(nip05.text);
} }
}, [nip05.text]); }, [nip05.text]);
return ( return (
<> <>
<button <button
type="button" type="button"
onClick={() => openModal()} onClick={() => openModal()}
className="inline-flex w-36 h-10 items-center justify-center rounded-md bg-zinc-900 hover:bg-fuchsia-500 text-sm font-medium" className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-zinc-900 text-sm font-medium hover:bg-fuchsia-500"
> >
Edit profile Edit profile
</button> </button>
<Transition appear show={isOpen} as={Fragment}> <Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}> <Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" /> <div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child> </Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center"> <div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0 scale-95" enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100" enterTo="opacity-100 scale-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col rounded-lg border-t border-zinc-800/50 bg-zinc-900"> <Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col rounded-lg border-t border-zinc-800/50 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-5"> <div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Dialog.Title <Dialog.Title
as="h3" as="h3"
className="text-lg font-semibold leading-none text-zinc-100" className="text-lg font-semibold leading-none text-zinc-100"
> >
Edit profile Edit profile
</Dialog.Title> </Dialog.Title>
<button <button
type="button" type="button"
onClick={closeModal} onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900" className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
> >
<CancelIcon className="w-5 h-5 text-zinc-300" /> <CancelIcon className="h-5 w-5 text-zinc-300" />
</button> </button>
</div> </div>
</div> </div>
<div className="flex h-full w-full flex-col overflow-y-auto"> <div className="flex h-full w-full flex-col overflow-y-auto">
<form onSubmit={handleSubmit(onSubmit)} className="mb-0"> <form onSubmit={handleSubmit(onSubmit)} className="mb-0">
<input <input
type={"hidden"} type={'hidden'}
{...register("picture")} {...register('picture')}
value={picture} value={picture}
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500" className="shadow-input relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/> />
<input <input
type={"hidden"} type={'hidden'}
{...register("banner")} {...register('banner')}
value={banner} value={banner}
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500" className="shadow-input relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/> />
<div className="relative"> <div className="relative">
<div className="relative w-full h-44 bg-zinc-800"> <div className="relative h-44 w-full bg-zinc-800">
<Image <Image
src={banner} src={banner}
fallback="https://void.cat/d/QY1myro5tkHVs2nY7dy74b.jpg" fallback="https://void.cat/d/QY1myro5tkHVs2nY7dy74b.jpg"
alt="user's banner" alt="user's banner"
className="h-full w-full object-cover" className="h-full w-full object-cover"
/> />
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-10 w-full h-full"> <div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
<BannerUploader setBanner={setBanner} /> <BannerUploader setBanner={setBanner} />
</div> </div>
</div> </div>
<div className="px-4 mb-5"> <div className="mb-5 px-4">
<div className="z-10 relative h-14 w-14 -mt-7"> <div className="relative z-10 -mt-7 h-14 w-14">
<Image <Image
src={picture} src={picture}
fallback={DEFAULT_AVATAR} fallback={DEFAULT_AVATAR}
alt="user's avatar" alt="user's avatar"
className="h-14 w-14 object-cover ring-2 ring-zinc-900 rounded-lg" className="h-14 w-14 rounded-lg object-cover ring-2 ring-zinc-900"
/> />
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-10 w-full h-full"> <div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
<AvatarUploader setPicture={setPicture} /> <AvatarUploader setPicture={setPicture} />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div className="flex flex-col gap-4 px-4 pb-4"> <div className="flex flex-col gap-4 px-4 pb-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-sm font-semibold uppercase tracking-wider text-zinc-400"> <label
Name htmlFor="name"
</label> className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
<input >
type={"text"} Name
{...register("name", { </label>
required: true, <input
minLength: 4, type={'text'}
})} {...register('name', {
spellCheck={false} required: true,
className="relative h-10 w-full rounded-lg px-3 py-2 !outline-none bg-zinc-800 text-zinc-100 placeholder:text-zinc-500" minLength: 4,
/> })}
</div> spellCheck={false}
<div className="flex flex-col gap-1"> className="relative h-10 w-full rounded-lg bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
<label className="text-sm font-semibold uppercase tracking-wider text-zinc-400"> />
Lume ID / NIP-05 </div>
</label> <div className="flex flex-col gap-1">
<div className="relative"> <label
<input htmlFor="nip05"
{...register("nip05", { className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
required: true, >
minLength: 4, Lume ID / NIP-05
})} </label>
spellCheck={false} <div className="relative">
className="relative h-10 w-full rounded-lg px-3 py-2 !outline-none bg-zinc-800 text-zinc-100 placeholder:text-zinc-500" <input
/> {...register('nip05', {
<div className="absolute top-1/2 right-2 transform -translate-y-1/2"> required: true,
{nip05.verified ? ( minLength: 4,
<span className="inline-flex items-center gap-1 rounded h-6 px-2 bg-green-500 text-sm font-medium"> })}
<CheckCircleIcon className="w-4 h-4 text-white" /> spellCheck={false}
Verified className="relative h-10 w-full rounded-lg bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
</span> />
) : ( <div className="absolute right-2 top-1/2 -translate-y-1/2 transform">
<span className="inline-flex items-center gap-1 rounded h-6 px-2 bg-red-500 text-sm font-medium"> {nip05.verified ? (
<UnverifiedIcon className="w-4 h-4 text-white" /> <span className="inline-flex h-6 items-center gap-1 rounded bg-green-500 px-2 text-sm font-medium">
Unverified <CheckCircleIcon className="h-4 w-4 text-white" />
</span> Verified
)} </span>
</div> ) : (
{errors.nip05 && ( <span className="inline-flex h-6 items-center gap-1 rounded bg-red-500 px-2 text-sm font-medium">
<p className="mt-1 text-sm text-red-400"> <UnverifiedIcon className="h-4 w-4 text-white" />
{errors.nip05.message.toString()} Unverified
</p> </span>
)} )}
</div> </div>
</div> {errors.nip05 && (
<div className="flex flex-col gap-1"> <p className="mt-1 text-sm text-red-400">
<label className="text-sm font-semibold uppercase tracking-wider text-zinc-400"> {errors.nip05.message.toString()}
Bio </p>
</label> )}
<textarea </div>
{...register("about")} </div>
spellCheck={false} <div className="flex flex-col gap-1">
className="relative resize-none h-20 w-full rounded-lg px-3 py-2 !outline-none bg-zinc-800 text-zinc-100 placeholder:text-zinc-500" <label
/> htmlFor="about"
</div> className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
<div className="flex flex-col gap-1"> >
<label className="text-sm font-semibold uppercase tracking-wider text-zinc-400"> Bio
Website </label>
</label> <textarea
<input {...register('about')}
type={"text"} spellCheck={false}
{...register("website", { required: false })} className="relative h-20 w-full resize-none rounded-lg bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
spellCheck={false} />
className="relative h-10 w-full rounded-lg px-3 py-2 !outline-none bg-zinc-800 text-zinc-100 placeholder:text-zinc-500" </div>
/> <div className="flex flex-col gap-1">
</div> <label
<div> htmlFor="website"
<button className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
type="submit" >
disabled={!isValid} Website
className="inline-flex items-center justify-center gap-1 transform active:translate-y-1 disabled:pointer-events-none disabled:opacity-50 focus:outline-none h-11 w-full bg-fuchsia-500 rounded-md font-medium text-zinc-100 hover:bg-fuchsia-600" </label>
> <input
{loading ? ( type={'text'}
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" /> {...register('website', { required: false })}
) : ( spellCheck={false}
"Update" className="relative h-10 w-full rounded-lg bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
)} />
</button> </div>
</div> <div>
</div> <button
</form> type="submit"
</div> disabled={!isValid}
</Dialog.Panel> className="inline-flex h-11 w-full transform items-center justify-center gap-1 rounded-md bg-fuchsia-500 font-medium text-zinc-100 hover:bg-fuchsia-600 focus:outline-none active:translate-y-1 disabled:pointer-events-none disabled:opacity-50"
</Transition.Child> >
</div> {loading ? (
</Dialog> <LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
</Transition> ) : (
</> 'Update'
); )}
</button>
</div>
</div>
</form>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
} }

View File

@@ -1,22 +1,15 @@
import { SVGProps } from "react"; import { SVGProps } from 'react';
export function ArrowLeftIcon( export function ArrowLeftIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>, return (
) { <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
return ( <path
<svg d="M10 18.25L3.75 12M3.75 12L10 5.75M3.75 12H20.25"
viewBox="0 0 24 24" stroke="currentColor"
fill="none" strokeWidth={1.5}
xmlns="http://www.w3.org/2000/svg" strokeLinecap="round"
{...props} strokeLinejoin="round"
> />
<path </svg>
d="M10 18.25L3.75 12M3.75 12L10 5.75M3.75 12H20.25" );
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
} }

View File

@@ -1,22 +1,15 @@
import { SVGProps } from "react"; import { SVGProps } from 'react';
export function ArrowRightIcon( export function ArrowRightIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>, return (
) { <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
return ( <path
<svg d="M14 5.75L20.25 12M20.25 12L14 18.25M20.25 12H3.75"
viewBox="0 0 24 24" stroke="currentColor"
fill="none" strokeWidth={1.5}
xmlns="http://www.w3.org/2000/svg" strokeLinecap="round"
{...props} strokeLinejoin="round"
> />
<path </svg>
d="M14 5.75L20.25 12M20.25 12L14 18.25M20.25 12H3.75" );
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
} }

View File

@@ -1,24 +1,24 @@
import { SVGProps } from "react"; import { SVGProps } from 'react';
export function ArrowRightCircleIcon( export function ArrowRightCircleIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>, props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>
) { ) {
return ( return (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="24" width="24"
height="24" height="24"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
{...props} {...props}
> >
<path <path
stroke="currentColor" stroke="currentColor"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="1.5" strokeWidth="1.5"
d="M7.75 12h8M13 8.75l2.896 2.896a.5.5 0 010 .708L13 15.25M21.25 12a9.25 9.25 0 11-18.5 0 9.25 9.25 0 0118.5 0z" d="M7.75 12h8M13 8.75l2.896 2.896a.5.5 0 010 .708L13 15.25M21.25 12a9.25 9.25 0 11-18.5 0 9.25 9.25 0 0118.5 0z"
/> />
</svg> </svg>
); );
} }

View File

@@ -1,22 +1,20 @@
import { SVGProps } from "react"; import { SVGProps } from 'react';
export function BellIcon( export function BellIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>, return (
) { <svg
return ( width={24}
<svg height={24}
width={24} viewBox="0 0 24 24"
height={24} fill="none"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"
fill="none" {...props}
xmlns="http://www.w3.org/2000/svg" >
{...props} <path
> d="M16 18.25C15.3267 20.0159 13.7891 21.25 12 21.25C10.2109 21.25 8.67327 20.0159 8 18.25M20.5 18.25L18.9554 8.67345C18.4048 5.2596 15.458 2.75 12 2.75C8.54203 2.75 5.59523 5.2596 5.04461 8.67345L3.5 18.25H20.5Z"
<path stroke="currentColor"
d="M16 18.25C15.3267 20.0159 13.7891 21.25 12 21.25C10.2109 21.25 8.67327 20.0159 8 18.25M20.5 18.25L18.9554 8.67345C18.4048 5.2596 15.458 2.75 12 2.75C8.54203 2.75 5.59523 5.2596 5.04461 8.67345L3.5 18.25H20.5Z" strokeWidth={1.5}
stroke="currentColor" />
strokeWidth={1.5} </svg>
/> );
</svg>
);
} }

View File

@@ -1,21 +1,14 @@
import { SVGProps } from "react"; import { SVGProps } from 'react';
export function CancelIcon( export function CancelIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>, return (
) { <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
return ( <path
<svg d="M4.75 4.75L19.25 19.25M19.25 4.75L4.75 19.25"
viewBox="0 0 24 24" stroke="currentColor"
fill="none" strokeWidth={1.5}
xmlns="http://www.w3.org/2000/svg" strokeLinecap="round"
{...props} />
> </svg>
<path );
d="M4.75 4.75L19.25 19.25M19.25 4.75L4.75 19.25"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
/>
</svg>
);
} }

View File

@@ -1,23 +1,23 @@
import { SVGProps } from "react"; import { SVGProps } from 'react';
export function CheckCircleIcon( export function CheckCircleIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>, props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>
) { ) {
return ( return (
<svg <svg
width={24} width={24}
height={24} height={24}
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
{...props} {...props}
> >
<path <path
fillRule="evenodd" fillRule="evenodd"
clipRule="evenodd" clipRule="evenodd"
d="M12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2ZM15.5805 9.97493C15.8428 9.65434 15.7955 9.18183 15.4749 8.91953C15.1543 8.65724 14.6818 8.70449 14.4195 9.02507L10.4443 13.8837L9.03033 12.4697C8.73744 12.1768 8.26256 12.1768 7.96967 12.4697C7.67678 12.7626 7.67678 13.2374 7.96967 13.5303L9.96967 15.5303C10.1195 15.6802 10.3257 15.7596 10.5374 15.7491C10.749 15.7385 10.9463 15.6389 11.0805 15.4749L15.5805 9.97493Z" d="M12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2ZM15.5805 9.97493C15.8428 9.65434 15.7955 9.18183 15.4749 8.91953C15.1543 8.65724 14.6818 8.70449 14.4195 9.02507L10.4443 13.8837L9.03033 12.4697C8.73744 12.1768 8.26256 12.1768 7.96967 12.4697C7.67678 12.7626 7.67678 13.2374 7.96967 13.5303L9.96967 15.5303C10.1195 15.6802 10.3257 15.7596 10.5374 15.7491C10.749 15.7385 10.9463 15.6389 11.0805 15.4749L15.5805 9.97493Z"
fill="currentColor" fill="currentColor"
/> />
</svg> </svg>
); );
} }

View File

@@ -1,24 +1,24 @@
import { SVGProps } from "react"; import { SVGProps } from 'react';
export function ChevronDownIcon( export function ChevronDownIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>, props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>
) { ) {
return ( return (
<svg <svg
width={24} width={24}
height={24} height={24}
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
{...props} {...props}
> >
<path <path
d="M8 10L12 14L16 10" d="M8 10L12 14L16 10"
stroke="currentColor" stroke="currentColor"
strokeWidth={1.5} strokeWidth={1.5}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
/> />
</svg> </svg>
); );
} }

View File

@@ -1,24 +1,24 @@
import { SVGProps } from "react"; import { SVGProps } from 'react';
export function ChevronRightIcon( export function ChevronRightIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>, props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>
) { ) {
return ( return (
<svg <svg
width={24} width={24}
height={24} height={24}
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
{...props} {...props}
> >
<path <path
d="M10 16L14 12L10 8" d="M10 16L14 12L10 8"
stroke="currentColor" stroke="currentColor"
strokeWidth={1.5} strokeWidth={1.5}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
/> />
</svg> </svg>
); );
} }

View File

@@ -1,23 +1,21 @@
import { SVGProps } from "react"; import { SVGProps } from 'react';
export function CommandIcon( export function CommandIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>, return (
) { <svg
return ( xmlns="http://www.w3.org/2000/svg"
<svg width="24"
xmlns="http://www.w3.org/2000/svg" height="24"
width="24" fill="none"
height="24" viewBox="0 0 24 24"
fill="none" {...props}
viewBox="0 0 24 24" >
{...props} <path
> stroke="currentColor"
<path strokeLinecap="square"
stroke="currentColor" strokeWidth="1.5"
strokeLinecap="square" d="M9.25 9.25V6.5A2.75 2.75 0 106.5 9.25h2.75zm0 0h5.5m-5.5 0v5.5m5.5-5.5V6.5a2.75 2.75 0 112.75 2.75h-2.75zm0 0v5.5m0 0h-5.5m5.5 0v2.75a2.75 2.75 0 102.75-2.75h-2.75zm-5.5 0v2.75a2.75 2.75 0 11-2.75-2.75h2.75z"
strokeWidth="1.5" />
d="M9.25 9.25V6.5A2.75 2.75 0 106.5 9.25h2.75zm0 0h5.5m-5.5 0v5.5m5.5-5.5V6.5a2.75 2.75 0 112.75 2.75h-2.75zm0 0v5.5m0 0h-5.5m5.5 0v2.75a2.75 2.75 0 102.75-2.75h-2.75zm-5.5 0v2.75a2.75 2.75 0 11-2.75-2.75h2.75z" </svg>
/> );
</svg>
);
} }

View File

@@ -1,21 +1,19 @@
import { SVGProps } from "react"; import { SVGProps } from 'react';
export function ComposeIcon( export function ComposeIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>, return (
) { <svg
return ( width={24}
<svg height={24}
width={24} viewBox="0 0 24 24"
height={24} fill="none"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"
fill="none" {...props}
xmlns="http://www.w3.org/2000/svg" >
{...props} <path
> d="M12.75 8.25L12.2197 7.71967C12.079 7.86032 12 8.05109 12 8.25H12.75ZM12.75 11.25H12C12 11.6642 12.3358 12 12.75 12V11.25ZM15.75 11.25V12C15.9489 12 16.1397 11.921 16.2803 11.7803L15.75 11.25ZM18.75 2.25L19.2803 1.71967C18.9874 1.42678 18.5126 1.42678 18.2197 1.71967L18.75 2.25ZM21.75 5.25L22.2803 5.78033C22.5732 5.48744 22.5732 5.01256 22.2803 4.71967L21.75 5.25ZM20.25 20.25V21C20.6642 21 21 20.6642 21 20.25H20.25ZM3.75 20.25H3C3 20.6642 3.33579 21 3.75 21V20.25ZM3.75 3.75V3C3.33579 3 3 3.33579 3 3.75H3.75ZM11.25 4.5C11.6642 4.5 12 4.16421 12 3.75C12 3.33579 11.6642 3 11.25 3V4.5ZM21 12.75C21 12.3358 20.6642 12 20.25 12C19.8358 12 19.5 12.3358 19.5 12.75H21ZM12 8.25V11.25H13.5V8.25H12ZM12.75 12H15.75V10.5H12.75V12ZM13.2803 8.78033L19.2803 2.78033L18.2197 1.71967L12.2197 7.71967L13.2803 8.78033ZM18.2197 2.78033L21.2197 5.78033L22.2803 4.71967L19.2803 1.71967L18.2197 2.78033ZM21.2197 4.71967L15.2197 10.7197L16.2803 11.7803L22.2803 5.78033L21.2197 4.71967ZM20.25 19.5H3.75V21H20.25V19.5ZM4.5 20.25V3.75H3V20.25H4.5ZM3.75 4.5H11.25V3H3.75V4.5ZM19.5 12.75V20.25H21V12.75H19.5Z"
<path fill="currentColor"
d="M12.75 8.25L12.2197 7.71967C12.079 7.86032 12 8.05109 12 8.25H12.75ZM12.75 11.25H12C12 11.6642 12.3358 12 12.75 12V11.25ZM15.75 11.25V12C15.9489 12 16.1397 11.921 16.2803 11.7803L15.75 11.25ZM18.75 2.25L19.2803 1.71967C18.9874 1.42678 18.5126 1.42678 18.2197 1.71967L18.75 2.25ZM21.75 5.25L22.2803 5.78033C22.5732 5.48744 22.5732 5.01256 22.2803 4.71967L21.75 5.25ZM20.25 20.25V21C20.6642 21 21 20.6642 21 20.25H20.25ZM3.75 20.25H3C3 20.6642 3.33579 21 3.75 21V20.25ZM3.75 3.75V3C3.33579 3 3 3.33579 3 3.75H3.75ZM11.25 4.5C11.6642 4.5 12 4.16421 12 3.75C12 3.33579 11.6642 3 11.25 3V4.5ZM21 12.75C21 12.3358 20.6642 12 20.25 12C19.8358 12 19.5 12.3358 19.5 12.75H21ZM12 8.25V11.25H13.5V8.25H12ZM12.75 12H15.75V10.5H12.75V12ZM13.2803 8.78033L19.2803 2.78033L18.2197 1.71967L12.2197 7.71967L13.2803 8.78033ZM18.2197 2.78033L21.2197 5.78033L22.2803 4.71967L19.2803 1.71967L18.2197 2.78033ZM21.2197 4.71967L15.2197 10.7197L16.2803 11.7803L22.2803 5.78033L21.2197 4.71967ZM20.25 19.5H3.75V21H20.25V19.5ZM4.5 20.25V3.75H3V20.25H4.5ZM3.75 4.5H11.25V3H3.75V4.5ZM19.5 12.75V20.25H21V12.75H19.5Z" />
fill="currentColor" </svg>
/> );
</svg>
);
} }

View File

@@ -1,24 +1,22 @@
import { SVGProps } from "react"; import { SVGProps } from 'react';
export function CopyIcon( export function CopyIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>, return (
) { <svg
return ( width={24}
<svg height={24}
width={24} viewBox="0 0 24 24"
height={24} fill="none"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"
fill="none" {...props}
xmlns="http://www.w3.org/2000/svg" >
{...props} <path
> d="M15.25 15.25V21.25H2.75V8.75H8.75M8.75 15.25H21.25V2.75H8.75V15.25Z"
<path stroke="currentColor"
d="M15.25 15.25V21.25H2.75V8.75H8.75M8.75 15.25H21.25V2.75H8.75V15.25Z" strokeWidth={1.5}
stroke="currentColor" strokeLinecap="round"
strokeWidth={1.5} strokeLinejoin="round"
strokeLinecap="round" />
strokeLinejoin="round" </svg>
/> );
</svg>
);
} }

View File

@@ -1,24 +1,22 @@
import { SVGProps } from "react"; import { SVGProps } from 'react';
export function EditIcon( export function EditIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>, return (
) { <svg
return ( width={24}
<svg height={24}
width={24} viewBox="0 0 24 24"
height={24} fill="none"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"
fill="none" {...props}
xmlns="http://www.w3.org/2000/svg" >
{...props} <path
> d="M13.25 6.25L17 2.5L21.5 7L17.75 10.75M13.25 6.25L2.75 16.75V21.25H7.25L17.75 10.75M13.25 6.25L17.75 10.75"
<path stroke="currentColor"
d="M13.25 6.25L17 2.5L21.5 7L17.75 10.75M13.25 6.25L2.75 16.75V21.25H7.25L17.75 10.75M13.25 6.25L17.75 10.75" strokeWidth={1.5}
stroke="currentColor" strokeLinecap="round"
strokeWidth={1.5} strokeLinejoin="round"
strokeLinecap="round" />
strokeLinejoin="round" </svg>
/> );
</svg>
);
} }

View File

@@ -1,66 +1,61 @@
import { SVGProps } from "react"; import { SVGProps } from 'react';
export function EmptyIcon( export function EmptyIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>, return (
) { <svg
return ( xmlns="http://www.w3.org/2000/svg"
<svg width="120"
xmlns="http://www.w3.org/2000/svg" height="120"
width="120" fill="none"
height="120" viewBox="0 0 120 120"
fill="none" {...props}
viewBox="0 0 120 120" >
{...props} <g clipPath="url(#clip0_110_63)">
> <path
<g clipPath="url(#clip0_110_63)"> fill="#27272A"
<path fillRule="evenodd"
fill="#27272A" d="M60 120c33.137 0 60-26.863 60-60S93.137 0 60 0C45.133 0 39.482 17.832 29 26.787 16.119 37.792 0 41.73 0 60c0 33.137 26.863 60 60 60z"
fillRule="evenodd" clipRule="evenodd"
d="M60 120c33.137 0 60-26.863 60-60S93.137 0 60 0C45.133 0 39.482 17.832 29 26.787 16.119 37.792 0 41.73 0 60c0 33.137 26.863 60 60 60z" />
clipRule="evenodd" <g filter="url(#filter0_f_110_63)">
/> <path
<g filter="url(#filter0_f_110_63)"> fill="#18181B"
<path fillRule="evenodd"
fill="#18181B" d="M64 101c19.33 0 35-13.208 35-29.5S83.33 42 64 42c-8.672 0-11.969 8.767-18.083 13.17C38.403 60.58 29 62.517 29 71.5 29 87.792 44.67 101 64 101z"
fillRule="evenodd" clipRule="evenodd"
d="M64 101c19.33 0 35-13.208 35-29.5S83.33 42 64 42c-8.672 0-11.969 8.767-18.083 13.17C38.403 60.58 29 62.517 29 71.5 29 87.792 44.67 101 64 101z" />
clipRule="evenodd" </g>
/> <path
</g> fill="#3F3F46"
<path fillRule="evenodd"
fill="#3F3F46" d="M82.941 59H65.06C59.504 59 55 63.476 55 68.997v4.871c0 5.521 4.504 9.997 10.059 9.997h18.879l5.779 4.685a2.02 2.02 0 002.83-.286c.293-.356.453-.803.453-1.263V68.997C93 63.476 88.496 59 82.941 59z"
fillRule="evenodd" clipRule="evenodd"
d="M82.941 59H65.06C59.504 59 55 63.476 55 68.997v4.871c0 5.521 4.504 9.997 10.059 9.997h18.879l5.779 4.685a2.02 2.02 0 002.83-.286c.293-.356.453-.803.453-1.263V68.997C93 63.476 88.496 59 82.941 59z" />
clipRule="evenodd" <path
/> fill="#D4D4D8"
<path fillRule="evenodd"
fill="#D4D4D8" d="M41.161 39h32.678C81.659 39 88 45.408 88 53.314v12.864c0 7.905-6.34 14.314-14.161 14.314H41.547l-9.186 7.742a3.244 3.244 0 01-4.603-.422A3.325 3.325 0 0127 85.697V53.314C27 45.408 33.34 39 41.161 39z"
fillRule="evenodd" clipRule="evenodd"
d="M41.161 39h32.678C81.659 39 88 45.408 88 53.314v12.864c0 7.905-6.34 14.314-14.161 14.314H41.547l-9.186 7.742a3.244 3.244 0 01-4.603-.422A3.325 3.325 0 0127 85.697V53.314C27 45.408 33.34 39 41.161 39z" />
clipRule="evenodd" </g>
/> <defs>
</g> <filter
<defs> id="filter0_f_110_63"
<filter width="92"
id="filter0_f_110_63" height="81"
width="92" x="18"
height="81" y="31"
x="18" colorInterpolationFilters="sRGB"
y="31" filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB" >
filterUnits="userSpaceOnUse" <feFlood floodOpacity="0" result="BackgroundImageFix" />
> <feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feFlood floodOpacity="0" result="BackgroundImageFix" /> <feGaussianBlur result="effect1_foregroundBlur_110_63" stdDeviation="5.5" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" /> </filter>
<feGaussianBlur <clipPath id="clip0_110_63">
result="effect1_foregroundBlur_110_63" <path fill="#fff" d="M0 0H120V120H0z" />
stdDeviation="5.5" </clipPath>
/> </defs>
</filter> </svg>
<clipPath id="clip0_110_63"> );
<path fill="#fff" d="M0 0H120V120H0z" />
</clipPath>
</defs>
</svg>
);
} }

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