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",
"private": true,
"version": "1.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"tauri": "tauri",
"add-migrate": "cd src-tauri/ && sqlx migrate add",
"prepare": "husky install"
},
"lint-staged": {
"**/*.{js,ts,jsx,tsx}": "rome check --apply"
},
"dependencies": {
"@floating-ui/react": "^0.23.1",
"@headlessui/react": "^1.7.15",
"@nostr-dev-kit/ndk": "0.6.0",
"@radix-ui/react-popover": "^1.0.6",
"@radix-ui/react-tooltip": "^1.0.6",
"@tanstack/react-query": "^4.29.19",
"@tanstack/react-virtual": "3.0.0-beta.54",
"@tauri-apps/api": "^1.4.0",
"cheerio": "1.0.0-rc.12",
"dayjs": "^1.11.8",
"destr": "^1.2.2",
"framer-motion": "^10.12.17",
"get-urls": "^11.0.0",
"immer": "^10.0.2",
"light-bolt11-decoder": "^3.0.0",
"nostr-tools": "^1.12.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.45.1",
"react-hotkeys-hook": "^4.4.0",
"react-player": "^2.12.0",
"react-router-dom": "^6.14.0",
"react-string-replace": "^1.1.1",
"react-virtuoso": "^4.3.11",
"slate": "^0.94.1",
"slate-history": "^0.93.0",
"slate-react": "^0.94.2",
"tailwind-merge": "^1.13.2",
"tauri-plugin-autostart-api": "github:tauri-apps/tauri-plugin-autostart#v1",
"tauri-plugin-sql-api": "github:tauri-apps/tauri-plugin-sql",
"zustand": "^4.3.8"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.9",
"@tauri-apps/cli": "^1.4.0",
"@types/node": "^18.16.18",
"@types/react": "^18.2.14",
"@types/react-dom": "^18.2.6",
"@types/youtube-player": "^5.5.7",
"@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.14",
"cross-env": "^7.0.3",
"csstype": "^3.1.2",
"encoding": "^0.1.13",
"husky": "^8.0.3",
"lint-staged": "^13.2.3",
"postcss": "^8.4.24",
"prop-types": "^15.8.1",
"rome": "12.1.0",
"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"
}
"name": "lume",
"private": true,
"version": "1.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"tauri": "tauri",
"add-migrate": "cd src-tauri/ && sqlx migrate add",
"prepare": "husky install",
"lint": "eslint ./src --fix",
"format": "prettier ./src --write"
},
"lint-staged": {
"src/*.{ts, tsx}": "eslint --fix",
"src/*.{ts, tsx, css, md, html, json}": "prettier --cache --write"
},
"dependencies": {
"@floating-ui/react": "^0.23.1",
"@headlessui/react": "^1.7.15",
"@nostr-dev-kit/ndk": "0.6.0",
"@radix-ui/react-popover": "^1.0.6",
"@radix-ui/react-tooltip": "^1.0.6",
"@tanstack/react-query": "^4.29.19",
"@tanstack/react-virtual": "3.0.0-beta.54",
"@tauri-apps/api": "^1.4.0",
"cheerio": "1.0.0-rc.12",
"dayjs": "^1.11.8",
"destr": "^1.2.2",
"framer-motion": "^10.12.17",
"get-urls": "^11.0.0",
"immer": "^10.0.2",
"light-bolt11-decoder": "^3.0.0",
"nostr-tools": "^1.12.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.45.1",
"react-hotkeys-hook": "^4.4.0",
"react-player": "^2.12.0",
"react-router-dom": "^6.14.0",
"react-string-replace": "^1.1.1",
"react-virtuoso": "^4.3.11",
"slate": "^0.94.1",
"slate-history": "^0.93.0",
"slate-react": "^0.94.2",
"tailwind-merge": "^1.13.2",
"tauri-plugin-autostart-api": "github:tauri-apps/tauri-plugin-autostart#v1",
"tauri-plugin-sql-api": "github:tauri-apps/tauri-plugin-sql",
"zustand": "^4.3.8"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.9",
"@tauri-apps/cli": "^1.4.0",
"@trivago/prettier-plugin-sort-imports": "^4.1.1",
"@types/node": "^18.16.18",
"@types/react": "^18.2.14",
"@types/react-dom": "^18.2.6",
"@types/youtube-player": "^5.5.7",
"@typescript-eslint/eslint-plugin": "^5.61.0",
"@typescript-eslint/parser": "^5.61.0",
"@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.14",
"cross-env": "^7.0.3",
"csstype": "^3.1.2",
"encoding": "^0.1.13",
"eslint": "^8.44.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-jsx-a11y": "^6.7.1",
"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 { AuthCreateScreen } from "@app/auth/create";
import { CreateStep1Screen } from "@app/auth/create/step-1";
import { CreateStep2Screen } from "@app/auth/create/step-2";
import { CreateStep3Screen } from "@app/auth/create/step-3";
import { CreateStep4Screen } from "@app/auth/create/step-4";
import { AuthImportScreen } from "@app/auth/import";
import { ImportStep1Screen } from "@app/auth/import/step-1";
import { ImportStep2Screen } from "@app/auth/import/step-2";
import { OnboardingScreen } from "@app/auth/onboarding";
import { WelcomeScreen } from "@app/auth/welcome";
import { ChannelScreen } from "@app/channel";
import { ChatScreen } from "@app/chat";
import { ErrorScreen } from "@app/error";
import { Root } from "@app/root";
import { AccountSettingsScreen } from "@app/settings/account";
import { GeneralSettingsScreen } from "@app/settings/general";
import { ShortcutsSettingsScreen } from "@app/settings/shortcuts";
import { SpaceScreen } from "@app/space";
import { TrendingScreen } from "@app/trending";
import { UserScreen } from "@app/user";
import { AppLayout } from "@shared/appLayout";
import { AuthLayout } from "@shared/authLayout";
import { Protected } from "@shared/protected";
import { SettingsLayout } from "@shared/settingsLayout";
import { RouterProvider, createBrowserRouter } from "react-router-dom";
import { RouterProvider, createBrowserRouter } from 'react-router-dom';
import { AuthCreateScreen } from '@app/auth/create';
import { CreateStep1Screen } from '@app/auth/create/step-1';
import { CreateStep2Screen } from '@app/auth/create/step-2';
import { CreateStep3Screen } from '@app/auth/create/step-3';
import { CreateStep4Screen } from '@app/auth/create/step-4';
import { AuthImportScreen } from '@app/auth/import';
import { ImportStep1Screen } from '@app/auth/import/step-1';
import { ImportStep2Screen } from '@app/auth/import/step-2';
import { OnboardingScreen } from '@app/auth/onboarding';
import { WelcomeScreen } from '@app/auth/welcome';
import { ChannelScreen } from '@app/channel';
import { ChatScreen } from '@app/chat';
import { ErrorScreen } from '@app/error';
import { Root } from '@app/root';
import { AccountSettingsScreen } from '@app/settings/account';
import { GeneralSettingsScreen } from '@app/settings/general';
import { ShortcutsSettingsScreen } from '@app/settings/shortcuts';
import { SpaceScreen } from '@app/space';
import { TrendingScreen } from '@app/trending';
import { UserScreen } from '@app/user';
import { AppLayout } from '@shared/appLayout';
import { AuthLayout } from '@shared/authLayout';
import { Protected } from '@shared/protected';
import { SettingsLayout } from '@shared/settingsLayout';
import './index.css';
const router = createBrowserRouter([
{
path: "/",
element: (
<Protected>
<Root />
</Protected>
),
errorElement: <ErrorScreen />,
},
{
path: "/auth",
element: <AuthLayout />,
children: [
{ path: "welcome", element: <WelcomeScreen /> },
{ path: "onboarding", element: <OnboardingScreen /> },
{
path: "import",
element: <AuthImportScreen />,
children: [
{ path: "", element: <ImportStep1Screen /> },
{ path: "step-2", element: <ImportStep2Screen /> },
],
},
{
path: "create",
element: <AuthCreateScreen />,
children: [
{ path: "", element: <CreateStep1Screen /> },
{ path: "step-2", element: <CreateStep2Screen /> },
{ path: "step-3", element: <CreateStep3Screen /> },
{ path: "step-4", element: <CreateStep4Screen /> },
],
},
],
},
{
path: "/app",
element: (
<Protected>
<AppLayout />
</Protected>
),
children: [
{ path: "space", element: <SpaceScreen /> },
{ path: "trending", element: <TrendingScreen /> },
{ path: "user/:pubkey", element: <UserScreen /> },
{ path: "chat/:pubkey", element: <ChatScreen /> },
{ path: "channel/:id", element: <ChannelScreen /> },
],
},
{
path: "/settings",
element: (
<Protected>
<SettingsLayout />
</Protected>
),
children: [
{ path: "general", element: <GeneralSettingsScreen /> },
{ path: "shortcuts", element: <ShortcutsSettingsScreen /> },
{ path: "account", element: <AccountSettingsScreen /> },
],
},
{
path: '/',
element: (
<Protected>
<Root />
</Protected>
),
errorElement: <ErrorScreen />,
},
{
path: '/auth',
element: <AuthLayout />,
children: [
{ path: 'welcome', element: <WelcomeScreen /> },
{ path: 'onboarding', element: <OnboardingScreen /> },
{
path: 'import',
element: <AuthImportScreen />,
children: [
{ path: '', element: <ImportStep1Screen /> },
{ path: 'step-2', element: <ImportStep2Screen /> },
],
},
{
path: 'create',
element: <AuthCreateScreen />,
children: [
{ path: '', element: <CreateStep1Screen /> },
{ path: 'step-2', element: <CreateStep2Screen /> },
{ path: 'step-3', element: <CreateStep3Screen /> },
{ path: 'step-4', element: <CreateStep4Screen /> },
],
},
],
},
{
path: '/app',
element: (
<Protected>
<AppLayout />
</Protected>
),
children: [
{ path: 'space', element: <SpaceScreen /> },
{ path: 'trending', element: <TrendingScreen /> },
{ path: 'user/:pubkey', element: <UserScreen /> },
{ path: 'chat/:pubkey', element: <ChatScreen /> },
{ path: 'channel/:id', element: <ChannelScreen /> },
],
},
{
path: '/settings',
element: (
<Protected>
<SettingsLayout />
</Protected>
),
children: [
{ path: 'general', element: <GeneralSettingsScreen /> },
{ path: 'shortcuts', element: <ShortcutsSettingsScreen /> },
{ path: 'account', element: <AccountSettingsScreen /> },
],
},
]);
export default function App() {
return (
<RouterProvider
router={router}
fallbackElement={<p>Loading..</p>}
future={{ v7_startTransition: true }}
/>
);
return (
<RouterProvider
router={router}
fallbackElement={<p>Loading..</p>}
future={{ v7_startTransition: true }}
/>
);
}

View File

@@ -1,44 +1,43 @@
import { Image } from "@shared/image";
import { DEFAULT_AVATAR } from "@stores/constants";
import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey";
import { Image } from '@shared/image';
export function User({
pubkey,
fallback,
}: { pubkey: string; fallback?: string }) {
const { status, user } = useProfile(pubkey, fallback);
import { DEFAULT_AVATAR } from '@stores/constants';
if (status === "loading") {
return (
<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>
);
}
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
return (
<div className="flex items-center gap-2">
<div className="relative h-10 w-10 shrink rounded-md">
<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>
);
export function User({ pubkey, fallback }: { pubkey: string; fallback?: string }) {
const { status, user } = useProfile(pubkey, fallback);
if (status === 'loading') {
return (
<div className="flex items-center gap-2">
<div className="relative h-10 w-10 shrink-0 animate-pulse rounded-md bg-zinc-800" />
<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" />
<span className="h-3 w-1/3 animate-pulse rounded bg-zinc-800" />
</div>
</div>
);
}
return (
<div className="flex items-center gap-2">
<div className="relative h-10 w-10 shrink rounded-md">
<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() {
return (
<div className="flex h-full w-full items-center justify-center">
<Outlet />
</div>
);
return (
<div className="flex h-full w-full items-center justify-center">
<Outlet />
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,103 +1,103 @@
import { usePublish } from "@libs/ndk";
import { LoaderIcon } from "@shared/icons";
import { ArrowRightCircleIcon } from "@shared/icons/arrowRightCircle";
import { User } from "@shared/user";
import { useAccount } from "@utils/hooks/useAccount";
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { usePublish } from '@libs/ndk';
import { LoaderIcon } from '@shared/icons';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
import { User } from '@shared/user';
import { useAccount } from '@utils/hooks/useAccount';
export function OnboardingScreen() {
const publish = usePublish();
const navigate = useNavigate();
const publish = usePublish();
const navigate = useNavigate();
const { status, account } = useAccount();
const [loading, setLoading] = useState(false);
const { status, account } = useAccount();
const [loading, setLoading] = useState(false);
const submit = async () => {
try {
setLoading(true);
const submit = async () => {
try {
setLoading(true);
// publish event
publish({
content:
"Running Lume, fighting for better future, join us here: https://lume.nu",
kind: 1,
tags: [],
});
// publish event
publish({
content:
'Running Lume, fighting for better future, join us here: https://lume.nu',
kind: 1,
tags: [],
});
// redirect to home
setTimeout(() => navigate("/", { replace: true }), 1200);
} catch (error) {
console.log(error);
}
};
// redirect to home
setTimeout(() => navigate('/', { replace: true }), 1200);
} catch (error) {
console.log(error);
}
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="mb-2 text-xl font-semibold text-zinc-100">
👋 Hello, welcome you to Lume
</h1>
<p className="text-sm text-zinc-300">
You're a part of better future that we're fighting
</p>
<p className="text-sm text-zinc-300">
If Lume gets your attention, please help us spread via button below
</p>
</div>
<div className="w-full border-t border-zinc-800/50 bg-zinc-900 rounded-xl">
<div className="h-min w-full px-5 py-3">
{status === "success" && (
<User
pubkey={account.pubkey}
time={Math.floor(Date.now() / 1000)}
/>
)}
<div className="-mt-6 pl-[49px] select-text whitespace-pre-line break-words text-base text-zinc-100">
<p>Running Lume, fighting for better future</p>
<p>
join us here:{" "}
<a
href="https://lume.nu"
className="text-fuchsia-500 hover:text-fuchsia-600 no-underline font-normal"
target="_blank"
rel="noreferrer"
>
https://lume.nu
</a>
</p>
</div>
</div>
</div>
<div className="mt-4 w-full flex flex-col gap-2">
<button
type="button"
onClick={() => submit()}
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"
>
{loading ? (
<>
<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="w-5 h-5" />
</>
)}
</button>
<Link
to="/"
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-lg px-6 text-sm font-medium text-zinc-200"
>
Skip for now
</Link>
</div>
</div>
</div>
);
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="mb-2 text-xl font-semibold text-zinc-100">
👋 Hello, welcome you to Lume
</h1>
<p className="text-sm text-zinc-300">
You&apos;re a part of better future that we&apos;re fighting
</p>
<p className="text-sm text-zinc-300">
If Lume gets your attention, please help us spread via button below
</p>
</div>
<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">
{status === 'success' && (
<User 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>
join us here:{' '}
<a
href="https://lume.nu"
className="font-normal text-fuchsia-500 no-underline hover:text-fuchsia-600"
target="_blank"
rel="noreferrer"
>
https://lume.nu
</a>
</p>
</div>
</div>
</div>
<div className="mt-4 flex w-full flex-col gap-2">
<button
type="button"
onClick={() => submit()}
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"
>
{loading ? (
<>
<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" />
</>
)}
</button>
<Link
to="/"
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-lg px-6 text-sm font-medium text-zinc-200"
>
Skip for now
</Link>
</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() {
return (
<div className="w-full h-full grid 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="w-full h-full flex flex-col justify-center px-4 py-4 gap-2">
<h1 className="text-zinc-700 text-4xl font-bold leading-none text-transparent">
Preserve your <span className="text-fuchsia-300">freedom</span>
</h1>
<h2 className="text-zinc-700 text-4xl font-bold leading-none text-transparent">
Protect your <span className="text-red-300">future</span>
</h2>
<h3 className="text-zinc-700 text-4xl font-bold leading-none text-transparent">
Stack <span className="text-orange-300">bitcoin</span>
</h3>
<h3 className="text-zinc-700 text-4xl font-bold leading-none text-transparent">
Use <span className="text-purple-300">nostr</span>
</h3>
</div>
<div className="mt-auto w-full flex flex-col gap-2 px-4 py-4">
<Link
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"
>
<span className="w-5" />
<span>Login with private key</span>
<ArrowRightCircleIcon className="w-5 h-5" />
</Link>
<Link
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"
>
Create new key
</Link>
</div>
</div>
<div
className="col-span-5 bg-zinc-900 rounded-xl bg-cover bg-center"
style={{
backgroundImage: `url("https://void.cat/d/Ps1b36vu5pdkEA2w75usuB")`,
}}
/>
<div
className="col-span-2 bg-zinc-900 rounded-xl bg-cover bg-center"
style={{
backgroundImage: `url("https://void.cat/d/5FdJcBP5ZXKAjYqV8hpcp3")`,
}}
/>
</div>
);
return (
<div className="grid h-full w-full grid-cols-12 gap-4 px-4 py-4">
<div className="col-span-5 flex flex-col rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="flex h-full w-full flex-col justify-center gap-2 px-4 py-4">
<h1 className="text-4xl font-bold leading-none text-transparent text-zinc-700">
Preserve your <span className="text-fuchsia-300">freedom</span>
</h1>
<h2 className="text-4xl font-bold leading-none text-transparent text-zinc-700">
Protect your <span className="text-red-300">future</span>
</h2>
<h3 className="text-4xl font-bold leading-none text-transparent text-zinc-700">
Stack <span className="text-orange-300">bitcoin</span>
</h3>
<h3 className="text-4xl font-bold leading-none text-transparent text-zinc-700">
Use <span className="text-purple-300">nostr</span>
</h3>
</div>
<div className="mt-auto flex w-full flex-col gap-2 px-4 py-4">
<Link
to="/auth/import"
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>Login with private key</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</Link>
<Link
to="/auth/create"
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
</Link>
</div>
</div>
<div
className="col-span-5 rounded-xl bg-zinc-900 bg-cover bg-center"
style={{
backgroundImage: `url("https://void.cat/d/Ps1b36vu5pdkEA2w75usuB")`,
}}
/>
<div
className="col-span-2 rounded-xl bg-zinc-900 bg-cover bg-center"
style={{
backgroundImage: `url("https://void.cat/d/5FdJcBP5ZXKAjYqV8hpcp3")`,
}}
/>
</div>
);
}

View File

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

View File

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

View File

@@ -1,33 +1,34 @@
import { useChannelProfile } from "@app/channel/hooks/useChannelProfile";
import { NavLink } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { NavLink } from 'react-router-dom';
import { twMerge } from 'tailwind-merge';
import { useChannelProfile } from '@app/channel/hooks/useChannelProfile';
export function ChannelsListItem({ data }: { data: any }) {
const channel = useChannelProfile(data.event_id);
return (
<NavLink
to={`/app/channel/${data.event_id}`}
preventScrollReset={true}
className={({ isActive }) =>
twMerge(
"inline-flex h-9 items-center gap-2.5 rounded-md px-2.5",
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">
<span className="text-xs text-zinc-100">#</span>
</div>
<div className="w-full inline-flex items-center justify-between">
<h5 className="truncate font-medium text-zinc-200">{channel?.name}</h5>
<div className="flex items-center">
{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">
{data.new_messages}
</span>
)}
</div>
</div>
</NavLink>
);
const channel = useChannelProfile(data.event_id);
return (
<NavLink
to={`/app/channel/${data.event_id}`}
preventScrollReset={true}
className={({ isActive }) =>
twMerge(
'inline-flex h-9 items-center gap-2.5 rounded-md px-2.5',
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">
<span className="text-xs text-zinc-100">#</span>
</div>
<div className="inline-flex w-full items-center justify-between">
<h5 className="truncate font-medium text-zinc-200">{channel?.name}</h5>
<div className="flex items-center">
{data.new_messages && (
<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}
</span>
)}
</div>
</div>
</NavLink>
);
}

View File

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

View File

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

View File

@@ -1,29 +1,28 @@
import { Member } from "@app/channel/components/member";
import { getChannelUsers } from "@libs/storage";
import { useQuery } from "@tanstack/react-query";
import { useQuery } from '@tanstack/react-query';
import { Member } from '@app/channel/components/member';
import { getChannelUsers } from '@libs/storage';
export function ChannelMembers({ id }: { id: string }) {
const { status, data, isFetching } = useQuery(
["channel-members", id],
async () => {
return await getChannelUsers(id);
},
);
const { status, data, isFetching } = useQuery(['channel-members', id], async () => {
return await getChannelUsers(id);
});
return (
<div className="mt-3">
<h5 className="border-b border-zinc-900 pb-1 font-semibold text-zinc-200">
Members
</h5>
<div className="mt-3 w-full flex flex-wrap gap-1.5">
{status === "loading" || isFetching ? (
<p>Loading...</p>
) : (
data.map((member: { pubkey: string }) => (
<Member key={member.pubkey} pubkey={member.pubkey} />
))
)}
</div>
</div>
);
return (
<div className="mt-3">
<h5 className="border-b border-zinc-900 pb-1 font-semibold text-zinc-200">
Members
</h5>
<div className="mt-3 flex w-full flex-wrap gap-1.5">
{status === 'loading' || isFetching ? (
<p>Loading...</p>
) : (
data.map((member: { pubkey: string }) => (
<Member key={member.pubkey} pubkey={member.pubkey} />
))
)}
</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 { CancelIcon, EnterIcon } from "@shared/icons";
import { MediaUploader } from "@shared/mediaUploader";
import { RelayContext } from "@shared/relayProvider";
import { useChannelMessages } from "@stores/channels";
import { dateToUnix } from "@utils/date";
import { useAccount } from "@utils/hooks/useAccount";
import { useContext, useState } from "react";
import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { useContext, useState } from 'react';
import { UserReply } from '@app/channel/components/messages/userReply';
import { CancelIcon, EnterIcon } from '@shared/icons';
import { MediaUploader } from '@shared/mediaUploader';
import { RelayContext } from '@shared/relayProvider';
import { useChannelMessages } from '@stores/channels';
import { dateToUnix } from '@utils/date';
import { useAccount } from '@utils/hooks/useAccount';
export function ChannelMessageForm({ channelID }: { channelID: string }) {
const ndk = useContext(RelayContext);
const ndk = useContext(RelayContext);
const [value, setValue] = useState("");
const [replyTo, closeReply] = useChannelMessages((state: any) => [
state.replyTo,
state.closeReply,
]);
const [value, setValue] = useState('');
const [replyTo, closeReply] = useChannelMessages((state: any) => [
state.replyTo,
state.closeReply,
]);
const { account } = useAccount();
const { account } = useAccount();
const submit = () => {
let tags: string[][];
const submit = () => {
let tags: string[][];
if (replyTo.id !== null) {
tags = [
["e", channelID, "", "root"],
["e", replyTo.id, "", "reply"],
["p", replyTo.pubkey, ""],
];
} else {
tags = [["e", channelID, "", "root"]];
}
if (replyTo.id !== null) {
tags = [
['e', channelID, '', 'root'],
['e', replyTo.id, '', 'reply'],
['p', replyTo.pubkey, ''],
];
} else {
tags = [['e', channelID, '', 'root']];
}
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const event = new NDKEvent(ndk);
// build event
event.content = value;
event.kind = 42;
event.created_at = dateToUnix();
event.pubkey = account.pubkey;
event.tags = tags;
const event = new NDKEvent(ndk);
// build event
event.content = value;
event.kind = 42;
event.created_at = dateToUnix();
event.pubkey = account.pubkey;
event.tags = tags;
// publish event
event.publish();
// publish event
event.publish();
// reset state
setValue("");
};
// reset state
setValue('');
};
const handleEnterPress = (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
};
const handleEnterPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submit();
}
};
const stopReply = () => {
closeReply();
};
const stopReply = () => {
closeReply();
};
return (
<div className={`relative w-full ${replyTo.id ? "h-36" : "h-24"}`}>
{replyTo.id && (
<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 w-full flex-col">
<UserReply pubkey={replyTo.pubkey} />
<div className="-mt-5 pl-[38px]">
<div className="text-base text-zinc-100">{replyTo.content}</div>
</div>
</div>
<button
type="button"
onClick={() => stopReply()}
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" />
</button>
</div>
</div>
)}
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleEnterPress}
spellCheck={false}
placeholder="Message"
className={`relative ${
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`}
/>
<div className="absolute right-2 bottom-0 h-11">
<div className="h-full flex gap-3 items-center justify-end text-zinc-500">
<MediaUploader setState={setValue} />
<button
type="button"
onClick={submit}
className="inline-flex items-center gap-1 text-sm leading-none"
>
<EnterIcon width={14} height={14} className="" />
Send
</button>
</div>
</div>
</div>
);
return (
<div className={`relative w-full ${replyTo.id ? 'h-36' : 'h-24'}`}>
{replyTo.id && (
<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 w-full flex-col">
<UserReply pubkey={replyTo.pubkey} />
<div className="-mt-5 pl-[38px]">
<div className="text-base text-zinc-100">{replyTo.content}</div>
</div>
</div>
<button
type="button"
onClick={() => stopReply()}
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" />
</button>
</div>
</div>
)}
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleEnterPress}
spellCheck={false}
placeholder="Message"
className={`relative ${
replyTo.id ? 'h-36 pt-16' : 'h-24 pt-3'
} w-full resize-none rounded-md bg-zinc-800 px-5 !outline-none placeholder:text-zinc-500`}
/>
<div className="absolute bottom-0 right-2 h-11">
<div className="flex h-full items-center justify-end gap-3 text-zinc-500">
<MediaUploader setState={setValue} />
<button
type="button"
onClick={submit}
className="inline-flex items-center gap-1 text-sm leading-none"
>
<EnterIcon width={14} height={14} className="" />
Send
</button>
</div>
</div>
</div>
);
}

View File

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

View File

@@ -1,60 +1,56 @@
import { MessageHideButton } from "@app/channel/components/messages/hideButton";
import { MessageMuteButton } from "@app/channel/components/messages/muteButton";
import { MessageReplyButton } from "@app/channel/components/messages/replyButton";
import { MentionNote } from "@shared/notes/mentions/note";
import { ImagePreview } from "@shared/notes/preview/image";
import { LinkPreview } from "@shared/notes/preview/link";
import { VideoPreview } from "@shared/notes/preview/video";
import { User } from "@shared/user";
import { parser } from "@utils/parser";
import { LumeEvent } from "@utils/types";
import { MessageHideButton } from '@app/channel/components/messages/hideButton';
import { MessageMuteButton } from '@app/channel/components/messages/muteButton';
import { MessageReplyButton } from '@app/channel/components/messages/replyButton';
import { MentionNote } from '@shared/notes/mentions/note';
import { ImagePreview } from '@shared/notes/preview/image';
import { LinkPreview } from '@shared/notes/preview/link';
import { VideoPreview } from '@shared/notes/preview/video';
import { User } from '@shared/user';
import { parser } from '@utils/parser';
import { LumeEvent } from '@utils/types';
export function ChannelMessageItem({ data }: { data: LumeEvent }) {
const content = parser(data);
const content = parser(data);
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="flex flex-col">
<User pubkey={data.pubkey} time={data.created_at} isChat={true} />
<div className="-mt-[20px] pl-[49px]">
<p className="select-text whitespace-pre-line break-words text-base text-zinc-100">
{content.parsed}
</p>
{Array.isArray(content.images) && content.images.length ? (
<ImagePreview urls={content.images} />
) : (
<></>
)}
{Array.isArray(content.videos) && content.videos.length ? (
<VideoPreview urls={content.videos} />
) : (
<></>
)}
{Array.isArray(content.links) && content.links.length ? (
<LinkPreview urls={content.links} />
) : (
<></>
)}
{Array.isArray(content.notes) && content.notes.length ? (
content.notes.map((note: string) => (
<MentionNote key={note} id={note} />
))
) : (
<></>
)}
</div>
</div>
<div className="absolute -top-4 right-4 z-10 hidden group-hover:inline-flex">
<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">
<MessageReplyButton
id={data.id}
pubkey={data.pubkey}
content={data.content}
/>
<MessageHideButton id={data.id} />
<MessageMuteButton pubkey={data.pubkey} />
</div>
</div>
</div>
);
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="flex flex-col">
<User pubkey={data.pubkey} time={data.created_at} isChat={true} />
<div className="-mt-[20px] pl-[49px]">
<p className="select-text whitespace-pre-line break-words text-base text-zinc-100">
{content.parsed}
</p>
{Array.isArray(content.images) && content.images.length ? (
<ImagePreview urls={content.images} />
) : (
<></>
)}
{Array.isArray(content.videos) && content.videos.length ? (
<VideoPreview urls={content.videos} />
) : (
<></>
)}
{Array.isArray(content.links) && content.links.length ? (
<LinkPreview urls={content.links} />
) : (
<></>
)}
{Array.isArray(content.notes) && content.notes.length ? (
content.notes.map((note: string) => <MentionNote key={note} id={note} />)
) : (
<></>
)}
</div>
</div>
<div className="absolute -top-4 right-4 z-10 hidden group-hover:inline-flex">
<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">
<MessageReplyButton id={data.id} pubkey={data.pubkey} content={data.content} />
<MessageHideButton id={data.id} />
<MessageMuteButton pubkey={data.pubkey} />
</div>
</div>
</div>
);
}

View File

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

View File

@@ -1,27 +1,32 @@
import { ReplyMessageIcon } from "@shared/icons";
import { Tooltip } from "@shared/tooltip_dep";
import { useChannelMessages } from "@stores/channels";
import { ReplyMessageIcon } from '@shared/icons';
import { Tooltip } from '@shared/tooltip_dep';
import { useChannelMessages } from '@stores/channels';
export function MessageReplyButton({
id,
pubkey,
content,
}: { id: string; pubkey: string; content: string }) {
const openReply = useChannelMessages((state: any) => state.openReply);
id,
pubkey,
content,
}: {
id: string;
pubkey: string;
content: string;
}) {
const openReply = useChannelMessages((state: any) => state.openReply);
const createReply = () => {
openReply(id, pubkey, content);
};
const createReply = () => {
openReply(id, pubkey, content);
};
return (
<Tooltip message="Reply to message">
<button
type="button"
onClick={() => createReply()}
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" />
</button>
</Tooltip>
);
return (
<Tooltip message="Reply to message">
<button
type="button"
onClick={() => createReply()}
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" />
</button>
</Tooltip>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,49 +1,45 @@
import { useDecryptMessage } from "@app/chat/hooks/useDecryptMessage";
import { MentionNote } from "@shared/notes/mentions/note";
import { ImagePreview } from "@shared/notes/preview/image";
import { LinkPreview } from "@shared/notes/preview/link";
import { VideoPreview } from "@shared/notes/preview/video";
import { User } from "@shared/user";
import { parser } from "@utils/parser";
import { useDecryptMessage } from '@app/chat/hooks/useDecryptMessage';
import { MentionNote } from '@shared/notes/mentions/note';
import { ImagePreview } from '@shared/notes/preview/image';
import { LinkPreview } from '@shared/notes/preview/link';
import { VideoPreview } from '@shared/notes/preview/video';
import { User } from '@shared/user';
import { parser } from '@utils/parser';
export function ChatMessageItem({
data,
userPubkey,
userPrivkey,
data,
userPubkey,
userPrivkey,
}: {
data: any;
userPubkey: string;
userPrivkey: string;
data: any;
userPubkey: string;
userPrivkey: string;
}) {
const decryptedContent = useDecryptMessage(data, userPubkey, userPrivkey);
// if we have decrypted content, use it instead of the encrypted content
if (decryptedContent) {
data["content"] = decryptedContent;
}
// parse the note content
const content = parser(data);
const decryptedContent = useDecryptMessage(data, userPubkey, userPrivkey);
// if we have decrypted content, use it instead of the encrypted content
if (decryptedContent) {
data['content'] = decryptedContent;
}
// parse the note content
const content = parser(data);
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 flex-col">
<User
pubkey={data.sender_pubkey}
time={data.created_at}
isChat={true}
/>
<div className="-mt-[20px] pl-[49px]">
<p className="select-text whitespace-pre-line break-words text-base text-zinc-100">
{content.parsed}
</p>
{content.images.length > 0 && <ImagePreview urls={content.images} />}
{content.videos.length > 0 && <VideoPreview urls={content.videos} />}
{content.links.length > 0 && <LinkPreview urls={content.links} />}
{content.notes.length > 0 &&
content.notes.map((note: string) => (
<MentionNote key={note} id={note} />
))}
</div>
</div>
</div>
);
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 flex-col">
<User pubkey={data.sender_pubkey} time={data.created_at} isChat={true} />
<div className="-mt-[20px] pl-[49px]">
<p className="select-text whitespace-pre-line break-words text-base text-zinc-100">
{content.parsed}
</p>
{content.images.length > 0 && <ImagePreview urls={content.images} />}
{content.videos.length > 0 && <VideoPreview urls={content.videos} />}
{content.links.length > 0 && <LinkPreview urls={content.links} />}
{content.notes.length > 0 &&
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 { CancelIcon, LoaderIcon, PlusIcon } from "@shared/icons";
import { useAccount } from "@utils/hooks/useAccount";
import { Fragment, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Dialog, Transition } from '@headlessui/react';
import { Fragment, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { CancelIcon, LoaderIcon, PlusIcon } from '@shared/icons';
import { useAccount } from '@utils/hooks/useAccount';
export function NewMessageModal() {
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const { status, account } = useAccount();
const follows = account ? JSON.parse(account.follows) : [];
const { status, account } = useAccount();
const follows = account ? JSON.parse(account.follows) : [];
const closeModal = () => {
setIsOpen(false);
};
const closeModal = () => {
setIsOpen(false);
};
const openModal = () => {
setIsOpen(true);
};
const openModal = () => {
setIsOpen(true);
};
const openChat = (pubkey: string) => {
closeModal();
navigate(`/app/chat/${pubkey}`);
};
const openChat = (pubkey: string) => {
closeModal();
navigate(`/app/chat/${pubkey}`);
};
return (
<>
<button
type="button"
onClick={() => openModal()}
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">
<PlusIcon width={12} height={12} className="text-zinc-500" />
</div>
<div>
<h5 className="font-medium text-zinc-400">New chat</h5>
</div>
</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
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">
<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 items-center justify-between">
<Dialog.Title
as="h3"
className="text-lg font-semibold leading-none text-zinc-100"
>
New chat
</Dialog.Title>
<button
type="button"
onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon
width={20}
height={20}
className="text-zinc-300"
/>
</button>
</div>
<Dialog.Description className="text-sm leading-tight text-zinc-400">
All messages will be encrypted, but anyone can see who you
chat
</Dialog.Description>
</div>
</div>
<div className="h-[500px] flex flex-col pb-5 overflow-x-hidden overflow-y-auto">
{status === "loading" ? (
<div className="px-4 py-3 inline-flex items-center justify-center">
<LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-zinc-100" />
</div>
) : (
follows.map((follow) => (
<div
key={follow}
className="group flex items-center justify-between px-4 py-3 hover:bg-zinc-800"
>
<User pubkey={follow} />
<div>
<button
type="button"
onClick={() => openChat(follow)}
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"
>
Chat
</button>
</div>
</div>
))
)}
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
return (
<>
<button
type="button"
onClick={() => openModal()}
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">
<PlusIcon width={12} height={12} className="text-zinc-500" />
</div>
<div>
<h5 className="font-medium text-zinc-400">New chat</h5>
</div>
</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
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">
<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 items-center justify-between">
<Dialog.Title
as="h3"
className="text-lg font-semibold leading-none text-zinc-100"
>
New chat
</Dialog.Title>
<button
type="button"
onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon width={20} height={20} className="text-zinc-300" />
</button>
</div>
<Dialog.Description className="text-sm leading-tight text-zinc-400">
All messages will be encrypted, but anyone can see who you chat
</Dialog.Description>
</div>
</div>
<div className="flex h-[500px] flex-col overflow-y-auto overflow-x-hidden pb-5">
{status === 'loading' ? (
<div className="inline-flex items-center justify-center px-4 py-3">
<LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-zinc-100" />
</div>
) : (
follows.map((follow) => (
<div
key={follow}
className="group flex items-center justify-between px-4 py-3 hover:bg-zinc-800"
>
<User pubkey={follow} />
<div>
<button
type="button"
onClick={() => openChat(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"
>
Chat
</button>
</div>
</div>
))
)}
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,61 +1,58 @@
import { Switch } from "@headlessui/react";
import { getSetting, updateSetting } from "@libs/storage";
import { useEffect, useState } from "react";
import { twMerge } from "tailwind-merge";
import { disable, enable, isEnabled } from "tauri-plugin-autostart-api";
import { Switch } from '@headlessui/react';
import { useEffect, useState } from 'react';
import { twMerge } from 'tailwind-merge';
import { disable, enable, isEnabled } from 'tauri-plugin-autostart-api';
import { getSetting, updateSetting } from '@libs/storage';
export function AutoStartSetting() {
const [enabled, setEnabled] = useState(false);
const [enabled, setEnabled] = useState(false);
const toggle = async () => {
if (!enabled) {
await enable();
await updateSetting("auto_start", 1);
console.log(`registered for autostart? ${await isEnabled()}`);
} else {
await disable();
await updateSetting("auto_start", 0);
}
setEnabled(!enabled);
};
const toggle = async () => {
if (!enabled) {
await enable();
await updateSetting('auto_start', 1);
console.log(`registered for autostart? ${await isEnabled()}`);
} else {
await disable();
await updateSetting('auto_start', 0);
}
setEnabled(!enabled);
};
useEffect(() => {
async function getAppSetting() {
const setting = await getSetting("auto_start");
if (parseInt(setting) === 0) {
setEnabled(false);
} else {
setEnabled(true);
}
}
getAppSetting();
}, []);
useEffect(() => {
async function getAppSetting() {
const setting = await getSetting('auto_start');
if (parseInt(setting) === 0) {
setEnabled(false);
} else {
setEnabled(true);
}
}
getAppSetting();
}, []);
return (
<div className="px-5 py-4 inline-flex items-center justify-between">
<div className="flex flex-col gap-1">
<span className="leading-none font-medium text-zinc-200">
Auto start
</span>
<span className="leading-none text-sm text-zinc-400">
Auto start at login
</span>
</div>
<Switch
checked={enabled}
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",
enabled ? "bg-fuchsia-500" : "bg-zinc-700",
)}
>
<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",
enabled ? "translate-x-5" : "translate-x-0",
)}
/>
</Switch>
</div>
);
return (
<div className="inline-flex items-center justify-between px-5 py-4">
<div className="flex flex-col gap-1">
<span className="font-medium leading-none text-zinc-200">Auto start</span>
<span className="text-sm leading-none text-zinc-400">Auto start at login</span>
</div>
<Switch
checked={enabled}
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',
enabled ? 'bg-fuchsia-500' : 'bg-zinc-700'
)}
>
<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',
enabled ? 'translate-x-5' : 'translate-x-0'
)}
/>
</Switch>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,274 +1,252 @@
import { User } from "@app/auth/components/user";
import { Dialog, Transition } from "@headlessui/react";
import { Combobox } from "@headlessui/react";
import { createBlock } from "@libs/storage";
import { CancelIcon, CheckCircleIcon, CommandIcon } from "@shared/icons";
import { DEFAULT_AVATAR } from "@stores/constants";
import { ADD_FEEDBLOCK_SHORTCUT } from "@stores/shortcuts";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useAccount } from "@utils/hooks/useAccount";
import { nip19 } from "nostr-tools";
import { Fragment, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useHotkeys } from "react-hotkeys-hook";
import { Dialog, Transition } from '@headlessui/react';
import { Combobox } from '@headlessui/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools';
import { Fragment, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook';
import { User } from '@app/auth/components/user';
import { createBlock } from '@libs/storage';
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() {
const queryClient = useQueryClient();
const queryClient = useQueryClient();
const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [selected, setSelected] = useState([]);
const [query, setQuery] = useState("");
const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [selected, setSelected] = useState([]);
const [query, setQuery] = useState('');
const { status, account } = useAccount();
const { status, account } = useAccount();
const openModal = () => {
setIsOpen(true);
};
const openModal = () => {
setIsOpen(true);
};
const closeModal = () => {
setIsOpen(false);
};
const closeModal = () => {
setIsOpen(false);
};
useHotkeys(ADD_FEEDBLOCK_SHORTCUT, () => openModal());
useHotkeys(ADD_FEEDBLOCK_SHORTCUT, () => openModal());
const block = useMutation({
mutationFn: (data: any) => {
return createBlock(data.kind, data.title, data.content);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["blocks"] });
},
});
const block = useMutation({
mutationFn: (data: any) => {
return createBlock(data.kind, data.title, data.content);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['blocks'] });
},
});
const {
register,
handleSubmit,
reset,
formState: { isDirty, isValid },
} = useForm();
const {
register,
handleSubmit,
reset,
formState: { isDirty, isValid },
} = useForm();
const onSubmit = (data: any) => {
setLoading(true);
const onSubmit = (data: any) => {
setLoading(true);
selected.forEach((item, index) => {
if (item.substring(0, 4) === "npub") {
selected[index] = nip19.decode(item).data;
}
});
selected.forEach((item, index) => {
if (item.substring(0, 4) === 'npub') {
selected[index] = nip19.decode(item).data;
}
});
// insert to database
block.mutate({
kind: 1,
title: data.title,
content: JSON.stringify(selected),
});
// insert to database
block.mutate({
kind: 1,
title: data.title,
content: JSON.stringify(selected),
});
setLoading(false);
// reset form
reset();
// close modal
closeModal();
};
setLoading(false);
// reset form
reset();
// close modal
closeModal();
};
return (
<>
<button
type="button"
onClick={() => openModal()}
className="inline-flex w-56 h-9 items-center justify-start gap-2.5 rounded-md px-2.5"
>
<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">
<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-800/50 bg-zinc-900">
<span className="text-zinc-500 text-sm leading-none">F</span>
</div>
</div>
<div>
<h5 className="font-medium text-zinc-400">New feed block</h5>
</div>
</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
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">
<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 items-center justify-between">
<Dialog.Title
as="h3"
className="text-lg font-semibold leading-none text-zinc-100"
>
Create feed block
</Dialog.Title>
<button
type="button"
onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon
width={14}
height={14}
className="text-zinc-300"
/>
</button>
</div>
<Dialog.Description className="text-sm leading-tight text-zinc-400">
Specific newsfeed space for people you want to keep up to
date
</Dialog.Description>
</div>
</div>
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
<form
onSubmit={handleSubmit(onSubmit)}
className="flex h-full w-full flex-col gap-4 mb-0"
>
<div className="flex flex-col gap-1">
<label className="text-sm font-medium uppercase tracking-wider text-zinc-400">
Title *
</label>
<input
type={"text"}
{...register("title", {
required: true,
})}
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">
<label className="text-sm font-medium uppercase tracking-wider text-zinc-400">
Choose at least 1 user *
</label>
<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">
<div className="w-full px-3 py-2">
<Combobox
value={selected}
onChange={setSelected}
multiple
>
<Combobox.Input
onChange={(event) => setQuery(event.target.value)}
spellCheck={false}
autoFocus={false}
placeholder="Enter pubkey or npub..."
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"
/>
<Combobox.Options static>
{query.length > 0 && (
<Combobox.Option
value={query}
className="group w-full flex items-center justify-between px-2 py-2 rounded-md hover:bg-zinc-700"
>
{({ selected }) => (
<>
<div className="flex items-center gap-2">
<img
alt={query}
src={DEFAULT_AVATAR}
className="w-11 h-11 shrink-0 object-cover rounded"
/>
<div className="inline-flex flex-col gap-1">
<span className="text-base leading-tight text-zinc-400">
{query}
</span>
</div>
</div>
{selected && (
<CheckCircleIcon className="w-4 h-4 text-green-500" />
)}
</>
)}
</Combobox.Option>
)}
{status === "loading" ? (
<p>Loading...</p>
) : (
JSON.parse(account.follows).map((follow) => (
<Combobox.Option
key={follow}
value={follow}
className="group w-full flex items-center justify-between px-2 py-2 rounded-md hover:bg-zinc-700"
>
{({ selected }) => (
<>
<User pubkey={follow} />
{selected && (
<CheckCircleIcon className="w-4 h-4 text-green-500" />
)}
</>
)}
</Combobox.Option>
))
)}
</Combobox.Options>
</Combobox>
</div>
</div>
</div>
<div>
<button
type="submit"
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"
>
{loading ? (
<svg
className="h-4 w-4 animate-spin text-black dark:text-zinc-100"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<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>
</>
);
return (
<>
<button
type="button"
onClick={() => openModal()}
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="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" />
</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">
<span className="text-sm leading-none text-zinc-500">F</span>
</div>
</div>
<div>
<h5 className="font-medium text-zinc-400">New feed block</h5>
</div>
</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
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">
<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 items-center justify-between">
<Dialog.Title
as="h3"
className="text-lg font-semibold leading-none text-zinc-100"
>
Create feed block
</Dialog.Title>
<button
type="button"
onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon width={14} height={14} className="text-zinc-300" />
</button>
</div>
<Dialog.Description className="text-sm leading-tight text-zinc-400">
Specific newsfeed space for people you want to keep up to date
</Dialog.Description>
</div>
</div>
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
<form
onSubmit={handleSubmit(onSubmit)}
className="mb-0 flex h-full w-full flex-col gap-4"
>
<div className="flex flex-col gap-1">
<label
htmlFor="title"
className="text-sm font-medium uppercase tracking-wider text-zinc-400"
>
Title *
</label>
<input
type={'text'}
{...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"
/>
</div>
<div className="flex flex-col gap-1">
<span className="text-sm font-medium uppercase tracking-wider text-zinc-400">
Choose at least 1 user *
</span>
<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">
<div className="w-full px-3 py-2">
<Combobox value={selected} onChange={setSelected} multiple>
<Combobox.Input
onChange={(event) => setQuery(event.target.value)}
spellCheck={false}
placeholder="Enter pubkey or npub..."
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.Options static>
{query.length > 0 && (
<Combobox.Option
value={query}
className="group flex w-full items-center justify-between rounded-md px-2 py-2 hover:bg-zinc-700"
>
{({ selected }) => (
<>
<div className="flex items-center gap-2">
<img
alt={query}
src={DEFAULT_AVATAR}
className="h-11 w-11 shrink-0 rounded object-cover"
/>
<div className="inline-flex flex-col gap-1">
<span className="text-base leading-tight text-zinc-400">
{query}
</span>
</div>
</div>
{selected && (
<CheckCircleIcon className="h-4 w-4 text-green-500" />
)}
</>
)}
</Combobox.Option>
)}
{status === 'loading' ? (
<p>Loading...</p>
) : (
JSON.parse(account.follows).map((follow) => (
<Combobox.Option
key={follow}
value={follow}
className="group flex w-full items-center justify-between rounded-md px-2 py-2 hover:bg-zinc-700"
>
{({ selected }) => (
<>
<User pubkey={follow} />
{selected && (
<CheckCircleIcon className="h-4 w-4 text-green-500" />
)}
</>
)}
</Combobox.Option>
))
)}
</Combobox.Options>
</Combobox>
</div>
</div>
</div>
<div>
<button
type="submit"
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 ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
'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 { createBlock } from "@libs/storage";
import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { CancelIcon, CommandIcon } from "@shared/icons";
import { Image } from "@shared/image";
import { RelayContext } from "@shared/relayProvider";
import { DEFAULT_AVATAR } from "@stores/constants";
import { ADD_IMAGEBLOCK_SHORTCUT } from "@stores/shortcuts";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { open } from "@tauri-apps/api/dialog";
import { Body, fetch } from "@tauri-apps/api/http";
import { createBlobFromFile } from "@utils/createBlobFromFile";
import { dateToUnix } from "@utils/date";
import { useAccount } from "@utils/hooks/useAccount";
import { Fragment, useContext, useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useHotkeys } from "react-hotkeys-hook";
import { Dialog, Transition } from '@headlessui/react';
import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { open } from '@tauri-apps/api/dialog';
import { Body, fetch } from '@tauri-apps/api/http';
import { Fragment, useContext, useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook';
import { createBlock } from '@libs/storage';
import { CancelIcon, CommandIcon } from '@shared/icons';
import { Image } from '@shared/image';
import { RelayContext } from '@shared/relayProvider';
import { DEFAULT_AVATAR } from '@stores/constants';
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() {
const ndk = useContext(RelayContext);
const queryClient = useQueryClient();
const ndk = useContext(RelayContext);
const queryClient = useQueryClient();
const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [image, setImage] = useState("");
const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [image, setImage] = useState('');
const { account } = useAccount();
const { account } = useAccount();
const tags = useRef(null);
const tags = useRef(null);
const openModal = () => {
setIsOpen(true);
};
const openModal = () => {
setIsOpen(true);
};
const closeModal = () => {
setIsOpen(false);
};
const closeModal = () => {
setIsOpen(false);
};
useHotkeys(ADD_IMAGEBLOCK_SHORTCUT, () => openModal());
useHotkeys(ADD_IMAGEBLOCK_SHORTCUT, () => openModal());
const {
register,
handleSubmit,
reset,
setValue,
formState: { isDirty, isValid },
} = useForm();
const {
register,
handleSubmit,
reset,
setValue,
formState: { isDirty, isValid },
} = useForm();
const openFileDialog = async () => {
const selected: any = await open({
multiple: false,
filters: [
{
name: "Image",
extensions: ["png", "jpeg", "jpg"],
},
],
});
const openFileDialog = async () => {
const selected: any = await open({
multiple: false,
filters: [
{
name: 'Image',
extensions: ['png', 'jpeg', 'jpg'],
},
],
});
if (Array.isArray(selected)) {
// user selected multiple files
} else if (selected === null) {
// user cancelled the selection
} else {
const filename = selected.split("/").pop();
const file = await createBlobFromFile(selected);
const buf = await file.arrayBuffer();
if (Array.isArray(selected)) {
// user selected multiple files
} else if (selected === null) {
// user cancelled the selection
} else {
const filename = selected.split('/').pop();
const file = await createBlobFromFile(selected);
const buf = await file.arrayBuffer();
const res: any = await fetch("https://void.cat/upload?cli=false", {
method: "POST",
timeout: 5,
headers: {
accept: "*/*",
"Content-Type": "application/octet-stream",
"V-Filename": filename,
"V-Description": "Upload from https://lume.nu",
"V-Strip-Metadata": "true",
},
body: Body.bytes(buf),
});
const res: any = await fetch('https://void.cat/upload?cli=false', {
method: 'POST',
timeout: 5,
headers: {
accept: '*/*',
'Content-Type': 'application/octet-stream',
'V-Filename': filename,
'V-Description': 'Upload from https://lume.nu',
'V-Strip-Metadata': 'true',
},
body: Body.bytes(buf),
});
if (res.ok) {
const imageURL = `https://void.cat/d/${res.data.file.id}.webp`;
tags.current = [
["url", imageURL],
["m", res.data.file.metadata.mimeType],
["x", res.data.file.metadata.digest],
["size", res.data.file.metadata.size],
["magnet", res.data.file.metadata.magnetLink],
];
if (res.ok) {
const imageURL = `https://void.cat/d/${res.data.file.id}.webp`;
tags.current = [
['url', imageURL],
['m', res.data.file.metadata.mimeType],
['x', res.data.file.metadata.digest],
['size', res.data.file.metadata.size],
['magnet', res.data.file.metadata.magnetLink],
];
setImage(imageURL);
}
}
};
setImage(imageURL);
}
}
};
const block = useMutation({
mutationFn: (data: any) => {
return createBlock(data.kind, data.title, data.content);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["blocks"] });
},
});
const block = useMutation({
mutationFn: (data: any) => {
return createBlock(data.kind, data.title, data.content);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['blocks'] });
},
});
const onSubmit = (data: any) => {
setLoading(true);
const onSubmit = (data: any) => {
setLoading(true);
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const event = new NDKEvent(ndk);
// build event
event.content = data.title;
event.kind = 1063;
event.created_at = dateToUnix();
event.pubkey = account.pubkey;
event.tags = tags.current;
const event = new NDKEvent(ndk);
// build event
event.content = data.title;
event.kind = 1063;
event.created_at = dateToUnix();
event.pubkey = account.pubkey;
event.tags = tags.current;
// publish event
event.publish();
// publish event
event.publish();
// mutate
block.mutate({ kind: 0, title: data.title, content: data.content });
// mutate
block.mutate({ kind: 0, title: data.title, content: data.content });
setLoading(false);
// reset form
reset();
// close modal
closeModal();
};
setLoading(false);
// reset form
reset();
// close modal
closeModal();
};
useEffect(() => {
setValue("content", image);
}, [setValue, image]);
useEffect(() => {
setValue('content', image);
}, [setValue, image]);
return (
<>
<button
type="button"
onClick={() => openModal()}
className="inline-flex w-56 h-9 items-center justify-start gap-2.5 rounded-md px-2.5"
>
<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">
<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-800/50 bg-zinc-900">
<span className="text-zinc-500 text-sm leading-none">I</span>
</div>
</div>
<div>
<h5 className="font-medium text-zinc-400">New image block</h5>
</div>
</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
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">
<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 items-center justify-between">
<Dialog.Title
as="h3"
className="text-lg font-semibold leading-none text-zinc-100"
>
Create image block
</Dialog.Title>
<button
type="button"
onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon
width={14}
height={14}
className="text-zinc-300"
/>
</button>
</div>
<Dialog.Description className="text-sm leading-tight text-zinc-400">
Pin your favorite image to Space then you can view every
time that you use Lume, your image will be broadcast to
Nostr Relay as well
</Dialog.Description>
</div>
</div>
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
<form
onSubmit={handleSubmit(onSubmit)}
className="flex h-full w-full flex-col gap-4 mb-0"
>
<input
type={"hidden"}
{...register("content")}
value={image}
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="flex flex-col gap-1">
<label className="text-sm font-medium uppercase tracking-wider text-zinc-400">
Title *
</label>
<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">
<input
type={"text"}
{...register("title", {
required: true,
})}
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 className="flex flex-col gap-1">
<label className="text-sm font-medium uppercase tracking-wider text-zinc-400">
Picture
</label>
<div className="relative inline-flex h-56 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950">
<Image
src={image}
fallback={DEFAULT_AVATAR}
alt="content"
className="relative z-10 max-h-[156px] h-auto w-[150px] object-cover rounded-md"
/>
<div className="absolute bottom-3 right-3 z-10">
<button
onClick={() => openFileDialog()}
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>
</div>
</div>
</div>
<div>
<button
type="submit"
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"
>
{loading ? (
<svg
className="h-4 w-4 animate-spin text-black dark:text-zinc-100"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<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>
</>
);
return (
<>
<button
type="button"
onClick={() => openModal()}
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="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" />
</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">
<span className="text-sm leading-none text-zinc-500">I</span>
</div>
</div>
<div>
<h5 className="font-medium text-zinc-400">New image block</h5>
</div>
</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
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">
<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 items-center justify-between">
<Dialog.Title
as="h3"
className="text-lg font-semibold leading-none text-zinc-100"
>
Create image block
</Dialog.Title>
<button
type="button"
onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon width={14} height={14} className="text-zinc-300" />
</button>
</div>
<Dialog.Description className="text-sm leading-tight text-zinc-400">
Pin your favorite image to Space then you can view every time that
you use Lume, your image will be broadcast to Nostr Relay as well
</Dialog.Description>
</div>
</div>
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
<form
onSubmit={handleSubmit(onSubmit)}
className="mb-0 flex h-full w-full flex-col gap-4"
>
<input
type={'hidden'}
{...register('content')}
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"
/>
<div className="flex flex-col gap-1">
<label
htmlFor="title"
className="text-sm font-medium uppercase tracking-wider text-zinc-400"
>
Title *
</label>
<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">
<input
type={'text'}
{...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"
/>
</div>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="picture"
className="text-sm font-medium uppercase tracking-wider text-zinc-400"
>
Picture
</label>
<div className="relative inline-flex h-56 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950">
<Image
src={image}
fallback={DEFAULT_AVATAR}
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
onClick={() => openFileDialog()}
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>
</div>
</div>
</div>
<div>
<button
type="submit"
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
className="h-4 w-4 animate-spin text-black dark:text-zinc-100"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<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,124 +1,113 @@
import { getNotesByAuthors, removeBlock } from "@libs/storage";
import { Note } from "@shared/notes/note";
import { NoteSkeleton } from "@shared/notes/skeleton";
import { TitleBar } from "@shared/titleBar";
import {
useInfiniteQuery,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useEffect, useRef } from "react";
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useEffect, useRef } from 'react';
import { getNotesByAuthors, removeBlock } from '@libs/storage';
import { Note } from '@shared/notes/note';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { TitleBar } from '@shared/titleBar';
const ITEM_PER_PAGE = 10;
export function FeedBlock({ params }: { params: any }) {
const queryClient = useQueryClient();
const { status, data, fetchNextPage, hasNextPage, isFetchingNextPage }: any =
useInfiniteQuery({
queryKey: ["newsfeed", params.content],
queryFn: async ({ pageParam = 0 }) => {
return await getNotesByAuthors(
params.content,
ITEM_PER_PAGE,
pageParam,
);
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const queryClient = useQueryClient();
const { status, data, fetchNextPage, hasNextPage, isFetchingNextPage }: any =
useInfiniteQuery({
queryKey: ['newsfeed', params.content],
queryFn: async ({ pageParam = 0 }) => {
return await getNotesByAuthors(params.content, ITEM_PER_PAGE, pageParam);
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const notes = data ? data.pages.flatMap((d: { data: any }) => d.data) : [];
const parentRef = useRef();
const notes = data ? data.pages.flatMap((d: { data: any }) => d.data) : [];
const parentRef = useRef();
const rowVirtualizer = useVirtualizer({
count: hasNextPage ? notes.length + 1 : notes.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 500,
overscan: 2,
});
const rowVirtualizer = useVirtualizer({
count: hasNextPage ? notes.length + 1 : notes.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 500,
overscan: 2,
});
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
useEffect(() => {
const [lastItem] = [...rowVirtualizer.getVirtualItems()].reverse();
useEffect(() => {
const [lastItem] = [...rowVirtualizer.getVirtualItems()].reverse();
if (!lastItem) {
return;
}
if (!lastItem) {
return;
}
if (
lastItem.index >= notes.length - 1 &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage();
}
}, [notes.length, fetchNextPage, rowVirtualizer.getVirtualItems()]);
if (lastItem.index >= notes.length - 1 && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [notes.length, fetchNextPage, rowVirtualizer.getVirtualItems()]);
const block = useMutation({
mutationFn: (id: string) => {
return removeBlock(id);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["blocks"] });
},
});
const block = useMutation({
mutationFn: (id: string) => {
return removeBlock(id);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['blocks'] });
},
});
const renderItem = (index: string | number) => {
const note = notes[index];
const renderItem = (index: string | number) => {
const note = notes[index];
if (!note) return;
return (
<div key={index} data-index={index} ref={rowVirtualizer.measureElement}>
<Note event={note} block={params.id} />
</div>
);
};
if (!note) return;
return (
<div key={index} data-index={index} ref={rowVirtualizer.measureElement}>
<Note event={note} block={params.id} />
</div>
);
};
return (
<div className="shrink-0 w-[400px] border-r border-zinc-900">
<TitleBar title={params.title} onClick={() => block.mutate(params.id)} />
<div
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"
style={{ contain: "strict" }}
>
{status === "loading" ? (
<div className="px-3 py-1.5">
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3">
<NoteSkeleton />
</div>
</div>
) : (
<div
className="relative w-full"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
}}
>
<div
className="absolute left-0 top-0 w-full"
style={{
transform: `translateY(${
itemsVirtualizer[0].start -
rowVirtualizer.options.scrollMargin
}px)`,
}}
>
{rowVirtualizer
.getVirtualItems()
.map((virtualRow) => renderItem(virtualRow.index))}
</div>
</div>
)}
{isFetchingNextPage && (
<div className="px-3 py-1.5">
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3">
<NoteSkeleton />
</div>
</div>
)}
</div>
</div>
);
return (
<div className="w-[400px] shrink-0 border-r border-zinc-900">
<TitleBar title={params.title} onClick={() => block.mutate(params.id)} />
<div
ref={parentRef}
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' }}
>
{status === 'loading' ? (
<div className="px-3 py-1.5">
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3">
<NoteSkeleton />
</div>
</div>
) : (
<div
className="relative w-full"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
}}
>
<div
className="absolute left-0 top-0 w-full"
style={{
transform: `translateY(${
itemsVirtualizer[0].start - rowVirtualizer.options.scrollMargin
}px)`,
}}
>
{rowVirtualizer
.getVirtualItems()
.map((virtualRow) => renderItem(virtualRow.index))}
</div>
</div>
)}
{isFetchingNextPage && (
<div className="px-3 py-1.5">
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3">
<NoteSkeleton />
</div>
</div>
)}
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ button {
}
.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) */

View File

@@ -1,76 +1,79 @@
import NDK, {
NDKConstructorParams,
NDKEvent,
NDKFilter,
NDKKind,
NDKPrivateKeySigner,
} from "@nostr-dev-kit/ndk";
import { RelayContext } from "@shared/relayProvider";
import { FULL_RELAYS } from "@stores/constants";
import { useAccount } from "@utils/hooks/useAccount";
import { useContext } from "react";
NDKConstructorParams,
NDKEvent,
NDKFilter,
NDKKind,
NDKPrivateKeySigner,
} from '@nostr-dev-kit/ndk';
import { useContext } from 'react';
import { RelayContext } from '@shared/relayProvider';
import { FULL_RELAYS } from '@stores/constants';
import { useAccount } from '@utils/hooks/useAccount';
export async function initNDK(relays?: string[]): Promise<NDK> {
const opts: NDKConstructorParams = {};
const defaultRelays = new Set(relays || FULL_RELAYS);
const opts: NDKConstructorParams = {};
const defaultRelays = new Set(relays || FULL_RELAYS);
opts.explicitRelayUrls = [...defaultRelays];
opts.explicitRelayUrls = [...defaultRelays];
const ndk = new NDK(opts);
await ndk.connect(500);
const ndk = new NDK(opts);
await ndk.connect(500);
return ndk;
return ndk;
}
export async function prefetchEvents(
ndk: NDK,
filter: NDKFilter,
ndk: NDK,
filter: NDKFilter
): Promise<Set<NDKEvent>> {
return new Promise((resolve) => {
const events: Map<string, NDKEvent> = new Map();
return new Promise((resolve) => {
const events: Map<string, NDKEvent> = new Map();
const relaySetSubscription = ndk.subscribe(filter, {
closeOnEose: true,
});
const relaySetSubscription = ndk.subscribe(filter, {
closeOnEose: true,
});
relaySetSubscription.on("event", (event: NDKEvent) => {
event.ndk = ndk;
events.set(event.tagId(), event);
});
relaySetSubscription.on('event', (event: NDKEvent) => {
event.ndk = ndk;
events.set(event.tagId(), event);
});
relaySetSubscription.on("eose", () => {
setTimeout(() => resolve(new Set(events.values())), 3000);
});
});
relaySetSubscription.on('eose', () => {
setTimeout(() => resolve(new Set(events.values())), 3000);
});
});
}
export function usePublish() {
const ndk = useContext(RelayContext);
const { account } = useAccount();
const ndk = useContext(RelayContext);
const { account } = useAccount();
const publish = async ({
content,
kind,
tags,
}: {
content: string;
kind: NDKKind;
tags: string[][];
}): Promise<NDKEvent> => {
const event = new NDKEvent(ndk);
const signer = new NDKPrivateKeySigner(account.privkey);
const publish = async ({
content,
kind,
tags,
}: {
content: string;
kind: NDKKind;
tags: string[][];
}): Promise<NDKEvent> => {
const event = new NDKEvent(ndk);
const signer = new NDKPrivateKeySigner(account.privkey);
event.content = content;
event.kind = kind;
event.created_at = Math.floor(Date.now() / 1000);
event.pubkey = account.pubkey;
event.tags = tags;
event.content = content;
event.kind = kind;
event.created_at = Math.floor(Date.now() / 1000);
event.pubkey = account.pubkey;
event.tags = tags;
await event.sign(signer);
await event.publish();
await event.sign(signer);
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 * as cheerio from "cheerio";
import { FetchOptions, ResponseType, fetch } from '@tauri-apps/api/http';
import * as cheerio from 'cheerio';
import { OPENGRAPH } from '@stores/constants';
interface ILinkPreviewOptions {
headers?: Record<string, string>;
imagesPropertyType?: string;
proxyUrl?: string;
timeout?: number;
followRedirects?: `follow` | `error` | `manual`;
resolveDNSHost?: (url: string) => Promise<string>;
handleRedirects?: (baseURL: string, forwardedURL: string) => boolean;
headers?: Record<string, string>;
imagesPropertyType?: string;
proxyUrl?: string;
timeout?: number;
followRedirects?: `follow` | `error` | `manual`;
resolveDNSHost?: (url: string) => Promise<string>;
handleRedirects?: (baseURL: string, forwardedURL: string) => boolean;
}
interface IPreFetchedResource {
headers: Record<string, string>;
status?: number;
imagesPropertyType?: string;
proxyUrl?: string;
url: string;
data: any;
headers: Record<string, string>;
status?: number;
imagesPropertyType?: string;
proxyUrl?: string;
url: string;
data: any;
}
function metaTag(doc: cheerio.CheerioAPI, type: string, attr: string) {
const nodes = doc(`meta[${attr}='${type}']`);
return nodes.length ? nodes : null;
const nodes = doc(`meta[${attr}='${type}']`);
return nodes.length ? nodes : null;
}
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) {
let title =
metaTagContent(doc, "og:title", "property") ||
metaTagContent(doc, "og:title", "name");
if (!title) {
title = doc("title").text();
}
return title;
let title =
metaTagContent(doc, 'og:title', 'property') ||
metaTagContent(doc, 'og:title', 'name');
if (!title) {
title = doc('title').text();
}
return title;
}
function getSiteName(doc: cheerio.CheerioAPI) {
const siteName =
metaTagContent(doc, "og:site_name", "property") ||
metaTagContent(doc, "og:site_name", "name");
return siteName;
const siteName =
metaTagContent(doc, 'og:site_name', 'property') ||
metaTagContent(doc, 'og:site_name', 'name');
return siteName;
}
function getDescription(doc: cheerio.CheerioAPI) {
const description =
metaTagContent(doc, "description", "name") ||
metaTagContent(doc, "Description", "name") ||
metaTagContent(doc, "og:description", "property");
return description;
const description =
metaTagContent(doc, 'description', 'name') ||
metaTagContent(doc, 'Description', 'name') ||
metaTagContent(doc, 'og:description', 'property');
return description;
}
function getMediaType(doc: cheerio.CheerioAPI) {
const node = metaTag(doc, "medium", "name");
if (node) {
const content = node.attr("content");
return content === "image" ? "photo" : content;
}
return (
metaTagContent(doc, "og:type", "property") ||
metaTagContent(doc, "og:type", "name")
);
const node = metaTag(doc, 'medium', 'name');
if (node) {
const content = node.attr('content');
return content === 'image' ? 'photo' : content;
}
return (
metaTagContent(doc, 'og:type', 'property') || metaTagContent(doc, 'og:type', 'name')
);
}
function getImages(
doc: cheerio.CheerioAPI,
rootUrl: string,
imagesPropertyType?: string,
doc: cheerio.CheerioAPI,
rootUrl: string,
imagesPropertyType?: string
) {
let images: string[] = [];
let nodes: cheerio.Cheerio<cheerio.Element> | null;
let src: string | undefined;
let dic: Record<string, boolean> = {};
let images: string[] = [];
let nodes: cheerio.Cheerio<cheerio.Element> | null;
let src: string | undefined;
let dic: Record<string, boolean> = {};
const imagePropertyType = imagesPropertyType ?? "og";
nodes =
metaTag(doc, `${imagePropertyType}:image`, "property") ||
metaTag(doc, `${imagePropertyType}:image`, "name");
const imagePropertyType = imagesPropertyType ?? 'og';
nodes =
metaTag(doc, `${imagePropertyType}:image`, 'property') ||
metaTag(doc, `${imagePropertyType}:image`, 'name');
if (nodes) {
nodes.each((_: number, node: cheerio.Element) => {
if (node.type === "tag") {
src = node.attribs.content;
if (src) {
src = new URL(src, rootUrl).href;
images.push(src);
}
}
});
}
if (nodes) {
nodes.each((_: number, node: cheerio.Element) => {
if (node.type === 'tag') {
src = node.attribs.content;
if (src) {
src = new URL(src, rootUrl).href;
images.push(src);
}
}
});
}
if (images.length <= 0 && !imagesPropertyType) {
src = doc("link[rel=image_src]").attr("href");
if (src) {
src = new URL(src, rootUrl).href;
images = [src];
} else {
nodes = doc("img");
if (images.length <= 0 && !imagesPropertyType) {
src = doc('link[rel=image_src]').attr('href');
if (src) {
src = new URL(src, rootUrl).href;
images = [src];
} else {
nodes = doc('img');
if (nodes?.length) {
dic = {};
images = [];
nodes.each((_: number, node: cheerio.Element) => {
if (node.type === "tag") src = node.attribs.src;
if (src && !dic[src]) {
dic[src] = true;
// width = node.attribs.width;
// height = node.attribs.height;
images.push(new URL(src, rootUrl).href);
}
});
}
}
}
if (nodes?.length) {
dic = {};
images = [];
nodes.each((_: number, node: cheerio.Element) => {
if (node.type === 'tag') src = node.attribs.src;
if (src && !dic[src]) {
dic[src] = true;
// width = node.attribs.width;
// height = node.attribs.height;
images.push(new URL(src, rootUrl).href);
}
});
}
}
}
return images;
return images;
}
function getVideos(doc: cheerio.CheerioAPI) {
const videos = [];
let nodeTypes;
let nodeSecureUrls;
let nodeType;
let nodeSecureUrl;
let video;
let videoType;
let videoSecureUrl;
let width;
let height;
let videoObj;
let index;
const videos = [];
let nodeTypes;
let nodeSecureUrls;
let nodeType;
let nodeSecureUrl;
let video;
let videoType;
let videoSecureUrl;
let width;
let height;
let videoObj;
let index;
const nodes =
metaTag(doc, "og:video", "property") || metaTag(doc, "og:video", "name");
const nodes = metaTag(doc, 'og:video', 'property') || metaTag(doc, 'og:video', 'name');
if (nodes?.length) {
nodeTypes =
metaTag(doc, "og:video:type", "property") ||
metaTag(doc, "og:video:type", "name");
nodeSecureUrls =
metaTag(doc, "og:video:secure_url", "property") ||
metaTag(doc, "og:video:secure_url", "name");
width =
metaTagContent(doc, "og:video:width", "property") ||
metaTagContent(doc, "og:video:width", "name");
height =
metaTagContent(doc, "og:video:height", "property") ||
metaTagContent(doc, "og:video:height", "name");
if (nodes?.length) {
nodeTypes =
metaTag(doc, 'og:video:type', 'property') || metaTag(doc, 'og:video:type', 'name');
nodeSecureUrls =
metaTag(doc, 'og:video:secure_url', 'property') ||
metaTag(doc, 'og:video:secure_url', 'name');
width =
metaTagContent(doc, 'og:video:width', 'property') ||
metaTagContent(doc, 'og:video:width', 'name');
height =
metaTagContent(doc, 'og:video:height', 'property') ||
metaTagContent(doc, 'og:video:height', 'name');
for (index = 0; index < nodes.length; index += 1) {
const node = nodes[index];
if (node.type === "tag") video = node.attribs.content;
for (index = 0; index < nodes.length; index += 1) {
const node = nodes[index];
if (node.type === 'tag') video = node.attribs.content;
nodeType = nodeTypes?.[index];
if (nodeType?.type === "tag") {
videoType = nodeType ? nodeType.attribs.content : null;
}
nodeType = nodeTypes?.[index];
if (nodeType?.type === 'tag') {
videoType = nodeType ? nodeType.attribs.content : null;
}
nodeSecureUrl = nodeSecureUrls?.[index];
if (nodeSecureUrl?.type === "tag") {
videoSecureUrl = nodeSecureUrl ? nodeSecureUrl.attribs.content : null;
}
nodeSecureUrl = nodeSecureUrls?.[index];
if (nodeSecureUrl?.type === 'tag') {
videoSecureUrl = nodeSecureUrl ? nodeSecureUrl.attribs.content : null;
}
videoObj = {
url: video,
secureUrl: videoSecureUrl,
type: videoType,
width,
height,
};
if (videoType && videoType.indexOf("video/") === 0) {
videos.splice(0, 0, videoObj);
} else {
videos.push(videoObj);
}
}
}
videoObj = {
url: video,
secureUrl: videoSecureUrl,
type: videoType,
width,
height,
};
if (videoType && videoType.indexOf('video/') === 0) {
videos.splice(0, 0, videoObj);
} else {
videos.push(videoObj);
}
}
}
return videos;
return videos;
}
// returns default favicon (//hostname/favicon.ico) for a url
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
function getFavicons(doc: cheerio.CheerioAPI, rootUrl: string) {
const images = [];
let nodes: cheerio.Cheerio<cheerio.Element> | never[] = [];
let src: string | undefined;
const images = [];
let nodes: cheerio.Cheerio<cheerio.Element> | never[] = [];
let src: string | undefined;
const relSelectors = [
"rel=icon",
`rel="shortcut icon"`,
"rel=apple-touch-icon",
];
const relSelectors = ['rel=icon', `rel="shortcut icon"`, 'rel=apple-touch-icon'];
relSelectors.forEach((relSelector) => {
// look for all icon tags
nodes = doc(`link[${relSelector}]`);
relSelectors.forEach((relSelector) => {
// look for all icon tags
nodes = doc(`link[${relSelector}]`);
// collect all images from icon tags
if (nodes.length) {
nodes.each((_: number, node: cheerio.Element) => {
if (node.type === "tag") src = node.attribs.href;
if (src) {
src = new URL(rootUrl).href;
images.push(src);
}
});
}
});
// collect all images from icon tags
if (nodes.length) {
nodes.each((_: number, node: cheerio.Element) => {
if (node.type === 'tag') src = node.attribs.href;
if (src) {
src = new URL(rootUrl).href;
images.push(src);
}
});
}
});
// if no icon images, use default favicon location
if (images.length <= 0) {
images.push(getDefaultFavicon(rootUrl));
}
// if no icon images, use default favicon location
if (images.length <= 0) {
images.push(getDefaultFavicon(rootUrl));
}
return images;
return images;
}
function parseImageResponse(url: string, contentType: string) {
return {
url,
mediaType: "image",
contentType,
favicons: [getDefaultFavicon(url)],
};
return {
url,
mediaType: 'image',
contentType,
favicons: [getDefaultFavicon(url)],
};
}
function parseAudioResponse(url: string, contentType: string) {
return {
url,
mediaType: "audio",
contentType,
favicons: [getDefaultFavicon(url)],
};
return {
url,
mediaType: 'audio',
contentType,
favicons: [getDefaultFavicon(url)],
};
}
function parseVideoResponse(url: string, contentType: string) {
return {
url,
mediaType: "video",
contentType,
favicons: [getDefaultFavicon(url)],
};
return {
url,
mediaType: 'video',
contentType,
favicons: [getDefaultFavicon(url)],
};
}
function parseApplicationResponse(url: string, contentType: string) {
return {
url,
mediaType: "application",
contentType,
favicons: [getDefaultFavicon(url)],
};
return {
url,
mediaType: 'application',
contentType,
favicons: [getDefaultFavicon(url)],
};
}
function parseTextResponse(
body: string,
url: string,
options: ILinkPreviewOptions = {},
contentType?: string,
body: string,
url: string,
options: ILinkPreviewOptions = {},
contentType?: string
) {
const doc = cheerio.load(body);
const doc = cheerio.load(body);
return {
url,
title: getTitle(doc),
siteName: getSiteName(doc),
description: getDescription(doc),
mediaType: getMediaType(doc) || "website",
contentType,
images: getImages(doc, url, options.imagesPropertyType),
videos: getVideos(doc),
favicons: getFavicons(doc, url),
};
return {
url,
title: getTitle(doc),
siteName: getSiteName(doc),
description: getDescription(doc),
mediaType: getMediaType(doc) || 'website',
contentType,
images: getImages(doc, url, options.imagesPropertyType),
videos: getVideos(doc),
favicons: getFavicons(doc, url),
};
}
function parseUnknownResponse(
body: string,
url: string,
options: ILinkPreviewOptions = {},
contentType?: string,
body: string,
url: string,
options: ILinkPreviewOptions = {},
contentType?: string
) {
return parseTextResponse(body, url, options, contentType);
return parseTextResponse(body, url, options, contentType);
}
function parseResponse(
response: IPreFetchedResource,
options?: ILinkPreviewOptions,
) {
try {
let contentType = response.headers["content-type"];
// console.warn(`original content type`, contentType);
if (contentType?.indexOf(";")) {
// eslint-disable-next-line prefer-destructuring
contentType = contentType.split(";")[0];
// console.warn(`splitting content type`, contentType);
}
function parseResponse(response: IPreFetchedResource, options?: ILinkPreviewOptions) {
try {
let contentType = response.headers['content-type'];
// console.warn(`original content type`, contentType);
if (contentType?.indexOf(';')) {
// eslint-disable-next-line prefer-destructuring
contentType = contentType.split(';')[0];
// console.warn(`splitting content type`, contentType);
}
if (!contentType) {
return parseUnknownResponse(response.data, response.url, options);
}
if (!contentType) {
return parseUnknownResponse(response.data, response.url, options);
}
if ((contentType as any) instanceof Array) {
// eslint-disable-next-line no-param-reassign, prefer-destructuring
contentType = contentType[0];
}
if ((contentType as any) instanceof Array) {
// eslint-disable-next-line no-param-reassign, prefer-destructuring
contentType = contentType[0];
}
// parse response depending on content type
if (OPENGRAPH.REGEX_CONTENT_TYPE_IMAGE.test(contentType)) {
return parseImageResponse(response.url, contentType);
}
if (OPENGRAPH.REGEX_CONTENT_TYPE_AUDIO.test(contentType)) {
return parseAudioResponse(response.url, contentType);
}
if (OPENGRAPH.REGEX_CONTENT_TYPE_VIDEO.test(contentType)) {
return parseVideoResponse(response.url, contentType);
}
if (OPENGRAPH.REGEX_CONTENT_TYPE_TEXT.test(contentType)) {
const htmlString = response.data;
return parseTextResponse(htmlString, response.url, options, contentType);
}
if (OPENGRAPH.REGEX_CONTENT_TYPE_APPLICATION.test(contentType)) {
return parseApplicationResponse(response.url, contentType);
}
const htmlString = response.data;
return parseUnknownResponse(htmlString, response.url, options);
} catch (e) {
throw new Error(
`link-preview-js could not fetch link information ${(
e as any
).toString()}`,
);
}
// parse response depending on content type
if (OPENGRAPH.REGEX_CONTENT_TYPE_IMAGE.test(contentType)) {
return parseImageResponse(response.url, contentType);
}
if (OPENGRAPH.REGEX_CONTENT_TYPE_AUDIO.test(contentType)) {
return parseAudioResponse(response.url, contentType);
}
if (OPENGRAPH.REGEX_CONTENT_TYPE_VIDEO.test(contentType)) {
return parseVideoResponse(response.url, contentType);
}
if (OPENGRAPH.REGEX_CONTENT_TYPE_TEXT.test(contentType)) {
const htmlString = response.data;
return parseTextResponse(htmlString, response.url, options, contentType);
}
if (OPENGRAPH.REGEX_CONTENT_TYPE_APPLICATION.test(contentType)) {
return parseApplicationResponse(response.url, contentType);
}
const htmlString = response.data;
return parseUnknownResponse(htmlString, response.url, options);
} catch (e) {
throw new Error(
`link-preview-js could not fetch link information ${(e as any).toString()}`
);
}
}
export async function getLinkPreview(text: string) {
const fetchUrl = text;
const options: FetchOptions = {
method: "GET",
timeout: 5,
responseType: ResponseType.Text,
};
const fetchUrl = text;
const options: FetchOptions = {
method: 'GET',
timeout: 5,
responseType: ResponseType.Text,
};
let response = await fetch(fetchUrl, options);
let response = await fetch(fetchUrl, options);
if (response.status > 300 && response.status < 309) {
const forwardedUrl = response.headers.location || "";
response = await fetch(forwardedUrl, options);
}
if (response.status > 300 && response.status < 309) {
const forwardedUrl = response.headers.location || '';
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;
// connect database (sqlite)
// path: tauri::api::path::BaseDirectory::App
export async function connect(): Promise<Database> {
if (db) {
return db;
}
db = await Database.load("sqlite:lume.db");
return db;
if (db) {
return db;
}
db = await Database.load('sqlite:lume.db');
return db;
}
// get active account
export async function getActiveAccount() {
const db = await connect();
const result: any = await db.select(
"SELECT * FROM accounts WHERE is_active = 1;",
);
if (result.length > 0) {
return result[0];
} else {
return null;
}
const db = await connect();
const result: any = await db.select('SELECT * FROM accounts WHERE is_active = 1;');
if (result.length > 0) {
return result[0];
} else {
return null;
}
}
// get all accounts
export async function getAccounts() {
const db = await connect();
return await db.select(
"SELECT * FROM accounts WHERE is_active = 0 ORDER BY created_at DESC;",
);
const db = await connect();
return await db.select(
'SELECT * FROM accounts WHERE is_active = 0 ORDER BY created_at DESC;'
);
}
// create account
export async function createAccount(
npub: string,
pubkey: string,
privkey: string,
follows?: string[][],
is_active?: number,
npub: string,
pubkey: string,
privkey: string,
follows?: string[][],
is_active?: number
) {
const db = await connect();
const res = await db.execute(
"INSERT OR IGNORE INTO accounts (npub, pubkey, privkey, follows, is_active) VALUES (?, ?, ?, ?, ?);",
[npub, pubkey, privkey, follows || "", is_active || 0],
);
if (res) {
await createBlock(
0,
"Preserve your freedom",
"https://void.cat/d/949GNg7ZjSLHm2eTR3jZqv",
);
}
const getAccount = await getActiveAccount();
return getAccount;
const db = await connect();
const res = await db.execute(
'INSERT OR IGNORE INTO accounts (npub, pubkey, privkey, follows, is_active) VALUES (?, ?, ?, ?, ?);',
[npub, pubkey, privkey, follows || '', is_active || 0]
);
if (res) {
await createBlock(
0,
'Preserve your freedom',
'https://void.cat/d/949GNg7ZjSLHm2eTR3jZqv'
);
}
const getAccount = await getActiveAccount();
return getAccount;
}
// update account
export async function updateAccount(
column: string,
value: string | string[],
pubkey: string,
column: string,
value: string | string[],
pubkey: string
) {
const db = await connect();
return await db.execute(
`UPDATE accounts SET ${column} = ? WHERE pubkey = ?;`,
[value, pubkey],
);
const db = await connect();
return await db.execute(`UPDATE accounts SET ${column} = ? WHERE pubkey = ?;`, [
value,
pubkey,
]);
}
// count total notes
export async function countTotalChannels() {
const db = await connect();
const result = await db.select('SELECT COUNT(*) AS "total" FROM channels;');
return result[0];
const db = await connect();
const result = await db.select('SELECT COUNT(*) AS "total" FROM channels;');
return result[0];
}
// count total notes
export async function countTotalNotes() {
const db = await connect();
const result = await db.select(
'SELECT COUNT(*) AS "total" FROM notes WHERE kind IN (1, 6);',
);
return parseInt(result[0].total);
const db = await connect();
const result = await db.select(
'SELECT COUNT(*) AS "total" FROM notes WHERE kind IN (1, 6);'
);
return parseInt(result[0].total);
}
// get all notes
export async function getNotes(limit: number, offset: number) {
const db = await connect();
const totalNotes = await countTotalNotes();
const nextCursor = offset + limit;
const db = await connect();
const totalNotes = await countTotalNotes();
const nextCursor = offset + limit;
const notes: any = { data: null, nextCursor: 0 };
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}";`,
);
const notes: any = { data: null, nextCursor: 0 };
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}";`
);
notes["data"] = query;
notes["nextCursor"] =
Math.round(totalNotes / nextCursor) > 1 ? nextCursor : undefined;
notes['data'] = query;
notes['nextCursor'] = Math.round(totalNotes / nextCursor) > 1 ? nextCursor : undefined;
return notes;
return notes;
}
// get all notes by pubkey
export async function getNotesByPubkey(pubkey: string) {
const db = await connect();
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;`,
);
const db = await connect();
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;`
);
return res;
return res;
}
// get all notes by authors
export async function getNotesByAuthors(
authors: string,
limit: number,
offset: number,
) {
const db = await connect();
const totalNotes = await countTotalNotes();
const nextCursor = offset + limit;
const array = JSON.parse(authors);
const finalArray = `'${array.join("','")}'`;
export async function getNotesByAuthors(authors: string, limit: number, offset: number) {
const db = await connect();
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 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}";`,
);
const notes: any = { data: null, nextCursor: 0 };
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}";`
);
notes["data"] = query;
notes["nextCursor"] =
Math.round(totalNotes / nextCursor) > 1 ? nextCursor : undefined;
notes['data'] = query;
notes['nextCursor'] = Math.round(totalNotes / nextCursor) > 1 ? nextCursor : undefined;
return notes;
return notes;
}
// get note by id
export async function getNoteByID(event_id: string) {
const db = await connect();
const result = await db.select(
`SELECT * FROM notes WHERE event_id = "${event_id}";`,
);
return result[0];
const db = await connect();
const result = await db.select(`SELECT * FROM notes WHERE event_id = "${event_id}";`);
return result[0];
}
// create note
export async function createNote(
event_id: string,
pubkey: string,
kind: number,
tags: any,
content: string,
created_at: number,
event_id: string,
pubkey: string,
kind: number,
tags: any,
content: string,
created_at: number
) {
const db = await connect();
const account = await getActiveAccount();
const parentID = getParentID(tags, event_id);
const db = await connect();
const account = await getActiveAccount();
const parentID = getParentID(tags, event_id);
return await db.execute(
"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],
);
return await db.execute(
'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]
);
}
// get note replies
export async function getReplies(parent_id: string) {
const db = await connect();
const result: any = await db.select(
`SELECT * FROM replies WHERE parent_id = "${parent_id}" ORDER BY created_at DESC;`,
);
return result;
const db = await connect();
const result: any = await db.select(
`SELECT * FROM replies WHERE parent_id = "${parent_id}" ORDER BY created_at DESC;`
);
return result;
}
// create reply note
export async function createReplyNote(
parent_id: string,
event_id: string,
pubkey: string,
kind: number,
tags: any,
content: string,
created_at: number,
parent_id: string,
event_id: string,
pubkey: string,
kind: number,
tags: any,
content: string,
created_at: number
) {
const db = await connect();
return await db.execute(
"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],
);
const db = await connect();
return await db.execute(
'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]
);
}
// get all channels
export async function getChannels() {
const db = await connect();
const result: any = await db.select(
"SELECT * FROM channels ORDER BY created_at DESC;",
);
return result;
const db = await connect();
const result: any = await db.select('SELECT * FROM channels ORDER BY created_at DESC;');
return result;
}
// get channel by id
export async function getChannel(id: string) {
const db = await connect();
const result = await db.select(
`SELECT * FROM channels WHERE event_id = "${id}";`,
);
return result[0];
const db = await connect();
const result = await db.select(`SELECT * FROM channels WHERE event_id = "${id}";`);
return result[0];
}
// create channel
export async function createChannel(
event_id: string,
pubkey: string,
name: string,
picture: string,
about: string,
created_at: number,
event_id: string,
pubkey: string,
name: string,
picture: string,
about: string,
created_at: number
) {
const db = await connect();
return await db.execute(
"INSERT OR IGNORE INTO channels (event_id, pubkey, name, picture, about, created_at) VALUES (?, ?, ?, ?, ?, ?);",
[event_id, pubkey, name, picture, about, created_at],
);
const db = await connect();
return await db.execute(
'INSERT OR IGNORE INTO channels (event_id, pubkey, name, picture, about, created_at) VALUES (?, ?, ?, ?, ?, ?);',
[event_id, pubkey, name, picture, about, created_at]
);
}
// update channel metadata
export async function updateChannelMetadata(event_id: string, value: string) {
const db = await connect();
const data = JSON.parse(value);
const db = await connect();
const data = JSON.parse(value);
return await db.execute(
"UPDATE channels SET name = ?, picture = ?, about = ? WHERE event_id = ?;",
[data.name, data.picture, data.about, event_id],
);
return await db.execute(
'UPDATE channels SET name = ?, picture = ?, about = ? WHERE event_id = ?;',
[data.name, data.picture, data.about, event_id]
);
}
// create channel messages
export async function createChannelMessage(
channel_id: string,
event_id: string,
pubkey: string,
kind: number,
content: string,
tags: string[][],
created_at: number,
channel_id: string,
event_id: string,
pubkey: string,
kind: number,
content: string,
tags: string[][],
created_at: number
) {
const db = await connect();
return await db.execute(
"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],
);
const db = await connect();
return await db.execute(
'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]
);
}
// get channel messages by channel id
export async function getChannelMessages(channel_id: string) {
const db = await connect();
return await db.select(
`SELECT * FROM channel_messages WHERE channel_id = "${channel_id}" ORDER BY created_at ASC;`,
);
const db = await connect();
return await db.select(
`SELECT * FROM channel_messages WHERE channel_id = "${channel_id}" ORDER BY created_at ASC;`
);
}
// get channel users
export async function getChannelUsers(channel_id: string) {
const db = await connect();
const result: any = await db.select(
`SELECT DISTINCT pubkey FROM channel_messages WHERE channel_id = "${channel_id}";`,
);
return result;
const db = await connect();
const result: any = await db.select(
`SELECT DISTINCT pubkey FROM channel_messages WHERE channel_id = "${channel_id}";`
);
return result;
}
// get all chats by pubkey
export async function getChatsByPubkey(pubkey: string) {
const db = await connect();
const result: any = await db.select(
`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 }));
return newArr;
const db = await connect();
const result: any = await db.select(
`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 }));
return newArr;
}
// get chat messages
export async function getChatMessages(
receiver_pubkey: string,
sender_pubkey: string,
) {
const db = await connect();
let receiver = [];
export async function getChatMessages(receiver_pubkey: string, sender_pubkey: string) {
const db = await connect();
let receiver = [];
const sender: any = await db.select(
`SELECT * FROM chats WHERE sender_pubkey = "${sender_pubkey}" AND receiver_pubkey = "${receiver_pubkey}";`,
);
const sender: any = await db.select(
`SELECT * FROM chats WHERE sender_pubkey = "${sender_pubkey}" AND receiver_pubkey = "${receiver_pubkey}";`
);
if (receiver_pubkey !== sender_pubkey) {
receiver = await db.select(
`SELECT * FROM chats WHERE sender_pubkey = "${receiver_pubkey}" AND receiver_pubkey = "${sender_pubkey}";`,
);
}
if (receiver_pubkey !== sender_pubkey) {
receiver = await db.select(
`SELECT * FROM chats WHERE sender_pubkey = "${receiver_pubkey}" AND receiver_pubkey = "${sender_pubkey}";`
);
}
const result = [...sender, ...receiver].sort(
(x: { created_at: number }, y: { created_at: number }) =>
x.created_at - y.created_at,
);
const result = [...sender, ...receiver].sort(
(x: { created_at: number }, y: { created_at: number }) => x.created_at - y.created_at
);
return result;
return result;
}
// create chat
export async function createChat(
event_id: string,
receiver_pubkey: string,
sender_pubkey: string,
content: string,
tags: string[][],
created_at: number,
event_id: string,
receiver_pubkey: string,
sender_pubkey: string,
content: string,
tags: string[][],
created_at: number
) {
const db = await connect();
await db.execute(
"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],
);
return sender_pubkey;
const db = await connect();
await db.execute(
'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]
);
return sender_pubkey;
}
// get setting
export async function getSetting(key: string) {
const db = await connect();
const result = await db.select(
`SELECT value FROM settings WHERE key = "${key}";`,
);
return result[0]?.value;
const db = await connect();
const result = await db.select(`SELECT value FROM settings WHERE key = "${key}";`);
return result[0]?.value;
}
// update setting
export async function updateSetting(key: string, value: string | number) {
const db = await connect();
return await db.execute(
`UPDATE settings SET value = "${value}" WHERE key = "${key}";`,
);
const db = await connect();
return await db.execute(`UPDATE settings SET value = "${value}" WHERE key = "${key}";`);
}
// get last login
export async function getLastLogin() {
const db = await connect();
const result = await db.select(
`SELECT value FROM settings WHERE key = "last_login";`,
);
if (result[0]) {
return parseInt(result[0].value);
} else {
return 0;
}
const db = await connect();
const result = await db.select(`SELECT value FROM settings WHERE key = "last_login";`);
if (result[0]) {
return parseInt(result[0].value);
} else {
return 0;
}
}
// update last login
export async function updateLastLogin(value: number) {
const db = await connect();
return await db.execute(
`UPDATE settings SET value = ${value} WHERE key = "last_login";`,
);
const db = await connect();
return await db.execute(
`UPDATE settings SET value = ${value} WHERE key = "last_login";`
);
}
// get blacklist by kind and account id
export async function getBlacklist(account_id: number, kind: number) {
const db = await connect();
return await db.select(
`SELECT * FROM blacklist WHERE account_id = "${account_id}" AND kind = "${kind}";`,
);
const db = await connect();
return await db.select(
`SELECT * FROM blacklist WHERE account_id = "${account_id}" AND kind = "${kind}";`
);
}
// get active blacklist by kind and account id
export async function getActiveBlacklist(account_id: number, kind: number) {
const db = await connect();
return await db.select(
`SELECT content FROM blacklist WHERE account_id = "${account_id}" AND kind = "${kind}" AND status = 1;`,
);
const db = await connect();
return await db.select(
`SELECT content FROM blacklist WHERE account_id = "${account_id}" AND kind = "${kind}" AND status = 1;`
);
}
// add to blacklist
export async function addToBlacklist(
account_id: number,
content: string,
kind: number,
status?: number,
account_id: number,
content: string,
kind: number,
status?: number
) {
const db = await connect();
return await db.execute(
"INSERT OR IGNORE INTO blacklist (account_id, content, kind, status) VALUES (?, ?, ?, ?);",
[account_id, content, kind, status || 1],
);
const db = await connect();
return await db.execute(
'INSERT OR IGNORE INTO blacklist (account_id, content, kind, status) VALUES (?, ?, ?, ?);',
[account_id, content, kind, status || 1]
);
}
// update item in blacklist
export async function updateItemInBlacklist(content: string, status: number) {
const db = await connect();
return await db.execute(
`UPDATE blacklist SET status = "${status}" WHERE content = "${content}";`,
);
const db = await connect();
return await db.execute(
`UPDATE blacklist SET status = "${status}" WHERE content = "${content}";`
);
}
// get all blocks
export async function getBlocks() {
const db = await connect();
const activeAccount = await getActiveAccount();
const result: any = await db.select(
`SELECT * FROM blocks WHERE account_id = "${activeAccount.id}" ORDER BY created_at DESC;`,
);
return result;
const db = await connect();
const activeAccount = await getActiveAccount();
const result: any = await db.select(
`SELECT * FROM blocks WHERE account_id = "${activeAccount.id}" ORDER BY created_at DESC;`
);
return result;
}
// create block
export async function createBlock(kind: number, title: string, content: any) {
const db = await connect();
const activeAccount = await getActiveAccount();
return await db.execute(
"INSERT OR IGNORE INTO blocks (account_id, kind, title, content) VALUES (?, ?, ?, ?);",
[activeAccount.id, kind, title, content],
);
const db = await connect();
const activeAccount = await getActiveAccount();
return await db.execute(
'INSERT OR IGNORE INTO blocks (account_id, kind, title, content) VALUES (?, ?, ?, ?);',
[activeAccount.id, kind, title, content]
);
}
// remove block
export async function removeBlock(id: string) {
const db = await connect();
return await db.execute(`DELETE FROM blocks WHERE id = "${id}";`);
const db = await connect();
return await db.execute(`DELETE FROM blocks WHERE id = "${id}";`);
}
// logout
export async function removeAll() {
const db = await connect();
await db.execute(`UPDATE settings SET value = "0" WHERE key = "last_login";`);
await db.execute("DELETE FROM replies;");
await db.execute("DELETE FROM notes;");
await db.execute("DELETE FROM blacklist;");
await db.execute("DELETE FROM blocks;");
await db.execute("DELETE FROM chats;");
await db.execute("DELETE FROM accounts;");
return true;
const db = await connect();
await db.execute(`UPDATE settings SET value = "0" WHERE key = "last_login";`);
await db.execute('DELETE FROM replies;');
await db.execute('DELETE FROM notes;');
await db.execute('DELETE FROM blacklist;');
await db.execute('DELETE FROM blocks;');
await db.execute('DELETE FROM chats;');
await db.execute('DELETE FROM accounts;');
return true;
}

View File

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

View File

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

View File

@@ -1,18 +1,20 @@
import { Image } from "@shared/image";
import { DEFAULT_AVATAR } from "@stores/constants";
import { useProfile } from "@utils/hooks/useProfile";
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
export function InactiveAccount({ data }: { data: any }) {
const { user } = useProfile(data.npub);
const { user } = useProfile(data.npub);
return (
<div className="relative h-9 w-9 shrink-0">
<Image
src={user?.image}
fallback={DEFAULT_AVATAR}
alt={data.npub}
className="h-9 w-9 rounded object-cover"
/>
</div>
);
return (
<div className="relative h-9 w-9 shrink-0">
<Image
src={user?.image}
fallback={DEFAULT_AVATAR}
alt={data.npub}
className="h-9 w-9 rounded object-cover"
/>
</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 }) {
const navigate = useNavigate();
const navigate = useNavigate();
const goBack = () => {
navigate(-1);
};
const goBack = () => {
navigate(-1);
};
const goForward = () => {
navigate(1);
};
const goForward = () => {
navigate(1);
};
return (
<div
data-tauri-drag-region
className={`shrink-0 flex h-11 w-full px-3 border-b border-zinc-900 items-center ${
reverse ? "justify-start" : "justify-end"
}`}
>
<div className="flex gap-2.5">
<button
type="button"
onClick={() => goBack()}
className="group inline-flex h-6 w-6 items-center justify-center rounded hover:bg-zinc-900"
>
<ArrowLeftIcon
width={16}
height={16}
className="text-zinc-500 group-hover:text-zinc-300"
/>
</button>
<button
type="button"
onClick={() => goForward()}
className="group inline-flex h-6 w-6 items-center justify-center rounded hover:bg-zinc-900"
>
<ArrowRightIcon
width={16}
height={16}
className="text-zinc-500 group-hover:text-zinc-300"
/>
</button>
</div>
</div>
);
return (
<div
data-tauri-drag-region
className={`flex h-11 w-full shrink-0 items-center border-b border-zinc-900 px-3 ${
reverse ? 'justify-start' : 'justify-end'
}`}
>
<div className="flex gap-2.5">
<button
type="button"
onClick={() => goBack()}
className="group inline-flex h-6 w-6 items-center justify-center rounded hover:bg-zinc-900"
>
<ArrowLeftIcon
width={16}
height={16}
className="text-zinc-500 group-hover:text-zinc-300"
/>
</button>
<button
type="button"
onClick={() => goForward()}
className="group inline-flex h-6 w-6 items-center justify-center rounded hover:bg-zinc-900"
>
<ArrowRightIcon
width={16}
height={16}
className="text-zinc-500 group-hover:text-zinc-300"
/>
</button>
</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() {
return (
<div className="flex w-screen h-screen">
<div className="relative flex flex-row shrink-0">
<Navigation />
</div>
<div className="w-full h-full">
<Outlet />
<ScrollRestoration />
</div>
</div>
);
return (
<div className="flex h-screen w-screen">
<div className="relative flex shrink-0 flex-row">
<Navigation />
</div>
<div className="h-full w-full">
<Outlet />
<ScrollRestoration />
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,20 @@
import { SVGProps } from "react";
import { SVGProps } from 'react';
export function BellIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) {
return (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
fill="none"
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"
stroke="currentColor"
strokeWidth={1.5}
/>
</svg>
);
export function BellIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
fill="none"
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"
stroke="currentColor"
strokeWidth={1.5}
/>
</svg>
);
}

View File

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

View File

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

View File

@@ -1,23 +1,21 @@
import { SVGProps } from "react";
import { SVGProps } from 'react';
export function CommandIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<path
stroke="currentColor"
strokeLinecap="square"
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>
);
export function CommandIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<path
stroke="currentColor"
strokeLinecap="square"
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>
);
}

View File

@@ -1,21 +1,19 @@
import { SVGProps } from "react";
import { SVGProps } from 'react';
export function ComposeIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) {
return (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
fill="none"
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"
fill="currentColor"
/>
</svg>
);
export function ComposeIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
fill="none"
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"
fill="currentColor"
/>
</svg>
);
}

View File

@@ -1,24 +1,22 @@
import { SVGProps } from "react";
import { SVGProps } from 'react';
export function CopyIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) {
return (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
fill="none"
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"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export function CopyIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
fill="none"
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"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

View File

@@ -1,24 +1,22 @@
import { SVGProps } from "react";
import { SVGProps } from 'react';
export function EditIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) {
return (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
fill="none"
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"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export function EditIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
fill="none"
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"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

View File

@@ -1,66 +1,61 @@
import { SVGProps } from "react";
import { SVGProps } from 'react';
export function EmptyIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="120"
height="120"
fill="none"
viewBox="0 0 120 120"
{...props}
>
<g clipPath="url(#clip0_110_63)">
<path
fill="#27272A"
fillRule="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
fill="#18181B"
fillRule="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
fill="#3F3F46"
fillRule="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"
fillRule="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>
<filter
id="filter0_f_110_63"
width="92"
height="81"
x="18"
y="31"
colorInterpolationFilters="sRGB"
filterUnits="userSpaceOnUse"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur
result="effect1_foregroundBlur_110_63"
stdDeviation="5.5"
/>
</filter>
<clipPath id="clip0_110_63">
<path fill="#fff" d="M0 0H120V120H0z" />
</clipPath>
</defs>
</svg>
);
export function EmptyIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="120"
height="120"
fill="none"
viewBox="0 0 120 120"
{...props}
>
<g clipPath="url(#clip0_110_63)">
<path
fill="#27272A"
fillRule="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
fill="#18181B"
fillRule="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
fill="#3F3F46"
fillRule="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"
fillRule="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>
<filter
id="filter0_f_110_63"
width="92"
height="81"
x="18"
y="31"
colorInterpolationFilters="sRGB"
filterUnits="userSpaceOnUse"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur result="effect1_foregroundBlur_110_63" stdDeviation="5.5" />
</filter>
<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