mirror of
https://github.com/luminous-devs/lume.git
synced 2024-09-18 11:13:30 +00:00
replace eslint/prettier with rome
This commit is contained in:
parent
48d690d33a
commit
409a625dcc
@ -1,8 +0,0 @@
|
||||
.git/
|
||||
|
||||
.vscode-test/
|
||||
out/
|
||||
dist/
|
||||
test-fixtures/
|
||||
node_modules/
|
||||
src-tauri/
|
24
.prettierrc
24
.prettierrc
@ -1,24 +0,0 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"printWidth": 120,
|
||||
"useTabs": false,
|
||||
"endOfLine": "lf",
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false,
|
||||
"importOrder": [
|
||||
"^@app/(.*)$",
|
||||
"^@shared/(.*)$",
|
||||
"^@icons/(.*)$",
|
||||
"^@stores/(.*)$",
|
||||
"^@utils/(.*)$",
|
||||
"<THIRD_PARTY_MODULES>",
|
||||
"^[./]"
|
||||
],
|
||||
"importOrderSeparation": true,
|
||||
"importOrderSortSpecifiers": true,
|
||||
"plugins": ["@trivago/prettier-plugin-sort-imports", "prettier-plugin-tailwindcss"],
|
||||
"pluginSearchDirs": false
|
||||
}
|
14
package.json
14
package.json
@ -10,8 +10,7 @@
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"lint-staged": {
|
||||
"**/*": "prettier --write --ignore-unknown",
|
||||
"**/*.{js,ts,jsx,tsx}": "eslint --fix"
|
||||
"**/*.{js,ts,jsx,tsx}": "rome check --apply"
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.23.1",
|
||||
@ -42,29 +41,20 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@tauri-apps/cli": "^1.3.1",
|
||||
"@trivago/prettier-plugin-sort-imports": "^4.1.1",
|
||||
"@types/node": "^18.16.9",
|
||||
"@types/react": "^18.2.6",
|
||||
"@types/react-dom": "^18.2.4",
|
||||
"@types/youtube-player": "^5.5.7",
|
||||
"@typescript-eslint/eslint-plugin": "^5.59.5",
|
||||
"@typescript-eslint/parser": "^5.59.5",
|
||||
"@vitejs/plugin-react-swc": "^3.3.1",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"cross-env": "^7.0.3",
|
||||
"csstype": "^3.1.2",
|
||||
"encoding": "^0.1.13",
|
||||
"eslint": "^8.40.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.3.5",
|
||||
"husky": "^8.0.3",
|
||||
"lint-staged": "^13.2.2",
|
||||
"postcss": "^8.4.23",
|
||||
"prettier": "^2.8.8",
|
||||
"prettier-plugin-tailwindcss": "^0.2.8",
|
||||
"prop-types": "^15.8.1",
|
||||
"rome": "12.1.0",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"typescript": "^4.9.5",
|
||||
"vite": "^4.3.5",
|
||||
|
3264
pnpm-lock.yaml
3264
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
18
rome.json
Normal file
18
rome.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1 +1 @@
|
||||
export { LayoutOnboarding as Layout } from './layout';
|
||||
export { LayoutOnboarding as Layout } from "./layout";
|
||||
|
@ -1,28 +1,32 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||
import { DEFAULT_AVATAR } from "@stores/constants";
|
||||
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { shortenKey } from '@utils/shortenKey';
|
||||
import { useProfile } from "@utils/hooks/useProfile";
|
||||
import { shortenKey } from "@utils/shortenKey";
|
||||
|
||||
export default function User({ pubkey }: { pubkey: string }) {
|
||||
const { user } = useProfile(pubkey);
|
||||
const { user } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative h-11 w-11 shrink rounded-md">
|
||||
<Image
|
||||
src={user?.picture || DEFAULT_AVATAR}
|
||||
alt={pubkey}
|
||||
className="h-11 w-11 rounded-md object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 flex-col items-start text-start">
|
||||
<span className="truncate font-medium leading-tight text-zinc-200">{user?.display_name || user?.name}</span>
|
||||
<span className="text-sm leading-tight text-zinc-400">{user?.nip05?.toLowerCase() || shortenKey(pubkey)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative h-11 w-11 shrink rounded-md">
|
||||
<Image
|
||||
src={user?.picture || DEFAULT_AVATAR}
|
||||
alt={pubkey}
|
||||
className="h-11 w-11 rounded-md object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 flex-col items-start text-start">
|
||||
<span className="truncate font-medium leading-tight text-zinc-200">
|
||||
{user?.display_name || user?.name}
|
||||
</span>
|
||||
<span className="text-sm leading-tight text-zinc-400">
|
||||
{user?.nip05?.toLowerCase() || shortenKey(pubkey)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,50 +1,67 @@
|
||||
import ArrowLeftIcon from '@icons/arrowLeft';
|
||||
import ArrowRightIcon from '@icons/arrowRight';
|
||||
import ArrowLeftIcon from "@icons/arrowLeft";
|
||||
import ArrowRightIcon from "@icons/arrowRight";
|
||||
|
||||
import useSWR from 'swr';
|
||||
import useSWR from "swr";
|
||||
|
||||
const fetcher = async () => {
|
||||
const { platform } = await import('@tauri-apps/api/os');
|
||||
return await platform();
|
||||
const { platform } = await import("@tauri-apps/api/os");
|
||||
return await platform();
|
||||
};
|
||||
|
||||
export function LayoutOnboarding({ children }: { children: React.ReactNode }) {
|
||||
const { data: platform } = useSWR('platform', fetcher);
|
||||
const { data: platform } = useSWR("platform", fetcher);
|
||||
|
||||
const goBack = () => {
|
||||
window.history.back();
|
||||
};
|
||||
const goBack = () => {
|
||||
window.history.back();
|
||||
};
|
||||
|
||||
const goForward = () => {
|
||||
window.history.forward();
|
||||
};
|
||||
const goForward = () => {
|
||||
window.history.forward();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white">
|
||||
<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 ${platform === 'darwin' ? 'pl-[68px]' : ''}`}>
|
||||
<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
|
||||
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">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white">
|
||||
<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 ${
|
||||
platform === "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">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,83 +1,98 @@
|
||||
import EyeOffIcon from '@icons/eyeOff';
|
||||
import EyeOnIcon from '@icons/eyeOn';
|
||||
import EyeOffIcon from "@icons/eyeOff";
|
||||
import EyeOnIcon from "@icons/eyeOn";
|
||||
|
||||
import { onboardingAtom } from '@stores/onboarding';
|
||||
import { onboardingAtom } from "@stores/onboarding";
|
||||
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { navigate } from 'vite-plugin-ssr/client/router';
|
||||
import { useSetAtom } from "jotai";
|
||||
import { generatePrivateKey, getPublicKey, nip19 } from "nostr-tools";
|
||||
import { useMemo, useState } from "react";
|
||||
import { navigate } from "vite-plugin-ssr/client/router";
|
||||
|
||||
export function Page() {
|
||||
const [type, setType] = useState('password');
|
||||
const setOnboarding = useSetAtom(onboardingAtom);
|
||||
const privkey = useMemo(() => generatePrivateKey(), []);
|
||||
const [type, setType] = useState("password");
|
||||
const setOnboarding = useSetAtom(onboardingAtom);
|
||||
const privkey = useMemo(() => generatePrivateKey(), []);
|
||||
|
||||
const pubkey = getPublicKey(privkey);
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
const nsec = nip19.nsecEncode(privkey);
|
||||
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 submit = () => {
|
||||
setOnboarding((prev) => ({ ...prev, pubkey: pubkey, privkey: privkey }));
|
||||
navigate('/auth/create/step-2');
|
||||
};
|
||||
const submit = () => {
|
||||
setOnboarding((prev) => ({ ...prev, pubkey: pubkey, privkey: privkey }));
|
||||
navigate("/auth/create/step-2");
|
||||
};
|
||||
|
||||
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="text-2xl font-semibold text-zinc-200">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-sm font-semibold text-zinc-400">Public Key</label>
|
||||
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<input
|
||||
readOnly
|
||||
value={npub}
|
||||
className="relative w-full rounded-lg border border-black/5 px-3.5 py-2.5 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold text-zinc-400">Private Key</label>
|
||||
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<input
|
||||
readOnly
|
||||
type={type}
|
||||
value={nsec}
|
||||
className="relative w-full rounded-lg border border-black/5 py-2.5 pl-3.5 pr-11 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-600"
|
||||
/>
|
||||
<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-200" />
|
||||
) : (
|
||||
<EyeOnIcon width={20} height={20} className="text-zinc-500 group-hover:text-zinc-200" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
className="w-full transform rounded-lg bg-fuchsia-500 px-3.5 py-2.5 font-medium text-white shadow-button hover:bg-fuchsia-600 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
<span>Continue →</span>
|
||||
</button>
|
||||
</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="text-2xl font-semibold text-zinc-200">
|
||||
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-sm font-semibold text-zinc-400">
|
||||
Public Key
|
||||
</label>
|
||||
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<input
|
||||
readOnly
|
||||
value={npub}
|
||||
className="relative w-full rounded-lg border border-black/5 px-3.5 py-2.5 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold text-zinc-400">
|
||||
Private Key
|
||||
</label>
|
||||
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<input
|
||||
readOnly
|
||||
type={type}
|
||||
value={nsec}
|
||||
className="relative w-full rounded-lg border border-black/5 py-2.5 pl-3.5 pr-11 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-600"
|
||||
/>
|
||||
<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-200"
|
||||
/>
|
||||
) : (
|
||||
<EyeOnIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-zinc-500 group-hover:text-zinc-200"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
className="w-full transform rounded-lg bg-fuchsia-500 px-3.5 py-2.5 font-medium text-white shadow-button hover:bg-fuchsia-600 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
<span>Continue →</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,123 +1,142 @@
|
||||
import { AvatarUploader } from '@shared/avatarUploader';
|
||||
import { Image } from '@shared/image';
|
||||
import { AvatarUploader } from "@shared/avatarUploader";
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||
import { onboardingAtom } from '@stores/onboarding';
|
||||
import { DEFAULT_AVATAR } from "@stores/constants";
|
||||
import { onboardingAtom } from "@stores/onboarding";
|
||||
|
||||
import { useAtom } from 'jotai';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { navigate } from 'vite-plugin-ssr/client/router';
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { navigate } from "vite-plugin-ssr/client/router";
|
||||
|
||||
export function Page() {
|
||||
const [image, setImage] = useState(DEFAULT_AVATAR);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [onboarding, setOnboarding] = useAtom(onboardingAtom);
|
||||
const [image, setImage] = useState(DEFAULT_AVATAR);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [onboarding, setOnboarding] = useAtom(onboardingAtom);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { isDirty, isValid },
|
||||
} = useForm({
|
||||
defaultValues: async () => {
|
||||
if (onboarding.metadata) {
|
||||
return onboarding.metadata;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { isDirty, isValid },
|
||||
} = useForm({
|
||||
defaultValues: async () => {
|
||||
if (onboarding.metadata) {
|
||||
return onboarding.metadata;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: any) => {
|
||||
setLoading(true);
|
||||
setOnboarding((prev) => ({ ...prev, metadata: data }));
|
||||
navigate('/auth/create/step-3');
|
||||
};
|
||||
const onSubmit = (data: any) => {
|
||||
setLoading(true);
|
||||
setOnboarding((prev) => ({ ...prev, metadata: data }));
|
||||
navigate("/auth/create/step-3");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setValue('picture', image);
|
||||
}, [setValue, image]);
|
||||
useEffect(() => {
|
||||
setValue("picture", image);
|
||||
}, [setValue, image]);
|
||||
|
||||
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="text-2xl font-semibold text-zinc-200">Create your profile</h1>
|
||||
</div>
|
||||
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
|
||||
<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-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">Avatar</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} alt="avatar" 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-xs font-semibold uppercase tracking-wider text-zinc-400">Display Name *</label>
|
||||
<div className="relative w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] 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-[7px] 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('display_name', { required: true, minLength: 4 })}
|
||||
spellCheck={false}
|
||||
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-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">About</label>
|
||||
<div className="relative h-20 w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] 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-[7px] 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">
|
||||
<textarea
|
||||
{...register('about')}
|
||||
spellCheck={false}
|
||||
className="relative h-20 w-full resize-none 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-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isDirty || !isValid}
|
||||
className="w-full transform rounded-lg bg-fuchsia-500 px-3.5 py-2.5 font-medium text-white shadow-button hover:bg-fuchsia-600 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{loading ? (
|
||||
<svg
|
||||
className="h-4 w-4 animate-spin text-black dark:text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<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"
|
||||
></path>
|
||||
</svg>
|
||||
) : (
|
||||
<span>Continue →</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</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="text-2xl font-semibold text-zinc-200">
|
||||
Create your profile
|
||||
</h1>
|
||||
</div>
|
||||
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<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-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
|
||||
Avatar
|
||||
</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}
|
||||
alt="avatar"
|
||||
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-xs font-semibold uppercase tracking-wider text-zinc-400">
|
||||
Display Name *
|
||||
</label>
|
||||
<div className="relative w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] 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-[7px] 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("display_name", {
|
||||
required: true,
|
||||
minLength: 4,
|
||||
})}
|
||||
spellCheck={false}
|
||||
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-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
|
||||
About
|
||||
</label>
|
||||
<div className="relative h-20 w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] 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-[7px] 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">
|
||||
<textarea
|
||||
{...register("about")}
|
||||
spellCheck={false}
|
||||
className="relative h-20 w-full resize-none 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-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isDirty || !isValid}
|
||||
className="w-full transform rounded-lg bg-fuchsia-500 px-3.5 py-2.5 font-medium text-white shadow-button hover:bg-fuchsia-600 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{loading ? (
|
||||
<svg
|
||||
className="h-4 w-4 animate-spin text-black dark:text-white"
|
||||
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>
|
||||
) : (
|
||||
<span>Continue →</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,182 +1,272 @@
|
||||
import User from '@app/auth/components/user';
|
||||
import User from "@app/auth/components/user";
|
||||
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import CheckCircleIcon from '@icons/checkCircle';
|
||||
import CheckCircleIcon from "@icons/checkCircle";
|
||||
|
||||
import { WRITEONLY_RELAYS } from '@stores/constants';
|
||||
import { onboardingAtom } from '@stores/onboarding';
|
||||
import { WRITEONLY_RELAYS } from "@stores/constants";
|
||||
import { onboardingAtom } from "@stores/onboarding";
|
||||
|
||||
import { createAccount, createPleb } from '@utils/storage';
|
||||
import { arrayToNIP02 } from '@utils/transform';
|
||||
import { createAccount, createPleb } from "@utils/storage";
|
||||
import { arrayToNIP02 } from "@utils/transform";
|
||||
|
||||
import { useAtom } from 'jotai';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { useContext, useState } from 'react';
|
||||
import { navigate } from 'vite-plugin-ssr/client/router';
|
||||
import { useAtom } from "jotai";
|
||||
import { getEventHash, signEvent } from "nostr-tools";
|
||||
import { useContext, useState } from "react";
|
||||
import { navigate } from "vite-plugin-ssr/client/router";
|
||||
|
||||
const initialList = [
|
||||
{ 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 Page() {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [follows, setFollows] = useState([]);
|
||||
const [onboarding] = useAtom(onboardingAtom);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [follows, setFollows] = useState([]);
|
||||
const [onboarding] = useAtom(onboardingAtom);
|
||||
|
||||
// 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 broadcastAccount = () => {
|
||||
// build event
|
||||
const event: any = {
|
||||
content: JSON.stringify(onboarding.metadata),
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 0,
|
||||
pubkey: onboarding.pubkey,
|
||||
tags: [],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, onboarding.privkey);
|
||||
// broadcast
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
};
|
||||
const broadcastAccount = () => {
|
||||
// build event
|
||||
const event: any = {
|
||||
content: JSON.stringify(onboarding.metadata),
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 0,
|
||||
pubkey: onboarding.pubkey,
|
||||
tags: [],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, onboarding.privkey);
|
||||
// broadcast
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
};
|
||||
|
||||
const broadcastContacts = () => {
|
||||
const nip02 = arrayToNIP02(follows);
|
||||
// build event
|
||||
const event: any = {
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 3,
|
||||
pubkey: onboarding.pubkey,
|
||||
tags: nip02,
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, onboarding.privkey);
|
||||
// broadcast
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
};
|
||||
const broadcastContacts = () => {
|
||||
const nip02 = arrayToNIP02(follows);
|
||||
// build event
|
||||
const event: any = {
|
||||
content: "",
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 3,
|
||||
pubkey: onboarding.pubkey,
|
||||
tags: nip02,
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, onboarding.privkey);
|
||||
// broadcast
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
};
|
||||
|
||||
// save follows to database then broadcast
|
||||
const submit = async () => {
|
||||
setLoading(true);
|
||||
// save follows to database then broadcast
|
||||
const submit = async () => {
|
||||
setLoading(true);
|
||||
|
||||
const followsIncludeSelf = follows.concat([onboarding.pubkey]);
|
||||
// insert to database
|
||||
createAccount(onboarding.pubkey, onboarding.privkey, onboarding.metadata, arrayToNIP02(followsIncludeSelf), 1)
|
||||
.then((res) => {
|
||||
if (res) {
|
||||
for (const tag of follows) {
|
||||
fetch(`https://us.rbr.bio/${tag}/metadata.json`)
|
||||
.then((data) => data.json())
|
||||
.then((data) => createPleb(tag, data ?? ''));
|
||||
}
|
||||
broadcastAccount();
|
||||
broadcastContacts();
|
||||
setTimeout(() => navigate('/', { overwriteLastHistoryEntry: true }), 2000);
|
||||
} else {
|
||||
console.error();
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
};
|
||||
const followsIncludeSelf = follows.concat([onboarding.pubkey]);
|
||||
// insert to database
|
||||
createAccount(
|
||||
onboarding.pubkey,
|
||||
onboarding.privkey,
|
||||
onboarding.metadata,
|
||||
arrayToNIP02(followsIncludeSelf),
|
||||
1,
|
||||
)
|
||||
.then((res) => {
|
||||
if (res) {
|
||||
for (const tag of follows) {
|
||||
fetch(`https://us.rbr.bio/${tag}/metadata.json`)
|
||||
.then((data) => data.json())
|
||||
.then((data) => createPleb(tag, data ?? ""));
|
||||
}
|
||||
broadcastAccount();
|
||||
broadcastContacts();
|
||||
setTimeout(
|
||||
() => navigate("/", { overwriteLastHistoryEntry: true }),
|
||||
2000,
|
||||
);
|
||||
} else {
|
||||
console.error();
|
||||
}
|
||||
})
|
||||
.catch(console.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="text-2xl font-semibold text-zinc-200">Personalized your newsfeed</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900">
|
||||
<div className="inline-flex h-10 w-full items-center gap-1 border-b border-zinc-800 px-4 text-sm font-medium text-zinc-400">
|
||||
Follow at least
|
||||
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text font-bold text-transparent">
|
||||
{follows.length}/10
|
||||
</span>{' '}
|
||||
plebs
|
||||
</div>
|
||||
<div className="scrollbar-hide flex h-96 flex-col overflow-y-auto py-2">
|
||||
{initialList.map((item: { pubkey: string }, index: number) => (
|
||||
<button
|
||||
key={index}
|
||||
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} />
|
||||
{follows.includes(item.pubkey) && (
|
||||
<div>
|
||||
<CheckCircleIcon width={16} height={16} className="text-green-400" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{follows.length >= 10 && (
|
||||
<button
|
||||
onClick={() => submit()}
|
||||
className="inline-flex h-10 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 px-3.5 font-medium text-white shadow-button hover:bg-fuchsia-600 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{loading === true ? (
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<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"
|
||||
></path>
|
||||
</svg>
|
||||
) : (
|
||||
<span>Continue →</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</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="text-2xl font-semibold text-zinc-200">
|
||||
Personalized your newsfeed
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900">
|
||||
<div className="inline-flex h-10 w-full items-center gap-1 border-b border-zinc-800 px-4 text-sm font-medium text-zinc-400">
|
||||
Follow at least
|
||||
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text font-bold text-transparent">
|
||||
{follows.length}/10
|
||||
</span>{" "}
|
||||
plebs
|
||||
</div>
|
||||
<div className="scrollbar-hide flex h-96 flex-col overflow-y-auto py-2">
|
||||
{initialList.map((item: { pubkey: string }, index: number) => (
|
||||
<button
|
||||
key={`item-${index}`}
|
||||
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} />
|
||||
{follows.includes(item.pubkey) && (
|
||||
<div>
|
||||
<CheckCircleIcon
|
||||
width={16}
|
||||
height={16}
|
||||
className="text-green-400"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{follows.length >= 10 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
className="inline-flex h-10 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 px-3.5 font-medium text-white shadow-button hover:bg-fuchsia-600 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{loading === true ? (
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-white"
|
||||
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>
|
||||
) : (
|
||||
<span>Continue →</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,124 +1,142 @@
|
||||
import { onboardingAtom } from '@stores/onboarding';
|
||||
import { onboardingAtom } from "@stores/onboarding";
|
||||
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { getPublicKey, nip19 } from 'nostr-tools';
|
||||
import { Resolver, useForm } from 'react-hook-form';
|
||||
import { navigate } from 'vite-plugin-ssr/client/router';
|
||||
import { useSetAtom } from "jotai";
|
||||
import { getPublicKey, nip19 } from "nostr-tools";
|
||||
import { Resolver, useForm } from "react-hook-form";
|
||||
import { navigate } from "vite-plugin-ssr/client/router";
|
||||
|
||||
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 Page() {
|
||||
const setOnboardingPrivkey = useSetAtom(onboardingAtom);
|
||||
const setOnboardingPrivkey = useSetAtom(onboardingAtom);
|
||||
|
||||
const {
|
||||
register,
|
||||
setError,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty, isValid, isSubmitting },
|
||||
} = useForm<FormValues>({ resolver });
|
||||
const {
|
||||
register,
|
||||
setError,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty, isValid, isSubmitting },
|
||||
} = useForm<FormValues>({ resolver });
|
||||
|
||||
const onSubmit = async (data: any) => {
|
||||
try {
|
||||
let privkey = data['key'];
|
||||
const onSubmit = async (data: any) => {
|
||||
try {
|
||||
let privkey = data["key"];
|
||||
|
||||
if (privkey.substring(0, 4) === 'nsec') {
|
||||
privkey = nip19.decode(privkey).data;
|
||||
}
|
||||
if (privkey.substring(0, 4) === "nsec") {
|
||||
privkey = nip19.decode(privkey).data;
|
||||
}
|
||||
|
||||
if (typeof getPublicKey(privkey) === 'string') {
|
||||
setOnboardingPrivkey((prev) => ({ ...prev, privkey: privkey }));
|
||||
navigate(`/auth/import/step-2`);
|
||||
}
|
||||
} catch (error) {
|
||||
setError('key', {
|
||||
type: 'custom',
|
||||
message: 'Private Key is invalid, please check again',
|
||||
});
|
||||
}
|
||||
};
|
||||
if (typeof getPublicKey(privkey) === "string") {
|
||||
setOnboardingPrivkey((prev) => ({ ...prev, privkey: privkey }));
|
||||
navigate("/auth/import/step-2");
|
||||
}
|
||||
} catch (error) {
|
||||
setError("key", {
|
||||
type: "custom",
|
||||
message: "Private Key is invalid, please check again",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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="text-2xl font-semibold text-zinc-200">Import your key</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
{/* #TODO: add function */}
|
||||
<button className="inline-flex w-full transform items-center justify-center gap-1.5 rounded-lg bg-zinc-900 px-3.5 py-2.5 font-medium text-zinc-400 active:translate-y-1">
|
||||
<div className="inline-flex items-center rounded-md bg-zinc-400/10 px-2 py-0.5 text-xs font-medium ring-1 ring-inset ring-zinc-400/20">
|
||||
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-transparent">
|
||||
Coming soon
|
||||
</span>
|
||||
</div>
|
||||
<span>Continue with Nostr Connect</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-zinc-800"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="bg-zinc-950 px-2 text-sm text-zinc-500">or</span>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] 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
|
||||
{...register('key', { required: true, minLength: 32 })}
|
||||
type={'password'}
|
||||
placeholder="Paste private key here..."
|
||||
className="relative w-full rounded-lg border border-black/5 px-3.5 py-2.5 text-center shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-red-400">{errors.key && <p>{errors.key.message}</p>}</span>
|
||||
</div>
|
||||
<div className="flex h-9 items-center justify-center">
|
||||
{isSubmitting ? (
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<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"
|
||||
></path>
|
||||
</svg>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isDirty || !isValid}
|
||||
className="w-full transform rounded-lg bg-fuchsia-500 px-3.5 py-2.5 font-medium text-white shadow-button hover:bg-fuchsia-600 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
<span className="drop-shadow-lg">Continue →</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</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="text-2xl font-semibold text-zinc-200">
|
||||
Import your key
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
{/* #TODO: add function */}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex w-full transform items-center justify-center gap-1.5 rounded-lg bg-zinc-900 px-3.5 py-2.5 font-medium text-zinc-400 active:translate-y-1"
|
||||
>
|
||||
<div className="inline-flex items-center rounded-md bg-zinc-400/10 px-2 py-0.5 text-xs font-medium ring-1 ring-inset ring-zinc-400/20">
|
||||
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-transparent">
|
||||
Coming soon
|
||||
</span>
|
||||
</div>
|
||||
<span>Continue with Nostr Connect</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-zinc-800" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="bg-zinc-950 px-2 text-sm text-zinc-500">or</span>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] 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
|
||||
{...register("key", { required: true, minLength: 32 })}
|
||||
type={"password"}
|
||||
placeholder="Paste private key here..."
|
||||
className="relative w-full rounded-lg border border-black/5 px-3.5 py-2.5 text-center shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-red-400">
|
||||
{errors.key && <p>{errors.key.message}</p>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex h-9 items-center justify-center">
|
||||
{isSubmitting ? (
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-white"
|
||||
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>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isDirty || !isValid}
|
||||
className="w-full transform rounded-lg bg-fuchsia-500 px-3.5 py-2.5 font-medium text-white shadow-button hover:bg-fuchsia-600 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
<span className="drop-shadow-lg">Continue →</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,143 +1,158 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { Image } from "@shared/image";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import { DEFAULT_AVATAR, READONLY_RELAYS } from '@stores/constants';
|
||||
import { onboardingAtom } from '@stores/onboarding';
|
||||
import { DEFAULT_AVATAR, READONLY_RELAYS } from "@stores/constants";
|
||||
import { onboardingAtom } from "@stores/onboarding";
|
||||
|
||||
import { shortenKey } from '@utils/shortenKey';
|
||||
import { createAccount, createPleb } from '@utils/storage';
|
||||
import { shortenKey } from "@utils/shortenKey";
|
||||
import { createAccount, createPleb } from "@utils/storage";
|
||||
|
||||
import { useAtom } from 'jotai';
|
||||
import { getPublicKey } from 'nostr-tools';
|
||||
import { useContext, useMemo, useState } from 'react';
|
||||
import useSWRSubscription from 'swr/subscription';
|
||||
import { navigate } from 'vite-plugin-ssr/client/router';
|
||||
import { useAtom } from "jotai";
|
||||
import { getPublicKey } from "nostr-tools";
|
||||
import { useContext, useMemo, useState } from "react";
|
||||
import useSWRSubscription from "swr/subscription";
|
||||
import { navigate } from "vite-plugin-ssr/client/router";
|
||||
|
||||
export function Page() {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [onboarding, setOnboarding] = useAtom(onboardingAtom);
|
||||
const pubkey = useMemo(() => (onboarding.privkey ? getPublicKey(onboarding.privkey) : ''), [onboarding.privkey]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [onboarding, setOnboarding] = useAtom(onboardingAtom);
|
||||
const pubkey = useMemo(
|
||||
() => (onboarding.privkey ? getPublicKey(onboarding.privkey) : ""),
|
||||
[onboarding.privkey],
|
||||
);
|
||||
|
||||
const { data, error } = useSWRSubscription(pubkey ? pubkey : null, (key, { next }) => {
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [0, 3],
|
||||
authors: [key],
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
switch (event.kind) {
|
||||
case 0:
|
||||
// update state
|
||||
next(null, JSON.parse(event.content));
|
||||
// create account
|
||||
setOnboarding((prev) => ({ ...prev, metadata: event.content }));
|
||||
break;
|
||||
case 3:
|
||||
setOnboarding((prev) => ({ ...prev, follows: event.tags }));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
);
|
||||
const { data, error } = useSWRSubscription(
|
||||
pubkey ? pubkey : null,
|
||||
(key, { next }) => {
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [0, 3],
|
||||
authors: [key],
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
switch (event.kind) {
|
||||
case 0:
|
||||
// update state
|
||||
next(null, JSON.parse(event.content));
|
||||
// create account
|
||||
setOnboarding((prev) => ({ ...prev, metadata: event.content }));
|
||||
break;
|
||||
case 3:
|
||||
setOnboarding((prev) => ({ ...prev, follows: event.tags }));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const submit = () => {
|
||||
// show loading indicator
|
||||
setLoading(true);
|
||||
const submit = () => {
|
||||
// show loading indicator
|
||||
setLoading(true);
|
||||
|
||||
const follows = onboarding.follows.concat([['p', pubkey]]);
|
||||
// insert to database
|
||||
createAccount(pubkey, onboarding.privkey, onboarding.metadata, follows, 1)
|
||||
.then((res) => {
|
||||
if (res) {
|
||||
for (const tag of onboarding.follows) {
|
||||
fetch(`https://us.rbr.bio/${tag[1]}/metadata.json`)
|
||||
.then((data) => data.json())
|
||||
.then((data) => createPleb(tag[1], data ?? ''));
|
||||
}
|
||||
setTimeout(() => navigate('/', { overwriteLastHistoryEntry: true }), 2000);
|
||||
} else {
|
||||
console.error();
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
};
|
||||
const follows = onboarding.follows.concat([["p", pubkey]]);
|
||||
// insert to database
|
||||
createAccount(pubkey, onboarding.privkey, onboarding.metadata, follows, 1)
|
||||
.then((res) => {
|
||||
if (res) {
|
||||
for (const tag of onboarding.follows) {
|
||||
fetch(`https://us.rbr.bio/${tag[1]}/metadata.json`)
|
||||
.then((data) => data.json())
|
||||
.then((data) => createPleb(tag[1], data ?? ""));
|
||||
}
|
||||
setTimeout(
|
||||
() => navigate("/", { overwriteLastHistoryEntry: true }),
|
||||
2000,
|
||||
);
|
||||
} else {
|
||||
console.error();
|
||||
}
|
||||
})
|
||||
.catch(console.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="text-2xl font-semibold">{loading ? 'Creating...' : 'Continue with'}</h1>
|
||||
</div>
|
||||
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||
{error && <div>Failed to load profile</div>}
|
||||
{!data ? (
|
||||
<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>
|
||||
<h3 className="mb-1 h-4 w-16 animate-pulse rounded bg-zinc-800"></h3>
|
||||
<p className="h-3 w-36 animate-pulse rounded bg-zinc-800"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Image
|
||||
className="relative inline-flex h-11 w-11 rounded-lg ring-2 ring-zinc-900"
|
||||
src={data.picture || DEFAULT_AVATAR}
|
||||
alt={pubkey}
|
||||
/>
|
||||
<div>
|
||||
<h3 className="font-medium leading-none text-zinc-200">{data.display_name || data.name}</h3>
|
||||
<p className="text-sm text-zinc-400">{data.nip05 || shortenKey(pubkey)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
className="inline-flex h-10 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 px-3.5 font-medium text-white shadow-button hover:bg-fuchsia-600 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{loading ? (
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-black dark:text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<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"
|
||||
></path>
|
||||
</svg>
|
||||
) : (
|
||||
<span>Continue →</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</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="text-2xl font-semibold">
|
||||
{loading ? "Creating..." : "Continue with"}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||
{error && <div>Failed to load profile</div>}
|
||||
{!data ? (
|
||||
<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">
|
||||
<div className="flex items-center gap-2">
|
||||
<Image
|
||||
className="relative inline-flex h-11 w-11 rounded-lg ring-2 ring-zinc-900"
|
||||
src={data.picture || DEFAULT_AVATAR}
|
||||
alt={pubkey}
|
||||
/>
|
||||
<div>
|
||||
<h3 className="font-medium leading-none text-zinc-200">
|
||||
{data.display_name || data.name}
|
||||
</h3>
|
||||
<p className="text-sm text-zinc-400">
|
||||
{data.nip05 || shortenKey(pubkey)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
className="inline-flex h-10 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 px-3.5 font-medium text-white shadow-button hover:bg-fuchsia-600 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{loading ? (
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-black dark:text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<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>
|
||||
) : (
|
||||
<span>Continue →</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,48 +1,48 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
import ArrowRightIcon from '@icons/arrowRight';
|
||||
import ArrowRightIcon from "@icons/arrowRight";
|
||||
|
||||
const PLEBS = [
|
||||
'https://133332.xyz/p.jpg',
|
||||
'https://void.cat/d/3Bp6jSHURFNQ9u3pK8nwtq.webp',
|
||||
'https://i.imgur.com/f8SyhRL.jpg',
|
||||
'http://nostr.build/i/6369.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1622010345589190656/mAPqsmtz_400x400.jpg',
|
||||
'https://media.tenor.com/l5arkXy9RfIAAAAd/thunder.gif',
|
||||
'https://nostr.build/i/p/nostr.build_0e412058980ed2ac4adf3de639304c9e970e2745ba9ca19c75f984f4f6da4971.jpeg',
|
||||
'https://nostr.build/i/nostr.build_864a019a6c1d3a90a17363553d32b71de618d250f02cf0a59ca19fb3029fd5bc.jpg',
|
||||
'https://void.cat/d/8zE9T8a39YfUVjrLM4xcpE.webp',
|
||||
'https://avatars.githubusercontent.com/u/89577423',
|
||||
'https://pbs.twimg.com/profile_images/1363180486080663554/iN-r_BiM_400x400.jpg',
|
||||
'https://void.cat/d/JUBBqXgCcGBEh7jUgJaayy',
|
||||
'https://phase1.attract-eu.com/wp-content/uploads/2020/03/ATTRACT_HPLM.png',
|
||||
'https://www.retro-synthwave.com/wp-content/uploads/2017/01/PowerGlove-23.jpg',
|
||||
'https://void.cat/d/KvAEMvYNmy1rfCH6a7HZzh.webp',
|
||||
'https://media.giphy.com/media/NqfMNCkyGwtXhKFlCR/giphy-downsized-large.gif',
|
||||
'https://i.imgur.com/VGpUNFS.jpg',
|
||||
'https://nostr.build/i/p/nostr.build_b39254db43d5557df99d1eb516f1c2f56a21a01b10c248f6eb66aa827c9a90f4.jpeg',
|
||||
'https://davidcoen.it/wp-content/uploads/2020/11/7004972-taglio.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1570432066348515330/26PtCuwF_400x400.jpg',
|
||||
'https://nostr.build/i/nostr.build_9d33ee801aa08955be174554832952ab95a65d5e015176834c8aa9a4e2f2e3a5.jpg',
|
||||
'https://www.linkpicture.com/q/0FE78CFF-C931-4568-A7AA-DD8AEE889992.jpeg',
|
||||
'https://nostr.build/i/nostr.build_97d6e2d25dd92422eb3d6d645b7cee9ed9c614f331be7e6f7db9ccfdbc5ee260.png',
|
||||
'https://pbs.twimg.com/profile_images/1569570198348337152/-n1KD74u_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1600149653898596354/5PVe-r-J_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1639659216372658178/Dnn-Ysp-_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1554429112978120706/yr1hXl6R_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1615478486688272385/q2ECeZDX_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1638644441773748226/tNsA6RpG_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1607882836740120576/3Tg1mTYJ_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1401907430339002369/WKrP9Esn_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1523971278478131200/TMPzfvhE_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1626421539884204032/aj4tmzsk_400x400.png',
|
||||
'https://pbs.twimg.com/profile_images/1582771691779985408/C9MHYIgt_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1409612480465276931/38Vyx4e8_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1549826566787588098/MlduJCZO_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/539211568035004416/sBMjPR9q_400x400.jpeg',
|
||||
'https://pbs.twimg.com/profile_images/1548660003522887682/1QMHmles_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1362497143999787013/KLUoN1Vn_400x400.png',
|
||||
'https://pbs.twimg.com/profile_images/1600434913240563713/AssmMGwf_400x400.jpg',
|
||||
"https://133332.xyz/p.jpg",
|
||||
"https://void.cat/d/3Bp6jSHURFNQ9u3pK8nwtq.webp",
|
||||
"https://i.imgur.com/f8SyhRL.jpg",
|
||||
"http://nostr.build/i/6369.jpg",
|
||||
"https://pbs.twimg.com/profile_images/1622010345589190656/mAPqsmtz_400x400.jpg",
|
||||
"https://media.tenor.com/l5arkXy9RfIAAAAd/thunder.gif",
|
||||
"https://nostr.build/i/p/nostr.build_0e412058980ed2ac4adf3de639304c9e970e2745ba9ca19c75f984f4f6da4971.jpeg",
|
||||
"https://nostr.build/i/nostr.build_864a019a6c1d3a90a17363553d32b71de618d250f02cf0a59ca19fb3029fd5bc.jpg",
|
||||
"https://void.cat/d/8zE9T8a39YfUVjrLM4xcpE.webp",
|
||||
"https://avatars.githubusercontent.com/u/89577423",
|
||||
"https://pbs.twimg.com/profile_images/1363180486080663554/iN-r_BiM_400x400.jpg",
|
||||
"https://void.cat/d/JUBBqXgCcGBEh7jUgJaayy",
|
||||
"https://phase1.attract-eu.com/wp-content/uploads/2020/03/ATTRACT_HPLM.png",
|
||||
"https://www.retro-synthwave.com/wp-content/uploads/2017/01/PowerGlove-23.jpg",
|
||||
"https://void.cat/d/KvAEMvYNmy1rfCH6a7HZzh.webp",
|
||||
"https://media.giphy.com/media/NqfMNCkyGwtXhKFlCR/giphy-downsized-large.gif",
|
||||
"https://i.imgur.com/VGpUNFS.jpg",
|
||||
"https://nostr.build/i/p/nostr.build_b39254db43d5557df99d1eb516f1c2f56a21a01b10c248f6eb66aa827c9a90f4.jpeg",
|
||||
"https://davidcoen.it/wp-content/uploads/2020/11/7004972-taglio.jpg",
|
||||
"https://pbs.twimg.com/profile_images/1570432066348515330/26PtCuwF_400x400.jpg",
|
||||
"https://nostr.build/i/nostr.build_9d33ee801aa08955be174554832952ab95a65d5e015176834c8aa9a4e2f2e3a5.jpg",
|
||||
"https://www.linkpicture.com/q/0FE78CFF-C931-4568-A7AA-DD8AEE889992.jpeg",
|
||||
"https://nostr.build/i/nostr.build_97d6e2d25dd92422eb3d6d645b7cee9ed9c614f331be7e6f7db9ccfdbc5ee260.png",
|
||||
"https://pbs.twimg.com/profile_images/1569570198348337152/-n1KD74u_400x400.jpg",
|
||||
"https://pbs.twimg.com/profile_images/1600149653898596354/5PVe-r-J_400x400.jpg",
|
||||
"https://pbs.twimg.com/profile_images/1639659216372658178/Dnn-Ysp-_400x400.jpg",
|
||||
"https://pbs.twimg.com/profile_images/1554429112978120706/yr1hXl6R_400x400.jpg",
|
||||
"https://pbs.twimg.com/profile_images/1615478486688272385/q2ECeZDX_400x400.jpg",
|
||||
"https://pbs.twimg.com/profile_images/1638644441773748226/tNsA6RpG_400x400.jpg",
|
||||
"https://pbs.twimg.com/profile_images/1607882836740120576/3Tg1mTYJ_400x400.jpg",
|
||||
"https://pbs.twimg.com/profile_images/1401907430339002369/WKrP9Esn_400x400.jpg",
|
||||
"https://pbs.twimg.com/profile_images/1523971278478131200/TMPzfvhE_400x400.jpg",
|
||||
"https://pbs.twimg.com/profile_images/1626421539884204032/aj4tmzsk_400x400.png",
|
||||
"https://pbs.twimg.com/profile_images/1582771691779985408/C9MHYIgt_400x400.jpg",
|
||||
"https://pbs.twimg.com/profile_images/1409612480465276931/38Vyx4e8_400x400.jpg",
|
||||
"https://pbs.twimg.com/profile_images/1549826566787588098/MlduJCZO_400x400.jpg",
|
||||
"https://pbs.twimg.com/profile_images/539211568035004416/sBMjPR9q_400x400.jpeg",
|
||||
"https://pbs.twimg.com/profile_images/1548660003522887682/1QMHmles_400x400.jpg",
|
||||
"https://pbs.twimg.com/profile_images/1362497143999787013/KLUoN1Vn_400x400.png",
|
||||
"https://pbs.twimg.com/profile_images/1600434913240563713/AssmMGwf_400x400.jpg",
|
||||
];
|
||||
|
||||
const DURATION = 50000;
|
||||
@ -52,65 +52,80 @@ const PLEBS_PER_ROW = 20;
|
||||
const random = (min, max) => Math.floor(Math.random() * (max - min)) + min;
|
||||
const shuffle = (arr) => [...arr].sort(() => 0.5 - Math.random());
|
||||
|
||||
const InfiniteLoopSlider = ({ children, duration, reverse }: { children: any; duration: any; reverse: any }) => {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex w-fit"
|
||||
style={{
|
||||
animationName: 'loop',
|
||||
animationIterationCount: 'infinite',
|
||||
animationDirection: reverse ? 'reverse' : 'normal',
|
||||
animationDuration: duration + 'ms',
|
||||
animationTimingFunction: 'linear',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const InfiniteLoopSlider = ({
|
||||
children,
|
||||
duration,
|
||||
reverse,
|
||||
}: { children: any; duration: any; reverse: any }) => {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex w-fit"
|
||||
style={{
|
||||
animationName: "loop",
|
||||
animationIterationCount: "infinite",
|
||||
animationDirection: reverse ? "reverse" : "normal",
|
||||
animationDuration: `${duration}ms`,
|
||||
animationTimingFunction: "linear",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="grid h-full w-full grid-rows-5">
|
||||
<div className="row-span-3 overflow-hidden">
|
||||
<div className="relaive flex w-full max-w-full shrink-0 flex-col gap-4 overflow-hidden p-4">
|
||||
{[...new Array(ROWS)].map((_, i) => (
|
||||
<InfiniteLoopSlider key={i} duration={random(DURATION - 5000, DURATION + 20000)} reverse={i % 2}>
|
||||
{shuffle(PLEBS)
|
||||
.slice(0, PLEBS_PER_ROW)
|
||||
.map((tag) => (
|
||||
<div key={tag} className="relative mr-4 h-11 w-11 gap-2 rounded-md bg-zinc-900 shadow-xl">
|
||||
<Image src={tag} alt={tag} className="h-11 w-11 rounded-md border border-zinc-900" />
|
||||
</div>
|
||||
))}
|
||||
</InfiniteLoopSlider>
|
||||
))}
|
||||
<div className="pointer-events-none absolute inset-0 bg-fade" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="row-span-2 flex w-full flex-col items-center gap-4 overflow-hidden pt-6 min-[1050px]:gap-8 min-[1050px]:pt-10">
|
||||
<h1 className="animate-moveBg bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-5xl font-bold leading-none text-transparent">
|
||||
Let's start!
|
||||
</h1>
|
||||
<div className="mt-4 flex flex-col items-center gap-1.5">
|
||||
<a
|
||||
href="/auth/create"
|
||||
className="relative inline-flex h-14 w-64 items-center justify-center gap-2 rounded-full bg-zinc-900 px-6 text-lg font-medium ring-1 ring-zinc-800 hover:bg-zinc-800"
|
||||
>
|
||||
Create new key
|
||||
<ArrowRightIcon width={20} height={20} />
|
||||
</a>
|
||||
<a
|
||||
href="/auth/import"
|
||||
className="inline-flex h-14 w-64 items-center justify-center gap-2 rounded-full px-6 text-base font-medium text-zinc-300 hover:bg-zinc-800"
|
||||
>
|
||||
Login with private key
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="grid h-full w-full grid-rows-5">
|
||||
<div className="row-span-3 overflow-hidden">
|
||||
<div className="relaive flex w-full max-w-full shrink-0 flex-col gap-4 overflow-hidden p-4">
|
||||
{[...new Array(ROWS)].map((_, i) => (
|
||||
<InfiniteLoopSlider
|
||||
key={`item-${i}`}
|
||||
duration={random(DURATION - 5000, DURATION + 20000)}
|
||||
reverse={i % 2}
|
||||
>
|
||||
{shuffle(PLEBS)
|
||||
.slice(0, PLEBS_PER_ROW)
|
||||
.map((tag) => (
|
||||
<div
|
||||
key={tag}
|
||||
className="relative mr-4 h-11 w-11 gap-2 rounded-md bg-zinc-900 shadow-xl"
|
||||
>
|
||||
<Image
|
||||
src={tag}
|
||||
alt={tag}
|
||||
className="h-11 w-11 rounded-md border border-zinc-900"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</InfiniteLoopSlider>
|
||||
))}
|
||||
<div className="pointer-events-none absolute inset-0 bg-fade" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="row-span-2 flex w-full flex-col items-center gap-4 overflow-hidden pt-6 min-[1050px]:gap-8 min-[1050px]:pt-10">
|
||||
<h1 className="animate-moveBg bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-5xl font-bold leading-none text-transparent">
|
||||
Let's start!
|
||||
</h1>
|
||||
<div className="mt-4 flex flex-col items-center gap-1.5">
|
||||
<a
|
||||
href="/auth/create"
|
||||
className="relative inline-flex h-14 w-64 items-center justify-center gap-2 rounded-full bg-zinc-900 px-6 text-lg font-medium ring-1 ring-zinc-800 hover:bg-zinc-800"
|
||||
>
|
||||
Create new key
|
||||
<ArrowRightIcon width={20} height={20} />
|
||||
</a>
|
||||
<a
|
||||
href="/auth/import"
|
||||
className="inline-flex h-14 w-64 items-center justify-center gap-2 rounded-full px-6 text-base font-medium text-zinc-300 hover:bg-zinc-800"
|
||||
>
|
||||
Login with private key
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1 +1 @@
|
||||
export { LayoutChannel as Layout } from './layout';
|
||||
export { LayoutChannel as Layout } from "./layout";
|
||||
|
@ -1,53 +1,60 @@
|
||||
import MutedItem from '@app/channel/components/mutedItem';
|
||||
import MutedItem from "@app/channel/components/mutedItem";
|
||||
|
||||
import MuteIcon from '@icons/mute';
|
||||
import MuteIcon from "@icons/mute";
|
||||
|
||||
import { Popover, Transition } from '@headlessui/react';
|
||||
import { Fragment } from 'react';
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
import { Fragment } from "react";
|
||||
|
||||
export default 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-200" />
|
||||
</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-xs 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-200"
|
||||
/>
|
||||
</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-xs 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>
|
||||
);
|
||||
}
|
||||
|
@ -1,254 +1,275 @@
|
||||
import { AvatarUploader } from '@shared/avatarUploader';
|
||||
import { Image } from '@shared/image';
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { AvatarUploader } from "@shared/avatarUploader";
|
||||
import { Image } from "@shared/image";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import CancelIcon from '@icons/cancel';
|
||||
import PlusIcon from '@icons/plus';
|
||||
import CancelIcon from "@icons/cancel";
|
||||
import PlusIcon from "@icons/plus";
|
||||
|
||||
import { DEFAULT_AVATAR, WRITEONLY_RELAYS } from '@stores/constants';
|
||||
import { DEFAULT_AVATAR, WRITEONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { dateToUnix } from '@utils/date';
|
||||
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
|
||||
import { createChannel } from '@utils/storage';
|
||||
import { dateToUnix } from "@utils/date";
|
||||
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
|
||||
import { createChannel } from "@utils/storage";
|
||||
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { Fragment, useContext, useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useSWRConfig } from 'swr';
|
||||
import { navigate } from 'vite-plugin-ssr/client/router';
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { getEventHash, signEvent } from "nostr-tools";
|
||||
import { Fragment, useContext, useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { navigate } from "vite-plugin-ssr/client/router";
|
||||
|
||||
export default function ChannelCreateModal() {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const { account, isError, isLoading } = useActiveAccount();
|
||||
const { mutate } = useSWRConfig();
|
||||
const { account, isError, isLoading } = useActiveAccount();
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [image, setImage] = useState(DEFAULT_AVATAR);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [image, setImage] = useState(DEFAULT_AVATAR);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
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 onSubmit = (data: any) => {
|
||||
setLoading(true);
|
||||
const onSubmit = (data: any) => {
|
||||
setLoading(true);
|
||||
|
||||
if (!isError && !isLoading && account) {
|
||||
const event: any = {
|
||||
content: JSON.stringify(data),
|
||||
created_at: dateToUnix(),
|
||||
kind: 40,
|
||||
pubkey: account.pubkey,
|
||||
tags: [],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
if (!isError && !isLoading && account) {
|
||||
const event: any = {
|
||||
content: JSON.stringify(data),
|
||||
created_at: dateToUnix(),
|
||||
kind: 40,
|
||||
pubkey: account.pubkey,
|
||||
tags: [],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
|
||||
// publish channel
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// insert to database
|
||||
createChannel(event.id, event.pubkey, event.content, event.created_at);
|
||||
// update channe llist
|
||||
mutate('channels');
|
||||
// reset form
|
||||
reset();
|
||||
setTimeout(() => {
|
||||
// close modal
|
||||
setIsOpen(false);
|
||||
// redirect to channel page
|
||||
navigate(`/app/channel?id=${event.id}`);
|
||||
}, 2000);
|
||||
} else {
|
||||
console.log('error');
|
||||
}
|
||||
};
|
||||
// publish channel
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// insert to database
|
||||
createChannel(event.id, event.pubkey, event.content, event.created_at);
|
||||
// update channe llist
|
||||
mutate("channels");
|
||||
// reset form
|
||||
reset();
|
||||
setTimeout(() => {
|
||||
// close modal
|
||||
setIsOpen(false);
|
||||
// redirect to channel page
|
||||
navigate(`/app/channel?id=${event.id}`);
|
||||
}, 2000);
|
||||
} else {
|
||||
console.log("error");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setValue('picture', image);
|
||||
}, [setValue, image]);
|
||||
useEffect(() => {
|
||||
setValue("picture", image);
|
||||
}, [setValue, image]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openModal()}
|
||||
className="group inline-flex h-8 items-center gap-2.5 rounded-md px-2.5 hover:bg-zinc-900"
|
||||
>
|
||||
<div className="inline-flex h-5 w-5 shrink items-center justify-center rounded bg-zinc-900 group-hover:bg-zinc-800">
|
||||
<PlusIcon width={12} height={12} className="text-zinc-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="text-[13px] font-semibold text-zinc-500 group-hover:text-zinc-400">Add a new 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 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-2xl font-semibold leading-none text-transparent"
|
||||
>
|
||||
Create channel
|
||||
</Dialog.Title>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
autoFocus={false}
|
||||
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">
|
||||
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">
|
||||
<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-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold 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} 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-xs font-semibold uppercase tracking-wider text-zinc-400">
|
||||
Channel name *
|
||||
</label>
|
||||
<div className="relative w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] 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-[7px] 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('name', { required: true, minLength: 4 })}
|
||||
spellCheck={false}
|
||||
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-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
|
||||
Description
|
||||
</label>
|
||||
<div className="relative h-20 w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] 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-[7px] 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">
|
||||
<textarea
|
||||
{...register('about')}
|
||||
spellCheck={false}
|
||||
className="relative h-20 w-full resize-none 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-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-14 items-center justify-between gap-1 rounded-lg bg-zinc-800 px-4 py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<span className="text-sm font-bold leading-none text-zinc-200">Make Private</span>
|
||||
<div className="inline-flex items-center rounded-md bg-zinc-400/10 px-2 py-0.5 text-xs font-medium ring-1 ring-inset ring-zinc-400/20">
|
||||
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-transparent">
|
||||
Coming soon
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm leading-none text-zinc-400">
|
||||
Private channels can only be viewed by member
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<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"></span>
|
||||
</button>
|
||||
</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-white 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-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<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"
|
||||
></path>
|
||||
</svg>
|
||||
) : (
|
||||
'Create channel'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openModal()}
|
||||
className="group inline-flex h-8 items-center gap-2.5 rounded-md px-2.5 hover:bg-zinc-900"
|
||||
>
|
||||
<div className="inline-flex h-5 w-5 shrink items-center justify-center rounded bg-zinc-900 group-hover:bg-zinc-800">
|
||||
<PlusIcon width={12} height={12} className="text-zinc-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="text-[13px] font-semibold text-zinc-500 group-hover:text-zinc-400">
|
||||
Add a new 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 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-2xl font-semibold leading-none text-transparent"
|
||||
>
|
||||
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="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"
|
||||
>
|
||||
<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-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold 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}
|
||||
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-xs font-semibold uppercase tracking-wider text-zinc-400">
|
||||
Channel name *
|
||||
</label>
|
||||
<div className="relative w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] 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-[7px] 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("name", {
|
||||
required: true,
|
||||
minLength: 4,
|
||||
})}
|
||||
spellCheck={false}
|
||||
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-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
|
||||
Description
|
||||
</label>
|
||||
<div className="relative h-20 w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] 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-[7px] 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">
|
||||
<textarea
|
||||
{...register("about")}
|
||||
spellCheck={false}
|
||||
className="relative h-20 w-full resize-none 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-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-14 items-center justify-between gap-1 rounded-lg bg-zinc-800 px-4 py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<span className="text-sm font-bold leading-none text-zinc-200">
|
||||
Make Private
|
||||
</span>
|
||||
<div className="inline-flex items-center rounded-md bg-zinc-400/10 px-2 py-0.5 text-xs font-medium ring-1 ring-inset ring-zinc-400/20">
|
||||
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-transparent">
|
||||
Coming soon
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm leading-none text-zinc-400">
|
||||
Private channels can only be viewed by member
|
||||
</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 rounded-lg bg-fuchsia-500 font-medium text-white 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-white"
|
||||
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>
|
||||
) : (
|
||||
"Create channel"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1,35 +1,41 @@
|
||||
import { useChannelProfile } from '@app/channel/hooks/useChannelProfile';
|
||||
import { useChannelProfile } from "@app/channel/hooks/useChannelProfile";
|
||||
|
||||
import { usePageContext } from '@utils/hooks/usePageContext';
|
||||
import { usePageContext } from "@utils/hooks/usePageContext";
|
||||
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export default function ChannelsListItem({ data }: { data: any }) {
|
||||
const channel: any = useChannelProfile(data.event_id, data.pubkey);
|
||||
const pageContext = usePageContext();
|
||||
const channel: any = useChannelProfile(data.event_id, data.pubkey);
|
||||
const pageContext = usePageContext();
|
||||
|
||||
const searchParams: any = pageContext.urlParsed.search;
|
||||
const pageID = searchParams.id;
|
||||
const searchParams: any = pageContext.urlParsed.search;
|
||||
const pageID = searchParams.id;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`/app/channel?id=${data.event_id}&channelpub=${data.pubkey}`}
|
||||
className={twMerge(
|
||||
'group inline-flex h-8 items-center gap-2.5 rounded-md px-2.5 hover:bg-zinc-900',
|
||||
pageID === data.event_id ? 'dark:bg-zinc-900 dark:text-zinc-100 hover:dark:bg-zinc-800' : ''
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
'inline-flex h-5 w-5 items-center justify-center rounded bg-zinc-900 group-hover:bg-zinc-800',
|
||||
pageID === data.event_id ? 'dark:bg-zinc-800 group-hover:dark:bg-zinc-700' : ''
|
||||
)}
|
||||
>
|
||||
<span className="text-xs text-zinc-200">#</span>
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="truncate text-[13px] font-semibold text-zinc-400">{channel?.name}</h5>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
return (
|
||||
<a
|
||||
href={`/app/channel?id=${data.event_id}&channelpub=${data.pubkey}`}
|
||||
className={twMerge(
|
||||
"group inline-flex h-8 items-center gap-2.5 rounded-md px-2.5 hover:bg-zinc-900",
|
||||
pageID === data.event_id
|
||||
? "dark:bg-zinc-900 dark:text-zinc-100 hover:dark:bg-zinc-800"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
"inline-flex h-5 w-5 items-center justify-center rounded bg-zinc-900 group-hover:bg-zinc-800",
|
||||
pageID === data.event_id
|
||||
? "dark:bg-zinc-800 group-hover:dark:bg-zinc-700"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<span className="text-xs text-zinc-200">#</span>
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="truncate text-[13px] font-semibold text-zinc-400">
|
||||
{channel?.name}
|
||||
</h5>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
@ -1,32 +1,34 @@
|
||||
import ChannelCreateModal from '@app/channel/components/createModal';
|
||||
import ChannelsListItem from '@app/channel/components/item';
|
||||
import ChannelCreateModal from "@app/channel/components/createModal";
|
||||
import ChannelsListItem from "@app/channel/components/item";
|
||||
|
||||
import { getChannels } from '@utils/storage';
|
||||
import { getChannels } from "@utils/storage";
|
||||
|
||||
import useSWR from 'swr';
|
||||
import useSWR from "swr";
|
||||
|
||||
const fetcher = () => getChannels(10, 0);
|
||||
|
||||
export default function ChannelsList() {
|
||||
const { data, error }: any = useSWR('channels', fetcher);
|
||||
const { data, error }: any = useSWR("channels", fetcher);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-px">
|
||||
{!data || error ? (
|
||||
<>
|
||||
<div className="inline-flex h-8 items-center gap-2 rounded-md px-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800"></div>
|
||||
<div className="h-3 w-full animate-pulse rounded-sm bg-zinc-800"></div>
|
||||
</div>
|
||||
<div className="inline-flex h-8 items-center gap-2 rounded-md px-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800"></div>
|
||||
<div className="h-3 w-full animate-pulse rounded-sm bg-zinc-800"></div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
data.map((item: { event_id: string }) => <ChannelsListItem key={item.event_id} data={item} />)
|
||||
)}
|
||||
<ChannelCreateModal />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col gap-px">
|
||||
{!data || error ? (
|
||||
<>
|
||||
<div className="inline-flex h-8 items-center gap-2 rounded-md px-2.5">
|
||||
<div className="relative h-5 w-5 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-8 items-center gap-2 rounded-md px-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800" />
|
||||
<div className="h-3 w-full animate-pulse rounded-sm bg-zinc-800" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
data.map((item: { event_id: string }) => (
|
||||
<ChannelsListItem key={item.event_id} data={item} />
|
||||
))
|
||||
)}
|
||||
<ChannelCreateModal />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,40 +1,44 @@
|
||||
import MiniMember from '@app/channel/components/miniMember';
|
||||
import MiniMember from "@app/channel/components/miniMember";
|
||||
|
||||
import { channelMembersAtom } from '@stores/channel';
|
||||
import { channelMembersAtom } from "@stores/channel";
|
||||
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useAtomValue } from "jotai";
|
||||
|
||||
export default function ChannelMembers() {
|
||||
const membersAsSet = useAtomValue(channelMembersAtom);
|
||||
const membersAsArray = [...membersAsSet];
|
||||
const miniMembersList = membersAsArray.slice(0, 4);
|
||||
const totalMembers =
|
||||
membersAsArray.length > 0
|
||||
? '+' +
|
||||
Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(membersAsArray.length)
|
||||
: 0;
|
||||
const membersAsSet = useAtomValue(channelMembersAtom);
|
||||
const membersAsArray = [...membersAsSet];
|
||||
const miniMembersList = membersAsArray.slice(0, 4);
|
||||
const totalMembers =
|
||||
membersAsArray.length > 0
|
||||
? `+${Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(membersAsArray.length)}`
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="group flex -space-x-2 overflow-hidden hover:-space-x-1">
|
||||
{miniMembersList.map((member, index) => (
|
||||
<MiniMember key={index} pubkey={member} />
|
||||
))}
|
||||
{totalMembers ? (
|
||||
<div className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-zinc-900 ring-2 ring-zinc-950 transition-all duration-150 ease-in-out group-hover:bg-zinc-800">
|
||||
<span className="text-xs font-medium text-zinc-400 group-hover:text-zinc-200">{totalMembers}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<button className="inline-flex h-8 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm text-white shadow-button">
|
||||
Invite
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<div className="group flex -space-x-2 overflow-hidden hover:-space-x-1">
|
||||
{miniMembersList.map((member, index) => (
|
||||
<MiniMember key={`item-${index}`} pubkey={member} />
|
||||
))}
|
||||
{totalMembers ? (
|
||||
<div className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-zinc-900 ring-2 ring-zinc-950 transition-all duration-150 ease-in-out group-hover:bg-zinc-800">
|
||||
<span className="text-xs font-medium text-zinc-400 group-hover:text-zinc-200">
|
||||
{totalMembers}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm text-white shadow-button"
|
||||
>
|
||||
Invite
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,71 +1,78 @@
|
||||
import ChannelMessageItem from '@app/channel/components/messages/item';
|
||||
import ChannelMessageItem from "@app/channel/components/messages/item";
|
||||
|
||||
import { sortedChannelMessagesAtom } from '@stores/channel';
|
||||
import { sortedChannelMessagesAtom } from "@stores/channel";
|
||||
|
||||
import { getHourAgo } from '@utils/date';
|
||||
import { getHourAgo } from "@utils/date";
|
||||
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
|
||||
export default function ChannelMessageList() {
|
||||
const now = useRef(new Date());
|
||||
const virtuosoRef = useRef(null);
|
||||
const data = useAtomValue(sortedChannelMessagesAtom);
|
||||
const now = useRef(new Date());
|
||||
const virtuosoRef = useRef(null);
|
||||
const data = useAtomValue(sortedChannelMessagesAtom);
|
||||
|
||||
const itemContent: any = useCallback(
|
||||
(index: string | number) => {
|
||||
return <ChannelMessageItem data={data[index]} />;
|
||||
},
|
||||
[data]
|
||||
);
|
||||
const itemContent: any = useCallback(
|
||||
(index: string | number) => {
|
||||
return <ChannelMessageItem data={data[index]} />;
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
const computeItemKey = useCallback(
|
||||
(index: string | number) => {
|
||||
return data[index].id;
|
||||
},
|
||||
[data]
|
||||
);
|
||||
const computeItemKey = useCallback(
|
||||
(index: string | number) => {
|
||||
return data[index].id;
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
data={data}
|
||||
itemContent={itemContent}
|
||||
components={{
|
||||
Header: () => (
|
||||
<div className="relative py-4">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<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-xs font-medium text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-800">
|
||||
{getHourAgo(24, now.current).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
EmptyPlaceholder: () => (
|
||||
<div className="flex flex-col gap-1 text-center">
|
||||
<h3 className="text-sm font-semibold leading-none text-zinc-200">Nothing to see here yet</h3>
|
||||
<p className="text-sm leading-none text-zinc-400">Be the first to share a message in this channel.</p>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
computeItemKey={computeItemKey}
|
||||
initialTopMostItemIndex={data.length - 1}
|
||||
alignToBottom={true}
|
||||
followOutput={true}
|
||||
overscan={50}
|
||||
increaseViewportBy={{ top: 200, bottom: 200 }}
|
||||
className="scrollbar-hide h-full w-full overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
data={data}
|
||||
itemContent={itemContent}
|
||||
components={{
|
||||
Header: () => (
|
||||
<div className="relative py-4">
|
||||
<div
|
||||
className="absolute inset-0 flex items-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<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-xs font-medium text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-800">
|
||||
{getHourAgo(24, now.current).toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
EmptyPlaceholder: () => (
|
||||
<div className="flex flex-col gap-1 text-center">
|
||||
<h3 className="text-sm font-semibold leading-none text-zinc-200">
|
||||
Nothing to see here yet
|
||||
</h3>
|
||||
<p className="text-sm leading-none text-zinc-400">
|
||||
Be the first to share a message in this channel.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
computeItemKey={computeItemKey}
|
||||
initialTopMostItemIndex={data.length - 1}
|
||||
alignToBottom={true}
|
||||
followOutput={true}
|
||||
overscan={50}
|
||||
increaseViewportBy={{ top: 200, bottom: 200 }}
|
||||
className="scrollbar-hide h-full w-full overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,128 +1,134 @@
|
||||
import UserReply from '@app/channel/components/messages/userReply';
|
||||
import UserReply from "@app/channel/components/messages/userReply";
|
||||
|
||||
import { ImagePicker } from '@shared/form/imagePicker';
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { ImagePicker } from "@shared/form/imagePicker";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import CancelIcon from '@icons/cancel';
|
||||
import CancelIcon from "@icons/cancel";
|
||||
|
||||
import { channelContentAtom, channelReplyAtom } from '@stores/channel';
|
||||
import { WRITEONLY_RELAYS } from '@stores/constants';
|
||||
import { channelContentAtom, channelReplyAtom } from "@stores/channel";
|
||||
import { WRITEONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { dateToUnix } from '@utils/date';
|
||||
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
|
||||
import { dateToUnix } from "@utils/date";
|
||||
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
|
||||
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { useResetAtom } from 'jotai/utils';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { useContext } from 'react';
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useResetAtom } from "jotai/utils";
|
||||
import { getEventHash, signEvent } from "nostr-tools";
|
||||
import { useContext } from "react";
|
||||
|
||||
export default function ChannelMessageForm({ channelID }: { channelID: string | string[] }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
export default function ChannelMessageForm({
|
||||
channelID,
|
||||
}: { channelID: string | string[] }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
|
||||
const [value, setValue] = useAtom(channelContentAtom);
|
||||
const resetValue = useResetAtom(channelContentAtom);
|
||||
const [value, setValue] = useAtom(channelContentAtom);
|
||||
const resetValue = useResetAtom(channelContentAtom);
|
||||
|
||||
const channelReply = useAtomValue(channelReplyAtom);
|
||||
const resetChannelReply = useResetAtom(channelReplyAtom);
|
||||
const channelReply = useAtomValue(channelReplyAtom);
|
||||
const resetChannelReply = useResetAtom(channelReplyAtom);
|
||||
|
||||
const submitEvent = () => {
|
||||
let tags: any[][];
|
||||
const submitEvent = () => {
|
||||
let tags: any[][];
|
||||
|
||||
if (channelReply.id !== null) {
|
||||
tags = [
|
||||
['e', channelID, '', 'root'],
|
||||
['e', channelReply.id, '', 'reply'],
|
||||
['p', channelReply.pubkey, ''],
|
||||
];
|
||||
} else {
|
||||
tags = [['e', channelID, '', 'root']];
|
||||
}
|
||||
if (channelReply.id !== null) {
|
||||
tags = [
|
||||
["e", channelID, "", "root"],
|
||||
["e", channelReply.id, "", "reply"],
|
||||
["p", channelReply.pubkey, ""],
|
||||
];
|
||||
} else {
|
||||
tags = [["e", channelID, "", "root"]];
|
||||
}
|
||||
|
||||
if (!isError && !isLoading && account) {
|
||||
const event: any = {
|
||||
content: value,
|
||||
created_at: dateToUnix(),
|
||||
kind: 42,
|
||||
pubkey: account.pubkey,
|
||||
tags: tags,
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
if (!isError && !isLoading && account) {
|
||||
const event: any = {
|
||||
content: value,
|
||||
created_at: dateToUnix(),
|
||||
kind: 42,
|
||||
pubkey: account.pubkey,
|
||||
tags: tags,
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
|
||||
// publish note
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// reset state
|
||||
resetValue();
|
||||
// reset channel reply
|
||||
resetChannelReply();
|
||||
} else {
|
||||
console.log('error');
|
||||
}
|
||||
};
|
||||
// publish note
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// reset state
|
||||
resetValue();
|
||||
// reset channel reply
|
||||
resetChannelReply();
|
||||
} else {
|
||||
console.log("error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnterPress = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submitEvent();
|
||||
}
|
||||
};
|
||||
const handleEnterPress = (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submitEvent();
|
||||
}
|
||||
};
|
||||
|
||||
const stopReply = () => {
|
||||
resetChannelReply();
|
||||
};
|
||||
const stopReply = () => {
|
||||
resetChannelReply();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative ${
|
||||
channelReply.id ? 'h-36' : 'h-24'
|
||||
} w-full overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] 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-[7px] 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`}
|
||||
>
|
||||
{channelReply.id && (
|
||||
<div className="absolute left-0 top-0 z-10 h-14 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={channelReply.pubkey} />
|
||||
<div className="-mt-3.5 pl-[32px]">
|
||||
<div className="text-xs text-zinc-200">{channelReply.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
<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 ${
|
||||
channelReply.id ? 'h-36 pt-16' : 'h-24 pt-3'
|
||||
} w-full resize-none rounded-lg border border-black/5 px-3.5 pb-3 text-sm shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500`}
|
||||
/>
|
||||
<div className="absolute bottom-2 w-full px-2">
|
||||
<div className="flex w-full items-center justify-between bg-zinc-800">
|
||||
<div className="flex items-center gap-2 divide-x divide-zinc-700">
|
||||
<ImagePicker type="channel" />
|
||||
<div className="flex items-center gap-2 pl-2"></div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => submitEvent()}
|
||||
disabled={value.length === 0 ? true : false}
|
||||
className="inline-flex h-8 w-16 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm font-medium shadow-button hover:bg-fuchsia-600 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={`relative ${
|
||||
channelReply.id ? "h-36" : "h-24"
|
||||
} w-full overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] 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-[7px] 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`}
|
||||
>
|
||||
{channelReply.id && (
|
||||
<div className="absolute left-0 top-0 z-10 h-14 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={channelReply.pubkey} />
|
||||
<div className="-mt-3.5 pl-[32px]">
|
||||
<div className="text-xs text-zinc-200">
|
||||
{channelReply.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 ${
|
||||
channelReply.id ? "h-36 pt-16" : "h-24 pt-3"
|
||||
} w-full resize-none rounded-lg border border-black/5 px-3.5 pb-3 text-sm shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500`}
|
||||
/>
|
||||
<div className="absolute bottom-2 w-full px-2">
|
||||
<div className="flex w-full items-center justify-between bg-zinc-800">
|
||||
<div className="flex items-center gap-2 divide-x divide-zinc-700">
|
||||
<ImagePicker type="channel" />
|
||||
<div className="flex items-center gap-2 pl-2" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submitEvent()}
|
||||
disabled={value.length === 0 ? true : false}
|
||||
className="inline-flex h-8 w-16 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm font-medium shadow-button hover:bg-fuchsia-600 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,141 +1,147 @@
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { Tooltip } from '@shared/tooltip';
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
import { Tooltip } from "@shared/tooltip";
|
||||
|
||||
import CancelIcon from '@icons/cancel';
|
||||
import HideIcon from '@icons/hide';
|
||||
import CancelIcon from "@icons/cancel";
|
||||
import HideIcon from "@icons/hide";
|
||||
|
||||
import { channelMessagesAtom } from '@stores/channel';
|
||||
import { WRITEONLY_RELAYS } from '@stores/constants';
|
||||
import { channelMessagesAtom } from "@stores/channel";
|
||||
import { WRITEONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { dateToUnix } from '@utils/date';
|
||||
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
|
||||
import { dateToUnix } from "@utils/date";
|
||||
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
|
||||
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { Fragment, useContext, useState } from 'react';
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { useAtom } from "jotai";
|
||||
import { getEventHash, signEvent } from "nostr-tools";
|
||||
import { Fragment, useContext, useState } from "react";
|
||||
|
||||
export default function MessageHideButton({ id }: { id: string }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const { account, isError, isLoading } = useActiveAccount();
|
||||
const pool: any = useContext(RelayContext);
|
||||
const { account, isError, isLoading } = useActiveAccount();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [messages, setMessages] = useAtom(channelMessagesAtom);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [messages, setMessages] = useAtom(channelMessagesAtom);
|
||||
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const openModal = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
const openModal = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const hideMessage = () => {
|
||||
if (!isError && !isLoading && account) {
|
||||
const event: any = {
|
||||
content: '',
|
||||
created_at: dateToUnix(),
|
||||
kind: 43,
|
||||
pubkey: account.pubkey,
|
||||
tags: [['e', id]],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
const hideMessage = () => {
|
||||
if (!isError && !isLoading && account) {
|
||||
const event: any = {
|
||||
content: "",
|
||||
created_at: dateToUnix(),
|
||||
kind: 43,
|
||||
pubkey: account.pubkey,
|
||||
tags: [["e", id]],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
|
||||
// publish note
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// update local state
|
||||
const cloneMessages = [...messages];
|
||||
const targetMessage = cloneMessages.findIndex((message) => message.id === id);
|
||||
cloneMessages[targetMessage]['hide'] = true;
|
||||
setMessages(cloneMessages);
|
||||
// close modal
|
||||
closeModal();
|
||||
} else {
|
||||
console.log('error');
|
||||
}
|
||||
};
|
||||
// publish note
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// update local state
|
||||
const cloneMessages = [...messages];
|
||||
const targetMessage = cloneMessages.findIndex(
|
||||
(message) => message.id === id,
|
||||
);
|
||||
cloneMessages[targetMessage]["hide"] = true;
|
||||
setMessages(cloneMessages);
|
||||
// close modal
|
||||
closeModal();
|
||||
} else {
|
||||
console.log("error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip message="Hide this message">
|
||||
<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-2xl font-semibold leading-none text-transparent"
|
||||
>
|
||||
Are you sure!
|
||||
</Dialog.Title>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
autoFocus={false}
|
||||
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-sm font-medium text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => hideMessage()}
|
||||
className="inline-flex h-9 items-center justify-center rounded-md bg-red-500 px-2 text-sm 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-2xl 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-sm font-medium text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => hideMessage()}
|
||||
className="inline-flex h-9 items-center justify-center rounded-md bg-red-500 px-2 text-sm font-medium text-zinc-100 hover:bg-red-600"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1,34 +1,42 @@
|
||||
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 ChannelMessageUser from '@app/channel/components/messages/user';
|
||||
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 ChannelMessageUser from "@app/channel/components/messages/user";
|
||||
|
||||
import { noteParser } from '@utils/parser';
|
||||
import { noteParser } from "@utils/parser";
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo } from "react";
|
||||
|
||||
export default function ChannelMessageItem({ data }: { data: any }) {
|
||||
const content = useMemo(() => noteParser(data), [data]);
|
||||
const content = useMemo(() => noteParser(data), [data]);
|
||||
|
||||
return (
|
||||
<div className="group relative flex h-min min-h-min w-full select-text flex-col px-5 py-2 hover:bg-black/20">
|
||||
<div className="flex flex-col">
|
||||
<ChannelMessageUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="-mt-[17px] pl-[48px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="whitespace-pre-line break-words text-sm leading-tight">
|
||||
{data.hide ? <span className="italic text-zinc-400">[hided message]</span> : content.parsed}
|
||||
</div>
|
||||
</div>
|
||||
</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-2 hover:bg-black/20">
|
||||
<div className="flex flex-col">
|
||||
<ChannelMessageUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="-mt-[17px] pl-[48px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="whitespace-pre-line break-words text-sm leading-tight">
|
||||
{data.hide ? (
|
||||
<span className="italic text-zinc-400">[hided message]</span>
|
||||
) : (
|
||||
content.parsed
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
@ -1,140 +1,146 @@
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { Tooltip } from '@shared/tooltip';
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
import { Tooltip } from "@shared/tooltip";
|
||||
|
||||
import CancelIcon from '@icons/cancel';
|
||||
import MuteIcon from '@icons/mute';
|
||||
import CancelIcon from "@icons/cancel";
|
||||
import MuteIcon from "@icons/mute";
|
||||
|
||||
import { channelMessagesAtom } from '@stores/channel';
|
||||
import { WRITEONLY_RELAYS } from '@stores/constants';
|
||||
import { channelMessagesAtom } from "@stores/channel";
|
||||
import { WRITEONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { dateToUnix } from '@utils/date';
|
||||
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
|
||||
import { dateToUnix } from "@utils/date";
|
||||
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
|
||||
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { Fragment, useContext, useState } from 'react';
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { useAtom } from "jotai";
|
||||
import { getEventHash, signEvent } from "nostr-tools";
|
||||
import { Fragment, useContext, useState } from "react";
|
||||
|
||||
export default function MessageMuteButton({ pubkey }: { pubkey: string }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const { account, isError, isLoading } = useActiveAccount();
|
||||
const pool: any = useContext(RelayContext);
|
||||
const { account, isError, isLoading } = useActiveAccount();
|
||||
|
||||
const [messages, setMessages] = useAtom(channelMessagesAtom);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [messages, setMessages] = useAtom(channelMessagesAtom);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const openModal = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
const openModal = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const muteUser = () => {
|
||||
if (!isError && !isLoading && account) {
|
||||
const event: any = {
|
||||
content: '',
|
||||
created_at: dateToUnix(),
|
||||
kind: 44,
|
||||
pubkey: account.pubkey,
|
||||
tags: [['p', pubkey]],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
const muteUser = () => {
|
||||
if (!isError && !isLoading && account) {
|
||||
const event: any = {
|
||||
content: "",
|
||||
created_at: dateToUnix(),
|
||||
kind: 44,
|
||||
pubkey: account.pubkey,
|
||||
tags: [["p", pubkey]],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
|
||||
// publish note
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// update local state
|
||||
const cloneMessages = [...messages];
|
||||
const finalMessages = cloneMessages.filter((message) => message.pubkey !== pubkey);
|
||||
setMessages(finalMessages);
|
||||
// close modal
|
||||
closeModal();
|
||||
} else {
|
||||
console.log('error');
|
||||
}
|
||||
};
|
||||
// publish note
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// update local state
|
||||
const cloneMessages = [...messages];
|
||||
const finalMessages = cloneMessages.filter(
|
||||
(message) => message.pubkey !== pubkey,
|
||||
);
|
||||
setMessages(finalMessages);
|
||||
// close modal
|
||||
closeModal();
|
||||
} else {
|
||||
console.log("error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip message="Mute this user">
|
||||
<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-2xl font-semibold leading-none text-transparent"
|
||||
>
|
||||
Are you sure!
|
||||
</Dialog.Title>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
autoFocus={false}
|
||||
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-sm font-medium text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => muteUser()}
|
||||
className="inline-flex h-9 items-center justify-center rounded-md bg-red-500 px-2 text-sm 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-2xl 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-sm font-medium text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => muteUser()}
|
||||
className="inline-flex h-9 items-center justify-center rounded-md bg-red-500 px-2 text-sm font-medium text-zinc-100 hover:bg-red-600"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1,26 +1,31 @@
|
||||
import { Tooltip } from '@shared/tooltip';
|
||||
import { Tooltip } from "@shared/tooltip";
|
||||
|
||||
import ReplyMessageIcon from '@icons/replyMessage';
|
||||
import ReplyMessageIcon from "@icons/replyMessage";
|
||||
|
||||
import { channelReplyAtom } from '@stores/channel';
|
||||
import { channelReplyAtom } from "@stores/channel";
|
||||
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useSetAtom } from "jotai";
|
||||
|
||||
export default function MessageReplyButton({ id, pubkey, content }: { id: string; pubkey: string; content: string }) {
|
||||
const setChannelReplyAtom = useSetAtom(channelReplyAtom);
|
||||
export default function MessageReplyButton({
|
||||
id,
|
||||
pubkey,
|
||||
content,
|
||||
}: { id: string; pubkey: string; content: string }) {
|
||||
const setChannelReplyAtom = useSetAtom(channelReplyAtom);
|
||||
|
||||
const createReply = () => {
|
||||
setChannelReplyAtom({ id: id, pubkey: pubkey, content: content });
|
||||
};
|
||||
const createReply = () => {
|
||||
setChannelReplyAtom({ id: id, pubkey: pubkey, content: content });
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip message="Reply to message">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
@ -1,49 +1,56 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
import { DEFAULT_AVATAR, IMGPROXY_URL } from '@stores/constants';
|
||||
import { DEFAULT_AVATAR, IMGPROXY_URL } from "@stores/constants";
|
||||
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { shortenKey } from '@utils/shortenKey';
|
||||
import { useProfile } from "@utils/hooks/useProfile";
|
||||
import { shortenKey } from "@utils/shortenKey";
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export default function ChannelMessageUser({ pubkey, time }: { pubkey: string; time: number }) {
|
||||
const { user, isError, isLoading } = useProfile(pubkey);
|
||||
export default function ChannelMessageUser({
|
||||
pubkey,
|
||||
time,
|
||||
}: { pubkey: string; time: number }) {
|
||||
const { user, isError, isLoading } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<div className="group flex items-start gap-3">
|
||||
{isError || isLoading ? (
|
||||
<>
|
||||
<div className="relative h-9 w-9 shrink animate-pulse rounded-md bg-zinc-800"></div>
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex items-baseline gap-2 text-sm">
|
||||
<div className="h-4 w-20 animate-pulse rounded bg-zinc-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative h-9 w-9 shrink rounded-md">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${user?.picture ? user.picture : DEFAULT_AVATAR}`}
|
||||
alt={pubkey}
|
||||
className="h-9 w-9 rounded-md object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex items-baseline gap-2 text-sm">
|
||||
<span className="font-semibold leading-none text-zinc-200 group-hover:underline">
|
||||
{user?.display_name || user?.name || shortenKey(pubkey)}
|
||||
</span>
|
||||
<span className="leading-none text-zinc-500">·</span>
|
||||
<span className="leading-none text-zinc-500">{dayjs().to(dayjs.unix(time))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="group flex items-start gap-3">
|
||||
{isError || isLoading ? (
|
||||
<>
|
||||
<div className="relative h-9 w-9 shrink animate-pulse rounded-md bg-zinc-800" />
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex items-baseline gap-2 text-sm">
|
||||
<div className="h-4 w-20 animate-pulse rounded bg-zinc-800" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative h-9 w-9 shrink rounded-md">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${
|
||||
user?.picture ? user.picture : DEFAULT_AVATAR
|
||||
}`}
|
||||
alt={pubkey}
|
||||
className="h-9 w-9 rounded-md object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex items-baseline gap-2 text-sm">
|
||||
<span className="font-semibold leading-none text-zinc-200 group-hover:underline">
|
||||
{user?.display_name || user?.name || shortenKey(pubkey)}
|
||||
</span>
|
||||
<span className="leading-none text-zinc-500">·</span>
|
||||
<span className="leading-none text-zinc-500">
|
||||
{dayjs().to(dayjs.unix(time))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,34 +1,36 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
import { DEFAULT_AVATAR, IMGPROXY_URL } from '@stores/constants';
|
||||
import { DEFAULT_AVATAR, IMGPROXY_URL } from "@stores/constants";
|
||||
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { shortenKey } from '@utils/shortenKey';
|
||||
import { useProfile } from "@utils/hooks/useProfile";
|
||||
import { shortenKey } from "@utils/shortenKey";
|
||||
|
||||
export default 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-1">
|
||||
{isError || isLoading ? (
|
||||
<>
|
||||
<div className="relative h-7 w-7 shrink animate-pulse overflow-hidden rounded bg-zinc-800"></div>
|
||||
<span className="h-2 w-10 animate-pulse rounded bg-zinc-800 text-xs font-medium leading-none text-zinc-500"></span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative h-7 w-7 shrink overflow-hidden rounded">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${user?.picture ? user.picture : DEFAULT_AVATAR}`}
|
||||
alt={pubkey}
|
||||
className="h-7 w-7 rounded object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-medium leading-none text-zinc-500">
|
||||
Replying to {user?.name || shortenKey(pubkey)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="group flex items-start gap-1">
|
||||
{isError || isLoading ? (
|
||||
<>
|
||||
<div className="relative h-7 w-7 shrink animate-pulse overflow-hidden rounded bg-zinc-800" />
|
||||
<span className="h-2 w-10 animate-pulse rounded bg-zinc-800 text-xs font-medium leading-none text-zinc-500" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative h-7 w-7 shrink overflow-hidden rounded">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${
|
||||
user?.picture ? user.picture : DEFAULT_AVATAR
|
||||
}`}
|
||||
alt={pubkey}
|
||||
className="h-7 w-7 rounded object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-medium leading-none text-zinc-500">
|
||||
Replying to {user?.name || shortenKey(pubkey)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,44 +1,49 @@
|
||||
import { useChannelProfile } from '@app/channel/hooks/useChannelProfile';
|
||||
import { useChannelProfile } from "@app/channel/hooks/useChannelProfile";
|
||||
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
import CopyIcon from '@icons/copy';
|
||||
import CopyIcon from "@icons/copy";
|
||||
|
||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||
import { DEFAULT_AVATAR } from "@stores/constants";
|
||||
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
export default function ChannelMetadata({ id, pubkey }: { id: string; pubkey: string }) {
|
||||
const metadata = useChannelProfile(id, pubkey);
|
||||
const noteID = id ? nip19.noteEncode(id) : null;
|
||||
export default function ChannelMetadata({
|
||||
id,
|
||||
pubkey,
|
||||
}: { id: string; pubkey: string }) {
|
||||
const metadata = useChannelProfile(id, pubkey);
|
||||
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="inline-flex items-center gap-2">
|
||||
<div className="relative shrink-0 rounded-md">
|
||||
<Image
|
||||
src={metadata?.picture || DEFAULT_AVATAR}
|
||||
alt={id}
|
||||
className="h-8 w-8 rounded bg-zinc-900 object-contain ring-2 ring-zinc-950"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<h5 className="truncate text-sm font-medium leading-none text-zinc-100">{metadata?.name}</h5>
|
||||
<button onClick={() => copyNoteID()}>
|
||||
<CopyIcon width={14} height={14} className="text-zinc-400" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs leading-none text-zinc-400">
|
||||
{metadata?.about || (noteID && noteID.substring(0, 24) + '...')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<div className="relative shrink-0 rounded-md">
|
||||
<Image
|
||||
src={metadata?.picture || DEFAULT_AVATAR}
|
||||
alt={id}
|
||||
className="h-8 w-8 rounded bg-zinc-900 object-contain ring-2 ring-zinc-950"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<h5 className="truncate text-sm font-medium leading-none text-zinc-100">
|
||||
{metadata?.name}
|
||||
</h5>
|
||||
<button type="button" onClick={() => copyNoteID()}>
|
||||
<CopyIcon width={14} height={14} className="text-zinc-400" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs leading-none text-zinc-400">
|
||||
{metadata?.about || (noteID && `${noteID.substring(0, 24)}...`)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,23 +1,23 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||
import { DEFAULT_AVATAR } from "@stores/constants";
|
||||
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { useProfile } from "@utils/hooks/useProfile";
|
||||
|
||||
export default function MiniMember({ pubkey }: { pubkey: string }) {
|
||||
const { user, isError, isLoading } = useProfile(pubkey);
|
||||
const { user, isError, isLoading } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isError || isLoading ? (
|
||||
<div className="h-8 w-8 animate-pulse rounded-md bg-zinc-800"></div>
|
||||
) : (
|
||||
<Image
|
||||
className="inline-block h-8 w-8 rounded-md bg-white ring-2 ring-zinc-950 transition-all duration-150 ease-in-out"
|
||||
src={user?.picture || DEFAULT_AVATAR}
|
||||
alt={user?.pubkey || 'user avatar'}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{isError || isLoading ? (
|
||||
<div className="h-8 w-8 animate-pulse rounded-md bg-zinc-800" />
|
||||
) : (
|
||||
<Image
|
||||
className="inline-block h-8 w-8 rounded-md bg-white ring-2 ring-zinc-950 transition-all duration-150 ease-in-out"
|
||||
src={user?.picture || DEFAULT_AVATAR}
|
||||
alt={user?.pubkey || "user avatar"}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1,80 +1,84 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||
import { DEFAULT_AVATAR } from "@stores/constants";
|
||||
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { shortenKey } from '@utils/shortenKey';
|
||||
import { useProfile } from "@utils/hooks/useProfile";
|
||||
import { shortenKey } from "@utils/shortenKey";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState } from "react";
|
||||
|
||||
export default 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('@utils/storage');
|
||||
const res = await updateItemInBlacklist(data.content, 0);
|
||||
if (res) {
|
||||
setStatus(0);
|
||||
}
|
||||
};
|
||||
const unmute = async () => {
|
||||
const { updateItemInBlacklist } = await import("@utils/storage");
|
||||
const res = await updateItemInBlacklist(data.content, 0);
|
||||
if (res) {
|
||||
setStatus(0);
|
||||
}
|
||||
};
|
||||
|
||||
const mute = async () => {
|
||||
const { updateItemInBlacklist } = await import('@utils/storage');
|
||||
const res = await updateItemInBlacklist(data.content, 1);
|
||||
if (res) {
|
||||
setStatus(1);
|
||||
}
|
||||
};
|
||||
const mute = async () => {
|
||||
const { updateItemInBlacklist } = await import("@utils/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>
|
||||
<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>
|
||||
<div className="h-2 w-10 animate-pulse bg-zinc-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="relative h-9 w-9 shrink rounded-md">
|
||||
<Image
|
||||
src={user?.picture || 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-sm font-medium leading-none text-zinc-200">
|
||||
{user?.display_name || user?.name || 'Pleb'}
|
||||
</span>
|
||||
<span className="text-xs leading-none text-zinc-400">{shortenKey(data.content)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{status === 1 ? (
|
||||
<button
|
||||
onClick={() => unmute()}
|
||||
className="inline-flex h-6 w-min items-center justify-center rounded px-1.5 text-xs font-medium leading-none text-zinc-400 hover:bg-zinc-800 hover:text-fuchsia-500"
|
||||
>
|
||||
Unmute
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => mute()}
|
||||
className="inline-flex h-6 w-min items-center justify-center rounded px-1.5 text-xs 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?.picture || 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-sm font-medium leading-none text-zinc-200">
|
||||
{user?.display_name || user?.name || "Pleb"}
|
||||
</span>
|
||||
<span className="text-xs 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-xs 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-xs font-medium leading-none text-zinc-400 hover:bg-zinc-800 hover:text-fuchsia-500"
|
||||
>
|
||||
Mute
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,247 +1,270 @@
|
||||
import { AvatarUploader } from '@shared/avatarUploader';
|
||||
import { Image } from '@shared/image';
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { AvatarUploader } from "@shared/avatarUploader";
|
||||
import { Image } from "@shared/image";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import CancelIcon from '@icons/cancel';
|
||||
import EditIcon from '@icons/edit';
|
||||
import CancelIcon from "@icons/cancel";
|
||||
import EditIcon from "@icons/edit";
|
||||
|
||||
import { DEFAULT_AVATAR, WRITEONLY_RELAYS } from '@stores/constants';
|
||||
import { DEFAULT_AVATAR, WRITEONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { dateToUnix } from '@utils/date';
|
||||
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
|
||||
import { getChannel } from '@utils/storage';
|
||||
import { dateToUnix } from "@utils/date";
|
||||
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
|
||||
import { getChannel } from "@utils/storage";
|
||||
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { Fragment, useContext, useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { getEventHash, signEvent } from "nostr-tools";
|
||||
import { Fragment, useContext, useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
export default function ChannelUpdateModal({ id }: { id: string }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const { account, isError, isLoading } = useActiveAccount();
|
||||
const pool: any = useContext(RelayContext);
|
||||
const { account, isError, isLoading } = useActiveAccount();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [image, setImage] = useState(DEFAULT_AVATAR);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [image, setImage] = useState(DEFAULT_AVATAR);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
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({
|
||||
defaultValues: async () => {
|
||||
const channel = await getChannel(id);
|
||||
const metadata = JSON.parse(channel.metadata);
|
||||
// update image state
|
||||
setImage(metadata.picture);
|
||||
// set default values
|
||||
return metadata;
|
||||
},
|
||||
});
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
formState: { isDirty, isValid },
|
||||
} = useForm({
|
||||
defaultValues: async () => {
|
||||
const channel = await getChannel(id);
|
||||
const metadata = JSON.parse(channel.metadata);
|
||||
// update image state
|
||||
setImage(metadata.picture);
|
||||
// set default values
|
||||
return metadata;
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: any) => {
|
||||
setLoading(true);
|
||||
const onSubmit = (data: any) => {
|
||||
setLoading(true);
|
||||
|
||||
if (!isError && !isLoading && account) {
|
||||
const event: any = {
|
||||
content: JSON.stringify(data),
|
||||
created_at: dateToUnix(),
|
||||
kind: 41,
|
||||
pubkey: account.pubkey,
|
||||
tags: [['e', id]],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
if (!isError && !isLoading && account) {
|
||||
const event: any = {
|
||||
content: JSON.stringify(data),
|
||||
created_at: dateToUnix(),
|
||||
kind: 41,
|
||||
pubkey: account.pubkey,
|
||||
tags: [["e", id]],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
|
||||
// publish channel
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// reset form
|
||||
reset();
|
||||
// close modal
|
||||
setIsOpen(false);
|
||||
setLoading(false);
|
||||
} else {
|
||||
console.log('error');
|
||||
}
|
||||
};
|
||||
// publish channel
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// reset form
|
||||
reset();
|
||||
// close modal
|
||||
setIsOpen(false);
|
||||
setLoading(false);
|
||||
} else {
|
||||
console.log("error");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setValue('picture', image);
|
||||
}, [setValue, image]);
|
||||
useEffect(() => {
|
||||
setValue("picture", image);
|
||||
}, [setValue, image]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openModal()}
|
||||
className="group inline-flex h-8 w-8 items-center justify-center rounded-md bg-zinc-900 hover:bg-zinc-800 focus:outline-none"
|
||||
>
|
||||
<EditIcon width={16} height={16} className="text-zinc-400 group-hover:text-zinc-200" />
|
||||
</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 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-2xl font-semibold leading-none text-transparent"
|
||||
>
|
||||
Update channel
|
||||
</Dialog.Title>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
autoFocus={false}
|
||||
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">
|
||||
New metadata will be published on all relays, and will be immediately available to all users, so
|
||||
please carefully.
|
||||
</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">
|
||||
<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-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold 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} 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-xs font-semibold uppercase tracking-wider text-zinc-400">
|
||||
Channel name *
|
||||
</label>
|
||||
<div className="relative w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] 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-[7px] 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('name', { required: true, minLength: 4 })}
|
||||
spellCheck={false}
|
||||
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-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
|
||||
Description
|
||||
</label>
|
||||
<div className="relative h-20 w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] 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-[7px] 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">
|
||||
<textarea
|
||||
{...register('about')}
|
||||
spellCheck={false}
|
||||
className="relative h-20 w-full resize-none 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-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-14 items-center justify-between gap-1 rounded-lg bg-zinc-800 px-4 py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<span className="text-sm font-bold leading-none text-zinc-200">Make Private</span>
|
||||
<div className="inline-flex items-center rounded-md bg-zinc-400/10 px-2 py-0.5 text-xs font-medium ring-1 ring-inset ring-zinc-400/20">
|
||||
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-transparent">
|
||||
Coming soon
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm leading-none text-zinc-400">
|
||||
Private channels can only be viewed by member
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<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"></span>
|
||||
</button>
|
||||
</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-white 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-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<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"
|
||||
></path>
|
||||
</svg>
|
||||
) : (
|
||||
'Update channel'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openModal()}
|
||||
className="group inline-flex h-8 w-8 items-center justify-center rounded-md bg-zinc-900 hover:bg-zinc-800 focus:outline-none"
|
||||
>
|
||||
<EditIcon
|
||||
width={16}
|
||||
height={16}
|
||||
className="text-zinc-400 group-hover:text-zinc-200"
|
||||
/>
|
||||
</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 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-2xl font-semibold leading-none text-transparent"
|
||||
>
|
||||
Update 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="leading-tight text-zinc-400">
|
||||
New metadata will be published on all relays, and will be
|
||||
immediately available to all users, so please carefully.
|
||||
</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"
|
||||
>
|
||||
<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-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold 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}
|
||||
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-xs font-semibold uppercase tracking-wider text-zinc-400">
|
||||
Channel name *
|
||||
</label>
|
||||
<div className="relative w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] 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-[7px] 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("name", {
|
||||
required: true,
|
||||
minLength: 4,
|
||||
})}
|
||||
spellCheck={false}
|
||||
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-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
|
||||
Description
|
||||
</label>
|
||||
<div className="relative h-20 w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] 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-[7px] 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">
|
||||
<textarea
|
||||
{...register("about")}
|
||||
spellCheck={false}
|
||||
className="relative h-20 w-full resize-none 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-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-14 items-center justify-between gap-1 rounded-lg bg-zinc-800 px-4 py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<span className="text-sm font-bold leading-none text-zinc-200">
|
||||
Make Private
|
||||
</span>
|
||||
<div className="inline-flex items-center rounded-md bg-zinc-400/10 px-2 py-0.5 text-xs font-medium ring-1 ring-inset ring-zinc-400/20">
|
||||
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-transparent">
|
||||
Coming soon
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm leading-none text-zinc-400">
|
||||
Private channels can only be viewed by member
|
||||
</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 rounded-lg bg-fuchsia-500 font-medium text-white 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-white"
|
||||
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>
|
||||
) : (
|
||||
"Update channel"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1,56 +1,59 @@
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import { READONLY_RELAYS } from '@stores/constants';
|
||||
import { READONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { getChannel, updateChannelMetadata } from '@utils/storage';
|
||||
import { getChannel, updateChannelMetadata } from "@utils/storage";
|
||||
|
||||
import { useContext } from 'react';
|
||||
import useSWR, { useSWRConfig } from 'swr';
|
||||
import useSWRSubscription from 'swr/subscription';
|
||||
import { useContext } from "react";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
import useSWRSubscription from "swr/subscription";
|
||||
|
||||
const fetcher = async ([, id]) => {
|
||||
const result = await getChannel(id);
|
||||
if (result) {
|
||||
return JSON.parse(result.metadata);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
const result = await getChannel(id);
|
||||
if (result) {
|
||||
return JSON.parse(result.metadata);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export function useChannelProfile(id: string, channelPubkey: string) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
const { data, isLoading } = useSWR(['channel-metadata', id], fetcher);
|
||||
const { mutate } = useSWRConfig();
|
||||
const { data, isLoading } = useSWR(["channel-metadata", id], fetcher);
|
||||
|
||||
useSWRSubscription(!isLoading && data ? ['channel-metadata', id] : null, ([, key], {}) => {
|
||||
// subscribe to channel
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
'#e': [key],
|
||||
authors: [channelPubkey],
|
||||
kinds: [41],
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: { content: string }) => {
|
||||
// update in local database
|
||||
updateChannelMetadata(key, event.content);
|
||||
// revaildate
|
||||
mutate(['channel-metadata', key]);
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
unsubscribeOnEose: true,
|
||||
}
|
||||
);
|
||||
useSWRSubscription(
|
||||
!isLoading && data ? ["channel-metadata", id] : null,
|
||||
([, key]) => {
|
||||
// subscribe to channel
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
"#e": [key],
|
||||
authors: [channelPubkey],
|
||||
kinds: [41],
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: { content: string }) => {
|
||||
// update in local database
|
||||
updateChannelMetadata(key, event.content);
|
||||
// revaildate
|
||||
mutate(["channel-metadata", key]);
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
unsubscribeOnEose: true,
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return data;
|
||||
return data;
|
||||
}
|
||||
|
@ -1,29 +1,31 @@
|
||||
import AppHeader from '@shared/appHeader';
|
||||
import MultiAccounts from '@shared/multiAccounts';
|
||||
import Navigation from '@shared/navigation';
|
||||
import AppHeader from "@shared/appHeader";
|
||||
import MultiAccounts from "@shared/multiAccounts";
|
||||
import Navigation from "@shared/navigation";
|
||||
|
||||
export function LayoutChannel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white">
|
||||
<div className="flex h-screen w-full flex-col">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="relative h-9 shrink-0 border-b border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
|
||||
>
|
||||
<AppHeader />
|
||||
</div>
|
||||
<div className="relative flex min-h-0 w-full flex-1">
|
||||
<div className="relative w-[68px] shrink-0 border-r border-zinc-900">
|
||||
<MultiAccounts />
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-4 xl:grid-cols-5">
|
||||
<div className="scrollbar-hide col-span-1 overflow-y-auto overflow-x-hidden border-r border-zinc-900">
|
||||
<Navigation />
|
||||
</div>
|
||||
<div className="col-span-3 m-3 overflow-hidden xl:col-span-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white">
|
||||
<div className="flex h-screen w-full flex-col">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="relative h-9 shrink-0 border-b border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
|
||||
>
|
||||
<AppHeader />
|
||||
</div>
|
||||
<div className="relative flex min-h-0 w-full flex-1">
|
||||
<div className="relative w-[68px] shrink-0 border-r border-zinc-900">
|
||||
<MultiAccounts />
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-4 xl:grid-cols-5">
|
||||
<div className="scrollbar-hide col-span-1 overflow-y-auto overflow-x-hidden border-r border-zinc-900">
|
||||
<Navigation />
|
||||
</div>
|
||||
<div className="col-span-3 m-3 overflow-hidden xl:col-span-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,127 +1,140 @@
|
||||
import ChannelBlackList from '@app/channel/components/blacklist';
|
||||
import ChannelMembers from '@app/channel/components/members';
|
||||
import ChannelMessageForm from '@app/channel/components/messages/form';
|
||||
import ChannelMetadata from '@app/channel/components/metadata';
|
||||
import ChannelUpdateModal from '@app/channel/components/updateModal';
|
||||
import ChannelBlackList from "@app/channel/components/blacklist";
|
||||
import ChannelMembers from "@app/channel/components/members";
|
||||
import ChannelMessageForm from "@app/channel/components/messages/form";
|
||||
import ChannelMetadata from "@app/channel/components/metadata";
|
||||
import ChannelUpdateModal from "@app/channel/components/updateModal";
|
||||
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import { channelMessagesAtom, channelReplyAtom } from '@stores/channel';
|
||||
import { READONLY_RELAYS } from '@stores/constants';
|
||||
import { channelMessagesAtom, channelReplyAtom } from "@stores/channel";
|
||||
import { READONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { dateToUnix, getHourAgo } from '@utils/date';
|
||||
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
|
||||
import { usePageContext } from '@utils/hooks/usePageContext';
|
||||
import { getActiveBlacklist, getBlacklist } from '@utils/storage';
|
||||
import { arrayObjToPureArr } from '@utils/transform';
|
||||
import { dateToUnix, getHourAgo } from "@utils/date";
|
||||
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
|
||||
import { usePageContext } from "@utils/hooks/usePageContext";
|
||||
import { getActiveBlacklist, getBlacklist } from "@utils/storage";
|
||||
import { arrayObjToPureArr } from "@utils/transform";
|
||||
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useResetAtom } from 'jotai/utils';
|
||||
import { Suspense, lazy, useContext, useEffect, useRef } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import useSWRSubscription from 'swr/subscription';
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useResetAtom } from "jotai/utils";
|
||||
import { Suspense, lazy, useContext, useEffect, useRef } from "react";
|
||||
import useSWR from "swr";
|
||||
import useSWRSubscription from "swr/subscription";
|
||||
|
||||
const fetchMuted = async ([, id]) => {
|
||||
const res = await getBlacklist(id, 44);
|
||||
const array = arrayObjToPureArr(res);
|
||||
return { original: res, array: array };
|
||||
const res = await getBlacklist(id, 44);
|
||||
const array = arrayObjToPureArr(res);
|
||||
return { original: res, array: array };
|
||||
};
|
||||
|
||||
const fetchHided = async ([, id]) => {
|
||||
const res = await getActiveBlacklist(id, 43);
|
||||
const array = arrayObjToPureArr(res);
|
||||
return array;
|
||||
const res = await getActiveBlacklist(id, 43);
|
||||
const array = arrayObjToPureArr(res);
|
||||
return array;
|
||||
};
|
||||
|
||||
const ChannelMessageList = lazy(() => import('@app/channel/components/messageList'));
|
||||
const ChannelMessageList = lazy(
|
||||
() => import("@app/channel/components/messageList"),
|
||||
);
|
||||
|
||||
export function Page() {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const pageContext = usePageContext();
|
||||
const searchParams: any = pageContext.urlParsed.search;
|
||||
const pool: any = useContext(RelayContext);
|
||||
const pageContext = usePageContext();
|
||||
const searchParams: any = pageContext.urlParsed.search;
|
||||
|
||||
const channelID = searchParams.id;
|
||||
const channelPubkey = searchParams.channelpub;
|
||||
const channelID = searchParams.id;
|
||||
const channelPubkey = searchParams.channelpub;
|
||||
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
const { data: muted } = useSWR(!isLoading && !isError && account ? ['muted', account.id] : null, fetchMuted);
|
||||
const { data: hided } = useSWR(!isLoading && !isError && account ? ['hided', account.id] : null, fetchHided);
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
const { data: muted } = useSWR(
|
||||
!isLoading && !isError && account ? ["muted", account.id] : null,
|
||||
fetchMuted,
|
||||
);
|
||||
const { data: hided } = useSWR(
|
||||
!isLoading && !isError && account ? ["hided", account.id] : null,
|
||||
fetchHided,
|
||||
);
|
||||
|
||||
const setChannelMessages = useSetAtom(channelMessagesAtom);
|
||||
const resetChannelMessages = useResetAtom(channelMessagesAtom);
|
||||
const resetChannelReply = useResetAtom(channelReplyAtom);
|
||||
const setChannelMessages = useSetAtom(channelMessagesAtom);
|
||||
const resetChannelMessages = useResetAtom(channelMessagesAtom);
|
||||
const resetChannelReply = useResetAtom(channelReplyAtom);
|
||||
|
||||
const now = useRef(new Date());
|
||||
const now = useRef(new Date());
|
||||
|
||||
useSWRSubscription(account && channelID && muted && hided ? ['channel', channelID] : null, ([, key], {}: any) => {
|
||||
// subscribe to channel
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
'#e': [key],
|
||||
kinds: [42],
|
||||
since: dateToUnix(getHourAgo(24, now.current)),
|
||||
limit: 20,
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: { id: string; pubkey: string }) => {
|
||||
const message: any = event;
|
||||
if (hided.includes(event.id)) {
|
||||
message['hide'] = true;
|
||||
} else {
|
||||
message['hide'] = false;
|
||||
}
|
||||
if (!muted.array.includes(event.pubkey)) {
|
||||
setChannelMessages((prev) => [...prev, message]);
|
||||
}
|
||||
}
|
||||
);
|
||||
useSWRSubscription(
|
||||
account && channelID && muted && hided ? ["channel", channelID] : null,
|
||||
([, key]) => {
|
||||
// subscribe to channel
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
"#e": [key],
|
||||
kinds: [42],
|
||||
since: dateToUnix(getHourAgo(24, now.current)),
|
||||
limit: 20,
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: { id: string; pubkey: string }) => {
|
||||
const message: any = event;
|
||||
if (hided.includes(event.id)) {
|
||||
message["hide"] = true;
|
||||
} else {
|
||||
message["hide"] = false;
|
||||
}
|
||||
if (!muted.array.includes(event.pubkey)) {
|
||||
setChannelMessages((prev) => [...prev, message]);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
|
||||
if (!ignore) {
|
||||
// reset channel reply
|
||||
resetChannelReply();
|
||||
// reset channel messages
|
||||
resetChannelMessages();
|
||||
}
|
||||
if (!ignore) {
|
||||
// reset channel reply
|
||||
resetChannelReply();
|
||||
// reset channel messages
|
||||
resetChannelMessages();
|
||||
}
|
||||
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col justify-between gap-2">
|
||||
<div className="flex h-11 w-full shrink-0 items-center justify-between">
|
||||
<div>
|
||||
<ChannelMetadata id={channelID} pubkey={channelPubkey} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ChannelMembers />
|
||||
{!muted ? <></> : <ChannelBlackList blacklist={muted.original} />}
|
||||
{!isLoading && !isError && account ? (
|
||||
account.pubkey === channelPubkey && <ChannelUpdateModal id={channelID} />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex w-full flex-1 flex-col justify-between rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
|
||||
<Suspense fallback={<p>Loading...</p>}>
|
||||
<ChannelMessageList />
|
||||
</Suspense>
|
||||
<div className="inline-flex shrink-0 p-3">
|
||||
<ChannelMessageForm channelID={channelID} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex h-full flex-col justify-between gap-2">
|
||||
<div className="flex h-11 w-full shrink-0 items-center justify-between">
|
||||
<div>
|
||||
<ChannelMetadata id={channelID} pubkey={channelPubkey} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ChannelMembers />
|
||||
{!muted ? <></> : <ChannelBlackList blacklist={muted.original} />}
|
||||
{!isLoading && !isError && account ? (
|
||||
account.pubkey === channelPubkey && (
|
||||
<ChannelUpdateModal id={channelID} />
|
||||
)
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex w-full flex-1 flex-col justify-between rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
|
||||
<Suspense fallback={<p>Loading...</p>}>
|
||||
<ChannelMessageList />
|
||||
</Suspense>
|
||||
<div className="inline-flex shrink-0 p-3">
|
||||
<ChannelMessageForm channelID={channelID} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1 +1 @@
|
||||
export { LayoutChat as Layout } from './layout';
|
||||
export { LayoutChat as Layout } from "./layout";
|
||||
|
@ -1,53 +1,55 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||
import { DEFAULT_AVATAR } from "@stores/constants";
|
||||
|
||||
import { usePageContext } from '@utils/hooks/usePageContext';
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { shortenKey } from '@utils/shortenKey';
|
||||
import { usePageContext } from "@utils/hooks/usePageContext";
|
||||
import { useProfile } from "@utils/hooks/useProfile";
|
||||
import { shortenKey } from "@utils/shortenKey";
|
||||
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export default function ChatsListItem({ pubkey }: { pubkey: string }) {
|
||||
const pageContext = usePageContext();
|
||||
const pageContext = usePageContext();
|
||||
|
||||
const searchParams: any = pageContext.urlParsed.search;
|
||||
const pagePubkey = searchParams.pubkey;
|
||||
const searchParams: any = pageContext.urlParsed.search;
|
||||
const pagePubkey = searchParams.pubkey;
|
||||
|
||||
const { user, isError, isLoading } = useProfile(pubkey);
|
||||
const { user, isError, isLoading } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isError && <div>error</div>}
|
||||
{isLoading && !user ? (
|
||||
<div className="inline-flex h-8 items-center gap-2.5 rounded-md px-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800"></div>
|
||||
<div>
|
||||
<div className="h-2.5 w-full animate-pulse truncate rounded bg-zinc-800 text-sm font-medium"></div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<a
|
||||
href={`/app/chat?pubkey=${pubkey}`}
|
||||
className={twMerge(
|
||||
'group inline-flex h-8 items-center gap-2.5 rounded-md px-2.5 hover:bg-zinc-900',
|
||||
pagePubkey === pubkey ? 'dark:bg-zinc-900 dark:text-zinc-100 hover:dark:bg-zinc-800' : ''
|
||||
)}
|
||||
>
|
||||
<div className="relative h-5 w-5 shrink-0 rounded">
|
||||
<Image
|
||||
src={user.picture || DEFAULT_AVATAR}
|
||||
alt={pubkey}
|
||||
className="h-5 w-5 rounded bg-white object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="truncate text-[13px] font-semibold text-zinc-400 group-hover:text-zinc-200">
|
||||
{user.display_name || user.name || shortenKey(pubkey)}
|
||||
</h5>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{isError && <div>error</div>}
|
||||
{isLoading && !user ? (
|
||||
<div className="inline-flex h-8 items-center gap-2.5 rounded-md px-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800" />
|
||||
<div>
|
||||
<div className="h-2.5 w-full animate-pulse truncate rounded bg-zinc-800 text-sm font-medium" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<a
|
||||
href={`/app/chat?pubkey=${pubkey}`}
|
||||
className={twMerge(
|
||||
"group inline-flex h-8 items-center gap-2.5 rounded-md px-2.5 hover:bg-zinc-900",
|
||||
pagePubkey === pubkey
|
||||
? "dark:bg-zinc-900 dark:text-zinc-100 hover:dark:bg-zinc-800"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<div className="relative h-5 w-5 shrink-0 rounded">
|
||||
<Image
|
||||
src={user.picture || DEFAULT_AVATAR}
|
||||
alt={pubkey}
|
||||
className="h-5 w-5 rounded bg-white object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="truncate text-[13px] font-semibold text-zinc-400 group-hover:text-zinc-200">
|
||||
{user.display_name || user.name || shortenKey(pubkey)}
|
||||
</h5>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1,34 +1,39 @@
|
||||
import ChatsListItem from '@app/chat/components/item';
|
||||
import ChatsListSelfItem from '@app/chat/components/self';
|
||||
import ChatsListItem from "@app/chat/components/item";
|
||||
import ChatsListSelfItem from "@app/chat/components/self";
|
||||
|
||||
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
|
||||
import { getChats } from '@utils/storage';
|
||||
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
|
||||
import { getChats } from "@utils/storage";
|
||||
|
||||
import useSWR from 'swr';
|
||||
import useSWR from "swr";
|
||||
|
||||
const fetcher = ([, account]) => getChats(account);
|
||||
|
||||
export default function ChatsList() {
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
const { data: chats, error }: any = useSWR(!isLoading && !isError && account ? ['chats', account] : null, fetcher);
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
const { data: chats, error }: any = useSWR(
|
||||
!isLoading && !isError && account ? ["chats", account] : null,
|
||||
fetcher,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-px">
|
||||
<ChatsListSelfItem />
|
||||
{!chats || error ? (
|
||||
<>
|
||||
<div className="inline-flex h-8 items-center gap-2 rounded-md px-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800"></div>
|
||||
<div className="h-3 w-full animate-pulse bg-zinc-800"></div>
|
||||
</div>
|
||||
<div className="inline-flex h-8 items-center gap-2 rounded-md px-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800"></div>
|
||||
<div className="h-3 w-full animate-pulse bg-zinc-800"></div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
chats.map((item: { pubkey: string }) => <ChatsListItem key={item.pubkey} pubkey={item.pubkey} />)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col gap-px">
|
||||
<ChatsListSelfItem />
|
||||
{!chats || error ? (
|
||||
<>
|
||||
<div className="inline-flex h-8 items-center gap-2 rounded-md px-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800" />
|
||||
<div className="h-3 w-full animate-pulse bg-zinc-800" />
|
||||
</div>
|
||||
<div className="inline-flex h-8 items-center gap-2 rounded-md px-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800" />
|
||||
<div className="h-3 w-full animate-pulse bg-zinc-800" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
chats.map((item: { pubkey: string }) => (
|
||||
<ChatsListItem key={item.pubkey} pubkey={item.pubkey} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,47 +1,53 @@
|
||||
import { ChatMessageItem } from '@app/chat/components/messages/item';
|
||||
import { ChatMessageItem } from "@app/chat/components/messages/item";
|
||||
|
||||
import { sortedChatMessagesAtom } from '@stores/chat';
|
||||
import { sortedChatMessagesAtom } from "@stores/chat";
|
||||
|
||||
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
|
||||
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
|
||||
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
|
||||
export default function ChatMessageList() {
|
||||
const { account } = useActiveAccount();
|
||||
const { account } = useActiveAccount();
|
||||
|
||||
const virtuosoRef = useRef(null);
|
||||
const data = useAtomValue(sortedChatMessagesAtom);
|
||||
const virtuosoRef = useRef(null);
|
||||
const data = useAtomValue(sortedChatMessagesAtom);
|
||||
|
||||
const itemContent: any = useCallback(
|
||||
(index: string | number) => {
|
||||
return <ChatMessageItem data={data[index]} userPubkey={account.pubkey} userPrivkey={account.privkey} />;
|
||||
},
|
||||
[account.privkey, account.pubkey, data]
|
||||
);
|
||||
const itemContent: any = useCallback(
|
||||
(index: string | number) => {
|
||||
return (
|
||||
<ChatMessageItem
|
||||
data={data[index]}
|
||||
userPubkey={account.pubkey}
|
||||
userPrivkey={account.privkey}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[account.privkey, account.pubkey, data],
|
||||
);
|
||||
|
||||
const computeItemKey = useCallback(
|
||||
(index: string | number) => {
|
||||
return data[index].id;
|
||||
},
|
||||
[data]
|
||||
);
|
||||
const computeItemKey = useCallback(
|
||||
(index: string | number) => {
|
||||
return data[index].id;
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<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 h-full w-full overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<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 h-full w-full overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,89 +1,92 @@
|
||||
import { ImagePicker } from '@shared/form/imagePicker';
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { ImagePicker } from "@shared/form/imagePicker";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import { chatContentAtom } from '@stores/chat';
|
||||
import { WRITEONLY_RELAYS } from '@stores/constants';
|
||||
import { chatContentAtom } from "@stores/chat";
|
||||
import { WRITEONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { dateToUnix } from '@utils/date';
|
||||
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
|
||||
import { dateToUnix } from "@utils/date";
|
||||
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
|
||||
|
||||
import { useAtom } from 'jotai';
|
||||
import { useResetAtom } from 'jotai/utils';
|
||||
import { getEventHash, nip04, signEvent } from 'nostr-tools';
|
||||
import { useCallback, useContext } from 'react';
|
||||
import { useAtom } from "jotai";
|
||||
import { useResetAtom } from "jotai/utils";
|
||||
import { getEventHash, nip04, signEvent } from "nostr-tools";
|
||||
import { useCallback, useContext } from "react";
|
||||
|
||||
export default function ChatMessageForm({ receiverPubkey }: { receiverPubkey: string }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
export default function ChatMessageForm({
|
||||
receiverPubkey,
|
||||
}: { receiverPubkey: string }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
|
||||
const [value, setValue] = useAtom(chatContentAtom);
|
||||
const resetValue = useResetAtom(chatContentAtom);
|
||||
const [value, setValue] = useAtom(chatContentAtom);
|
||||
const resetValue = useResetAtom(chatContentAtom);
|
||||
|
||||
const encryptMessage = useCallback(
|
||||
async (privkey: string) => {
|
||||
return await nip04.encrypt(privkey, receiverPubkey, value);
|
||||
},
|
||||
[receiverPubkey, value]
|
||||
);
|
||||
const encryptMessage = useCallback(
|
||||
async (privkey: string) => {
|
||||
return await nip04.encrypt(privkey, receiverPubkey, value);
|
||||
},
|
||||
[receiverPubkey, value],
|
||||
);
|
||||
|
||||
const submitEvent = () => {
|
||||
if (!isError && !isLoading && account) {
|
||||
encryptMessage(account.privkey)
|
||||
.then((encryptedContent) => {
|
||||
const event: any = {
|
||||
content: encryptedContent,
|
||||
created_at: dateToUnix(),
|
||||
kind: 4,
|
||||
pubkey: account.pubkey,
|
||||
tags: [['p', receiverPubkey]],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
// publish note
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// reset state
|
||||
resetValue();
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
};
|
||||
const submitEvent = () => {
|
||||
if (!isError && !isLoading && account) {
|
||||
encryptMessage(account.privkey)
|
||||
.then((encryptedContent) => {
|
||||
const event: any = {
|
||||
content: encryptedContent,
|
||||
created_at: dateToUnix(),
|
||||
kind: 4,
|
||||
pubkey: account.pubkey,
|
||||
tags: [["p", receiverPubkey]],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
// publish note
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// reset state
|
||||
resetValue();
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnterPress = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submitEvent();
|
||||
}
|
||||
};
|
||||
const handleEnterPress = (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submitEvent();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-24 w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] 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-[7px] 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">
|
||||
<div>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleEnterPress}
|
||||
spellCheck={false}
|
||||
placeholder="Message"
|
||||
className="relative h-24 w-full resize-none rounded-lg border border-black/5 px-3.5 py-3 text-sm shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-2 w-full px-2">
|
||||
<div className="flex w-full items-center justify-between bg-zinc-800">
|
||||
<div className="flex items-center gap-2 divide-x divide-zinc-700">
|
||||
<ImagePicker type="chat" />
|
||||
<div className="flex items-center gap-2 pl-2"></div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => submitEvent()}
|
||||
disabled={value.length === 0 ? true : false}
|
||||
className="inline-flex h-8 w-16 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm font-medium shadow-button hover:bg-fuchsia-600 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="relative h-24 w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] 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-[7px] 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">
|
||||
<div>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleEnterPress}
|
||||
spellCheck={false}
|
||||
placeholder="Message"
|
||||
className="relative h-24 w-full resize-none rounded-lg border border-black/5 px-3.5 py-3 text-sm shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-2 w-full px-2">
|
||||
<div className="flex w-full items-center justify-between bg-zinc-800">
|
||||
<div className="flex items-center gap-2 divide-x divide-zinc-700">
|
||||
<ImagePicker type="chat" />
|
||||
<div className="flex items-center gap-2 pl-2" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submitEvent()}
|
||||
disabled={value.length === 0 ? true : false}
|
||||
className="inline-flex h-8 w-16 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm font-medium shadow-button hover:bg-fuchsia-600 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,39 +1,49 @@
|
||||
import ChatMessageUser from '@app/chat/components/messages/user';
|
||||
import { useDecryptMessage } from '@app/chat/hooks/useDecryptMessage';
|
||||
import ImagePreview from '@app/note/components/preview/image';
|
||||
import VideoPreview from '@app/note/components/preview/video';
|
||||
import ChatMessageUser from "@app/chat/components/messages/user";
|
||||
import { useDecryptMessage } from "@app/chat/hooks/useDecryptMessage";
|
||||
import ImagePreview from "@app/note/components/preview/image";
|
||||
import VideoPreview from "@app/note/components/preview/video";
|
||||
|
||||
import { noteParser } from '@utils/parser';
|
||||
import { noteParser } from "@utils/parser";
|
||||
|
||||
import { memo } from 'react';
|
||||
import { memo } from "react";
|
||||
|
||||
export const ChatMessageItem = memo(function MessageListItem({
|
||||
data,
|
||||
userPubkey,
|
||||
userPrivkey,
|
||||
data,
|
||||
userPubkey,
|
||||
userPrivkey,
|
||||
}: {
|
||||
data: any;
|
||||
userPubkey: string;
|
||||
userPrivkey: string;
|
||||
data: any;
|
||||
userPubkey: string;
|
||||
userPrivkey: string;
|
||||
}) {
|
||||
const decryptedContent = useDecryptMessage(userPubkey, userPrivkey, data);
|
||||
// if we have decrypted content, use it instead of the encrypted content
|
||||
if (decryptedContent) {
|
||||
data['content'] = decryptedContent;
|
||||
}
|
||||
// parse the note content
|
||||
const content = noteParser(data);
|
||||
const decryptedContent = useDecryptMessage(userPubkey, userPrivkey, data);
|
||||
// if we have decrypted content, use it instead of the encrypted content
|
||||
if (decryptedContent) {
|
||||
data["content"] = decryptedContent;
|
||||
}
|
||||
// parse the note content
|
||||
const content = noteParser(data);
|
||||
|
||||
return (
|
||||
<div className="flex h-min min-h-min w-full select-text flex-col px-5 py-2 hover:bg-black/20">
|
||||
<div className="flex flex-col">
|
||||
<ChatMessageUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="-mt-[17px] pl-[48px]">
|
||||
<div className="whitespace-pre-line break-words text-sm leading-tight">{content.parsed}</div>
|
||||
{Array.isArray(content.images) && content.images.length ? <ImagePreview urls={content.images} /> : <></>}
|
||||
{Array.isArray(content.videos) && content.videos.length ? <VideoPreview urls={content.videos} /> : <></>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex h-min min-h-min w-full select-text flex-col px-5 py-2 hover:bg-black/20">
|
||||
<div className="flex flex-col">
|
||||
<ChatMessageUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="-mt-[17px] pl-[48px]">
|
||||
<div className="whitespace-pre-line break-words text-sm leading-tight">
|
||||
{content.parsed}
|
||||
</div>
|
||||
{Array.isArray(content.images) && content.images.length ? (
|
||||
<ImagePreview urls={content.images} />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{Array.isArray(content.videos) && content.videos.length ? (
|
||||
<VideoPreview urls={content.videos} />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
@ -1,49 +1,56 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
import { DEFAULT_AVATAR, IMGPROXY_URL } from '@stores/constants';
|
||||
import { DEFAULT_AVATAR, IMGPROXY_URL } from "@stores/constants";
|
||||
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { shortenKey } from '@utils/shortenKey';
|
||||
import { useProfile } from "@utils/hooks/useProfile";
|
||||
import { shortenKey } from "@utils/shortenKey";
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export default function ChatMessageUser({ pubkey, time }: { pubkey: string; time: number }) {
|
||||
const { user, isError, isLoading } = useProfile(pubkey);
|
||||
export default function ChatMessageUser({
|
||||
pubkey,
|
||||
time,
|
||||
}: { pubkey: string; time: number }) {
|
||||
const { user, isError, isLoading } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<div className="group flex items-start gap-3">
|
||||
{isError || isLoading ? (
|
||||
<>
|
||||
<div className="relative h-9 w-9 shrink animate-pulse rounded-md bg-zinc-800"></div>
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex items-baseline gap-2 text-sm">
|
||||
<div className="h-4 w-20 animate-pulse rounded bg-zinc-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative h-9 w-9 shrink rounded-md">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${user?.picture ? user.picture : DEFAULT_AVATAR}`}
|
||||
alt={pubkey}
|
||||
className="h-9 w-9 rounded-md object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex items-baseline gap-2 text-sm">
|
||||
<span className="font-semibold leading-none text-zinc-200 group-hover:underline">
|
||||
{user?.display_name || user?.name || shortenKey(pubkey)}
|
||||
</span>
|
||||
<span className="leading-none text-zinc-500">·</span>
|
||||
<span className="leading-none text-zinc-500">{dayjs().to(dayjs.unix(time))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="group flex items-start gap-3">
|
||||
{isError || isLoading ? (
|
||||
<>
|
||||
<div className="relative h-9 w-9 shrink animate-pulse rounded-md bg-zinc-800" />
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex items-baseline gap-2 text-sm">
|
||||
<div className="h-4 w-20 animate-pulse rounded bg-zinc-800" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative h-9 w-9 shrink rounded-md">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${
|
||||
user?.picture ? user.picture : DEFAULT_AVATAR
|
||||
}`}
|
||||
alt={pubkey}
|
||||
className="h-9 w-9 rounded-md object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex items-baseline gap-2 text-sm">
|
||||
<span className="font-semibold leading-none text-zinc-200 group-hover:underline">
|
||||
{user?.display_name || user?.name || shortenKey(pubkey)}
|
||||
</span>
|
||||
<span className="leading-none text-zinc-500">·</span>
|
||||
<span className="leading-none text-zinc-500">
|
||||
{dayjs().to(dayjs.unix(time))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,55 +1,59 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||
import { DEFAULT_AVATAR } from "@stores/constants";
|
||||
|
||||
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
|
||||
import { usePageContext } from '@utils/hooks/usePageContext';
|
||||
import { shortenKey } from '@utils/shortenKey';
|
||||
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
|
||||
import { usePageContext } from "@utils/hooks/usePageContext";
|
||||
import { shortenKey } from "@utils/shortenKey";
|
||||
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export default function ChatsListSelfItem() {
|
||||
const pageContext = usePageContext();
|
||||
const pageContext = usePageContext();
|
||||
|
||||
const searchParams: any = pageContext.urlParsed.search;
|
||||
const pagePubkey = searchParams.pubkey;
|
||||
const searchParams: any = pageContext.urlParsed.search;
|
||||
const pagePubkey = searchParams.pubkey;
|
||||
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
const profile = account ? JSON.parse(account.metadata) : null;
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
const profile = account ? JSON.parse(account.metadata) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isError && <div>error</div>}
|
||||
{isLoading && !account ? (
|
||||
<div className="inline-flex h-8 items-center gap-2.5 rounded-md px-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800"></div>
|
||||
<div>
|
||||
<div className="h-2.5 w-full animate-pulse truncate rounded bg-zinc-800 text-sm font-medium"></div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<a
|
||||
href={`/app/chat?pubkey=${account.pubkey}`}
|
||||
className={twMerge(
|
||||
'inline-flex h-8 items-center gap-2.5 rounded-md px-2.5 hover:bg-zinc-900',
|
||||
pagePubkey === account.pubkey ? 'dark:bg-zinc-900 dark:text-zinc-100 hover:dark:bg-zinc-800' : ''
|
||||
)}
|
||||
>
|
||||
<div className="relative h-5 w-5 shrink-0 rounded">
|
||||
<Image
|
||||
src={profile?.picture || DEFAULT_AVATAR}
|
||||
alt={account.pubkey}
|
||||
className="h-5 w-5 rounded bg-white object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="truncate text-[13px] font-semibold text-zinc-400">
|
||||
{profile?.display_name || profile?.name || shortenKey(account.pubkey)}{' '}
|
||||
<span className="text-zinc-500">(you)</span>
|
||||
</h5>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{isError && <div>error</div>}
|
||||
{isLoading && !account ? (
|
||||
<div className="inline-flex h-8 items-center gap-2.5 rounded-md px-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800" />
|
||||
<div>
|
||||
<div className="h-2.5 w-full animate-pulse truncate rounded bg-zinc-800 text-sm font-medium" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<a
|
||||
href={`/app/chat?pubkey=${account.pubkey}`}
|
||||
className={twMerge(
|
||||
"inline-flex h-8 items-center gap-2.5 rounded-md px-2.5 hover:bg-zinc-900",
|
||||
pagePubkey === account.pubkey
|
||||
? "dark:bg-zinc-900 dark:text-zinc-100 hover:dark:bg-zinc-800"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<div className="relative h-5 w-5 shrink-0 rounded">
|
||||
<Image
|
||||
src={profile?.picture || DEFAULT_AVATAR}
|
||||
alt={account.pubkey}
|
||||
className="h-5 w-5 rounded bg-white object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="truncate text-[13px] font-semibold text-zinc-400">
|
||||
{profile?.display_name ||
|
||||
profile?.name ||
|
||||
shortenKey(account.pubkey)}{" "}
|
||||
<span className="text-zinc-500">(you)</span>
|
||||
</h5>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1,28 +1,32 @@
|
||||
import { nip04 } from 'nostr-tools';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { nip04 } from "nostr-tools";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export function useDecryptMessage(userKey: string, userPriv: string, data: any) {
|
||||
const [content, setContent] = useState(null);
|
||||
export function useDecryptMessage(
|
||||
userKey: string,
|
||||
userPriv: string,
|
||||
data: any,
|
||||
) {
|
||||
const [content, setContent] = useState(null);
|
||||
|
||||
const extractSenderKey = useCallback(() => {
|
||||
const keyInTags = data.tags.find(([k, v]) => k === 'p' && v && v !== '')[1];
|
||||
if (keyInTags === userKey) {
|
||||
return data.pubkey;
|
||||
} else {
|
||||
return keyInTags;
|
||||
}
|
||||
}, [data.pubkey, data.tags, userKey]);
|
||||
const extractSenderKey = useCallback(() => {
|
||||
const keyInTags = data.tags.find(([k, v]) => k === "p" && v && v !== "")[1];
|
||||
if (keyInTags === userKey) {
|
||||
return data.pubkey;
|
||||
} else {
|
||||
return keyInTags;
|
||||
}
|
||||
}, [data.pubkey, data.tags, userKey]);
|
||||
|
||||
const decrypt = useCallback(async () => {
|
||||
const senderKey = extractSenderKey();
|
||||
const result = await nip04.decrypt(userPriv, senderKey, data.content);
|
||||
// update state with decrypt content
|
||||
setContent(result);
|
||||
}, [extractSenderKey, userPriv, data.content]);
|
||||
const decrypt = useCallback(async () => {
|
||||
const senderKey = extractSenderKey();
|
||||
const result = await nip04.decrypt(userPriv, senderKey, data.content);
|
||||
// update state with decrypt content
|
||||
setContent(result);
|
||||
}, [extractSenderKey, userPriv, data.content]);
|
||||
|
||||
useEffect(() => {
|
||||
decrypt().catch(console.error);
|
||||
}, [decrypt]);
|
||||
useEffect(() => {
|
||||
decrypt().catch(console.error);
|
||||
}, [decrypt]);
|
||||
|
||||
return content ? content : null;
|
||||
return content ? content : null;
|
||||
}
|
||||
|
@ -1,29 +1,31 @@
|
||||
import AppHeader from '@shared/appHeader';
|
||||
import MultiAccounts from '@shared/multiAccounts';
|
||||
import Navigation from '@shared/navigation';
|
||||
import AppHeader from "@shared/appHeader";
|
||||
import MultiAccounts from "@shared/multiAccounts";
|
||||
import Navigation from "@shared/navigation";
|
||||
|
||||
export function LayoutChat({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white">
|
||||
<div className="flex h-screen w-full flex-col">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="relative h-9 shrink-0 border-b border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
|
||||
>
|
||||
<AppHeader />
|
||||
</div>
|
||||
<div className="relative flex min-h-0 w-full flex-1">
|
||||
<div className="relative w-[68px] shrink-0 border-r border-zinc-900">
|
||||
<MultiAccounts />
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-4 xl:grid-cols-5">
|
||||
<div className="scrollbar-hide col-span-1 overflow-y-auto overflow-x-hidden border-r border-zinc-900">
|
||||
<Navigation />
|
||||
</div>
|
||||
<div className="col-span-3 m-3 overflow-hidden xl:col-span-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white">
|
||||
<div className="flex h-screen w-full flex-col">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="relative h-9 shrink-0 border-b border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
|
||||
>
|
||||
<AppHeader />
|
||||
</div>
|
||||
<div className="relative flex min-h-0 w-full flex-1">
|
||||
<div className="relative w-[68px] shrink-0 border-r border-zinc-900">
|
||||
<MultiAccounts />
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-4 xl:grid-cols-5">
|
||||
<div className="scrollbar-hide col-span-1 overflow-y-auto overflow-x-hidden border-r border-zinc-900">
|
||||
<Navigation />
|
||||
</div>
|
||||
<div className="col-span-3 m-3 overflow-hidden xl:col-span-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,80 +1,80 @@
|
||||
import ChatMessageForm from '@app/chat/components/messages/form';
|
||||
import ChatMessageForm from "@app/chat/components/messages/form";
|
||||
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import { chatMessagesAtom } from '@stores/chat';
|
||||
import { READONLY_RELAYS } from '@stores/constants';
|
||||
import { chatMessagesAtom } from "@stores/chat";
|
||||
import { READONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
|
||||
import { usePageContext } from '@utils/hooks/usePageContext';
|
||||
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
|
||||
import { usePageContext } from "@utils/hooks/usePageContext";
|
||||
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useResetAtom } from 'jotai/utils';
|
||||
import { Suspense, lazy, useContext, useEffect } from 'react';
|
||||
import useSWRSubscription from 'swr/subscription';
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useResetAtom } from "jotai/utils";
|
||||
import { Suspense, lazy, useContext, useEffect } from "react";
|
||||
import useSWRSubscription from "swr/subscription";
|
||||
|
||||
const ChatMessageList = lazy(() => import('@app/chat/components/messageList'));
|
||||
const ChatMessageList = lazy(() => import("@app/chat/components/messageList"));
|
||||
|
||||
export function Page() {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const pageContext = usePageContext();
|
||||
const searchParams: any = pageContext.urlParsed.search;
|
||||
const pool: any = useContext(RelayContext);
|
||||
const pageContext = usePageContext();
|
||||
const searchParams: any = pageContext.urlParsed.search;
|
||||
|
||||
const pubkey = searchParams.pubkey;
|
||||
const pubkey = searchParams.pubkey;
|
||||
|
||||
const { account } = useActiveAccount();
|
||||
const { account } = useActiveAccount();
|
||||
|
||||
const setChatMessages = useSetAtom(chatMessagesAtom);
|
||||
const resetChatMessages = useResetAtom(chatMessagesAtom);
|
||||
const setChatMessages = useSetAtom(chatMessagesAtom);
|
||||
const resetChatMessages = useResetAtom(chatMessagesAtom);
|
||||
|
||||
useSWRSubscription(account ? ['chat', pubkey] : null, ([, key], {}: any) => {
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [4],
|
||||
authors: [key],
|
||||
'#p': [account.pubkey],
|
||||
limit: 20,
|
||||
},
|
||||
{
|
||||
kinds: [4],
|
||||
authors: [account.pubkey],
|
||||
'#p': [key],
|
||||
limit: 20,
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
setChatMessages((prev) => [...prev, event]);
|
||||
}
|
||||
);
|
||||
useSWRSubscription(account ? ["chat", pubkey] : null, ([, key]) => {
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [4],
|
||||
authors: [key],
|
||||
"#p": [account.pubkey],
|
||||
limit: 20,
|
||||
},
|
||||
{
|
||||
kinds: [4],
|
||||
authors: [account.pubkey],
|
||||
"#p": [key],
|
||||
limit: 20,
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
setChatMessages((prev) => [...prev, event]);
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
|
||||
if (!ignore) {
|
||||
// reset chat messages
|
||||
resetChatMessages();
|
||||
}
|
||||
if (!ignore) {
|
||||
// reset chat messages
|
||||
resetChatMessages();
|
||||
}
|
||||
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col justify-between rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
|
||||
<Suspense fallback={<p>Loading...</p>}>
|
||||
<ChatMessageList />
|
||||
</Suspense>
|
||||
<div className="shrink-0 p-3">
|
||||
<ChatMessageForm receiverPubkey={pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col justify-between rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
|
||||
<Suspense fallback={<p>Loading...</p>}>
|
||||
<ChatMessageList />
|
||||
</Suspense>
|
||||
<div className="shrink-0 p-3">
|
||||
<ChatMessageForm receiverPubkey={pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1 +1 @@
|
||||
export const filesystemRoutingRoot = '/';
|
||||
export const filesystemRoutingRoot = "/";
|
||||
|
@ -1,24 +1,26 @@
|
||||
import { getActiveAccount } from '@utils/storage';
|
||||
import { getActiveAccount } from "@utils/storage";
|
||||
|
||||
import useSWR from 'swr';
|
||||
import { navigate } from 'vite-plugin-ssr/client/router';
|
||||
import useSWR from "swr";
|
||||
import { navigate } from "vite-plugin-ssr/client/router";
|
||||
|
||||
const fetcher = () => getActiveAccount();
|
||||
|
||||
export function Page() {
|
||||
const { data, isLoading } = useSWR('account', fetcher, {
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
});
|
||||
const { data, isLoading } = useSWR("account", fetcher, {
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
});
|
||||
|
||||
if (!isLoading && !data) {
|
||||
navigate('/auth', { overwriteLastHistoryEntry: true });
|
||||
}
|
||||
if (!isLoading && !data) {
|
||||
navigate("/auth", { overwriteLastHistoryEntry: true });
|
||||
}
|
||||
|
||||
if (!isLoading && data) {
|
||||
navigate('/app/inital-data', { overwriteLastHistoryEntry: true });
|
||||
}
|
||||
if (!isLoading && data) {
|
||||
navigate("/app/inital-data", { overwriteLastHistoryEntry: true });
|
||||
}
|
||||
|
||||
return <div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-white"></div>;
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-white" />
|
||||
);
|
||||
}
|
||||
|
@ -1,233 +1,246 @@
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import LumeIcon from '@icons/lume';
|
||||
import LumeIcon from "@icons/lume";
|
||||
|
||||
import { READONLY_RELAYS } from '@stores/constants';
|
||||
import { READONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { dateToUnix, getHourAgo } from '@utils/date';
|
||||
import { dateToUnix, getHourAgo } from "@utils/date";
|
||||
import {
|
||||
addToBlacklist,
|
||||
countTotalLongNotes,
|
||||
countTotalNotes,
|
||||
createChat,
|
||||
createNote,
|
||||
getActiveAccount,
|
||||
getLastLogin,
|
||||
updateLastLogin,
|
||||
} from '@utils/storage';
|
||||
import { getParentID, nip02ToArray } from '@utils/transform';
|
||||
addToBlacklist,
|
||||
countTotalLongNotes,
|
||||
countTotalNotes,
|
||||
createChat,
|
||||
createNote,
|
||||
getActiveAccount,
|
||||
getLastLogin,
|
||||
updateLastLogin,
|
||||
} from "@utils/storage";
|
||||
import { getParentID, nip02ToArray } from "@utils/transform";
|
||||
|
||||
import { useContext, useEffect, useRef } from 'react';
|
||||
import { navigate } from 'vite-plugin-ssr/client/router';
|
||||
import { useContext, useEffect, useRef } from "react";
|
||||
import { navigate } from "vite-plugin-ssr/client/router";
|
||||
|
||||
export function Page() {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const now = useRef(new Date());
|
||||
const pool: any = useContext(RelayContext);
|
||||
const now = useRef(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
let unsubscribe: () => void;
|
||||
let timeout: any;
|
||||
useEffect(() => {
|
||||
let unsubscribe: () => void;
|
||||
let timeout: any;
|
||||
|
||||
const fetchInitalData = async () => {
|
||||
const account = await getActiveAccount();
|
||||
const lastLogin = await getLastLogin();
|
||||
const notes = await countTotalNotes();
|
||||
const longNotes = await countTotalLongNotes();
|
||||
const fetchInitalData = async () => {
|
||||
const account = await getActiveAccount();
|
||||
const lastLogin = await getLastLogin();
|
||||
const notes = await countTotalNotes();
|
||||
const longNotes = await countTotalLongNotes();
|
||||
|
||||
const follows = nip02ToArray(JSON.parse(account.follows));
|
||||
const query = [];
|
||||
const follows = nip02ToArray(JSON.parse(account.follows));
|
||||
const query = [];
|
||||
|
||||
let sinceNotes: number;
|
||||
let sinceLongNotes: number;
|
||||
let sinceNotes: number;
|
||||
let sinceLongNotes: number;
|
||||
|
||||
if (notes === 0) {
|
||||
sinceNotes = dateToUnix(getHourAgo(48, now.current));
|
||||
} else {
|
||||
if (parseInt(lastLogin) > 0) {
|
||||
sinceNotes = parseInt(lastLogin);
|
||||
} else {
|
||||
sinceNotes = dateToUnix(getHourAgo(48, now.current));
|
||||
}
|
||||
}
|
||||
if (notes === 0) {
|
||||
sinceNotes = dateToUnix(getHourAgo(48, now.current));
|
||||
} else {
|
||||
if (parseInt(lastLogin) > 0) {
|
||||
sinceNotes = parseInt(lastLogin);
|
||||
} else {
|
||||
sinceNotes = dateToUnix(getHourAgo(48, now.current));
|
||||
}
|
||||
}
|
||||
|
||||
if (longNotes === 0) {
|
||||
sinceLongNotes = 0;
|
||||
} else {
|
||||
if (parseInt(lastLogin) > 0) {
|
||||
sinceLongNotes = parseInt(lastLogin);
|
||||
} else {
|
||||
sinceLongNotes = 0;
|
||||
}
|
||||
}
|
||||
if (longNotes === 0) {
|
||||
sinceLongNotes = 0;
|
||||
} else {
|
||||
if (parseInt(lastLogin) > 0) {
|
||||
sinceLongNotes = parseInt(lastLogin);
|
||||
} else {
|
||||
sinceLongNotes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// kind 1 (notes) query
|
||||
query.push({
|
||||
kinds: [1, 6, 1063],
|
||||
authors: follows,
|
||||
since: sinceNotes,
|
||||
until: dateToUnix(now.current),
|
||||
});
|
||||
// kind 1 (notes) query
|
||||
query.push({
|
||||
kinds: [1, 6, 1063],
|
||||
authors: follows,
|
||||
since: sinceNotes,
|
||||
until: dateToUnix(now.current),
|
||||
});
|
||||
|
||||
// kind 4 (chats) query
|
||||
query.push({
|
||||
kinds: [4],
|
||||
'#p': [account.pubkey],
|
||||
since: 0,
|
||||
until: dateToUnix(now.current),
|
||||
});
|
||||
// kind 4 (chats) query
|
||||
query.push({
|
||||
kinds: [4],
|
||||
"#p": [account.pubkey],
|
||||
since: 0,
|
||||
until: dateToUnix(now.current),
|
||||
});
|
||||
|
||||
// kind 43, 43 (mute user, hide message) query
|
||||
query.push({
|
||||
authors: [account.pubkey],
|
||||
kinds: [43, 44],
|
||||
since: 0,
|
||||
until: dateToUnix(now.current),
|
||||
});
|
||||
// kind 43, 43 (mute user, hide message) query
|
||||
query.push({
|
||||
authors: [account.pubkey],
|
||||
kinds: [43, 44],
|
||||
since: 0,
|
||||
until: dateToUnix(now.current),
|
||||
});
|
||||
|
||||
// kind 30023 (long post) query
|
||||
query.push({
|
||||
kinds: [30023],
|
||||
since: sinceLongNotes,
|
||||
until: dateToUnix(now.current),
|
||||
});
|
||||
// kind 30023 (long post) query
|
||||
query.push({
|
||||
kinds: [30023],
|
||||
since: sinceLongNotes,
|
||||
until: dateToUnix(now.current),
|
||||
});
|
||||
|
||||
// subscribe relays
|
||||
unsubscribe = pool.subscribe(
|
||||
query,
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
switch (event.kind) {
|
||||
// short text note
|
||||
case 1:
|
||||
const parentID = getParentID(event.tags, event.id);
|
||||
// insert event to local database
|
||||
createNote(
|
||||
event.id,
|
||||
account.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at,
|
||||
parentID
|
||||
);
|
||||
break;
|
||||
// chat
|
||||
case 4:
|
||||
if (event.pubkey !== account.pubkey) {
|
||||
createChat(account.id, event.pubkey, event.created_at);
|
||||
}
|
||||
break;
|
||||
// repost
|
||||
case 6:
|
||||
createNote(
|
||||
event.id,
|
||||
account.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at,
|
||||
event.id
|
||||
);
|
||||
break;
|
||||
// hide message (channel only)
|
||||
case 43:
|
||||
if (event.tags[0][0] === 'e') {
|
||||
addToBlacklist(account.id, event.tags[0][1], 43, 1);
|
||||
}
|
||||
break;
|
||||
// mute user (channel only)
|
||||
case 44:
|
||||
if (event.tags[0][0] === 'p') {
|
||||
addToBlacklist(account.id, event.tags[0][1], 44, 1);
|
||||
}
|
||||
break;
|
||||
case 1063:
|
||||
createNote(
|
||||
event.id,
|
||||
account.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at,
|
||||
''
|
||||
);
|
||||
break;
|
||||
// long post
|
||||
case 30023:
|
||||
// insert event to local database
|
||||
createNote(
|
||||
event.id,
|
||||
account.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at,
|
||||
''
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
() => {
|
||||
updateLastLogin(dateToUnix(now.current));
|
||||
timeout = setTimeout(() => {
|
||||
navigate('/app/today', { overwriteLastHistoryEntry: true });
|
||||
}, 5000);
|
||||
}
|
||||
);
|
||||
};
|
||||
// subscribe relays
|
||||
unsubscribe = pool.subscribe(
|
||||
query,
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
switch (event.kind) {
|
||||
// short text note
|
||||
case 1: {
|
||||
const parentID = getParentID(event.tags, event.id);
|
||||
// insert event to local database
|
||||
createNote(
|
||||
event.id,
|
||||
account.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at,
|
||||
parentID,
|
||||
);
|
||||
break;
|
||||
}
|
||||
// chat
|
||||
case 4:
|
||||
if (event.pubkey !== account.pubkey) {
|
||||
createChat(account.id, event.pubkey, event.created_at);
|
||||
}
|
||||
break;
|
||||
// repost
|
||||
case 6:
|
||||
createNote(
|
||||
event.id,
|
||||
account.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at,
|
||||
event.id,
|
||||
);
|
||||
break;
|
||||
// hide message (channel only)
|
||||
case 43:
|
||||
if (event.tags[0][0] === "e") {
|
||||
addToBlacklist(account.id, event.tags[0][1], 43, 1);
|
||||
}
|
||||
break;
|
||||
// mute user (channel only)
|
||||
case 44:
|
||||
if (event.tags[0][0] === "p") {
|
||||
addToBlacklist(account.id, event.tags[0][1], 44, 1);
|
||||
}
|
||||
break;
|
||||
case 1063:
|
||||
createNote(
|
||||
event.id,
|
||||
account.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at,
|
||||
"",
|
||||
);
|
||||
break;
|
||||
// long post
|
||||
case 30023:
|
||||
// insert event to local database
|
||||
createNote(
|
||||
event.id,
|
||||
account.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at,
|
||||
"",
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
() => {
|
||||
updateLastLogin(dateToUnix(now.current));
|
||||
timeout = setTimeout(() => {
|
||||
navigate("/app/today", { overwriteLastHistoryEntry: true });
|
||||
}, 5000);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
fetchInitalData().catch(console.error);
|
||||
fetchInitalData().catch(console.error);
|
||||
|
||||
return () => {
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [pool]);
|
||||
return () => {
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [pool]);
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-white">
|
||||
<div className="relative h-full overflow-hidden">
|
||||
{/* dragging area */}
|
||||
<div data-tauri-drag-region className="absolute left-0 top-0 z-20 h-16 w-full bg-transparent" />
|
||||
{/* end dragging area */}
|
||||
<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-white" />
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold leading-tight text-zinc-900 dark:text-zinc-100">
|
||||
Here'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">
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-black dark:text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<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"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-white">
|
||||
<div className="relative h-full overflow-hidden">
|
||||
{/* dragging area */}
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="absolute left-0 top-0 z-20 h-16 w-full bg-transparent"
|
||||
/>
|
||||
{/* end dragging area */}
|
||||
<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-white" />
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold leading-tight text-zinc-900 dark:text-zinc-100">
|
||||
Here'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">
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-black dark:text-white"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1 +1 @@
|
||||
export { LayoutNewsfeed as Layout } from './layout';
|
||||
export { LayoutNewsfeed as Layout } from "./layout";
|
||||
|
@ -1,38 +1,41 @@
|
||||
import { Kind1 } from '@app/note/components/kind1';
|
||||
import { Kind1063 } from '@app/note/components/kind1063';
|
||||
import NoteMetadata from '@app/note/components/metadata';
|
||||
import { NoteParent } from '@app/note/components/parent';
|
||||
import { NoteDefaultUser } from '@app/note/components/user/default';
|
||||
import { NoteWrapper } from '@app/note/components/wrapper';
|
||||
import { Kind1 } from "@app/note/components/kind1";
|
||||
import { Kind1063 } from "@app/note/components/kind1063";
|
||||
import NoteMetadata from "@app/note/components/metadata";
|
||||
import { NoteParent } from "@app/note/components/parent";
|
||||
import { NoteDefaultUser } from "@app/note/components/user/default";
|
||||
import { NoteWrapper } from "@app/note/components/wrapper";
|
||||
|
||||
import { noteParser } from '@utils/parser';
|
||||
import { isTagsIncludeID } from '@utils/transform';
|
||||
import { noteParser } from "@utils/parser";
|
||||
import { isTagsIncludeID } from "@utils/transform";
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function NoteBase({ event }: { event: any }) {
|
||||
const content = useMemo(() => noteParser(event), [event]);
|
||||
const checkParentID = isTagsIncludeID(event.parent_id, event.tags);
|
||||
const content = useMemo(() => noteParser(event), [event]);
|
||||
const checkParentID = isTagsIncludeID(event.parent_id, event.tags);
|
||||
|
||||
const href = event.parent_id ? `/app/note?id=${event.parent_id}` : `/app/note?id=${event.event_id}`;
|
||||
const href = event.parent_id
|
||||
? `/app/note?id=${event.parent_id}`
|
||||
: `/app/note?id=${event.event_id}`;
|
||||
|
||||
return (
|
||||
<NoteWrapper href={href} className="h-min w-full px-3 py-1.5">
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900 px-3 pt-3 shadow-input shadow-black/20">
|
||||
{event.parent_id && (event.parent_id !== event.event_id || checkParentID) ? (
|
||||
<NoteParent id={event.parent_id} />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<NoteDefaultUser pubkey={event.pubkey} time={event.created_at} />
|
||||
<div className="mt-3 pl-[46px]">
|
||||
{event.kind === 1 && <Kind1 content={content} />}
|
||||
{event.kind === 1063 && <Kind1063 metadata={event.tags} />}
|
||||
<NoteMetadata id={event.event_id} eventPubkey={event.pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NoteWrapper>
|
||||
);
|
||||
return (
|
||||
<NoteWrapper href={href} className="h-min w-full px-3 py-1.5">
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900 px-3 pt-3 shadow-input shadow-black/20">
|
||||
{event.parent_id &&
|
||||
(event.parent_id !== event.event_id || checkParentID) ? (
|
||||
<NoteParent id={event.parent_id} />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<NoteDefaultUser pubkey={event.pubkey} time={event.created_at} />
|
||||
<div className="mt-3 pl-[46px]">
|
||||
{event.kind === 1 && <Kind1 content={content} />}
|
||||
{event.kind === 1063 && <Kind1063 metadata={event.tags} />}
|
||||
<NoteMetadata id={event.event_id} eventPubkey={event.pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NoteWrapper>
|
||||
);
|
||||
}
|
||||
|
@ -1,31 +1,41 @@
|
||||
import { MentionNote } from '@app/note/components/mentions/note';
|
||||
import { MentionUser } from '@app/note/components/mentions/user';
|
||||
import ImagePreview from '@app/note/components/preview/image';
|
||||
import VideoPreview from '@app/note/components/preview/video';
|
||||
import { MentionNote } from "@app/note/components/mentions/note";
|
||||
import { MentionUser } from "@app/note/components/mentions/user";
|
||||
import ImagePreview from "@app/note/components/preview/image";
|
||||
import VideoPreview from "@app/note/components/preview/video";
|
||||
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
export function Kind1({ content }: { content: any }) {
|
||||
return (
|
||||
<>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[[remarkGfm]]}
|
||||
linkTarget="_blank"
|
||||
className="prose prose-zinc max-w-none select-text break-words dark:prose-invert prose-p:text-[15px] prose-p:leading-tight prose-a:text-[15px] prose-a:font-normal prose-a:leading-tight prose-a:text-fuchsia-500 prose-a:no-underline hover:prose-a:text-fuchsia-600 hover:prose-a:underline prose-ol:mb-1 prose-ul:mb-1 prose-li:text-[15px] prose-li:leading-tight"
|
||||
components={{
|
||||
em: ({ ...props }) => <MentionUser {...props} />,
|
||||
}}
|
||||
>
|
||||
{content.parsed}
|
||||
</ReactMarkdown>
|
||||
{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.notes) && content.notes.length ? (
|
||||
content.notes.map((note: string) => <MentionNote key={note} id={note} />)
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[[remarkGfm]]}
|
||||
linkTarget="_blank"
|
||||
className="prose prose-zinc max-w-none select-text break-words dark:prose-invert prose-p:text-[15px] prose-p:leading-tight prose-a:text-[15px] prose-a:font-normal prose-a:leading-tight prose-a:text-fuchsia-500 prose-a:no-underline hover:prose-a:text-fuchsia-600 hover:prose-a:underline prose-ol:mb-1 prose-ul:mb-1 prose-li:text-[15px] prose-li:leading-tight"
|
||||
components={{
|
||||
em: ({ ...props }) => <MentionUser {...props} />,
|
||||
}}
|
||||
>
|
||||
{content.parsed}
|
||||
</ReactMarkdown>
|
||||
{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.notes) && content.notes.length ? (
|
||||
content.notes.map((note: string) => (
|
||||
<MentionNote key={note} id={note} />
|
||||
))
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1,15 +1,21 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
function isImage(url: string) {
|
||||
return /\.(jpg|jpeg|gif|png|webp|avif)$/.test(url);
|
||||
return /\.(jpg|jpeg|gif|png|webp|avif)$/.test(url);
|
||||
}
|
||||
|
||||
export function Kind1063({ metadata }: { metadata: string[] }) {
|
||||
const url = metadata[0][1];
|
||||
const url = metadata[0][1];
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
{isImage(url) && <Image src={url} alt="image" className="h-auto w-full rounded-lg object-cover" />}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="mt-3">
|
||||
{isImage(url) && (
|
||||
<Image
|
||||
src={url}
|
||||
alt="image"
|
||||
className="h-auto w-full rounded-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,60 +1,66 @@
|
||||
import { Kind1 } from '@app/note/components/kind1';
|
||||
import { Kind1063 } from '@app/note/components/kind1063';
|
||||
import { NoteSkeleton } from '@app/note/components/skeleton';
|
||||
import { NoteDefaultUser } from '@app/note/components/user/default';
|
||||
import { NoteWrapper } from '@app/note/components/wrapper';
|
||||
import { Kind1 } from "@app/note/components/kind1";
|
||||
import { Kind1063 } from "@app/note/components/kind1063";
|
||||
import { NoteSkeleton } from "@app/note/components/skeleton";
|
||||
import { NoteDefaultUser } from "@app/note/components/user/default";
|
||||
import { NoteWrapper } from "@app/note/components/wrapper";
|
||||
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import { READONLY_RELAYS } from '@stores/constants';
|
||||
import { READONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { noteParser } from '@utils/parser';
|
||||
import { noteParser } from "@utils/parser";
|
||||
|
||||
import { memo, useContext } from 'react';
|
||||
import useSWRSubscription from 'swr/subscription';
|
||||
import { memo, useContext } from "react";
|
||||
import useSWRSubscription from "swr/subscription";
|
||||
|
||||
export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const { data, error } = useSWRSubscription(id ? id : null, (key, { next }) => {
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
ids: [key],
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
next(null, event);
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
unsubscribeOnEose: true,
|
||||
}
|
||||
);
|
||||
const { data, error } = useSWRSubscription(
|
||||
id ? id : null,
|
||||
(key, { next }) => {
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
ids: [key],
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
next(null, event);
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
unsubscribeOnEose: true,
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const kind1 = !error && data?.kind === 1 ? noteParser(data) : null;
|
||||
const kind1063 = !error && data?.kind === 1063 ? data.tags : null;
|
||||
const kind1 = !error && data?.kind === 1 ? noteParser(data) : null;
|
||||
const kind1063 = !error && data?.kind === 1063 ? data.tags : null;
|
||||
|
||||
return (
|
||||
<NoteWrapper href={`/app/note?id=${id}`} className="mt-3 rounded-lg border border-zinc-800 px-3 py-3">
|
||||
{data ? (
|
||||
<>
|
||||
<NoteDefaultUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="mt-1 pl-[46px]">
|
||||
{kind1 && <Kind1 content={kind1} />}
|
||||
{kind1063 && <Kind1063 metadata={kind1063} />}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<NoteSkeleton />
|
||||
)}
|
||||
</NoteWrapper>
|
||||
);
|
||||
return (
|
||||
<NoteWrapper
|
||||
href={`/app/note?id=${id}`}
|
||||
className="mt-3 rounded-lg border border-zinc-800 px-3 py-3"
|
||||
>
|
||||
{data ? (
|
||||
<>
|
||||
<NoteDefaultUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="mt-1 pl-[46px]">
|
||||
{kind1 && <Kind1 content={kind1} />}
|
||||
{kind1063 && <Kind1063 metadata={kind1063} />}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<NoteSkeleton />
|
||||
)}
|
||||
</NoteWrapper>
|
||||
);
|
||||
});
|
||||
|
@ -1,9 +1,13 @@
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { shortenKey } from '@utils/shortenKey';
|
||||
import { useProfile } from "@utils/hooks/useProfile";
|
||||
import { shortenKey } from "@utils/shortenKey";
|
||||
|
||||
export function MentionUser(props: { children: any[] }) {
|
||||
const pubkey = props.children[0];
|
||||
const { user } = useProfile(pubkey);
|
||||
const pubkey = props.children[0];
|
||||
const { user } = useProfile(pubkey);
|
||||
|
||||
return <span className="text-fuchsia-500">@{user?.name || user?.display_name || shortenKey(pubkey)}</span>;
|
||||
return (
|
||||
<span className="text-fuchsia-500">
|
||||
@{user?.name || user?.display_name || shortenKey(pubkey)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
@ -1,67 +1,79 @@
|
||||
import NoteLike from '@app/note/components/metadata/like';
|
||||
import NoteReply from '@app/note/components/metadata/reply';
|
||||
import NoteRepost from '@app/note/components/metadata/repost';
|
||||
import NoteLike from "@app/note/components/metadata/like";
|
||||
import NoteReply from "@app/note/components/metadata/reply";
|
||||
import NoteRepost from "@app/note/components/metadata/repost";
|
||||
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import ZapIcon from '@icons/zap';
|
||||
import ZapIcon from "@icons/zap";
|
||||
|
||||
import { READONLY_RELAYS } from '@stores/constants';
|
||||
import { READONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { useContext, useState } from 'react';
|
||||
import useSWRSubscription from 'swr/subscription';
|
||||
import { useContext, useState } from "react";
|
||||
import useSWRSubscription from "swr/subscription";
|
||||
|
||||
export default function NoteMetadata({ id, eventPubkey }: { id: string; eventPubkey: string }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
export default function NoteMetadata({
|
||||
id,
|
||||
eventPubkey,
|
||||
}: { id: string; eventPubkey: string }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const [replies, setReplies] = useState(0);
|
||||
const [reposts, setReposts] = useState(0);
|
||||
const [likes, setLikes] = useState(0);
|
||||
const [replies, setReplies] = useState(0);
|
||||
const [reposts, setReposts] = useState(0);
|
||||
const [likes, setLikes] = useState(0);
|
||||
|
||||
useSWRSubscription(id ? ['note-metadata', id] : null, ([, key], {}) => {
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
'#e': [key],
|
||||
since: 0,
|
||||
kinds: [1, 6, 7],
|
||||
limit: 20,
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
switch (event.kind) {
|
||||
case 1:
|
||||
setReplies((replies) => replies + 1);
|
||||
break;
|
||||
case 6:
|
||||
setReposts((reposts) => reposts + 1);
|
||||
break;
|
||||
case 7:
|
||||
if (event.content === '🤙' || event.content === '+') {
|
||||
setLikes((likes) => likes + 1);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
);
|
||||
useSWRSubscription(id ? ["note-metadata", id] : null, ([, key]) => {
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
"#e": [key],
|
||||
since: 0,
|
||||
kinds: [1, 6, 7],
|
||||
limit: 20,
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
switch (event.kind) {
|
||||
case 1:
|
||||
setReplies((replies) => replies + 1);
|
||||
break;
|
||||
case 6:
|
||||
setReposts((reposts) => reposts + 1);
|
||||
break;
|
||||
case 7:
|
||||
if (event.content === "🤙" || event.content === "+") {
|
||||
setLikes((likes) => likes + 1);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex h-12 items-center gap-16 border-t border-zinc-800/50">
|
||||
<NoteReply id={id} replies={replies} />
|
||||
<NoteLike id={id} pubkey={eventPubkey} likes={likes} />
|
||||
<NoteRepost id={id} pubkey={eventPubkey} reposts={reposts} />
|
||||
<button className="group inline-flex w-min items-center gap-1.5">
|
||||
<ZapIcon width={20} height={20} className="text-zinc-400 group-hover:text-orange-400" />
|
||||
<span className="text-sm leading-none text-zinc-400 group-hover:text-zinc-200">{0}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="mt-4 flex h-12 items-center gap-16 border-t border-zinc-800/50">
|
||||
<NoteReply id={id} replies={replies} />
|
||||
<NoteLike id={id} pubkey={eventPubkey} likes={likes} />
|
||||
<NoteRepost id={id} pubkey={eventPubkey} reposts={reposts} />
|
||||
<button
|
||||
type="button"
|
||||
className="group inline-flex w-min items-center gap-1.5"
|
||||
>
|
||||
<ZapIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-zinc-400 group-hover:text-orange-400"
|
||||
/>
|
||||
<span className="text-sm leading-none text-zinc-400 group-hover:text-zinc-200">
|
||||
{0}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,54 +1,68 @@
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import LikeIcon from '@icons/like';
|
||||
import LikeIcon from "@icons/like";
|
||||
|
||||
import { WRITEONLY_RELAYS } from '@stores/constants';
|
||||
import { WRITEONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { dateToUnix } from '@utils/date';
|
||||
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
|
||||
import { dateToUnix } from "@utils/date";
|
||||
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
|
||||
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { getEventHash, signEvent } from "nostr-tools";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
|
||||
export default function NoteLike({ id, pubkey, likes }: { id: string; pubkey: string; likes: number }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
export default function NoteLike({
|
||||
id,
|
||||
pubkey,
|
||||
likes,
|
||||
}: { id: string; pubkey: string; likes: number }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
|
||||
const [count, setCount] = useState(0);
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
const submitEvent = (e: any) => {
|
||||
e.stopPropagation();
|
||||
const submitEvent = (e: any) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!isLoading && !isError && account) {
|
||||
const event: any = {
|
||||
content: '+',
|
||||
kind: 7,
|
||||
tags: [
|
||||
['e', id],
|
||||
['p', pubkey],
|
||||
],
|
||||
created_at: dateToUnix(),
|
||||
pubkey: account.pubkey,
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
// publish event to all relays
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// update state
|
||||
setCount(count + 1);
|
||||
} else {
|
||||
console.log('error');
|
||||
}
|
||||
};
|
||||
if (!isLoading && !isError && account) {
|
||||
const event: any = {
|
||||
content: "+",
|
||||
kind: 7,
|
||||
tags: [
|
||||
["e", id],
|
||||
["p", pubkey],
|
||||
],
|
||||
created_at: dateToUnix(),
|
||||
pubkey: account.pubkey,
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
// publish event to all relays
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// update state
|
||||
setCount(count + 1);
|
||||
} else {
|
||||
console.log("error");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setCount(likes);
|
||||
}, [likes]);
|
||||
useEffect(() => {
|
||||
setCount(likes);
|
||||
}, [likes]);
|
||||
|
||||
return (
|
||||
<button type="button" onClick={(e) => submitEvent(e)} className="group inline-flex w-min items-center gap-1.5">
|
||||
<LikeIcon width={16} height={16} className="text-zinc-400 group-hover:text-rose-400" />
|
||||
<span className="text-sm leading-none text-zinc-400 group-hover:text-zinc-200">{count}</span>
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => submitEvent(e)}
|
||||
className="group inline-flex w-min items-center gap-1.5"
|
||||
>
|
||||
<LikeIcon
|
||||
width={16}
|
||||
height={16}
|
||||
className="text-zinc-400 group-hover:text-rose-400"
|
||||
/>
|
||||
<span className="text-sm leading-none text-zinc-400 group-hover:text-zinc-200">
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
@ -1,132 +1,150 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { Image } from "@shared/image";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import ReplyIcon from '@icons/reply';
|
||||
import ReplyIcon from "@icons/reply";
|
||||
|
||||
import { WRITEONLY_RELAYS } from '@stores/constants';
|
||||
import { WRITEONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { dateToUnix } from '@utils/date';
|
||||
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
|
||||
import { dateToUnix } from "@utils/date";
|
||||
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
|
||||
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { Fragment, useContext, useEffect, useState } from 'react';
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { getEventHash, signEvent } from "nostr-tools";
|
||||
import { Fragment, useContext, useEffect, useState } from "react";
|
||||
|
||||
export default function NoteReply({ id, replies }: { id: string; replies: number }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
export default function NoteReply({
|
||||
id,
|
||||
replies,
|
||||
}: { id: string; replies: number }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const [count, setCount] = useState(0);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [value, setValue] = useState('');
|
||||
const [count, setCount] = useState(0);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
const profile = account ? JSON.parse(account.metadata) : null;
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
const profile = account ? JSON.parse(account.metadata) : null;
|
||||
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const openModal = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
const openModal = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const submitEvent = () => {
|
||||
if (!isLoading && !isError && account) {
|
||||
const event: any = {
|
||||
content: value,
|
||||
created_at: dateToUnix(),
|
||||
kind: 1,
|
||||
pubkey: account.pubkey,
|
||||
tags: [['e', id]],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
const submitEvent = () => {
|
||||
if (!isLoading && !isError && account) {
|
||||
const event: any = {
|
||||
content: value,
|
||||
created_at: dateToUnix(),
|
||||
kind: 1,
|
||||
pubkey: account.pubkey,
|
||||
tags: [["e", id]],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
|
||||
// publish event
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// close modal
|
||||
setIsOpen(false);
|
||||
setCount(count + 1);
|
||||
} else {
|
||||
console.log('error');
|
||||
}
|
||||
};
|
||||
// publish event
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// close modal
|
||||
setIsOpen(false);
|
||||
setCount(count + 1);
|
||||
} else {
|
||||
console.log("error");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setCount(replies);
|
||||
}, [replies]);
|
||||
useEffect(() => {
|
||||
setCount(replies);
|
||||
}, [replies]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => openModal()} className="group inline-flex w-min items-center gap-1.5">
|
||||
<ReplyIcon width={16} height={16} className="text-zinc-400 group-hover:text-green-400" />
|
||||
<span className="text-sm leading-none text-zinc-400 group-hover:text-zinc-200">{count}</span>
|
||||
</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 border-zinc-800 bg-zinc-900 p-3">
|
||||
{/* root note */}
|
||||
{/* comment form */}
|
||||
<div className="flex gap-2">
|
||||
<div>
|
||||
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-md border border-white/10">
|
||||
<Image src={profile?.picture} alt="user's avatar" className="h-11 w-11 rounded-md object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative h-24 w-full flex-1 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<div>
|
||||
<textarea
|
||||
name="content"
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="Send your comment"
|
||||
className="relative h-24 w-full resize-none rounded-md border border-black/5 px-3.5 py-3 text-sm shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-2 w-full px-2">
|
||||
<div className="flex w-full items-center justify-between bg-zinc-800">
|
||||
<div className="flex items-center gap-2 divide-x divide-zinc-700">
|
||||
<div className="flex items-center gap-2 pl-2"></div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => submitEvent()}
|
||||
disabled={value.length === 0 ? true : false}
|
||||
className="inline-flex h-8 w-16 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm font-medium shadow-md shadow-fuchsia-900/50 hover:bg-fuchsia-600"
|
||||
>
|
||||
<span className="text-white drop-shadow">Send</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openModal()}
|
||||
className="group inline-flex w-min items-center gap-1.5"
|
||||
>
|
||||
<ReplyIcon
|
||||
width={16}
|
||||
height={16}
|
||||
className="text-zinc-400 group-hover:text-green-400"
|
||||
/>
|
||||
<span className="text-sm leading-none text-zinc-400 group-hover:text-zinc-200">
|
||||
{count}
|
||||
</span>
|
||||
</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 border-zinc-800 bg-zinc-900 p-3">
|
||||
{/* root note */}
|
||||
{/* comment form */}
|
||||
<div className="flex gap-2">
|
||||
<div>
|
||||
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-md border border-white/10">
|
||||
<Image
|
||||
src={profile?.picture}
|
||||
alt="user's avatar"
|
||||
className="h-11 w-11 rounded-md object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative h-24 w-full flex-1 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<div>
|
||||
<textarea
|
||||
name="content"
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="Send your comment"
|
||||
className="relative h-24 w-full resize-none rounded-md border border-black/5 px-3.5 py-3 text-sm shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-2 w-full px-2">
|
||||
<div className="flex w-full items-center justify-between bg-zinc-800">
|
||||
<div className="flex items-center gap-2 divide-x divide-zinc-700">
|
||||
<div className="flex items-center gap-2 pl-2" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submitEvent()}
|
||||
disabled={value.length === 0 ? true : false}
|
||||
className="inline-flex h-8 w-16 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm font-medium shadow-md shadow-fuchsia-900/50 hover:bg-fuchsia-600"
|
||||
>
|
||||
<span className="text-white drop-shadow">Send</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1,54 +1,68 @@
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import RepostIcon from '@icons/repost';
|
||||
import RepostIcon from "@icons/repost";
|
||||
|
||||
import { WRITEONLY_RELAYS } from '@stores/constants';
|
||||
import { WRITEONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { dateToUnix } from '@utils/date';
|
||||
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
|
||||
import { dateToUnix } from "@utils/date";
|
||||
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
|
||||
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { getEventHash, signEvent } from "nostr-tools";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
|
||||
export default function NoteRepost({ id, pubkey, reposts }: { id: string; pubkey: string; reposts: number }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
export default function NoteRepost({
|
||||
id,
|
||||
pubkey,
|
||||
reposts,
|
||||
}: { id: string; pubkey: string; reposts: number }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
|
||||
const [count, setCount] = useState(0);
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
const submitEvent = (e: any) => {
|
||||
e.stopPropagation();
|
||||
const submitEvent = (e: any) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!isLoading && !isError && account) {
|
||||
const event: any = {
|
||||
content: '',
|
||||
kind: 6,
|
||||
tags: [
|
||||
['e', id],
|
||||
['p', pubkey],
|
||||
],
|
||||
created_at: dateToUnix(),
|
||||
pubkey: account.pubkey,
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
// publish event to all relays
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// update state
|
||||
setCount(count + 1);
|
||||
} else {
|
||||
console.log('error');
|
||||
}
|
||||
};
|
||||
if (!isLoading && !isError && account) {
|
||||
const event: any = {
|
||||
content: "",
|
||||
kind: 6,
|
||||
tags: [
|
||||
["e", id],
|
||||
["p", pubkey],
|
||||
],
|
||||
created_at: dateToUnix(),
|
||||
pubkey: account.pubkey,
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
// publish event to all relays
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// update state
|
||||
setCount(count + 1);
|
||||
} else {
|
||||
console.log("error");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setCount(reposts);
|
||||
}, [reposts]);
|
||||
useEffect(() => {
|
||||
setCount(reposts);
|
||||
}, [reposts]);
|
||||
|
||||
return (
|
||||
<button type="button" onClick={(e) => submitEvent(e)} className="group inline-flex w-min items-center gap-1.5">
|
||||
<RepostIcon width={16} height={16} className="text-zinc-400 group-hover:text-blue-400" />
|
||||
<span className="text-sm leading-none text-zinc-400 group-hover:text-zinc-200">{count}</span>
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => submitEvent(e)}
|
||||
className="group inline-flex w-min items-center gap-1.5"
|
||||
>
|
||||
<RepostIcon
|
||||
width={16}
|
||||
height={16}
|
||||
className="text-zinc-400 group-hover:text-blue-400"
|
||||
/>
|
||||
<span className="text-sm leading-none text-zinc-400 group-hover:text-zinc-200">
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
@ -1,62 +1,65 @@
|
||||
import { Kind1 } from '@app/note/components/kind1';
|
||||
import { Kind1063 } from '@app/note/components/kind1063';
|
||||
import NoteMetadata from '@app/note/components/metadata';
|
||||
import { NoteSkeleton } from '@app/note/components/skeleton';
|
||||
import { NoteDefaultUser } from '@app/note/components/user/default';
|
||||
import { Kind1 } from "@app/note/components/kind1";
|
||||
import { Kind1063 } from "@app/note/components/kind1063";
|
||||
import NoteMetadata from "@app/note/components/metadata";
|
||||
import { NoteSkeleton } from "@app/note/components/skeleton";
|
||||
import { NoteDefaultUser } from "@app/note/components/user/default";
|
||||
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import { READONLY_RELAYS } from '@stores/constants';
|
||||
import { READONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { noteParser } from '@utils/parser';
|
||||
import { noteParser } from "@utils/parser";
|
||||
|
||||
import { memo, useContext } from 'react';
|
||||
import useSWRSubscription from 'swr/subscription';
|
||||
import { memo, useContext } from "react";
|
||||
import useSWRSubscription from "swr/subscription";
|
||||
|
||||
export const NoteParent = memo(function NoteParent({ id }: { id: string }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const { data, error } = useSWRSubscription(id ? id : null, (key, { next }) => {
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
ids: [key],
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
next(null, event);
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
unsubscribeOnEose: true,
|
||||
}
|
||||
);
|
||||
const { data, error } = useSWRSubscription(
|
||||
id ? id : null,
|
||||
(key, { next }) => {
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
ids: [key],
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
next(null, event);
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
unsubscribeOnEose: true,
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const kind1 = !error && data?.kind === 1 ? noteParser(data) : null;
|
||||
const kind1063 = !error && data?.kind === 1063 ? data.tags : null;
|
||||
const kind1 = !error && data?.kind === 1 ? noteParser(data) : null;
|
||||
const kind1063 = !error && data?.kind === 1063 ? data.tags : null;
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col pb-6">
|
||||
<div className="absolute left-[16px] top-0 h-full w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600"></div>
|
||||
{data ? (
|
||||
<>
|
||||
<NoteDefaultUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="mt-3 pl-[46px]">
|
||||
{kind1 && <Kind1 content={kind1} />}
|
||||
{kind1063 && <Kind1063 metadata={kind1063} />}
|
||||
<NoteMetadata id={data.id} eventPubkey={data.pubkey} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<NoteSkeleton />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="relative flex flex-col pb-6">
|
||||
<div className="absolute left-[16px] top-0 h-full w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600" />
|
||||
{data ? (
|
||||
<>
|
||||
<NoteDefaultUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="mt-3 pl-[46px]">
|
||||
{kind1 && <Kind1 content={kind1} />}
|
||||
{kind1063 && <Kind1063 metadata={kind1063} />}
|
||||
<NoteMetadata id={data.id} eventPubkey={data.pubkey} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<NoteSkeleton />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
@ -1,11 +1,15 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
export default function ImagePreview({ urls }: { urls: string[] }) {
|
||||
return (
|
||||
<div className="mt-3 grid h-full w-full grid-cols-3">
|
||||
<div className="col-span-3">
|
||||
<Image src={urls[0]} alt="image" className="h-auto w-full rounded-lg object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="mt-3 grid h-full w-full grid-cols-3">
|
||||
<div className="col-span-3">
|
||||
<Image
|
||||
src={urls[0]}
|
||||
alt="image"
|
||||
className="h-auto w-full rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,14 +1,14 @@
|
||||
import { MediaOutlet, MediaPlayer } from '@vidstack/react';
|
||||
import { MediaOutlet, MediaPlayer } from "@vidstack/react";
|
||||
|
||||
export default function VideoPreview({ urls }: { urls: string[] }) {
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="relative mt-2 flex w-full flex-col overflow-hidden rounded-lg bg-zinc-950"
|
||||
>
|
||||
<MediaPlayer src={urls[0]} poster="" controls>
|
||||
<MediaOutlet />
|
||||
</MediaPlayer>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="relative mt-2 flex w-full flex-col overflow-hidden rounded-lg bg-zinc-950"
|
||||
>
|
||||
<MediaPlayer src={urls[0]} poster="" controls>
|
||||
<MediaOutlet />
|
||||
</MediaPlayer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,21 +1,24 @@
|
||||
import { RootNote } from '@app/note/components/rootNote';
|
||||
import { NoteRepostUser } from '@app/note/components/user/repost';
|
||||
import { NoteWrapper } from '@app/note/components/wrapper';
|
||||
import { RootNote } from "@app/note/components/rootNote";
|
||||
import { NoteRepostUser } from "@app/note/components/user/repost";
|
||||
import { NoteWrapper } from "@app/note/components/wrapper";
|
||||
|
||||
import { getQuoteID } from '@utils/transform';
|
||||
import { getQuoteID } from "@utils/transform";
|
||||
|
||||
export function NoteQuoteRepost({ event }: { event: any }) {
|
||||
const rootID = getQuoteID(event.tags);
|
||||
const rootID = getQuoteID(event.tags);
|
||||
|
||||
return (
|
||||
<NoteWrapper href={`/app/note?id=${rootID}`} className="h-min w-full px-3 py-1.5">
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
|
||||
<div className="relative px-3 pb-5 pt-3">
|
||||
<div className="absolute left-[29px] top-[20px] h-[70px] w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600"></div>
|
||||
<NoteRepostUser pubkey={event.pubkey} time={event.created_at} />
|
||||
</div>
|
||||
<RootNote id={rootID} fallback={event.content} />
|
||||
</div>
|
||||
</NoteWrapper>
|
||||
);
|
||||
return (
|
||||
<NoteWrapper
|
||||
href={`/app/note?id=${rootID}`}
|
||||
className="h-min w-full px-3 py-1.5"
|
||||
>
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
|
||||
<div className="relative px-3 pb-5 pt-3">
|
||||
<div className="absolute left-[29px] top-[20px] h-[70px] w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600" />
|
||||
<NoteRepostUser pubkey={event.pubkey} time={event.created_at} />
|
||||
</div>
|
||||
<RootNote id={rootID} fallback={event.content} />
|
||||
</div>
|
||||
</NoteWrapper>
|
||||
);
|
||||
}
|
||||
|
@ -1,74 +1,79 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { Image } from "@shared/image";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import { WRITEONLY_RELAYS } from '@stores/constants';
|
||||
import { WRITEONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { dateToUnix } from '@utils/date';
|
||||
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
|
||||
import { dateToUnix } from "@utils/date";
|
||||
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
|
||||
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { useContext, useState } from 'react';
|
||||
import { getEventHash, signEvent } from "nostr-tools";
|
||||
import { useContext, useState } from "react";
|
||||
|
||||
export default function NoteReplyForm({ id }: { id: string }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
const pool: any = useContext(RelayContext);
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
|
||||
const [value, setValue] = useState('');
|
||||
const profile = account ? JSON.parse(account.metadata) : null;
|
||||
const [value, setValue] = useState("");
|
||||
const profile = account ? JSON.parse(account.metadata) : null;
|
||||
|
||||
const submitEvent = () => {
|
||||
if (!isLoading && !isError && account) {
|
||||
const event: any = {
|
||||
content: value,
|
||||
created_at: dateToUnix(),
|
||||
kind: 1,
|
||||
pubkey: account.pubkey,
|
||||
tags: [['e', id]],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
const submitEvent = () => {
|
||||
if (!isLoading && !isError && account) {
|
||||
const event: any = {
|
||||
content: value,
|
||||
created_at: dateToUnix(),
|
||||
kind: 1,
|
||||
pubkey: account.pubkey,
|
||||
tags: [["e", id]],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
|
||||
// publish note
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// reset form
|
||||
setValue('');
|
||||
} else {
|
||||
console.log('error');
|
||||
}
|
||||
};
|
||||
// publish note
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// reset form
|
||||
setValue("");
|
||||
} else {
|
||||
console.log("error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2.5 px-3 py-4">
|
||||
<div>
|
||||
<div className="relative h-9 w-9 shrink-0 overflow-hidden rounded-md">
|
||||
<Image src={profile?.picture} alt={account?.pubkey} className="h-9 w-9 rounded-md object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative h-24 w-full flex-1 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] 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-[7px] 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">
|
||||
<div>
|
||||
<textarea
|
||||
name="content"
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="Reply to this thread..."
|
||||
className="relative h-24 w-full resize-none rounded-md border border-black/5 px-3.5 py-3 text-sm shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-2 w-full px-2">
|
||||
<div className="flex w-full items-center justify-between bg-zinc-800">
|
||||
<div className="flex items-center gap-2 divide-x divide-zinc-700"></div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => submitEvent()}
|
||||
disabled={value.length === 0 ? true : false}
|
||||
className="inline-flex h-8 w-16 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm font-medium shadow-button hover:bg-fuchsia-600 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Reply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex gap-2.5 px-3 py-4">
|
||||
<div>
|
||||
<div className="relative h-9 w-9 shrink-0 overflow-hidden rounded-md">
|
||||
<Image
|
||||
src={profile?.picture}
|
||||
alt={account?.pubkey}
|
||||
className="h-9 w-9 rounded-md object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative h-24 w-full flex-1 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] 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-[7px] 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">
|
||||
<div>
|
||||
<textarea
|
||||
name="content"
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="Reply to this thread..."
|
||||
className="relative h-24 w-full resize-none rounded-md border border-black/5 px-3.5 py-3 text-sm shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-2 w-full px-2">
|
||||
<div className="flex w-full items-center justify-between bg-zinc-800">
|
||||
<div className="flex items-center gap-2 divide-x divide-zinc-700" />
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submitEvent()}
|
||||
disabled={value.length === 0 ? true : false}
|
||||
className="inline-flex h-8 w-16 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm font-medium shadow-button hover:bg-fuchsia-600 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Reply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,19 +1,19 @@
|
||||
import { Kind1 } from '@app/note/components/kind1';
|
||||
import NoteReplyUser from '@app/note/components/user/reply';
|
||||
import { Kind1 } from "@app/note/components/kind1";
|
||||
import NoteReplyUser from "@app/note/components/user/reply";
|
||||
|
||||
import { noteParser } from '@utils/parser';
|
||||
import { noteParser } from "@utils/parser";
|
||||
|
||||
export default function Reply({ data }: { data: any }) {
|
||||
const content = noteParser(data);
|
||||
const content = noteParser(data);
|
||||
|
||||
return (
|
||||
<div className="flex h-min min-h-min w-full select-text flex-col px-3 py-3">
|
||||
<div className="flex flex-col">
|
||||
<NoteReplyUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="-mt-[18px] pl-[46px]">
|
||||
<Kind1 content={content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex h-min min-h-min w-full select-text flex-col px-3 py-3">
|
||||
<div className="flex flex-col">
|
||||
<NoteReplyUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="-mt-[18px] pl-[46px]">
|
||||
<Kind1 content={content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,66 +1,69 @@
|
||||
import NoteReplyForm from '@app/note/components/replies/form';
|
||||
import Reply from '@app/note/components/replies/item';
|
||||
import NoteReplyForm from "@app/note/components/replies/form";
|
||||
import Reply from "@app/note/components/replies/item";
|
||||
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import { READONLY_RELAYS } from '@stores/constants';
|
||||
import { READONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { sortEvents } from '@utils/transform';
|
||||
import { sortEvents } from "@utils/transform";
|
||||
|
||||
import { useContext } from 'react';
|
||||
import useSWRSubscription from 'swr/subscription';
|
||||
import { useContext } from "react";
|
||||
import useSWRSubscription from "swr/subscription";
|
||||
|
||||
export default function RepliesList({ id }: { id: string }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const { data, error } = useSWRSubscription(id ? ['note-replies', id] : null, ([, key], { next }) => {
|
||||
// subscribe to note
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
'#e': [key],
|
||||
since: 0,
|
||||
kinds: [1, 1063],
|
||||
limit: 20,
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
next(null, (prev: any) => (prev ? [...prev, event] : [event]));
|
||||
}
|
||||
);
|
||||
const { data, error } = useSWRSubscription(
|
||||
id ? ["note-replies", id] : null,
|
||||
([, key], { next }) => {
|
||||
// subscribe to note
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
"#e": [key],
|
||||
since: 0,
|
||||
kinds: [1, 1063],
|
||||
limit: 20,
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
next(null, (prev: any) => (prev ? [...prev, event] : [event]));
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-5">
|
||||
<div className="mb-2">
|
||||
<h5 className="text-lg font-semibold text-zinc-300">Replies</h5>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
|
||||
<div className="flex flex-col divide-y divide-zinc-800">
|
||||
<NoteReplyForm id={id} />
|
||||
{error && <div>failed to load</div>}
|
||||
{!data ? (
|
||||
<div className="flex gap-2 px-3 py-4">
|
||||
<div className="relative h-9 w-9 shrink animate-pulse rounded-md bg-zinc-800"></div>
|
||||
<div className="flex w-full flex-1 flex-col justify-center gap-1">
|
||||
<div className="flex items-baseline gap-2 text-sm">
|
||||
<div className="h-2.5 w-20 animate-pulse rounded-sm bg-zinc-800"></div>
|
||||
</div>
|
||||
<div className="h-4 w-44 animate-pulse rounded-sm bg-zinc-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
sortEvents(data).map((event: any) => {
|
||||
return <Reply key={event.id} data={event} />;
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="mt-5">
|
||||
<div className="mb-2">
|
||||
<h5 className="text-lg font-semibold text-zinc-300">Replies</h5>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
|
||||
<div className="flex flex-col divide-y divide-zinc-800">
|
||||
<NoteReplyForm id={id} />
|
||||
{error && <div>failed to load</div>}
|
||||
{!data ? (
|
||||
<div className="flex gap-2 px-3 py-4">
|
||||
<div className="relative h-9 w-9 shrink animate-pulse rounded-md bg-zinc-800" />
|
||||
<div className="flex w-full flex-1 flex-col justify-center gap-1">
|
||||
<div className="flex items-baseline gap-2 text-sm">
|
||||
<div className="h-2.5 w-20 animate-pulse rounded-sm bg-zinc-800" />
|
||||
</div>
|
||||
<div className="h-4 w-44 animate-pulse rounded-sm bg-zinc-800" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
sortEvents(data).map((event: any) => {
|
||||
return <Reply key={event.id} data={event} />;
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,95 +1,107 @@
|
||||
import { Kind1 } from '@app/note/components/kind1';
|
||||
import { Kind1063 } from '@app/note/components/kind1063';
|
||||
import NoteMetadata from '@app/note/components/metadata';
|
||||
import { NoteSkeleton } from '@app/note/components/skeleton';
|
||||
import { NoteDefaultUser } from '@app/note/components/user/default';
|
||||
import { Kind1 } from "@app/note/components/kind1";
|
||||
import { Kind1063 } from "@app/note/components/kind1063";
|
||||
import NoteMetadata from "@app/note/components/metadata";
|
||||
import { NoteSkeleton } from "@app/note/components/skeleton";
|
||||
import { NoteDefaultUser } from "@app/note/components/user/default";
|
||||
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import { READONLY_RELAYS } from '@stores/constants';
|
||||
import { READONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { noteParser } from '@utils/parser';
|
||||
import { noteParser } from "@utils/parser";
|
||||
|
||||
import { memo, useContext } from 'react';
|
||||
import useSWRSubscription from 'swr/subscription';
|
||||
import { navigate } from 'vite-plugin-ssr/client/router';
|
||||
import { memo, useContext } from "react";
|
||||
import useSWRSubscription from "swr/subscription";
|
||||
import { navigate } from "vite-plugin-ssr/client/router";
|
||||
|
||||
function isJSON(str: string) {
|
||||
try {
|
||||
JSON.parse(str);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
try {
|
||||
JSON.parse(str);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export const RootNote = memo(function RootNote({ id, fallback }: { id: string; fallback?: any }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const parseFallback = isJSON(fallback) ? JSON.parse(fallback) : null;
|
||||
export const RootNote = memo(function RootNote({
|
||||
id,
|
||||
fallback,
|
||||
}: { id: string; fallback?: any }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const parseFallback = isJSON(fallback) ? JSON.parse(fallback) : null;
|
||||
|
||||
const { data, error } = useSWRSubscription(parseFallback ? null : id, (key, { next }) => {
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
ids: [key],
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
next(null, event);
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
unsubscribeOnEose: true,
|
||||
}
|
||||
);
|
||||
const { data, error } = useSWRSubscription(
|
||||
parseFallback ? null : id,
|
||||
(key, { next }) => {
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
ids: [key],
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
next(null, event);
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
unsubscribeOnEose: true,
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const openNote = (e) => {
|
||||
const selection = window.getSelection();
|
||||
if (selection.toString().length === 0) {
|
||||
navigate(`/app/note?id=${id}`);
|
||||
} else {
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
const openNote = (e) => {
|
||||
const selection = window.getSelection();
|
||||
if (selection.toString().length === 0) {
|
||||
navigate(`/app/note?id=${id}`);
|
||||
} else {
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const kind1 = !error && data?.kind === 1 ? noteParser(data) : null;
|
||||
const kind1063 = !error && data?.kind === 1063 ? data.tags : null;
|
||||
const kind1 = !error && data?.kind === 1 ? noteParser(data) : null;
|
||||
const kind1063 = !error && data?.kind === 1063 ? data.tags : null;
|
||||
|
||||
if (parseFallback) {
|
||||
const contentFallback = noteParser(parseFallback);
|
||||
if (parseFallback) {
|
||||
const contentFallback = noteParser(parseFallback);
|
||||
|
||||
return (
|
||||
<div onClick={(e) => openNote(e)} className="flex flex-col px-3">
|
||||
<NoteDefaultUser pubkey={parseFallback.pubkey} time={parseFallback.created_at} />
|
||||
<div className="mt-3 pl-[46px]">
|
||||
<Kind1 content={contentFallback} />
|
||||
<NoteMetadata id={parseFallback.id} eventPubkey={parseFallback.pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div onKeyDown={(e) => openNote(e)} className="flex flex-col px-3">
|
||||
<NoteDefaultUser
|
||||
pubkey={parseFallback.pubkey}
|
||||
time={parseFallback.created_at}
|
||||
/>
|
||||
<div className="mt-3 pl-[46px]">
|
||||
<Kind1 content={contentFallback} />
|
||||
<NoteMetadata
|
||||
id={parseFallback.id}
|
||||
eventPubkey={parseFallback.pubkey}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div onClick={(e) => openNote(e)} className="flex flex-col px-3">
|
||||
{data ? (
|
||||
<>
|
||||
<NoteDefaultUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="mt-3 pl-[46px]">
|
||||
{kind1 && <Kind1 content={kind1} />}
|
||||
{kind1063 && <Kind1063 metadata={kind1063} />}
|
||||
<NoteMetadata id={data.id} eventPubkey={data.pubkey} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<NoteSkeleton />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div onKeyDown={(e) => openNote(e)} className="flex flex-col px-3">
|
||||
{data ? (
|
||||
<>
|
||||
<NoteDefaultUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="mt-3 pl-[46px]">
|
||||
{kind1 && <Kind1 content={kind1} />}
|
||||
{kind1063 && <Kind1063 metadata={kind1063} />}
|
||||
<NoteMetadata id={data.id} eventPubkey={data.pubkey} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<NoteSkeleton />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
@ -1,20 +1,20 @@
|
||||
export function NoteSkeleton() {
|
||||
return (
|
||||
<div className="flex h-min flex-col pb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="relative h-9 w-9 shrink overflow-hidden rounded-md bg-zinc-700" />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="h-3 w-20 rounded-sm bg-zinc-700" />
|
||||
<div className="h-2 w-12 rounded-sm bg-zinc-700" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 animate-pulse pl-[46px]">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="h-4 w-full rounded-sm bg-zinc-700" />
|
||||
<div className="h-4 w-2/3 rounded-sm bg-zinc-700" />
|
||||
<div className="h-4 w-1/2 rounded-sm bg-zinc-700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex h-min flex-col pb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="relative h-9 w-9 shrink overflow-hidden rounded-md bg-zinc-700" />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="h-3 w-20 rounded-sm bg-zinc-700" />
|
||||
<div className="h-2 w-12 rounded-sm bg-zinc-700" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 animate-pulse pl-[46px]">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="h-4 w-full rounded-sm bg-zinc-700" />
|
||||
<div className="h-4 w-2/3 rounded-sm bg-zinc-700" />
|
||||
<div className="h-4 w-1/2 rounded-sm bg-zinc-700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,94 +1,105 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
import { DEFAULT_AVATAR, IMGPROXY_URL } from '@stores/constants';
|
||||
import { DEFAULT_AVATAR, IMGPROXY_URL } from "@stores/constants";
|
||||
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { shortenKey } from '@utils/shortenKey';
|
||||
import { useProfile } from "@utils/hooks/useProfile";
|
||||
import { shortenKey } from "@utils/shortenKey";
|
||||
|
||||
import { Popover, Transition } from '@headlessui/react';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import { Fragment } from 'react';
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { Fragment } from "react";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export function NoteDefaultUser({ pubkey, time }: { pubkey: string; time: number }) {
|
||||
const { user } = useProfile(pubkey);
|
||||
export function NoteDefaultUser({
|
||||
pubkey,
|
||||
time,
|
||||
}: { pubkey: string; time: number }) {
|
||||
const { user } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<Popover className="relative flex items-center gap-2.5">
|
||||
<Popover.Button className="h-9 w-9 shrink-0 overflow-hidden rounded-md bg-zinc-900">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${user?.picture ? user.picture : DEFAULT_AVATAR}`}
|
||||
alt={pubkey}
|
||||
className="h-9 w-9 object-cover"
|
||||
/>
|
||||
</Popover.Button>
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h5 className="text-sm font-semibold leading-none">
|
||||
{user?.display_name || user?.name || <div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700"></div>}
|
||||
</h5>
|
||||
<div className="flex items-baseline gap-1.5 text-sm leading-none text-zinc-500">
|
||||
<span>{user?.nip05 || shortenKey(pubkey)}</span>
|
||||
<span>•</span>
|
||||
<span>{dayjs().to(dayjs.unix(time), true)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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 left-0 top-8 z-10 mt-3 w-screen max-w-sm px-4 sm:px-0 lg:max-w-3xl">
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full max-w-xs overflow-hidden rounded-lg border border-zinc-700 bg-zinc-900 shadow-input ring-1 ring-black ring-opacity-5"
|
||||
>
|
||||
<div className="flex items-start gap-2.5 border-b border-zinc-800 px-3 py-3">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:200:200/plain/${user?.picture ? user.picture : DEFAULT_AVATAR}`}
|
||||
alt={pubkey}
|
||||
className="h-14 w-14 shrink-0 rounded-lg object-cover"
|
||||
/>
|
||||
<div className="flex w-full flex-1 flex-col gap-2">
|
||||
<div className="inline-flex w-2/3 flex-col gap-0.5">
|
||||
<h5 className="text-sm font-semibold leading-none">
|
||||
{user?.display_name || user?.name || (
|
||||
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700"></div>
|
||||
)}
|
||||
</h5>
|
||||
<span className="truncate text-sm leading-none text-zinc-500">
|
||||
{user?.nip05 || shortenKey(pubkey)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="line-clamp-3 text-sm leading-tight text-zinc-100">{user?.about}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-3">
|
||||
<a
|
||||
href={`/app/user?pubkey=${pubkey}`}
|
||||
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-800 text-sm font-medium"
|
||||
>
|
||||
View full profile
|
||||
</a>
|
||||
<a
|
||||
href={`/app/chat?pubkey=${pubkey}`}
|
||||
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-800 text-sm font-medium"
|
||||
>
|
||||
Message
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</Popover>
|
||||
);
|
||||
return (
|
||||
<Popover className="relative flex items-center gap-2.5">
|
||||
<Popover.Button className="h-9 w-9 shrink-0 overflow-hidden rounded-md bg-zinc-900">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${
|
||||
user?.picture ? user.picture : DEFAULT_AVATAR
|
||||
}`}
|
||||
alt={pubkey}
|
||||
className="h-9 w-9 object-cover"
|
||||
/>
|
||||
</Popover.Button>
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h5 className="text-sm font-semibold leading-none">
|
||||
{user?.display_name || user?.name || (
|
||||
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700" />
|
||||
)}
|
||||
</h5>
|
||||
<div className="flex items-baseline gap-1.5 text-sm leading-none text-zinc-500">
|
||||
<span>{user?.nip05 || shortenKey(pubkey)}</span>
|
||||
<span>•</span>
|
||||
<span>{dayjs().to(dayjs.unix(time), true)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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 left-0 top-8 z-10 mt-3 w-screen max-w-sm px-4 sm:px-0 lg:max-w-3xl">
|
||||
<div
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="w-full max-w-xs overflow-hidden rounded-lg border border-zinc-700 bg-zinc-900 shadow-input ring-1 ring-black ring-opacity-5"
|
||||
>
|
||||
<div className="flex items-start gap-2.5 border-b border-zinc-800 px-3 py-3">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:200:200/plain/${
|
||||
user?.picture ? user.picture : DEFAULT_AVATAR
|
||||
}`}
|
||||
alt={pubkey}
|
||||
className="h-14 w-14 shrink-0 rounded-lg object-cover"
|
||||
/>
|
||||
<div className="flex w-full flex-1 flex-col gap-2">
|
||||
<div className="inline-flex w-2/3 flex-col gap-0.5">
|
||||
<h5 className="text-sm font-semibold leading-none">
|
||||
{user?.display_name || user?.name || (
|
||||
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700" />
|
||||
)}
|
||||
</h5>
|
||||
<span className="truncate text-sm leading-none text-zinc-500">
|
||||
{user?.nip05 || shortenKey(pubkey)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="line-clamp-3 text-sm leading-tight text-zinc-100">
|
||||
{user?.about}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-3">
|
||||
<a
|
||||
href={`/app/user?pubkey=${pubkey}`}
|
||||
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-800 text-sm font-medium"
|
||||
>
|
||||
View full profile
|
||||
</a>
|
||||
<a
|
||||
href={`/app/chat?pubkey=${pubkey}`}
|
||||
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-800 text-sm font-medium"
|
||||
>
|
||||
Message
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
@ -1,36 +1,43 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
import { DEFAULT_AVATAR, IMGPROXY_URL } from '@stores/constants';
|
||||
import { DEFAULT_AVATAR, IMGPROXY_URL } from "@stores/constants";
|
||||
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { shortenKey } from '@utils/shortenKey';
|
||||
import { useProfile } from "@utils/hooks/useProfile";
|
||||
import { shortenKey } from "@utils/shortenKey";
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export default function NoteReplyUser({ pubkey, time }: { pubkey: string; time: number }) {
|
||||
const { user } = useProfile(pubkey);
|
||||
export default function NoteReplyUser({
|
||||
pubkey,
|
||||
time,
|
||||
}: { pubkey: string; time: number }) {
|
||||
const { user } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<div className="group flex items-start gap-2.5">
|
||||
<div className="relative h-9 w-9 shrink-0 rounded-md">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${user?.picture ? user.picture : DEFAULT_AVATAR}`}
|
||||
alt={pubkey}
|
||||
className="h-9 w-9 rounded-md object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex items-baseline gap-2 text-sm">
|
||||
<span className="font-semibold leading-none text-zinc-200 group-hover:underline">
|
||||
{user?.display_name || user?.name || shortenKey(pubkey)}
|
||||
</span>
|
||||
<span className="leading-none text-zinc-500">·</span>
|
||||
<span className="leading-none text-zinc-500">{dayjs().to(dayjs.unix(time), true)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="group flex items-start gap-2.5">
|
||||
<div className="relative h-9 w-9 shrink-0 rounded-md">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${
|
||||
user?.picture ? user.picture : DEFAULT_AVATAR
|
||||
}`}
|
||||
alt={pubkey}
|
||||
className="h-9 w-9 rounded-md object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex items-baseline gap-2 text-sm">
|
||||
<span className="font-semibold leading-none text-zinc-200 group-hover:underline">
|
||||
{user?.display_name || user?.name || shortenKey(pubkey)}
|
||||
</span>
|
||||
<span className="leading-none text-zinc-500">·</span>
|
||||
<span className="leading-none text-zinc-500">
|
||||
{dayjs().to(dayjs.unix(time), true)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,93 +1,106 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
import { DEFAULT_AVATAR, IMGPROXY_URL } from '@stores/constants';
|
||||
import { DEFAULT_AVATAR, IMGPROXY_URL } from "@stores/constants";
|
||||
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { shortenKey } from '@utils/shortenKey';
|
||||
import { useProfile } from "@utils/hooks/useProfile";
|
||||
import { shortenKey } from "@utils/shortenKey";
|
||||
|
||||
import { Popover, Transition } from '@headlessui/react';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import { Fragment } from 'react';
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { Fragment } from "react";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export function NoteRepostUser({ pubkey, time }: { pubkey: string; time: number }) {
|
||||
const { user } = useProfile(pubkey);
|
||||
export function NoteRepostUser({
|
||||
pubkey,
|
||||
time,
|
||||
}: { pubkey: string; time: number }) {
|
||||
const { user } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<Popover className="relative flex items-center gap-2.5">
|
||||
<Popover.Button className="h-9 w-9 shrink-0 overflow-hidden rounded-md bg-zinc-900">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${user?.picture ? user.picture : DEFAULT_AVATAR}`}
|
||||
alt={pubkey}
|
||||
className="h-9 w-9 rounded-md object-cover"
|
||||
/>
|
||||
</Popover.Button>
|
||||
<div className="flex items-baseline gap-1.5 text-sm">
|
||||
<h5 className="font-semibold leading-tight group-hover:underline">
|
||||
{user?.display_name || user?.name || <div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700"></div>}
|
||||
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-transparent">
|
||||
{' '}
|
||||
reposted
|
||||
</span>
|
||||
</h5>
|
||||
<span className="leading-tight text-zinc-500">·</span>
|
||||
<span className="text-zinc-500">{dayjs().to(dayjs.unix(time), true)}</span>
|
||||
</div>
|
||||
<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 left-0 top-8 z-10 mt-3 w-screen max-w-sm px-4 sm:px-0 lg:max-w-3xl">
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full max-w-xs overflow-hidden rounded-lg border border-zinc-700 bg-zinc-900 shadow-input ring-1 ring-black ring-opacity-5"
|
||||
>
|
||||
<div className="flex items-start gap-2.5 border-b border-zinc-800 px-3 py-3">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:200:200/plain/${user?.picture ? user.picture : DEFAULT_AVATAR}`}
|
||||
alt={pubkey}
|
||||
className="h-14 w-14 shrink-0 rounded-lg object-cover"
|
||||
/>
|
||||
<div className="flex w-full flex-1 flex-col gap-2">
|
||||
<div className="inline-flex w-2/3 flex-col gap-0.5">
|
||||
<h5 className="text-sm font-semibold leading-none">
|
||||
{user?.display_name || user?.name || (
|
||||
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700"></div>
|
||||
)}
|
||||
</h5>
|
||||
<span className="truncate text-sm leading-none text-zinc-500">
|
||||
{user?.nip05 || shortenKey(pubkey)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="line-clamp-3 text-sm leading-tight text-zinc-100">{user?.about}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-3">
|
||||
<a
|
||||
href={`/app/user?pubkey=${pubkey}`}
|
||||
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-800 text-sm font-medium"
|
||||
>
|
||||
View full profile
|
||||
</a>
|
||||
<a
|
||||
href={`/app/chat?pubkey=${pubkey}`}
|
||||
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-800 text-sm font-medium"
|
||||
>
|
||||
Message
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</Popover>
|
||||
);
|
||||
return (
|
||||
<Popover className="relative flex items-center gap-2.5">
|
||||
<Popover.Button className="h-9 w-9 shrink-0 overflow-hidden rounded-md bg-zinc-900">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${
|
||||
user?.picture ? user.picture : DEFAULT_AVATAR
|
||||
}`}
|
||||
alt={pubkey}
|
||||
className="h-9 w-9 rounded-md object-cover"
|
||||
/>
|
||||
</Popover.Button>
|
||||
<div className="flex items-baseline gap-1.5 text-sm">
|
||||
<h5 className="font-semibold leading-tight group-hover:underline">
|
||||
{user?.display_name || user?.name || (
|
||||
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700" />
|
||||
)}
|
||||
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-transparent">
|
||||
{" "}
|
||||
reposted
|
||||
</span>
|
||||
</h5>
|
||||
<span className="leading-tight text-zinc-500">·</span>
|
||||
<span className="text-zinc-500">
|
||||
{dayjs().to(dayjs.unix(time), true)}
|
||||
</span>
|
||||
</div>
|
||||
<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 left-0 top-8 z-10 mt-3 w-screen max-w-sm px-4 sm:px-0 lg:max-w-3xl">
|
||||
<div
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="w-full max-w-xs overflow-hidden rounded-lg border border-zinc-700 bg-zinc-900 shadow-input ring-1 ring-black ring-opacity-5"
|
||||
>
|
||||
<div className="flex items-start gap-2.5 border-b border-zinc-800 px-3 py-3">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:200:200/plain/${
|
||||
user?.picture ? user.picture : DEFAULT_AVATAR
|
||||
}`}
|
||||
alt={pubkey}
|
||||
className="h-14 w-14 shrink-0 rounded-lg object-cover"
|
||||
/>
|
||||
<div className="flex w-full flex-1 flex-col gap-2">
|
||||
<div className="inline-flex w-2/3 flex-col gap-0.5">
|
||||
<h5 className="text-sm font-semibold leading-none">
|
||||
{user?.display_name || user?.name || (
|
||||
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700" />
|
||||
)}
|
||||
</h5>
|
||||
<span className="truncate text-sm leading-none text-zinc-500">
|
||||
{user?.nip05 || shortenKey(pubkey)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="line-clamp-3 text-sm leading-tight text-zinc-100">
|
||||
{user?.about}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-3">
|
||||
<a
|
||||
href={`/app/user?pubkey=${pubkey}`}
|
||||
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-800 text-sm font-medium"
|
||||
>
|
||||
View full profile
|
||||
</a>
|
||||
<a
|
||||
href={`/app/chat?pubkey=${pubkey}`}
|
||||
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-800 text-sm font-medium"
|
||||
>
|
||||
Message
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
@ -1,26 +1,26 @@
|
||||
import { navigate } from 'vite-plugin-ssr/client/router';
|
||||
import { navigate } from "vite-plugin-ssr/client/router";
|
||||
|
||||
export function NoteWrapper({
|
||||
children,
|
||||
href,
|
||||
className,
|
||||
children,
|
||||
href,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
href: string;
|
||||
className: string;
|
||||
children: React.ReactNode;
|
||||
href: string;
|
||||
className: string;
|
||||
}) {
|
||||
const openThread = (event: any, href: string) => {
|
||||
const selection = window.getSelection();
|
||||
if (selection.toString().length === 0) {
|
||||
navigate(href, { keepScrollPosition: true });
|
||||
} else {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
const openThread = (event: any, href: string) => {
|
||||
const selection = window.getSelection();
|
||||
if (selection.toString().length === 0) {
|
||||
navigate(href, { keepScrollPosition: true });
|
||||
} else {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div onClick={(event) => openThread(event, href)} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div onKeyDown={(event) => openThread(event, href)} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,29 +1,31 @@
|
||||
import AppHeader from '@shared/appHeader';
|
||||
import MultiAccounts from '@shared/multiAccounts';
|
||||
import Navigation from '@shared/navigation';
|
||||
import AppHeader from "@shared/appHeader";
|
||||
import MultiAccounts from "@shared/multiAccounts";
|
||||
import Navigation from "@shared/navigation";
|
||||
|
||||
export function LayoutNewsfeed({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white">
|
||||
<div className="flex h-screen w-full flex-col">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="relative h-9 shrink-0 border-b border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
|
||||
>
|
||||
<AppHeader />
|
||||
</div>
|
||||
<div className="relative flex min-h-0 w-full flex-1">
|
||||
<div className="relative w-[68px] shrink-0 border-r border-zinc-900">
|
||||
<MultiAccounts />
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-4 xl:grid-cols-5">
|
||||
<div className="scrollbar-hide col-span-1 overflow-y-auto overflow-x-hidden border-r border-zinc-900">
|
||||
<Navigation />
|
||||
</div>
|
||||
<div className="col-span-3 overflow-hidden xl:col-span-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white">
|
||||
<div className="flex h-screen w-full flex-col">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="relative h-9 shrink-0 border-b border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
|
||||
>
|
||||
<AppHeader />
|
||||
</div>
|
||||
<div className="relative flex min-h-0 w-full flex-1">
|
||||
<div className="relative w-[68px] shrink-0 border-r border-zinc-900">
|
||||
<MultiAccounts />
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-4 xl:grid-cols-5">
|
||||
<div className="scrollbar-hide col-span-1 overflow-y-auto overflow-x-hidden border-r border-zinc-900">
|
||||
<Navigation />
|
||||
</div>
|
||||
<div className="col-span-3 overflow-hidden xl:col-span-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,86 +1,89 @@
|
||||
import { Kind1 } from '@app/note/components/kind1';
|
||||
import NoteMetadata from '@app/note/components/metadata';
|
||||
import RepliesList from '@app/note/components/replies/list';
|
||||
import { NoteDefaultUser } from '@app/note/components/user/default';
|
||||
import { Kind1 } from "@app/note/components/kind1";
|
||||
import NoteMetadata from "@app/note/components/metadata";
|
||||
import RepliesList from "@app/note/components/replies/list";
|
||||
import { NoteDefaultUser } from "@app/note/components/user/default";
|
||||
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import { READONLY_RELAYS } from '@stores/constants';
|
||||
import { READONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { usePageContext } from '@utils/hooks/usePageContext';
|
||||
import { noteParser } from '@utils/parser';
|
||||
import { usePageContext } from "@utils/hooks/usePageContext";
|
||||
import { noteParser } from "@utils/parser";
|
||||
|
||||
import { useContext } from 'react';
|
||||
import useSWRSubscription from 'swr/subscription';
|
||||
import { useContext } from "react";
|
||||
import useSWRSubscription from "swr/subscription";
|
||||
|
||||
export function Page() {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const pageContext = usePageContext();
|
||||
const searchParams: any = pageContext.urlParsed.search;
|
||||
const pool: any = useContext(RelayContext);
|
||||
const pageContext = usePageContext();
|
||||
const searchParams: any = pageContext.urlParsed.search;
|
||||
|
||||
const noteID = searchParams.id;
|
||||
const noteID = searchParams.id;
|
||||
|
||||
const { data, error } = useSWRSubscription(noteID ? ['note', noteID] : null, ([, key], { next }) => {
|
||||
// subscribe to note
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
ids: [key],
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
next(null, event);
|
||||
}
|
||||
);
|
||||
const { data, error } = useSWRSubscription(
|
||||
noteID ? ["note", noteID] : null,
|
||||
([, key], { next }) => {
|
||||
// subscribe to note
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
ids: [key],
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
next(null, event);
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const content = !error && data ? noteParser(data) : null;
|
||||
const content = !error && data ? noteParser(data) : null;
|
||||
|
||||
return (
|
||||
<div className="scrollbar-hide h-full w-full overflow-y-auto">
|
||||
<div className="p-3">
|
||||
<div className="relative w-full rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
|
||||
{!data || error ? (
|
||||
<div className="animated-pulse p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-zinc-700" />
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="h-4 w-16 rounded bg-zinc-700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="h-16 w-full rounded bg-zinc-700" />
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="h-4 w-12 rounded bg-zinc-700" />
|
||||
<div className="h-4 w-12 rounded bg-zinc-700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative z-10 flex flex-col">
|
||||
<div className="px-3 pt-3">
|
||||
<NoteDefaultUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="mt-3">
|
||||
<Kind1 content={content} />
|
||||
<NoteMetadata id={noteID} eventPubkey={data.pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<RepliesList id={noteID} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="scrollbar-hide h-full w-full overflow-y-auto">
|
||||
<div className="p-3">
|
||||
<div className="relative w-full rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
|
||||
{!data || error ? (
|
||||
<div className="animated-pulse p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-zinc-700" />
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="h-4 w-16 rounded bg-zinc-700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="h-16 w-full rounded bg-zinc-700" />
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="h-4 w-12 rounded bg-zinc-700" />
|
||||
<div className="h-4 w-12 rounded bg-zinc-700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative z-10 flex flex-col">
|
||||
<div className="px-3 pt-3">
|
||||
<NoteDefaultUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="mt-3">
|
||||
<Kind1 content={content} />
|
||||
<NoteMetadata id={noteID} eventPubkey={data.pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<RepliesList id={noteID} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
export function Page() {
|
||||
return (
|
||||
<div>
|
||||
<p>Space</p>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<p>Space</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
export function Page() {
|
||||
return (
|
||||
<div>
|
||||
<p>MySpace</p>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<p>MySpace</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1 +1 @@
|
||||
export { LayoutNewsfeed as Layout } from './layout';
|
||||
export { LayoutNewsfeed as Layout } from "./layout";
|
||||
|
@ -1,45 +1,58 @@
|
||||
import { CreateViewModal } from '@app/today/components/views/createModal';
|
||||
import { CreateViewModal } from "@app/today/components/views/createModal";
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
<div className="flex w-full gap-4">
|
||||
<button className="from-zinc-90 inline-flex h-11 items-center overflow-hidden border-b border-fuchsia-500 hover:bg-zinc-900">
|
||||
<span className="px-2 text-sm font-semibold text-zinc-300">Following</span>
|
||||
</button>
|
||||
<div className="flex h-11 items-center -space-x-1 overflow-hidden">
|
||||
<img className="inline-block h-6 w-6 rounded ring-2 ring-zinc-950" src="https://133332.xyz/p.jpg" alt="" />
|
||||
<img
|
||||
className="inline-block h-6 w-6 rounded ring-2 ring-zinc-950"
|
||||
src="https://void.cat/d/3Bp6jSHURFNQ9u3pK8nwtq.webp"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
className="inline-block h-6 w-6 rounded ring-2 ring-zinc-950"
|
||||
src="https://void.cat/d/8zE9T8a39YfUVjrLM4xcpE.webp"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
className="ring-zinc-95 ring-20 inline-block h-6 w-6 rounded"
|
||||
src="https://nostr.build/i/p/nostr.build_0e412058980ed2ac4adf3de639304c9e970e2745ba9ca19c75f984f4f6da4971.jpeg"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
className="ring-zinc-95 ring-20 inline-block h-6 w-6 rounded"
|
||||
src="https://davidcoen.it/wp-content/uploads/2020/11/7004972-taglio.jpg"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-11 items-center overflow-hidden">
|
||||
<img
|
||||
className="ring-zinc-95 ring-20 inline-block h-6 w-6 rounded"
|
||||
src="https://void.cat/d/KvAEMvYNmy1rfCH6a7HZzh.webp"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-11 items-center overflow-hidden">
|
||||
<img className="ring-zinc-95 ring-20 inline-block h-6 w-6 rounded" src="http://nostr.build/i/6369.jpg" alt="" />
|
||||
</div>
|
||||
<CreateViewModal />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex w-full gap-4">
|
||||
<button
|
||||
type="button"
|
||||
className="from-zinc-90 inline-flex h-11 items-center overflow-hidden border-b border-fuchsia-500 hover:bg-zinc-900"
|
||||
>
|
||||
<span className="px-2 text-sm font-semibold text-zinc-300">
|
||||
Following
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex h-11 items-center -space-x-1 overflow-hidden">
|
||||
<img
|
||||
className="inline-block h-6 w-6 rounded ring-2 ring-zinc-950"
|
||||
src="https://133332.xyz/p.jpg"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
className="inline-block h-6 w-6 rounded ring-2 ring-zinc-950"
|
||||
src="https://void.cat/d/3Bp6jSHURFNQ9u3pK8nwtq.webp"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
className="inline-block h-6 w-6 rounded ring-2 ring-zinc-950"
|
||||
src="https://void.cat/d/8zE9T8a39YfUVjrLM4xcpE.webp"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
className="ring-zinc-95 ring-20 inline-block h-6 w-6 rounded"
|
||||
src="https://nostr.build/i/p/nostr.build_0e412058980ed2ac4adf3de639304c9e970e2745ba9ca19c75f984f4f6da4971.jpeg"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
className="ring-zinc-95 ring-20 inline-block h-6 w-6 rounded"
|
||||
src="https://davidcoen.it/wp-content/uploads/2020/11/7004972-taglio.jpg"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-11 items-center overflow-hidden">
|
||||
<img
|
||||
className="ring-zinc-95 ring-20 inline-block h-6 w-6 rounded"
|
||||
src="https://void.cat/d/KvAEMvYNmy1rfCH6a7HZzh.webp"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-11 items-center overflow-hidden">
|
||||
<img
|
||||
className="ring-zinc-95 ring-20 inline-block h-6 w-6 rounded"
|
||||
src="http://nostr.build/i/6369.jpg"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<CreateViewModal />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,86 +1,89 @@
|
||||
import CancelIcon from '@icons/cancel';
|
||||
import PlusIcon from '@icons/plus';
|
||||
import CancelIcon from "@icons/cancel";
|
||||
import PlusIcon from "@icons/plus";
|
||||
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { Fragment, useState } from 'react';
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { Fragment, useState } from "react";
|
||||
|
||||
export function CreateViewModal() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const openModal = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
const openModal = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openModal}
|
||||
className="inline-flex h-11 items-center overflow-hidden border-b border-transparent hover:bg-zinc-900"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1 px-2 text-sm font-medium text-zinc-500">
|
||||
<PlusIcon width={14} height={14} />
|
||||
View
|
||||
</span>
|
||||
</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 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-1">
|
||||
<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"
|
||||
>
|
||||
Create a view
|
||||
</Dialog.Title>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
autoFocus={false}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
|
||||
>
|
||||
<CancelIcon width={16} height={16} className="text-zinc-400" />
|
||||
</button>
|
||||
</div>
|
||||
<Dialog.Description className="text-sm leading-tight text-zinc-400">
|
||||
View is specific feature help you pin who you want to see in your feed. You can add maximum 5
|
||||
people in a view.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto pb-5 pt-3"></div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openModal}
|
||||
className="inline-flex h-11 items-center overflow-hidden border-b border-transparent hover:bg-zinc-900"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1 px-2 text-sm font-medium text-zinc-500">
|
||||
<PlusIcon width={14} height={14} />
|
||||
View
|
||||
</span>
|
||||
</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 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-1">
|
||||
<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"
|
||||
>
|
||||
Create a view
|
||||
</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={16}
|
||||
height={16}
|
||||
className="text-zinc-400"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<Dialog.Description className="text-sm leading-tight text-zinc-400">
|
||||
View is specific feature help you pin who you want to see
|
||||
in your feed. You can add maximum 5 people in a view.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto pb-5 pt-3" />
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1,29 +1,31 @@
|
||||
import AppHeader from '@shared/appHeader';
|
||||
import MultiAccounts from '@shared/multiAccounts';
|
||||
import Navigation from '@shared/navigation';
|
||||
import AppHeader from "@shared/appHeader";
|
||||
import MultiAccounts from "@shared/multiAccounts";
|
||||
import Navigation from "@shared/navigation";
|
||||
|
||||
export function LayoutNewsfeed({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white">
|
||||
<div className="flex h-screen w-full flex-col">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="relative h-9 shrink-0 border-b border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
|
||||
>
|
||||
<AppHeader />
|
||||
</div>
|
||||
<div className="relative flex min-h-0 w-full flex-1">
|
||||
<div className="relative w-[68px] shrink-0 border-r border-zinc-900">
|
||||
<MultiAccounts />
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-4 xl:grid-cols-5">
|
||||
<div className="scrollbar-hide col-span-1 overflow-y-auto overflow-x-hidden border-r border-zinc-900">
|
||||
<Navigation />
|
||||
</div>
|
||||
<div className="col-span-3 overflow-hidden xl:col-span-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white">
|
||||
<div className="flex h-screen w-full flex-col">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="relative h-9 shrink-0 border-b border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
|
||||
>
|
||||
<AppHeader />
|
||||
</div>
|
||||
<div className="relative flex min-h-0 w-full flex-1">
|
||||
<div className="relative w-[68px] shrink-0 border-r border-zinc-900">
|
||||
<MultiAccounts />
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-4 xl:grid-cols-5">
|
||||
<div className="scrollbar-hide col-span-1 overflow-y-auto overflow-x-hidden border-r border-zinc-900">
|
||||
<Navigation />
|
||||
</div>
|
||||
<div className="col-span-3 overflow-hidden xl:col-span-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,113 +1,136 @@
|
||||
import { NoteBase } from '@app/note/components/base';
|
||||
import { NoteQuoteRepost } from '@app/note/components/quoteRepost';
|
||||
import { NoteSkeleton } from '@app/note/components/skeleton';
|
||||
import { Header } from '@app/today/components/header';
|
||||
import { NoteBase } from "@app/note/components/base";
|
||||
import { NoteQuoteRepost } from "@app/note/components/quoteRepost";
|
||||
import { NoteSkeleton } from "@app/note/components/skeleton";
|
||||
import { Header } from "@app/today/components/header";
|
||||
|
||||
import { getNotes } from '@utils/storage';
|
||||
import { getNotes } from "@utils/storage";
|
||||
|
||||
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";
|
||||
|
||||
const ITEM_PER_PAGE = 10;
|
||||
const TIME = Math.floor(Date.now() / 1000);
|
||||
|
||||
export function Page() {
|
||||
const { status, error, data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage }: any = useInfiniteQuery({
|
||||
queryKey: ['following'],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
return await getNotes(TIME, ITEM_PER_PAGE, pageParam);
|
||||
},
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
});
|
||||
const {
|
||||
status,
|
||||
error,
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
}: any = useInfiniteQuery({
|
||||
queryKey: ["following"],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
return await getNotes(TIME, ITEM_PER_PAGE, pageParam);
|
||||
},
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
});
|
||||
|
||||
const allRows = data ? data.pages.flatMap((d: { data: any }) => d.data) : [];
|
||||
const parentRef = useRef();
|
||||
const allRows = data ? data.pages.flatMap((d: { data: any }) => d.data) : [];
|
||||
const parentRef = useRef();
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: hasNextPage ? allRows.length + 1 : allRows.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 400,
|
||||
overscan: 2,
|
||||
});
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: hasNextPage ? allRows.length + 1 : allRows.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 400,
|
||||
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 >= allRows.length - 1 && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fetchNextPage, allRows.length, rowVirtualizer.getVirtualItems()]);
|
||||
if (
|
||||
lastItem.index >= allRows.length - 1 &&
|
||||
hasNextPage &&
|
||||
!isFetchingNextPage
|
||||
) {
|
||||
fetchNextPage();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fetchNextPage, allRows.length, rowVirtualizer.getVirtualItems()]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="scrollbar-hide flex h-full flex-col justify-between gap-1.5 overflow-y-auto"
|
||||
style={{ contain: 'strict' }}
|
||||
>
|
||||
<div className="flex h-11 w-full shrink-0 items-center justify-between border-b border-zinc-900 px-3">
|
||||
<Header />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{status === 'loading' ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-3 shadow-input shadow-black/20">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
) : status === 'error' ? (
|
||||
<div>{error.message}</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) => {
|
||||
const note = allRows[virtualRow.index];
|
||||
if (note) {
|
||||
if (note.kind === 1) {
|
||||
return (
|
||||
<div key={virtualRow.index} data-index={virtualRow.index} ref={rowVirtualizer.measureElement}>
|
||||
<NoteBase key={note.event_id} event={note} />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div key={virtualRow.index} data-index={virtualRow.index} ref={rowVirtualizer.measureElement}>
|
||||
<NoteQuoteRepost key={note.event_id} event={note} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{isFetching && !isFetchingNextPage ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-3 shadow-input shadow-black/20">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="scrollbar-hide flex h-full flex-col justify-between gap-1.5 overflow-y-auto"
|
||||
style={{ contain: "strict" }}
|
||||
>
|
||||
<div className="flex h-11 w-full shrink-0 items-center justify-between border-b border-zinc-900 px-3">
|
||||
<Header />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{status === "loading" ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-3 shadow-input shadow-black/20">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
) : status === "error" ? (
|
||||
<div>{error.message}</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) => {
|
||||
const note = allRows[virtualRow.index];
|
||||
if (note) {
|
||||
if (note.kind === 1) {
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.index}
|
||||
data-index={virtualRow.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
>
|
||||
<NoteBase key={note.event_id} event={note} />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.index}
|
||||
data-index={virtualRow.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
>
|
||||
<NoteQuoteRepost key={note.event_id} event={note} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{isFetching && !isFetchingNextPage ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-3 shadow-input shadow-black/20">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { Root, createRoot, hydrateRoot } from 'react-dom/client';
|
||||
import 'vidstack/styles/defaults.css';
|
||||
import { StrictMode } from "react";
|
||||
import { Root, createRoot, hydrateRoot } from "react-dom/client";
|
||||
import "vidstack/styles/defaults.css";
|
||||
|
||||
import './index.css';
|
||||
import { Shell } from './shell';
|
||||
import { PageContextClient } from './types';
|
||||
import "./index.css";
|
||||
import { Shell } from "./shell";
|
||||
import { PageContextClient } from "./types";
|
||||
|
||||
export const clientRouting = true;
|
||||
export const hydrationCanBeAborted = true;
|
||||
@ -12,27 +12,30 @@ export const hydrationCanBeAborted = true;
|
||||
let root: Root;
|
||||
|
||||
export async function render(pageContext: PageContextClient) {
|
||||
const { Page, pageProps } = pageContext;
|
||||
const { Page, pageProps } = pageContext;
|
||||
|
||||
if (!Page) throw new Error('Client-side render() hook expects pageContext.Page to be defined');
|
||||
if (!Page)
|
||||
throw new Error(
|
||||
"Client-side render() hook expects pageContext.Page to be defined",
|
||||
);
|
||||
|
||||
const page = (
|
||||
<StrictMode>
|
||||
<Shell pageContext={pageContext}>
|
||||
<Page {...pageProps} />
|
||||
</Shell>
|
||||
</StrictMode>
|
||||
);
|
||||
const page = (
|
||||
<StrictMode>
|
||||
<Shell pageContext={pageContext}>
|
||||
<Page {...pageProps} />
|
||||
</Shell>
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
const container = document.getElementById('app');
|
||||
// SPA
|
||||
if (container.innerHTML === '' || !pageContext.isHydration) {
|
||||
if (!root) {
|
||||
root = createRoot(container);
|
||||
}
|
||||
root.render(page);
|
||||
// SSR
|
||||
} else {
|
||||
root = hydrateRoot(container, page);
|
||||
}
|
||||
const container = document.getElementById("app");
|
||||
// SPA
|
||||
if (container.innerHTML === "" || !pageContext.isHydration) {
|
||||
if (!root) {
|
||||
root = createRoot(container);
|
||||
}
|
||||
root.render(page);
|
||||
// SSR
|
||||
} else {
|
||||
root = hydrateRoot(container, page);
|
||||
}
|
||||
}
|
||||
|
@ -1,33 +1,36 @@
|
||||
import { StrictMode } from 'react';
|
||||
import ReactDOMServer from 'react-dom/server';
|
||||
import { dangerouslySkipEscape, escapeInject } from 'vite-plugin-ssr/server';
|
||||
import { StrictMode } from "react";
|
||||
import ReactDOMServer from "react-dom/server";
|
||||
import { dangerouslySkipEscape, escapeInject } from "vite-plugin-ssr/server";
|
||||
|
||||
import { Shell } from './shell';
|
||||
import { PageContextServer } from './types';
|
||||
import { Shell } from "./shell";
|
||||
import { PageContextServer } from "./types";
|
||||
|
||||
export const passToClient = ['pageProps'];
|
||||
export const passToClient = ["pageProps"];
|
||||
|
||||
export function render(pageContext: PageContextServer) {
|
||||
let pageHtml: string;
|
||||
let pageHtml: string;
|
||||
|
||||
if (!pageContext.Page) {
|
||||
// SPA
|
||||
pageHtml = '';
|
||||
} else {
|
||||
// SSR / HTML-only
|
||||
const { Page, pageProps } = pageContext;
|
||||
if (!Page) throw new Error('My render() hook expects pageContext.Page to be defined');
|
||||
if (!pageContext.Page) {
|
||||
// SPA
|
||||
pageHtml = "";
|
||||
} else {
|
||||
// SSR / HTML-only
|
||||
const { Page, pageProps } = pageContext;
|
||||
if (!Page)
|
||||
throw new Error(
|
||||
"My render() hook expects pageContext.Page to be defined",
|
||||
);
|
||||
|
||||
pageHtml = ReactDOMServer.renderToString(
|
||||
<StrictMode>
|
||||
<Shell pageContext={pageContext}>
|
||||
<Page {...pageProps} />
|
||||
</Shell>
|
||||
</StrictMode>
|
||||
);
|
||||
}
|
||||
pageHtml = ReactDOMServer.renderToString(
|
||||
<StrictMode>
|
||||
<Shell pageContext={pageContext}>
|
||||
<Page {...pageProps} />
|
||||
</Shell>
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
|
||||
return escapeInject`<!DOCTYPE html>
|
||||
return escapeInject`<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
@ -1,17 +1,17 @@
|
||||
export function Page({ is404 }: { is404: boolean }) {
|
||||
if (is404) {
|
||||
return (
|
||||
<>
|
||||
<h1>404 Page Not Found</h1>
|
||||
<p>This page could not be found.</p>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<h1>500 Internal Server Error</h1>
|
||||
<p>Something went wrong.</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (is404) {
|
||||
return (
|
||||
<>
|
||||
<h1>404 Page Not Found</h1>
|
||||
<p>This page could not be found.</p>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<h1>500 Internal Server Error</h1>
|
||||
<p>Something went wrong.</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,7 @@
|
||||
export function LayoutDefault({ children }: { children: React.ReactNode }) {
|
||||
return <div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-white">{children}</div>;
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-white">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,24 +1,31 @@
|
||||
import { RelayProvider } from '@shared/relayProvider';
|
||||
import { RelayProvider } from "@shared/relayProvider";
|
||||
|
||||
import { PageContextProvider } from '@utils/hooks/usePageContext';
|
||||
import { PageContextProvider } from "@utils/hooks/usePageContext";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { LayoutDefault } from './layoutDefault';
|
||||
import { PageContext } from './types';
|
||||
import { LayoutDefault } from "./layoutDefault";
|
||||
import { PageContext } from "./types";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export function Shell({ children, pageContext }: { children: React.ReactNode; pageContext: PageContext }) {
|
||||
const Layout = (pageContext.exports.Layout as React.ElementType) || (LayoutDefault as React.ElementType);
|
||||
export function Shell({
|
||||
children,
|
||||
pageContext,
|
||||
}: { children: React.ReactNode; pageContext: PageContext }) {
|
||||
const Layout =
|
||||
(pageContext.exports.Layout as React.ElementType) ||
|
||||
(LayoutDefault as React.ElementType);
|
||||
|
||||
return (
|
||||
<PageContextProvider pageContext={pageContext}>
|
||||
<RelayProvider>
|
||||
<Layout>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</Layout>
|
||||
</RelayProvider>
|
||||
</PageContextProvider>
|
||||
);
|
||||
return (
|
||||
<PageContextProvider pageContext={pageContext}>
|
||||
<RelayProvider>
|
||||
<Layout>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</Layout>
|
||||
</RelayProvider>
|
||||
</PageContextProvider>
|
||||
);
|
||||
}
|
||||
|
@ -1,12 +1,12 @@
|
||||
import type {
|
||||
PageContextBuiltIn,
|
||||
/*
|
||||
PageContextBuiltIn,
|
||||
/*
|
||||
// When using Client Routing https://vite-plugin-ssr.com/clientRouting
|
||||
PageContextBuiltInClientWithClientRouting as PageContextBuiltInClient
|
||||
/*/
|
||||
// When using Server Routing
|
||||
PageContextBuiltInClientWithServerRouting as PageContextBuiltInClient, //*/
|
||||
} from 'vite-plugin-ssr/types';
|
||||
// When using Server Routing
|
||||
PageContextBuiltInClientWithServerRouting as PageContextBuiltInClient, //*/
|
||||
} from "vite-plugin-ssr/types";
|
||||
|
||||
export type { PageContextServer };
|
||||
export type { PageContextClient };
|
||||
@ -17,16 +17,16 @@ type Page = (pageProps: PageProps) => React.ReactElement;
|
||||
type PageProps = Record<string, never>;
|
||||
|
||||
export type PageContextCustom = {
|
||||
Page: Page;
|
||||
pageProps?: PageProps;
|
||||
redirectTo?: string;
|
||||
urlPathname: string;
|
||||
exports: {
|
||||
documentProps?: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
};
|
||||
Page: Page;
|
||||
pageProps?: PageProps;
|
||||
redirectTo?: string;
|
||||
urlPathname: string;
|
||||
exports: {
|
||||
documentProps?: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type PageContextServer = PageContextBuiltIn<Page> & PageContextCustom;
|
||||
|
@ -1,18 +1,21 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||
import { DEFAULT_AVATAR } from "@stores/constants";
|
||||
|
||||
export default function ActiveAccount({ user }: { user: any }) {
|
||||
const userData = JSON.parse(user.metadata);
|
||||
const userData = JSON.parse(user.metadata);
|
||||
|
||||
return (
|
||||
<button className="relative h-10 w-10 overflow-hidden rounded-lg">
|
||||
<Image
|
||||
src={userData.picture || DEFAULT_AVATAR}
|
||||
alt="user's avatar"
|
||||
loading="auto"
|
||||
className="h-10 w-10 object-cover"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="relative h-10 w-10 overflow-hidden rounded-lg"
|
||||
>
|
||||
<Image
|
||||
src={userData.picture || DEFAULT_AVATAR}
|
||||
alt="user's avatar"
|
||||
loading="auto"
|
||||
className="h-10 w-10 object-cover"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
@ -1,17 +1,17 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||
import { DEFAULT_AVATAR } from "@stores/constants";
|
||||
|
||||
export default function InactiveAccount({ user }: { user: any }) {
|
||||
const userData = JSON.parse(user.metadata);
|
||||
const userData = JSON.parse(user.metadata);
|
||||
|
||||
return (
|
||||
<div className="relative h-10 w-10 shrink rounded-lg">
|
||||
<Image
|
||||
src={userData.picture || DEFAULT_AVATAR}
|
||||
alt="user's avatar"
|
||||
className="h-10 w-10 rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="relative h-10 w-10 shrink rounded-lg">
|
||||
<Image
|
||||
src={userData.picture || DEFAULT_AVATAR}
|
||||
alt="user's avatar"
|
||||
className="h-10 w-10 rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,24 +1,27 @@
|
||||
import { usePageContext } from '@utils/hooks/usePageContext';
|
||||
import { usePageContext } from "@utils/hooks/usePageContext";
|
||||
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export default function ActiveLink({
|
||||
href,
|
||||
className,
|
||||
activeClassName,
|
||||
children,
|
||||
href,
|
||||
className,
|
||||
activeClassName,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
className: string;
|
||||
activeClassName: string;
|
||||
children: React.ReactNode;
|
||||
href: string;
|
||||
className: string;
|
||||
activeClassName: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const pageContext = usePageContext();
|
||||
const pathName = pageContext.urlPathname;
|
||||
const pageContext = usePageContext();
|
||||
const pathName = pageContext.urlPathname;
|
||||
|
||||
return (
|
||||
<a href={href} className={twMerge(className, href === pathName ? activeClassName : '')}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={twMerge(className, href === pathName ? activeClassName : "")}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
@ -1,55 +1,75 @@
|
||||
import ArrowLeftIcon from '@icons/arrowLeft';
|
||||
import ArrowRightIcon from '@icons/arrowRight';
|
||||
import RefreshIcon from '@icons/refresh';
|
||||
import ArrowLeftIcon from "@icons/arrowLeft";
|
||||
import ArrowRightIcon from "@icons/arrowRight";
|
||||
import RefreshIcon from "@icons/refresh";
|
||||
|
||||
export default function AppHeader() {
|
||||
const goBack = () => {
|
||||
window.history.back();
|
||||
};
|
||||
const goBack = () => {
|
||||
window.history.back();
|
||||
};
|
||||
|
||||
const goForward = () => {
|
||||
window.history.forward();
|
||||
};
|
||||
const goForward = () => {
|
||||
window.history.forward();
|
||||
};
|
||||
|
||||
const reload = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
const reload = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-tauri-drag-region className="flex h-full w-full flex-1 items-center px-2">
|
||||
<div data-tauri-drag-region className="flex w-full items-center justify-center gap-2">
|
||||
<div className="flex h-full items-center gap-2">
|
||||
<button
|
||||
onClick={() => goBack()}
|
||||
className="group inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
|
||||
>
|
||||
<ArrowLeftIcon width={14} height={14} className="text-zinc-500 group-hover:text-zinc-300" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => goForward()}
|
||||
className="group inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
|
||||
>
|
||||
<ArrowRightIcon width={14} height={14} className="text-zinc-500 group-hover:text-zinc-300" />
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
autoFocus={false}
|
||||
placeholder="Search..."
|
||||
className="h-6 w-[453px] rounded border border-zinc-800 bg-zinc-900 px-2.5 text-center text-[11px] text-sm leading-5 text-zinc-500 placeholder:leading-5 placeholder:text-zinc-600 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-full items-center gap-2">
|
||||
<button
|
||||
onClick={() => reload()}
|
||||
className="group inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
|
||||
>
|
||||
<RefreshIcon width={14} height={14} className="text-zinc-500 group-hover:text-zinc-300" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex h-full w-full flex-1 items-center px-2"
|
||||
>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex w-full items-center justify-center gap-2"
|
||||
>
|
||||
<div className="flex h-full items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goBack()}
|
||||
className="group inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
|
||||
>
|
||||
<ArrowLeftIcon
|
||||
width={14}
|
||||
height={14}
|
||||
className="text-zinc-500 group-hover:text-zinc-300"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goForward()}
|
||||
className="group inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
|
||||
>
|
||||
<ArrowRightIcon
|
||||
width={14}
|
||||
height={14}
|
||||
className="text-zinc-500 group-hover:text-zinc-300"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
placeholder="Search..."
|
||||
className="h-6 w-[453px] rounded border border-zinc-800 bg-zinc-900 px-2.5 text-center text-[11px] text-sm leading-5 text-zinc-500 placeholder:leading-5 placeholder:text-zinc-600 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-full items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reload()}
|
||||
className="group inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
|
||||
>
|
||||
<RefreshIcon
|
||||
width={14}
|
||||
height={14}
|
||||
className="text-zinc-500 group-hover:text-zinc-300"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,75 +1,86 @@
|
||||
import { createBlobFromFile } from '@utils/createBlobFromFile';
|
||||
import { createBlobFromFile } from "@utils/createBlobFromFile";
|
||||
|
||||
import { open } from '@tauri-apps/api/dialog';
|
||||
import { Body, fetch } from '@tauri-apps/api/http';
|
||||
import { useState } from 'react';
|
||||
import { open } from "@tauri-apps/api/dialog";
|
||||
import { Body, fetch } from "@tauri-apps/api/http";
|
||||
import { useState } from "react";
|
||||
|
||||
export function AvatarUploader({ valueState }: { valueState: 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 webpImage = 'https://void.cat/d/' + res.data.file.id + '.webp';
|
||||
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 webpImage = `https://void.cat/d/${res.data.file.id}.webp`;
|
||||
|
||||
valueState(webpImage);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
valueState(webpImage);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => openFileDialog()}
|
||||
type="button"
|
||||
className="inline-flex h-6 items-center justify-center rounded bg-zinc-900 px-3 text-xs font-medium text-zinc-200 ring-1 ring-zinc-800 hover:bg-zinc-700"
|
||||
>
|
||||
{loading ? (
|
||||
<svg
|
||||
className="h-4 w-4 animate-spin text-black dark:text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<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"
|
||||
></path>
|
||||
</svg>
|
||||
) : (
|
||||
<span className="leading-none">Upload</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<button
|
||||
onClick={() => openFileDialog()}
|
||||
type="button"
|
||||
className="inline-flex h-6 items-center justify-center rounded bg-zinc-900 px-3 text-xs font-medium text-zinc-200 ring-1 ring-zinc-800 hover:bg-zinc-700"
|
||||
>
|
||||
{loading ? (
|
||||
<svg
|
||||
className="h-4 w-4 animate-spin text-black dark:text-white"
|
||||
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>
|
||||
) : (
|
||||
<span className="leading-none">Upload</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
@ -1,124 +1,134 @@
|
||||
import PlusCircleIcon from '@shared/icons/plusCircle';
|
||||
import PlusCircleIcon from "@shared/icons/plusCircle";
|
||||
|
||||
import { createBlobFromFile } from '@utils/createBlobFromFile';
|
||||
import { createBlobFromFile } from "@utils/createBlobFromFile";
|
||||
|
||||
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 { 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";
|
||||
|
||||
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"
|
||||
autoFocus={false}
|
||||
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-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<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"
|
||||
></path>
|
||||
</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-white"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
@ -1,92 +1,105 @@
|
||||
import { Post } from '@shared/composer/types/post';
|
||||
import { User } from '@shared/composer/user';
|
||||
import { Post } from "@shared/composer/types/post";
|
||||
import { User } from "@shared/composer/user";
|
||||
|
||||
import CancelIcon from '@icons/cancel';
|
||||
import ChevronDownIcon from '@icons/chevronDown';
|
||||
import ChevronRightIcon from '@icons/chevronRight';
|
||||
import ComposeIcon from '@icons/compose';
|
||||
import CancelIcon from "@icons/cancel";
|
||||
import ChevronDownIcon from "@icons/chevronDown";
|
||||
import ChevronRightIcon from "@icons/chevronRight";
|
||||
import ComposeIcon from "@icons/compose";
|
||||
|
||||
import { composerAtom } from '@stores/composer';
|
||||
import { composerAtom } from "@stores/composer";
|
||||
|
||||
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
|
||||
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
|
||||
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { Fragment, useState } from 'react';
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { useAtom } from "jotai";
|
||||
import { Fragment, useState } from "react";
|
||||
|
||||
export function ComposerModal() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [composer] = useAtom(composerAtom);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [composer] = useAtom(composerAtom);
|
||||
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const openModal = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
const openModal = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
autoFocus={false}
|
||||
onClick={() => openModal()}
|
||||
className="inline-flex h-7 w-max items-center justify-center gap-1 rounded-md bg-fuchsia-500 px-2.5 text-xs font-medium text-zinc-200 shadow-button hover:bg-fuchsia-600 focus:outline-none"
|
||||
>
|
||||
<ComposeIcon width={14} height={14} />
|
||||
Compose
|
||||
</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 h-min w-full max-w-xl rounded-lg border border-zinc-800 bg-zinc-900">
|
||||
<div className="flex items-center justify-between px-4 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div>{!isLoading && !isError && account && <User data={account} />}</div>
|
||||
<span>
|
||||
<ChevronRightIcon width={14} height={14} className="text-zinc-500" />
|
||||
</span>
|
||||
<div className="inline-flex h-6 w-max items-center justify-center gap-0.5 rounded bg-zinc-800 pl-3 pr-1.5 text-xs font-medium text-zinc-400 shadow-mini-button">
|
||||
New Post
|
||||
<ChevronDownIcon width={14} height={14} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
onClick={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>
|
||||
{composer.type === 'post' && account && <Post pubkey={account.pubkey} privkey={account.privkey} />}
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openModal()}
|
||||
className="inline-flex h-7 w-max items-center justify-center gap-1 rounded-md bg-fuchsia-500 px-2.5 text-xs font-medium text-zinc-200 shadow-button hover:bg-fuchsia-600 focus:outline-none"
|
||||
>
|
||||
<ComposeIcon width={14} height={14} />
|
||||
Compose
|
||||
</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 h-min w-full max-w-xl rounded-lg border border-zinc-800 bg-zinc-900">
|
||||
<div className="flex items-center justify-between px-4 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
{!isLoading && !isError && account && (
|
||||
<User data={account} />
|
||||
)}
|
||||
</div>
|
||||
<span>
|
||||
<ChevronRightIcon
|
||||
width={14}
|
||||
height={14}
|
||||
className="text-zinc-500"
|
||||
/>
|
||||
</span>
|
||||
<div className="inline-flex h-6 w-max items-center justify-center gap-0.5 rounded bg-zinc-800 pl-3 pr-1.5 text-xs font-medium text-zinc-400 shadow-mini-button">
|
||||
New Post
|
||||
<ChevronDownIcon width={14} height={14} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
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>
|
||||
{composer.type === "post" && account && (
|
||||
<Post pubkey={account.pubkey} privkey={account.privkey} />
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1,124 +1,141 @@
|
||||
import { ImageUploader } from '@shared/composer/imageUploader';
|
||||
import TrashIcon from '@shared/icons/trash';
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { ImageUploader } from "@shared/composer/imageUploader";
|
||||
import TrashIcon from "@shared/icons/trash";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import { WRITEONLY_RELAYS } from '@stores/constants';
|
||||
import { WRITEONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { dateToUnix } from '@utils/date';
|
||||
import { dateToUnix } from "@utils/date";
|
||||
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { useCallback, useContext, 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 { getEventHash, signEvent } from "nostr-tools";
|
||||
import { useCallback, useContext, useMemo, useState } from "react";
|
||||
import { Node, Transforms, createEditor } from "slate";
|
||||
import { withHistory } from "slate-history";
|
||||
import {
|
||||
Editable,
|
||||
ReactEditor,
|
||||
Slate,
|
||||
useSlateStatic,
|
||||
withReact,
|
||||
} from "slate-react";
|
||||
|
||||
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: any; children: any; element: any }) => {
|
||||
const editor: any = useSlateStatic();
|
||||
const path = ReactEditor.findPath(editor, element);
|
||||
const ImagePreview = ({
|
||||
attributes,
|
||||
children,
|
||||
element,
|
||||
}: { attributes: any; children: any; element: any }) => {
|
||||
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 src={element.url} className="m-0 h-auto w-full rounded-md" />
|
||||
<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-xs 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 w-full rounded-md"
|
||||
/>
|
||||
<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-xs font-medium text-zinc-400 shadow-mini-button hover:bg-zinc-700"
|
||||
>
|
||||
<TrashIcon width={14} height={14} className="text-zinc-100" />
|
||||
</button>
|
||||
</div>
|
||||
</figure>
|
||||
);
|
||||
};
|
||||
|
||||
export function Post({ pubkey, privkey }: { pubkey: string; privkey: string }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const editor = useMemo(() => withReact(withImages(withHistory(createEditor()))), []);
|
||||
const [content, setContent] = useState<Node[]>([
|
||||
{
|
||||
children: [
|
||||
{
|
||||
text: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const editor = useMemo(
|
||||
() => withReact(withImages(withHistory(createEditor()))),
|
||||
[],
|
||||
);
|
||||
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 submit = () => {
|
||||
// serialize content
|
||||
const serializedContent = serialize(content);
|
||||
console.log(serializedContent);
|
||||
const submit = () => {
|
||||
// serialize content
|
||||
const serializedContent = serialize(content);
|
||||
console.log(serializedContent);
|
||||
|
||||
const event: any = {
|
||||
content: serializedContent,
|
||||
created_at: dateToUnix(),
|
||||
kind: 1,
|
||||
pubkey: pubkey,
|
||||
tags: [],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, privkey);
|
||||
const event: any = {
|
||||
content: serializedContent,
|
||||
created_at: dateToUnix(),
|
||||
kind: 1,
|
||||
pubkey: pubkey,
|
||||
tags: [],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, privkey);
|
||||
|
||||
// publish note
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
};
|
||||
// publish note
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
};
|
||||
|
||||
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} />;
|
||||
}
|
||||
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>
|
||||
<div className="prose prose-zinc relative h-max w-full max-w-none select-text break-words pb-3 dark:prose-invert prose-p:mb-0.5 prose-p:mt-0 prose-p:text-[15px] prose-p:leading-tight prose-a:text-[15px] prose-a:font-normal prose-a:leading-tight prose-a:text-fuchsia-500 prose-a:no-underline hover:prose-a:text-fuchsia-600 hover:prose-a:underline prose-ol:mb-1 prose-ul:mb-1 prose-li:text-[15px] prose-li:leading-tight">
|
||||
<Editable
|
||||
autoFocus
|
||||
placeholder="What's on your mind?"
|
||||
spellCheck="false"
|
||||
className="!min-h-[86px]"
|
||||
renderElement={renderElement}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<ImageUploader />
|
||||
<button
|
||||
type="button"
|
||||
autoFocus={false}
|
||||
onClick={submit}
|
||||
className="inline-flex h-7 w-max items-center justify-center gap-1 rounded-md bg-fuchsia-500 px-3.5 text-xs font-medium text-zinc-200 shadow-button hover:bg-fuchsia-600"
|
||||
>
|
||||
Post
|
||||
</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="prose prose-zinc relative h-max w-full max-w-none select-text break-words pb-3 dark:prose-invert prose-p:mb-0.5 prose-p:mt-0 prose-p:text-[15px] prose-p:leading-tight prose-a:text-[15px] prose-a:font-normal prose-a:leading-tight prose-a:text-fuchsia-500 prose-a:no-underline hover:prose-a:text-fuchsia-600 hover:prose-a:underline prose-ol:mb-1 prose-ul:mb-1 prose-li:text-[15px] prose-li:leading-tight">
|
||||
<Editable
|
||||
autoFocus
|
||||
placeholder="What's on your mind?"
|
||||
spellCheck="false"
|
||||
className="!min-h-[86px]"
|
||||
renderElement={renderElement}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<ImageUploader />
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
className="inline-flex h-7 w-max items-center justify-center gap-1 rounded-md bg-fuchsia-500 px-3.5 text-xs font-medium text-zinc-200 shadow-button hover:bg-fuchsia-600"
|
||||
>
|
||||
Post
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Slate>
|
||||
);
|
||||
}
|
||||
|
@ -1,25 +1,27 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
import { DEFAULT_AVATAR, IMGPROXY_URL } from '@stores/constants';
|
||||
import { DEFAULT_AVATAR, IMGPROXY_URL } from "@stores/constants";
|
||||
|
||||
export function User({ data }: { data: any }) {
|
||||
const metadata = JSON.parse(data.metadata);
|
||||
const metadata = JSON.parse(data.metadata);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 shrink-0 overflow-hidden rounded bg-zinc-900">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${metadata?.picture ? metadata.picture : DEFAULT_AVATAR}`}
|
||||
alt={data.pubkey}
|
||||
className="h-8 w-8 object-cover"
|
||||
loading="auto"
|
||||
/>
|
||||
</div>
|
||||
<h5 className="text-sm font-semibold leading-none text-zinc-100">
|
||||
{metadata?.display_name || metadata?.name || (
|
||||
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700"></div>
|
||||
)}
|
||||
</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={`${IMGPROXY_URL}/rs:fit:100:100/plain/${
|
||||
metadata?.picture ? metadata.picture : DEFAULT_AVATAR
|
||||
}`}
|
||||
alt={data.pubkey}
|
||||
className="h-8 w-8 object-cover"
|
||||
loading="auto"
|
||||
/>
|
||||
</div>
|
||||
<h5 className="text-sm font-semibold leading-none text-zinc-100">
|
||||
{metadata?.display_name || metadata?.name || (
|
||||
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700" />
|
||||
)}
|
||||
</h5>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,117 +1,130 @@
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import HeartBeatIcon from '@icons/heartbeat';
|
||||
import HeartBeatIcon from "@icons/heartbeat";
|
||||
|
||||
import { READONLY_RELAYS } from '@stores/constants';
|
||||
import { hasNewerNoteAtom } from '@stores/note';
|
||||
import { READONLY_RELAYS } from "@stores/constants";
|
||||
import { hasNewerNoteAtom } from "@stores/note";
|
||||
|
||||
import { dateToUnix } from '@utils/date';
|
||||
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
|
||||
import { createChat, createNote, updateAccount } from '@utils/storage';
|
||||
import { getParentID, nip02ToArray } from '@utils/transform';
|
||||
import { dateToUnix } from "@utils/date";
|
||||
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
|
||||
import { createChat, createNote, updateAccount } from "@utils/storage";
|
||||
import { getParentID, nip02ToArray } from "@utils/transform";
|
||||
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useContext, useRef } from 'react';
|
||||
import useSWRSubscription from 'swr/subscription';
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useContext, useRef } from "react";
|
||||
import useSWRSubscription from "swr/subscription";
|
||||
|
||||
export default function EventCollector() {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const setHasNewerNote = useSetAtom(hasNewerNoteAtom);
|
||||
const now = useRef(new Date());
|
||||
const setHasNewerNote = useSetAtom(hasNewerNoteAtom);
|
||||
const now = useRef(new Date());
|
||||
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
|
||||
useSWRSubscription(!isLoading && !isError && account ? ['eventCollector', account] : null, ([, key], {}) => {
|
||||
const follows = JSON.parse(key.follows);
|
||||
const followsAsArray = nip02ToArray(follows);
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [1, 6],
|
||||
authors: followsAsArray,
|
||||
since: dateToUnix(now.current),
|
||||
},
|
||||
{
|
||||
kinds: [0, 3],
|
||||
authors: [key.pubkey],
|
||||
},
|
||||
{
|
||||
kinds: [4],
|
||||
'#p': [key.pubkey],
|
||||
since: dateToUnix(now.current),
|
||||
},
|
||||
{
|
||||
kinds: [30023],
|
||||
since: dateToUnix(now.current),
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
switch (event.kind) {
|
||||
// metadata
|
||||
case 0:
|
||||
updateAccount('metadata', event.content, event.pubkey);
|
||||
break;
|
||||
// short text note
|
||||
case 1:
|
||||
const parentID = getParentID(event.tags, event.id);
|
||||
createNote(
|
||||
event.id,
|
||||
account.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at,
|
||||
parentID
|
||||
);
|
||||
// notify user reload to get newer note
|
||||
setHasNewerNote(true);
|
||||
break;
|
||||
// contacts
|
||||
case 3:
|
||||
// update account's folllows with NIP-02 tag list
|
||||
updateAccount('follows', event.tags, event.pubkey);
|
||||
break;
|
||||
// chat
|
||||
case 4:
|
||||
if (event.pubkey !== key.pubkey) {
|
||||
createChat(key.id, event.pubkey, event.created_at);
|
||||
}
|
||||
break;
|
||||
// repost
|
||||
case 6:
|
||||
createNote(
|
||||
event.id,
|
||||
key.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at,
|
||||
event.id
|
||||
);
|
||||
break;
|
||||
// long post
|
||||
case 30023:
|
||||
// insert event to local database
|
||||
createNote(event.id, account.id, event.pubkey, event.kind, event.tags, event.content, event.created_at, '');
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
);
|
||||
useSWRSubscription(
|
||||
!isLoading && !isError && account ? ["eventCollector", account] : null,
|
||||
([, key]) => {
|
||||
const follows = JSON.parse(key.follows);
|
||||
const followsAsArray = nip02ToArray(follows);
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [1, 6],
|
||||
authors: followsAsArray,
|
||||
since: dateToUnix(now.current),
|
||||
},
|
||||
{
|
||||
kinds: [0, 3],
|
||||
authors: [key.pubkey],
|
||||
},
|
||||
{
|
||||
kinds: [4],
|
||||
"#p": [key.pubkey],
|
||||
since: dateToUnix(now.current),
|
||||
},
|
||||
{
|
||||
kinds: [30023],
|
||||
since: dateToUnix(now.current),
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
switch (event.kind) {
|
||||
// metadata
|
||||
case 0:
|
||||
updateAccount("metadata", event.content, event.pubkey);
|
||||
break;
|
||||
// short text note
|
||||
case 1: {
|
||||
const parentID = getParentID(event.tags, event.id);
|
||||
createNote(
|
||||
event.id,
|
||||
account.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at,
|
||||
parentID,
|
||||
);
|
||||
// notify user reload to get newer note
|
||||
setHasNewerNote(true);
|
||||
break;
|
||||
}
|
||||
// contacts
|
||||
case 3:
|
||||
// update account's folllows with NIP-02 tag list
|
||||
updateAccount("follows", event.tags, event.pubkey);
|
||||
break;
|
||||
// chat
|
||||
case 4:
|
||||
if (event.pubkey !== key.pubkey) {
|
||||
createChat(key.id, event.pubkey, event.created_at);
|
||||
}
|
||||
break;
|
||||
// repost
|
||||
case 6:
|
||||
createNote(
|
||||
event.id,
|
||||
key.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at,
|
||||
event.id,
|
||||
);
|
||||
break;
|
||||
// long post
|
||||
case 30023:
|
||||
// insert event to local database
|
||||
createNote(
|
||||
event.id,
|
||||
account.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at,
|
||||
"",
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="inline-flex h-6 w-6 items-center justify-center rounded text-zinc-400 hover:bg-zinc-900 hover:text-zinc-200">
|
||||
<HeartBeatIcon width={14} height={14} />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="inline-flex h-6 w-6 items-center justify-center rounded text-zinc-400 hover:bg-zinc-900 hover:text-zinc-200">
|
||||
<HeartBeatIcon width={14} height={14} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,98 +1,110 @@
|
||||
import PlusIcon from '@icons/plus';
|
||||
import PlusIcon from "@icons/plus";
|
||||
|
||||
import { channelContentAtom } from '@stores/channel';
|
||||
import { chatContentAtom } from '@stores/chat';
|
||||
import { noteContentAtom } from '@stores/note';
|
||||
import { channelContentAtom } from "@stores/channel";
|
||||
import { chatContentAtom } from "@stores/chat";
|
||||
import { noteContentAtom } from "@stores/note";
|
||||
|
||||
import { createBlobFromFile } from '@utils/createBlobFromFile';
|
||||
import { createBlobFromFile } from "@utils/createBlobFromFile";
|
||||
|
||||
import { open } from '@tauri-apps/api/dialog';
|
||||
import { Body, fetch } from '@tauri-apps/api/http';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useState } from 'react';
|
||||
import { open } from "@tauri-apps/api/dialog";
|
||||
import { Body, fetch } from "@tauri-apps/api/http";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useState } from "react";
|
||||
|
||||
export function ImagePicker({ type }: { type: string }) {
|
||||
let atom;
|
||||
let atom;
|
||||
|
||||
switch (type) {
|
||||
case 'note':
|
||||
atom = noteContentAtom;
|
||||
break;
|
||||
case 'chat':
|
||||
atom = chatContentAtom;
|
||||
break;
|
||||
case 'channel':
|
||||
atom = channelContentAtom;
|
||||
break;
|
||||
default:
|
||||
throw new Error('Invalid type');
|
||||
}
|
||||
switch (type) {
|
||||
case "note":
|
||||
atom = noteContentAtom;
|
||||
break;
|
||||
case "chat":
|
||||
atom = chatContentAtom;
|
||||
break;
|
||||
case "channel":
|
||||
atom = channelContentAtom;
|
||||
break;
|
||||
default:
|
||||
throw new Error("Invalid type");
|
||||
}
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const setValue = useSetAtom(atom);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const setValue = useSetAtom(atom);
|
||||
|
||||
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 webpImage = 'https://void.cat/d/' + res.data.file.id + '.webp';
|
||||
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 webpImage = `https://void.cat/d/${res.data.file.id}.webp`;
|
||||
|
||||
setValue((content: string) => content + ' ' + webpImage);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
setValue((content: string) => `${content} ${webpImage}`);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => openFileDialog()}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-700"
|
||||
>
|
||||
{loading ? (
|
||||
<svg
|
||||
className="h-4 w-4 animate-spin text-black dark:text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<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"
|
||||
></path>
|
||||
</svg>
|
||||
) : (
|
||||
<PlusIcon width={16} height={16} className="text-zinc-400" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openFileDialog()}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-700"
|
||||
>
|
||||
{loading ? (
|
||||
<svg
|
||||
className="h-4 w-4 animate-spin text-black dark:text-white"
|
||||
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>
|
||||
) : (
|
||||
<PlusIcon width={16} height={16} className="text-zinc-400" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
@ -1,15 +1,22 @@
|
||||
import { SVGProps } from 'react';
|
||||
import { SVGProps } from "react";
|
||||
|
||||
export default 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 default 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>
|
||||
);
|
||||
}
|
||||
|
@ -1,15 +1,22 @@
|
||||
import { SVGProps } from 'react';
|
||||
import { SVGProps } from "react";
|
||||
|
||||
export default 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 default 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>
|
||||
);
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user