replace eslint/prettier with rome

This commit is contained in:
Ren Amamiya 2023-05-14 17:05:53 +07:00
parent 48d690d33a
commit 409a625dcc
154 changed files with 7639 additions and 8525 deletions

View File

@ -1,8 +0,0 @@
.git/
.vscode-test/
out/
dist/
test-fixtures/
node_modules/
src-tauri/

View File

@ -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
}

View File

@ -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",

File diff suppressed because it is too large Load Diff

18
rome.json Normal file
View 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"
}
}
}
}

View File

@ -1 +1 @@
export { LayoutOnboarding as Layout } from './layout';
export { LayoutOnboarding as Layout } from "./layout";

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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&apos;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&apos;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>
);
}

View File

@ -1 +1 @@
export { LayoutChannel as Layout } from './layout';
export { LayoutChannel as Layout } from "./layout";

View File

@ -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>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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"}
/>
)}
</>
);
}

View File

@ -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>
);
}

View File

@ -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>
</>
);
}

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -1 +1 @@
export { LayoutChat as Layout } from './layout';
export { LayoutChat as Layout } from "./layout";

View File

@ -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>
)}
</>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
});

View File

@ -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>
);
}

View File

@ -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>
)}
</>
);
}

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -1 +1 @@
export const filesystemRoutingRoot = '/';
export const filesystemRoutingRoot = "/";

View File

@ -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" />
);
}

View File

@ -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&apos;s an interesting fact:
</h3>
<p className="font-medium text-zinc-300 dark:text-zinc-600">
Bitcoin and Nostr can be used by anyone, and no one can stop you!
</p>
</div>
</div>
<div className="absolute bottom-16 left-1/2 -translate-x-1/2 transform">
<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&apos;s an interesting fact:
</h3>
<p className="font-medium text-zinc-300 dark:text-zinc-600">
Bitcoin and Nostr can be used by anyone, and no one can stop
you!
</p>
</div>
</div>
<div className="absolute bottom-16 left-1/2 -translate-x-1/2 transform">
<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>
);
}

View File

@ -1 +1 @@
export { LayoutNewsfeed as Layout } from './layout';
export { LayoutNewsfeed as Layout } from "./layout";

View File

@ -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>
);
}

View File

@ -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} />
))
) : (
<></>
)}
</>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
});

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
});

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
});

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -1,7 +1,7 @@
export function Page() {
return (
<div>
<p>Space</p>
</div>
);
return (
<div>
<p>Space</p>
</div>
);
}

View File

@ -1,7 +1,7 @@
export function Page() {
return (
<div>
<p>MySpace</p>
</div>
);
return (
<div>
<p>MySpace</p>
</div>
);
}

View File

@ -1 +1 @@
export { LayoutNewsfeed as Layout } from './layout';
export { LayoutNewsfeed as Layout } from "./layout";

View File

@ -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>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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);
}
}

View File

@ -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" />

View File

@ -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>
</>
);
}
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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;

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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