replace eslint/prettier with rome
This commit is contained in:
@@ -1 +1 @@
|
||||
export { LayoutOnboarding as Layout } from './layout';
|
||||
export { LayoutOnboarding as Layout } from "./layout";
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||
import { DEFAULT_AVATAR } from "@stores/constants";
|
||||
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { shortenKey } from '@utils/shortenKey';
|
||||
import { useProfile } from "@utils/hooks/useProfile";
|
||||
import { shortenKey } from "@utils/shortenKey";
|
||||
|
||||
export default function User({ pubkey }: { pubkey: string }) {
|
||||
const { user } = useProfile(pubkey);
|
||||
const { user } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative h-11 w-11 shrink rounded-md">
|
||||
<Image
|
||||
src={user?.picture || DEFAULT_AVATAR}
|
||||
alt={pubkey}
|
||||
className="h-11 w-11 rounded-md object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 flex-col items-start text-start">
|
||||
<span className="truncate font-medium leading-tight text-zinc-200">{user?.display_name || user?.name}</span>
|
||||
<span className="text-sm leading-tight text-zinc-400">{user?.nip05?.toLowerCase() || shortenKey(pubkey)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative h-11 w-11 shrink rounded-md">
|
||||
<Image
|
||||
src={user?.picture || DEFAULT_AVATAR}
|
||||
alt={pubkey}
|
||||
className="h-11 w-11 rounded-md object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 flex-col items-start text-start">
|
||||
<span className="truncate font-medium leading-tight text-zinc-200">
|
||||
{user?.display_name || user?.name}
|
||||
</span>
|
||||
<span className="text-sm leading-tight text-zinc-400">
|
||||
{user?.nip05?.toLowerCase() || shortenKey(pubkey)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,50 +1,67 @@
|
||||
import ArrowLeftIcon from '@icons/arrowLeft';
|
||||
import ArrowRightIcon from '@icons/arrowRight';
|
||||
import ArrowLeftIcon from "@icons/arrowLeft";
|
||||
import ArrowRightIcon from "@icons/arrowRight";
|
||||
|
||||
import useSWR from 'swr';
|
||||
import useSWR from "swr";
|
||||
|
||||
const fetcher = async () => {
|
||||
const { platform } = await import('@tauri-apps/api/os');
|
||||
return await platform();
|
||||
const { platform } = await import("@tauri-apps/api/os");
|
||||
return await platform();
|
||||
};
|
||||
|
||||
export function LayoutOnboarding({ children }: { children: React.ReactNode }) {
|
||||
const { data: platform } = useSWR('platform', fetcher);
|
||||
const { data: platform } = useSWR("platform", fetcher);
|
||||
|
||||
const goBack = () => {
|
||||
window.history.back();
|
||||
};
|
||||
const goBack = () => {
|
||||
window.history.back();
|
||||
};
|
||||
|
||||
const goForward = () => {
|
||||
window.history.forward();
|
||||
};
|
||||
const goForward = () => {
|
||||
window.history.forward();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white">
|
||||
<div className="flex h-screen w-full flex-col">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="relative h-11 shrink-0 border border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
|
||||
>
|
||||
<div data-tauri-drag-region className="flex h-full w-full flex-1 items-center px-2">
|
||||
<div className={`flex h-full items-center gap-2 ${platform === 'darwin' ? 'pl-[68px]' : ''}`}>
|
||||
<button
|
||||
onClick={() => goBack()}
|
||||
className="group inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-900"
|
||||
>
|
||||
<ArrowLeftIcon width={16} height={16} className="text-zinc-500 group-hover:text-zinc-300" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => goForward()}
|
||||
className="group inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-900"
|
||||
>
|
||||
<ArrowRightIcon width={16} height={16} className="text-zinc-500 group-hover:text-zinc-300" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex min-h-0 w-full flex-1">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white">
|
||||
<div className="flex h-screen w-full flex-col">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="relative h-11 shrink-0 border border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
|
||||
>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex h-full w-full flex-1 items-center px-2"
|
||||
>
|
||||
<div
|
||||
className={`flex h-full items-center gap-2 ${
|
||||
platform === "darwin" ? "pl-[68px]" : ""
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goBack()}
|
||||
className="group inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-900"
|
||||
>
|
||||
<ArrowLeftIcon
|
||||
width={16}
|
||||
height={16}
|
||||
className="text-zinc-500 group-hover:text-zinc-300"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goForward()}
|
||||
className="group inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-900"
|
||||
>
|
||||
<ArrowRightIcon
|
||||
width={16}
|
||||
height={16}
|
||||
className="text-zinc-500 group-hover:text-zinc-300"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex min-h-0 w-full flex-1">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,83 +1,98 @@
|
||||
import EyeOffIcon from '@icons/eyeOff';
|
||||
import EyeOnIcon from '@icons/eyeOn';
|
||||
import EyeOffIcon from "@icons/eyeOff";
|
||||
import EyeOnIcon from "@icons/eyeOn";
|
||||
|
||||
import { onboardingAtom } from '@stores/onboarding';
|
||||
import { onboardingAtom } from "@stores/onboarding";
|
||||
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { navigate } from 'vite-plugin-ssr/client/router';
|
||||
import { useSetAtom } from "jotai";
|
||||
import { generatePrivateKey, getPublicKey, nip19 } from "nostr-tools";
|
||||
import { useMemo, useState } from "react";
|
||||
import { navigate } from "vite-plugin-ssr/client/router";
|
||||
|
||||
export function Page() {
|
||||
const [type, setType] = useState('password');
|
||||
const setOnboarding = useSetAtom(onboardingAtom);
|
||||
const privkey = useMemo(() => generatePrivateKey(), []);
|
||||
const [type, setType] = useState("password");
|
||||
const setOnboarding = useSetAtom(onboardingAtom);
|
||||
const privkey = useMemo(() => generatePrivateKey(), []);
|
||||
|
||||
const pubkey = getPublicKey(privkey);
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
const nsec = nip19.nsecEncode(privkey);
|
||||
const pubkey = getPublicKey(privkey);
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
const nsec = nip19.nsecEncode(privkey);
|
||||
|
||||
// toggle private key
|
||||
const showPrivateKey = () => {
|
||||
if (type === 'password') {
|
||||
setType('text');
|
||||
} else {
|
||||
setType('password');
|
||||
}
|
||||
};
|
||||
// toggle private key
|
||||
const showPrivateKey = () => {
|
||||
if (type === "password") {
|
||||
setType("text");
|
||||
} else {
|
||||
setType("password");
|
||||
}
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
setOnboarding((prev) => ({ ...prev, pubkey: pubkey, privkey: privkey }));
|
||||
navigate('/auth/create/step-2');
|
||||
};
|
||||
const submit = () => {
|
||||
setOnboarding((prev) => ({ ...prev, pubkey: pubkey, privkey: privkey }));
|
||||
navigate("/auth/create/step-2");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-2xl font-semibold text-zinc-200">Lume is auto-generated key for you</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold text-zinc-400">Public Key</label>
|
||||
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<input
|
||||
readOnly
|
||||
value={npub}
|
||||
className="relative w-full rounded-lg border border-black/5 px-3.5 py-2.5 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold text-zinc-400">Private Key</label>
|
||||
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<input
|
||||
readOnly
|
||||
type={type}
|
||||
value={nsec}
|
||||
className="relative w-full rounded-lg border border-black/5 py-2.5 pl-3.5 pr-11 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-600"
|
||||
/>
|
||||
<button
|
||||
onClick={() => showPrivateKey()}
|
||||
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
|
||||
>
|
||||
{type === 'password' ? (
|
||||
<EyeOffIcon width={20} height={20} className="text-zinc-500 group-hover:text-zinc-200" />
|
||||
) : (
|
||||
<EyeOnIcon width={20} height={20} className="text-zinc-500 group-hover:text-zinc-200" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
className="w-full transform rounded-lg bg-fuchsia-500 px-3.5 py-2.5 font-medium text-white shadow-button hover:bg-fuchsia-600 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
<span>Continue →</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-2xl font-semibold text-zinc-200">
|
||||
Lume is auto-generated key for you
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold text-zinc-400">
|
||||
Public Key
|
||||
</label>
|
||||
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<input
|
||||
readOnly
|
||||
value={npub}
|
||||
className="relative w-full rounded-lg border border-black/5 px-3.5 py-2.5 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold text-zinc-400">
|
||||
Private Key
|
||||
</label>
|
||||
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<input
|
||||
readOnly
|
||||
type={type}
|
||||
value={nsec}
|
||||
className="relative w-full rounded-lg border border-black/5 py-2.5 pl-3.5 pr-11 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-600"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showPrivateKey()}
|
||||
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
|
||||
>
|
||||
{type === "password" ? (
|
||||
<EyeOffIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-zinc-500 group-hover:text-zinc-200"
|
||||
/>
|
||||
) : (
|
||||
<EyeOnIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-zinc-500 group-hover:text-zinc-200"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
className="w-full transform rounded-lg bg-fuchsia-500 px-3.5 py-2.5 font-medium text-white shadow-button hover:bg-fuchsia-600 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
<span>Continue →</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,123 +1,142 @@
|
||||
import { AvatarUploader } from '@shared/avatarUploader';
|
||||
import { Image } from '@shared/image';
|
||||
import { AvatarUploader } from "@shared/avatarUploader";
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||
import { onboardingAtom } from '@stores/onboarding';
|
||||
import { DEFAULT_AVATAR } from "@stores/constants";
|
||||
import { onboardingAtom } from "@stores/onboarding";
|
||||
|
||||
import { useAtom } from 'jotai';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { navigate } from 'vite-plugin-ssr/client/router';
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { navigate } from "vite-plugin-ssr/client/router";
|
||||
|
||||
export function Page() {
|
||||
const [image, setImage] = useState(DEFAULT_AVATAR);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [onboarding, setOnboarding] = useAtom(onboardingAtom);
|
||||
const [image, setImage] = useState(DEFAULT_AVATAR);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [onboarding, setOnboarding] = useAtom(onboardingAtom);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { isDirty, isValid },
|
||||
} = useForm({
|
||||
defaultValues: async () => {
|
||||
if (onboarding.metadata) {
|
||||
return onboarding.metadata;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { isDirty, isValid },
|
||||
} = useForm({
|
||||
defaultValues: async () => {
|
||||
if (onboarding.metadata) {
|
||||
return onboarding.metadata;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: any) => {
|
||||
setLoading(true);
|
||||
setOnboarding((prev) => ({ ...prev, metadata: data }));
|
||||
navigate('/auth/create/step-3');
|
||||
};
|
||||
const onSubmit = (data: any) => {
|
||||
setLoading(true);
|
||||
setOnboarding((prev) => ({ ...prev, metadata: data }));
|
||||
navigate("/auth/create/step-3");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setValue('picture', image);
|
||||
}, [setValue, image]);
|
||||
useEffect(() => {
|
||||
setValue("picture", image);
|
||||
}, [setValue, image]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-2xl font-semibold text-zinc-200">Create your profile</h1>
|
||||
</div>
|
||||
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
|
||||
<input
|
||||
type={'hidden'}
|
||||
{...register('picture')}
|
||||
value={image}
|
||||
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">Avatar</label>
|
||||
<div className="relative inline-flex h-36 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950">
|
||||
<Image src={image} alt="avatar" className="relative z-10 h-11 w-11 rounded-md" />
|
||||
<div className="absolute bottom-3 right-3 z-10">
|
||||
<AvatarUploader valueState={setImage} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">Display Name *</label>
|
||||
<div className="relative w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
|
||||
<input
|
||||
type={'text'}
|
||||
{...register('display_name', { required: true, minLength: 4 })}
|
||||
spellCheck={false}
|
||||
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">About</label>
|
||||
<div className="relative h-20 w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
|
||||
<textarea
|
||||
{...register('about')}
|
||||
spellCheck={false}
|
||||
className="relative h-20 w-full resize-none rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isDirty || !isValid}
|
||||
className="w-full transform rounded-lg bg-fuchsia-500 px-3.5 py-2.5 font-medium text-white shadow-button hover:bg-fuchsia-600 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{loading ? (
|
||||
<svg
|
||||
className="h-4 w-4 animate-spin text-black dark:text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
) : (
|
||||
<span>Continue →</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-2xl font-semibold text-zinc-200">
|
||||
Create your profile
|
||||
</h1>
|
||||
</div>
|
||||
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<input
|
||||
type={"hidden"}
|
||||
{...register("picture")}
|
||||
value={image}
|
||||
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
|
||||
Avatar
|
||||
</label>
|
||||
<div className="relative inline-flex h-36 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950">
|
||||
<Image
|
||||
src={image}
|
||||
alt="avatar"
|
||||
className="relative z-10 h-11 w-11 rounded-md"
|
||||
/>
|
||||
<div className="absolute bottom-3 right-3 z-10">
|
||||
<AvatarUploader valueState={setImage} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
|
||||
Display Name *
|
||||
</label>
|
||||
<div className="relative w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
|
||||
<input
|
||||
type={"text"}
|
||||
{...register("display_name", {
|
||||
required: true,
|
||||
minLength: 4,
|
||||
})}
|
||||
spellCheck={false}
|
||||
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
|
||||
About
|
||||
</label>
|
||||
<div className="relative h-20 w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
|
||||
<textarea
|
||||
{...register("about")}
|
||||
spellCheck={false}
|
||||
className="relative h-20 w-full resize-none rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isDirty || !isValid}
|
||||
className="w-full transform rounded-lg bg-fuchsia-500 px-3.5 py-2.5 font-medium text-white shadow-button hover:bg-fuchsia-600 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{loading ? (
|
||||
<svg
|
||||
className="h-4 w-4 animate-spin text-black dark:text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<title id="loading">Loading</title>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<span>Continue →</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,182 +1,272 @@
|
||||
import User from '@app/auth/components/user';
|
||||
import User from "@app/auth/components/user";
|
||||
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import CheckCircleIcon from '@icons/checkCircle';
|
||||
import CheckCircleIcon from "@icons/checkCircle";
|
||||
|
||||
import { WRITEONLY_RELAYS } from '@stores/constants';
|
||||
import { onboardingAtom } from '@stores/onboarding';
|
||||
import { WRITEONLY_RELAYS } from "@stores/constants";
|
||||
import { onboardingAtom } from "@stores/onboarding";
|
||||
|
||||
import { createAccount, createPleb } from '@utils/storage';
|
||||
import { arrayToNIP02 } from '@utils/transform';
|
||||
import { createAccount, createPleb } from "@utils/storage";
|
||||
import { arrayToNIP02 } from "@utils/transform";
|
||||
|
||||
import { useAtom } from 'jotai';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { useContext, useState } from 'react';
|
||||
import { navigate } from 'vite-plugin-ssr/client/router';
|
||||
import { useAtom } from "jotai";
|
||||
import { getEventHash, signEvent } from "nostr-tools";
|
||||
import { useContext, useState } from "react";
|
||||
import { navigate } from "vite-plugin-ssr/client/router";
|
||||
|
||||
const initialList = [
|
||||
{ pubkey: '82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2' },
|
||||
{ pubkey: 'a341f45ff9758f570a21b000c17d4e53a3a497c8397f26c0e6d61e5acffc7a98' },
|
||||
{ pubkey: '04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9' },
|
||||
{ pubkey: 'c4eabae1be3cf657bc1855ee05e69de9f059cb7a059227168b80b89761cbc4e0' },
|
||||
{ pubkey: '6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93' },
|
||||
{ pubkey: 'e88a691e98d9987c964521dff60025f60700378a4879180dcbbb4a5027850411' },
|
||||
{ pubkey: '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d' },
|
||||
{ pubkey: 'c49d52a573366792b9a6e4851587c28042fb24fa5625c6d67b8c95c8751aca15' },
|
||||
{ pubkey: 'e33fe65f1fde44c6dc17eeb38fdad0fceaf1cae8722084332ed1e32496291d42' },
|
||||
{ pubkey: '84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240' },
|
||||
{ pubkey: '703e26b4f8bc0fa57f99d815dbb75b086012acc24fc557befa310f5aa08d1898' },
|
||||
{ pubkey: 'bf2376e17ba4ec269d10fcc996a4746b451152be9031fa48e74553dde5526bce' },
|
||||
{ pubkey: '4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0' },
|
||||
{ pubkey: 'c9b19ffcd43e6a5f23b3d27106ce19e4ad2df89ba1031dd4617f1b591e108965' },
|
||||
{ pubkey: 'c7dccba4fe4426a7b1ea239a5637ba40fab9862c8c86b3330fe65e9f667435f6' },
|
||||
{ pubkey: '6e1534f56fc9e937e06237c8ba4b5662bcacc4e1a3cfab9c16d89390bec4fca3' },
|
||||
{ pubkey: '50d94fc2d8580c682b071a542f8b1e31a200b0508bab95a33bef0855df281d63' },
|
||||
{ pubkey: '3d2e51508699f98f0f2bdbe7a45b673c687fe6420f466dc296d90b908d51d594' },
|
||||
{ pubkey: '6e3f51664e19e082df5217fd4492bb96907405a0b27028671dd7f297b688608c' },
|
||||
{ pubkey: '2edbcea694d164629854a52583458fd6d965b161e3c48b57d3aff01940558884' },
|
||||
{ pubkey: '3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24' },
|
||||
{ pubkey: 'eab0e756d32b80bcd464f3d844b8040303075a13eabc3599a762c9ac7ab91f4f' },
|
||||
{ pubkey: 'be1d89794bf92de5dd64c1e60f6a2c70c140abac9932418fee30c5c637fe9479' },
|
||||
{ pubkey: 'a5e93aef8e820cbc7ab7b6205f854b87aed4b48c5f6b30fbbeba5c99e40dcf3f' },
|
||||
{ pubkey: '1989034e56b8f606c724f45a12ce84a11841621aaf7182a1f6564380b9c4276b' },
|
||||
{ pubkey: 'c48b5cced5ada74db078df6b00fa53fc1139d73bf0ed16de325d52220211dbd5' },
|
||||
{ pubkey: '460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c' },
|
||||
{ pubkey: '7f3b464b9ff3623630485060cbda3a7790131c5339a7803bde8feb79a5e1b06a' },
|
||||
{ pubkey: 'b99dbca0184a32ce55904cb267b22e434823c97f418f36daf5d2dff0dd7b5c27' },
|
||||
{ pubkey: 'e9e4276490374a0daf7759fd5f475deff6ffb9b0fc5fa98c902b5f4b2fe3bba2' },
|
||||
{ pubkey: 'ea2e3c814d08a378f8a5b8faecb2884d05855975c5ca4b5c25e2d6f936286f14' },
|
||||
{ pubkey: 'ff04a0e6cd80c141b0b55825fed127d4532a6eecdb7e743a38a3c28bf9f44609' },
|
||||
{
|
||||
pubkey: "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2",
|
||||
},
|
||||
{
|
||||
pubkey: "a341f45ff9758f570a21b000c17d4e53a3a497c8397f26c0e6d61e5acffc7a98",
|
||||
},
|
||||
{
|
||||
pubkey: "04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9",
|
||||
},
|
||||
{
|
||||
pubkey: "c4eabae1be3cf657bc1855ee05e69de9f059cb7a059227168b80b89761cbc4e0",
|
||||
},
|
||||
{
|
||||
pubkey: "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93",
|
||||
},
|
||||
{
|
||||
pubkey: "e88a691e98d9987c964521dff60025f60700378a4879180dcbbb4a5027850411",
|
||||
},
|
||||
{
|
||||
pubkey: "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
|
||||
},
|
||||
{
|
||||
pubkey: "c49d52a573366792b9a6e4851587c28042fb24fa5625c6d67b8c95c8751aca15",
|
||||
},
|
||||
{
|
||||
pubkey: "e33fe65f1fde44c6dc17eeb38fdad0fceaf1cae8722084332ed1e32496291d42",
|
||||
},
|
||||
{
|
||||
pubkey: "84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240",
|
||||
},
|
||||
{
|
||||
pubkey: "703e26b4f8bc0fa57f99d815dbb75b086012acc24fc557befa310f5aa08d1898",
|
||||
},
|
||||
{
|
||||
pubkey: "bf2376e17ba4ec269d10fcc996a4746b451152be9031fa48e74553dde5526bce",
|
||||
},
|
||||
{
|
||||
pubkey: "4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0",
|
||||
},
|
||||
{
|
||||
pubkey: "c9b19ffcd43e6a5f23b3d27106ce19e4ad2df89ba1031dd4617f1b591e108965",
|
||||
},
|
||||
{
|
||||
pubkey: "c7dccba4fe4426a7b1ea239a5637ba40fab9862c8c86b3330fe65e9f667435f6",
|
||||
},
|
||||
{
|
||||
pubkey: "6e1534f56fc9e937e06237c8ba4b5662bcacc4e1a3cfab9c16d89390bec4fca3",
|
||||
},
|
||||
{
|
||||
pubkey: "50d94fc2d8580c682b071a542f8b1e31a200b0508bab95a33bef0855df281d63",
|
||||
},
|
||||
{
|
||||
pubkey: "3d2e51508699f98f0f2bdbe7a45b673c687fe6420f466dc296d90b908d51d594",
|
||||
},
|
||||
{
|
||||
pubkey: "6e3f51664e19e082df5217fd4492bb96907405a0b27028671dd7f297b688608c",
|
||||
},
|
||||
{
|
||||
pubkey: "2edbcea694d164629854a52583458fd6d965b161e3c48b57d3aff01940558884",
|
||||
},
|
||||
{
|
||||
pubkey: "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24",
|
||||
},
|
||||
{
|
||||
pubkey: "eab0e756d32b80bcd464f3d844b8040303075a13eabc3599a762c9ac7ab91f4f",
|
||||
},
|
||||
{
|
||||
pubkey: "be1d89794bf92de5dd64c1e60f6a2c70c140abac9932418fee30c5c637fe9479",
|
||||
},
|
||||
{
|
||||
pubkey: "a5e93aef8e820cbc7ab7b6205f854b87aed4b48c5f6b30fbbeba5c99e40dcf3f",
|
||||
},
|
||||
{
|
||||
pubkey: "1989034e56b8f606c724f45a12ce84a11841621aaf7182a1f6564380b9c4276b",
|
||||
},
|
||||
{
|
||||
pubkey: "c48b5cced5ada74db078df6b00fa53fc1139d73bf0ed16de325d52220211dbd5",
|
||||
},
|
||||
{
|
||||
pubkey: "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
|
||||
},
|
||||
{
|
||||
pubkey: "7f3b464b9ff3623630485060cbda3a7790131c5339a7803bde8feb79a5e1b06a",
|
||||
},
|
||||
{
|
||||
pubkey: "b99dbca0184a32ce55904cb267b22e434823c97f418f36daf5d2dff0dd7b5c27",
|
||||
},
|
||||
{
|
||||
pubkey: "e9e4276490374a0daf7759fd5f475deff6ffb9b0fc5fa98c902b5f4b2fe3bba2",
|
||||
},
|
||||
{
|
||||
pubkey: "ea2e3c814d08a378f8a5b8faecb2884d05855975c5ca4b5c25e2d6f936286f14",
|
||||
},
|
||||
{
|
||||
pubkey: "ff04a0e6cd80c141b0b55825fed127d4532a6eecdb7e743a38a3c28bf9f44609",
|
||||
},
|
||||
];
|
||||
|
||||
export function Page() {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [follows, setFollows] = useState([]);
|
||||
const [onboarding] = useAtom(onboardingAtom);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [follows, setFollows] = useState([]);
|
||||
const [onboarding] = useAtom(onboardingAtom);
|
||||
|
||||
// toggle follow state
|
||||
const toggleFollow = (pubkey: string) => {
|
||||
const arr = follows.includes(pubkey) ? follows.filter((i) => i !== pubkey) : [...follows, pubkey];
|
||||
setFollows(arr);
|
||||
};
|
||||
// toggle follow state
|
||||
const toggleFollow = (pubkey: string) => {
|
||||
const arr = follows.includes(pubkey)
|
||||
? follows.filter((i) => i !== pubkey)
|
||||
: [...follows, pubkey];
|
||||
setFollows(arr);
|
||||
};
|
||||
|
||||
const broadcastAccount = () => {
|
||||
// build event
|
||||
const event: any = {
|
||||
content: JSON.stringify(onboarding.metadata),
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 0,
|
||||
pubkey: onboarding.pubkey,
|
||||
tags: [],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, onboarding.privkey);
|
||||
// broadcast
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
};
|
||||
const broadcastAccount = () => {
|
||||
// build event
|
||||
const event: any = {
|
||||
content: JSON.stringify(onboarding.metadata),
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 0,
|
||||
pubkey: onboarding.pubkey,
|
||||
tags: [],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, onboarding.privkey);
|
||||
// broadcast
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
};
|
||||
|
||||
const broadcastContacts = () => {
|
||||
const nip02 = arrayToNIP02(follows);
|
||||
// build event
|
||||
const event: any = {
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 3,
|
||||
pubkey: onboarding.pubkey,
|
||||
tags: nip02,
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, onboarding.privkey);
|
||||
// broadcast
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
};
|
||||
const broadcastContacts = () => {
|
||||
const nip02 = arrayToNIP02(follows);
|
||||
// build event
|
||||
const event: any = {
|
||||
content: "",
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 3,
|
||||
pubkey: onboarding.pubkey,
|
||||
tags: nip02,
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, onboarding.privkey);
|
||||
// broadcast
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
};
|
||||
|
||||
// save follows to database then broadcast
|
||||
const submit = async () => {
|
||||
setLoading(true);
|
||||
// save follows to database then broadcast
|
||||
const submit = async () => {
|
||||
setLoading(true);
|
||||
|
||||
const followsIncludeSelf = follows.concat([onboarding.pubkey]);
|
||||
// insert to database
|
||||
createAccount(onboarding.pubkey, onboarding.privkey, onboarding.metadata, arrayToNIP02(followsIncludeSelf), 1)
|
||||
.then((res) => {
|
||||
if (res) {
|
||||
for (const tag of follows) {
|
||||
fetch(`https://us.rbr.bio/${tag}/metadata.json`)
|
||||
.then((data) => data.json())
|
||||
.then((data) => createPleb(tag, data ?? ''));
|
||||
}
|
||||
broadcastAccount();
|
||||
broadcastContacts();
|
||||
setTimeout(() => navigate('/', { overwriteLastHistoryEntry: true }), 2000);
|
||||
} else {
|
||||
console.error();
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
};
|
||||
const followsIncludeSelf = follows.concat([onboarding.pubkey]);
|
||||
// insert to database
|
||||
createAccount(
|
||||
onboarding.pubkey,
|
||||
onboarding.privkey,
|
||||
onboarding.metadata,
|
||||
arrayToNIP02(followsIncludeSelf),
|
||||
1,
|
||||
)
|
||||
.then((res) => {
|
||||
if (res) {
|
||||
for (const tag of follows) {
|
||||
fetch(`https://us.rbr.bio/${tag}/metadata.json`)
|
||||
.then((data) => data.json())
|
||||
.then((data) => createPleb(tag, data ?? ""));
|
||||
}
|
||||
broadcastAccount();
|
||||
broadcastContacts();
|
||||
setTimeout(
|
||||
() => navigate("/", { overwriteLastHistoryEntry: true }),
|
||||
2000,
|
||||
);
|
||||
} else {
|
||||
console.error();
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-2xl font-semibold text-zinc-200">Personalized your newsfeed</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900">
|
||||
<div className="inline-flex h-10 w-full items-center gap-1 border-b border-zinc-800 px-4 text-sm font-medium text-zinc-400">
|
||||
Follow at least
|
||||
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text font-bold text-transparent">
|
||||
{follows.length}/10
|
||||
</span>{' '}
|
||||
plebs
|
||||
</div>
|
||||
<div className="scrollbar-hide flex h-96 flex-col overflow-y-auto py-2">
|
||||
{initialList.map((item: { pubkey: string }, index: number) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={() => toggleFollow(item.pubkey)}
|
||||
className="inline-flex transform items-center justify-between bg-zinc-900 px-4 py-2 hover:bg-zinc-800 active:translate-y-1"
|
||||
>
|
||||
<User pubkey={item.pubkey} />
|
||||
{follows.includes(item.pubkey) && (
|
||||
<div>
|
||||
<CheckCircleIcon width={16} height={16} className="text-green-400" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{follows.length >= 10 && (
|
||||
<button
|
||||
onClick={() => submit()}
|
||||
className="inline-flex h-10 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 px-3.5 font-medium text-white shadow-button hover:bg-fuchsia-600 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{loading === true ? (
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
) : (
|
||||
<span>Continue →</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-2xl font-semibold text-zinc-200">
|
||||
Personalized your newsfeed
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900">
|
||||
<div className="inline-flex h-10 w-full items-center gap-1 border-b border-zinc-800 px-4 text-sm font-medium text-zinc-400">
|
||||
Follow at least
|
||||
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text font-bold text-transparent">
|
||||
{follows.length}/10
|
||||
</span>{" "}
|
||||
plebs
|
||||
</div>
|
||||
<div className="scrollbar-hide flex h-96 flex-col overflow-y-auto py-2">
|
||||
{initialList.map((item: { pubkey: string }, index: number) => (
|
||||
<button
|
||||
key={`item-${index}`}
|
||||
type="button"
|
||||
onClick={() => toggleFollow(item.pubkey)}
|
||||
className="inline-flex transform items-center justify-between bg-zinc-900 px-4 py-2 hover:bg-zinc-800 active:translate-y-1"
|
||||
>
|
||||
<User pubkey={item.pubkey} />
|
||||
{follows.includes(item.pubkey) && (
|
||||
<div>
|
||||
<CheckCircleIcon
|
||||
width={16}
|
||||
height={16}
|
||||
className="text-green-400"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{follows.length >= 10 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
className="inline-flex h-10 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 px-3.5 font-medium text-white shadow-button hover:bg-fuchsia-600 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{loading === true ? (
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<title id="loading">Loading</title>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<span>Continue →</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,124 +1,142 @@
|
||||
import { onboardingAtom } from '@stores/onboarding';
|
||||
import { onboardingAtom } from "@stores/onboarding";
|
||||
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { getPublicKey, nip19 } from 'nostr-tools';
|
||||
import { Resolver, useForm } from 'react-hook-form';
|
||||
import { navigate } from 'vite-plugin-ssr/client/router';
|
||||
import { useSetAtom } from "jotai";
|
||||
import { getPublicKey, nip19 } from "nostr-tools";
|
||||
import { Resolver, useForm } from "react-hook-form";
|
||||
import { navigate } from "vite-plugin-ssr/client/router";
|
||||
|
||||
type FormValues = {
|
||||
key: string;
|
||||
key: string;
|
||||
};
|
||||
|
||||
const resolver: Resolver<FormValues> = async (values) => {
|
||||
return {
|
||||
values: values.key ? values : {},
|
||||
errors: !values.key
|
||||
? {
|
||||
key: {
|
||||
type: 'required',
|
||||
message: 'This is required.',
|
||||
},
|
||||
}
|
||||
: {},
|
||||
};
|
||||
return {
|
||||
values: values.key ? values : {},
|
||||
errors: !values.key
|
||||
? {
|
||||
key: {
|
||||
type: "required",
|
||||
message: "This is required.",
|
||||
},
|
||||
}
|
||||
: {},
|
||||
};
|
||||
};
|
||||
|
||||
export function Page() {
|
||||
const setOnboardingPrivkey = useSetAtom(onboardingAtom);
|
||||
const setOnboardingPrivkey = useSetAtom(onboardingAtom);
|
||||
|
||||
const {
|
||||
register,
|
||||
setError,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty, isValid, isSubmitting },
|
||||
} = useForm<FormValues>({ resolver });
|
||||
const {
|
||||
register,
|
||||
setError,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty, isValid, isSubmitting },
|
||||
} = useForm<FormValues>({ resolver });
|
||||
|
||||
const onSubmit = async (data: any) => {
|
||||
try {
|
||||
let privkey = data['key'];
|
||||
const onSubmit = async (data: any) => {
|
||||
try {
|
||||
let privkey = data["key"];
|
||||
|
||||
if (privkey.substring(0, 4) === 'nsec') {
|
||||
privkey = nip19.decode(privkey).data;
|
||||
}
|
||||
if (privkey.substring(0, 4) === "nsec") {
|
||||
privkey = nip19.decode(privkey).data;
|
||||
}
|
||||
|
||||
if (typeof getPublicKey(privkey) === 'string') {
|
||||
setOnboardingPrivkey((prev) => ({ ...prev, privkey: privkey }));
|
||||
navigate(`/auth/import/step-2`);
|
||||
}
|
||||
} catch (error) {
|
||||
setError('key', {
|
||||
type: 'custom',
|
||||
message: 'Private Key is invalid, please check again',
|
||||
});
|
||||
}
|
||||
};
|
||||
if (typeof getPublicKey(privkey) === "string") {
|
||||
setOnboardingPrivkey((prev) => ({ ...prev, privkey: privkey }));
|
||||
navigate("/auth/import/step-2");
|
||||
}
|
||||
} catch (error) {
|
||||
setError("key", {
|
||||
type: "custom",
|
||||
message: "Private Key is invalid, please check again",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-2xl font-semibold text-zinc-200">Import your key</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
{/* #TODO: add function */}
|
||||
<button className="inline-flex w-full transform items-center justify-center gap-1.5 rounded-lg bg-zinc-900 px-3.5 py-2.5 font-medium text-zinc-400 active:translate-y-1">
|
||||
<div className="inline-flex items-center rounded-md bg-zinc-400/10 px-2 py-0.5 text-xs font-medium ring-1 ring-inset ring-zinc-400/20">
|
||||
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-transparent">
|
||||
Coming soon
|
||||
</span>
|
||||
</div>
|
||||
<span>Continue with Nostr Connect</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-zinc-800"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="bg-zinc-950 px-2 text-sm text-zinc-500">or</span>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
|
||||
<input
|
||||
{...register('key', { required: true, minLength: 32 })}
|
||||
type={'password'}
|
||||
placeholder="Paste private key here..."
|
||||
className="relative w-full rounded-lg border border-black/5 px-3.5 py-2.5 text-center shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-red-400">{errors.key && <p>{errors.key.message}</p>}</span>
|
||||
</div>
|
||||
<div className="flex h-9 items-center justify-center">
|
||||
{isSubmitting ? (
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isDirty || !isValid}
|
||||
className="w-full transform rounded-lg bg-fuchsia-500 px-3.5 py-2.5 font-medium text-white shadow-button hover:bg-fuchsia-600 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
<span className="drop-shadow-lg">Continue →</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-2xl font-semibold text-zinc-200">
|
||||
Import your key
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
{/* #TODO: add function */}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex w-full transform items-center justify-center gap-1.5 rounded-lg bg-zinc-900 px-3.5 py-2.5 font-medium text-zinc-400 active:translate-y-1"
|
||||
>
|
||||
<div className="inline-flex items-center rounded-md bg-zinc-400/10 px-2 py-0.5 text-xs font-medium ring-1 ring-inset ring-zinc-400/20">
|
||||
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-transparent">
|
||||
Coming soon
|
||||
</span>
|
||||
</div>
|
||||
<span>Continue with Nostr Connect</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-zinc-800" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="bg-zinc-950 px-2 text-sm text-zinc-500">or</span>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
|
||||
<input
|
||||
{...register("key", { required: true, minLength: 32 })}
|
||||
type={"password"}
|
||||
placeholder="Paste private key here..."
|
||||
className="relative w-full rounded-lg border border-black/5 px-3.5 py-2.5 text-center shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-red-400">
|
||||
{errors.key && <p>{errors.key.message}</p>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex h-9 items-center justify-center">
|
||||
{isSubmitting ? (
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<title id="loading">Loading</title>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isDirty || !isValid}
|
||||
className="w-full transform rounded-lg bg-fuchsia-500 px-3.5 py-2.5 font-medium text-white shadow-button hover:bg-fuchsia-600 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
<span className="drop-shadow-lg">Continue →</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,143 +1,158 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { Image } from "@shared/image";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import { DEFAULT_AVATAR, READONLY_RELAYS } from '@stores/constants';
|
||||
import { onboardingAtom } from '@stores/onboarding';
|
||||
import { DEFAULT_AVATAR, READONLY_RELAYS } from "@stores/constants";
|
||||
import { onboardingAtom } from "@stores/onboarding";
|
||||
|
||||
import { shortenKey } from '@utils/shortenKey';
|
||||
import { createAccount, createPleb } from '@utils/storage';
|
||||
import { shortenKey } from "@utils/shortenKey";
|
||||
import { createAccount, createPleb } from "@utils/storage";
|
||||
|
||||
import { useAtom } from 'jotai';
|
||||
import { getPublicKey } from 'nostr-tools';
|
||||
import { useContext, useMemo, useState } from 'react';
|
||||
import useSWRSubscription from 'swr/subscription';
|
||||
import { navigate } from 'vite-plugin-ssr/client/router';
|
||||
import { useAtom } from "jotai";
|
||||
import { getPublicKey } from "nostr-tools";
|
||||
import { useContext, useMemo, useState } from "react";
|
||||
import useSWRSubscription from "swr/subscription";
|
||||
import { navigate } from "vite-plugin-ssr/client/router";
|
||||
|
||||
export function Page() {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [onboarding, setOnboarding] = useAtom(onboardingAtom);
|
||||
const pubkey = useMemo(() => (onboarding.privkey ? getPublicKey(onboarding.privkey) : ''), [onboarding.privkey]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [onboarding, setOnboarding] = useAtom(onboardingAtom);
|
||||
const pubkey = useMemo(
|
||||
() => (onboarding.privkey ? getPublicKey(onboarding.privkey) : ""),
|
||||
[onboarding.privkey],
|
||||
);
|
||||
|
||||
const { data, error } = useSWRSubscription(pubkey ? pubkey : null, (key, { next }) => {
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [0, 3],
|
||||
authors: [key],
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
switch (event.kind) {
|
||||
case 0:
|
||||
// update state
|
||||
next(null, JSON.parse(event.content));
|
||||
// create account
|
||||
setOnboarding((prev) => ({ ...prev, metadata: event.content }));
|
||||
break;
|
||||
case 3:
|
||||
setOnboarding((prev) => ({ ...prev, follows: event.tags }));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
);
|
||||
const { data, error } = useSWRSubscription(
|
||||
pubkey ? pubkey : null,
|
||||
(key, { next }) => {
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [0, 3],
|
||||
authors: [key],
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
switch (event.kind) {
|
||||
case 0:
|
||||
// update state
|
||||
next(null, JSON.parse(event.content));
|
||||
// create account
|
||||
setOnboarding((prev) => ({ ...prev, metadata: event.content }));
|
||||
break;
|
||||
case 3:
|
||||
setOnboarding((prev) => ({ ...prev, follows: event.tags }));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const submit = () => {
|
||||
// show loading indicator
|
||||
setLoading(true);
|
||||
const submit = () => {
|
||||
// show loading indicator
|
||||
setLoading(true);
|
||||
|
||||
const follows = onboarding.follows.concat([['p', pubkey]]);
|
||||
// insert to database
|
||||
createAccount(pubkey, onboarding.privkey, onboarding.metadata, follows, 1)
|
||||
.then((res) => {
|
||||
if (res) {
|
||||
for (const tag of onboarding.follows) {
|
||||
fetch(`https://us.rbr.bio/${tag[1]}/metadata.json`)
|
||||
.then((data) => data.json())
|
||||
.then((data) => createPleb(tag[1], data ?? ''));
|
||||
}
|
||||
setTimeout(() => navigate('/', { overwriteLastHistoryEntry: true }), 2000);
|
||||
} else {
|
||||
console.error();
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
};
|
||||
const follows = onboarding.follows.concat([["p", pubkey]]);
|
||||
// insert to database
|
||||
createAccount(pubkey, onboarding.privkey, onboarding.metadata, follows, 1)
|
||||
.then((res) => {
|
||||
if (res) {
|
||||
for (const tag of onboarding.follows) {
|
||||
fetch(`https://us.rbr.bio/${tag[1]}/metadata.json`)
|
||||
.then((data) => data.json())
|
||||
.then((data) => createPleb(tag[1], data ?? ""));
|
||||
}
|
||||
setTimeout(
|
||||
() => navigate("/", { overwriteLastHistoryEntry: true }),
|
||||
2000,
|
||||
);
|
||||
} else {
|
||||
console.error();
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-2xl font-semibold">{loading ? 'Creating...' : 'Continue with'}</h1>
|
||||
</div>
|
||||
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||
{error && <div>Failed to load profile</div>}
|
||||
{!data ? (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-11 w-11 animate-pulse rounded-lg bg-zinc-800"></div>
|
||||
<div>
|
||||
<h3 className="mb-1 h-4 w-16 animate-pulse rounded bg-zinc-800"></h3>
|
||||
<p className="h-3 w-36 animate-pulse rounded bg-zinc-800"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Image
|
||||
className="relative inline-flex h-11 w-11 rounded-lg ring-2 ring-zinc-900"
|
||||
src={data.picture || DEFAULT_AVATAR}
|
||||
alt={pubkey}
|
||||
/>
|
||||
<div>
|
||||
<h3 className="font-medium leading-none text-zinc-200">{data.display_name || data.name}</h3>
|
||||
<p className="text-sm text-zinc-400">{data.nip05 || shortenKey(pubkey)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
className="inline-flex h-10 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 px-3.5 font-medium text-white shadow-button hover:bg-fuchsia-600 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{loading ? (
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-black dark:text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
) : (
|
||||
<span>Continue →</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{loading ? "Creating..." : "Continue with"}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||
{error && <div>Failed to load profile</div>}
|
||||
{!data ? (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-11 w-11 animate-pulse rounded-lg bg-zinc-800" />
|
||||
<div>
|
||||
<h3 className="mb-1 h-4 w-16 animate-pulse rounded bg-zinc-800" />
|
||||
<p className="h-3 w-36 animate-pulse rounded bg-zinc-800" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Image
|
||||
className="relative inline-flex h-11 w-11 rounded-lg ring-2 ring-zinc-900"
|
||||
src={data.picture || DEFAULT_AVATAR}
|
||||
alt={pubkey}
|
||||
/>
|
||||
<div>
|
||||
<h3 className="font-medium leading-none text-zinc-200">
|
||||
{data.display_name || data.name}
|
||||
</h3>
|
||||
<p className="text-sm text-zinc-400">
|
||||
{data.nip05 || shortenKey(pubkey)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
className="inline-flex h-10 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 px-3.5 font-medium text-white shadow-button hover:bg-fuchsia-600 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{loading ? (
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-black dark:text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<span>Continue →</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
import ArrowRightIcon from '@icons/arrowRight';
|
||||
import ArrowRightIcon from "@icons/arrowRight";
|
||||
|
||||
const PLEBS = [
|
||||
'https://133332.xyz/p.jpg',
|
||||
'https://void.cat/d/3Bp6jSHURFNQ9u3pK8nwtq.webp',
|
||||
'https://i.imgur.com/f8SyhRL.jpg',
|
||||
'http://nostr.build/i/6369.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1622010345589190656/mAPqsmtz_400x400.jpg',
|
||||
'https://media.tenor.com/l5arkXy9RfIAAAAd/thunder.gif',
|
||||
'https://nostr.build/i/p/nostr.build_0e412058980ed2ac4adf3de639304c9e970e2745ba9ca19c75f984f4f6da4971.jpeg',
|
||||
'https://nostr.build/i/nostr.build_864a019a6c1d3a90a17363553d32b71de618d250f02cf0a59ca19fb3029fd5bc.jpg',
|
||||
'https://void.cat/d/8zE9T8a39YfUVjrLM4xcpE.webp',
|
||||
'https://avatars.githubusercontent.com/u/89577423',
|
||||
'https://pbs.twimg.com/profile_images/1363180486080663554/iN-r_BiM_400x400.jpg',
|
||||
'https://void.cat/d/JUBBqXgCcGBEh7jUgJaayy',
|
||||
'https://phase1.attract-eu.com/wp-content/uploads/2020/03/ATTRACT_HPLM.png',
|
||||
'https://www.retro-synthwave.com/wp-content/uploads/2017/01/PowerGlove-23.jpg',
|
||||
'https://void.cat/d/KvAEMvYNmy1rfCH6a7HZzh.webp',
|
||||
'https://media.giphy.com/media/NqfMNCkyGwtXhKFlCR/giphy-downsized-large.gif',
|
||||
'https://i.imgur.com/VGpUNFS.jpg',
|
||||
'https://nostr.build/i/p/nostr.build_b39254db43d5557df99d1eb516f1c2f56a21a01b10c248f6eb66aa827c9a90f4.jpeg',
|
||||
'https://davidcoen.it/wp-content/uploads/2020/11/7004972-taglio.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1570432066348515330/26PtCuwF_400x400.jpg',
|
||||
'https://nostr.build/i/nostr.build_9d33ee801aa08955be174554832952ab95a65d5e015176834c8aa9a4e2f2e3a5.jpg',
|
||||
'https://www.linkpicture.com/q/0FE78CFF-C931-4568-A7AA-DD8AEE889992.jpeg',
|
||||
'https://nostr.build/i/nostr.build_97d6e2d25dd92422eb3d6d645b7cee9ed9c614f331be7e6f7db9ccfdbc5ee260.png',
|
||||
'https://pbs.twimg.com/profile_images/1569570198348337152/-n1KD74u_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1600149653898596354/5PVe-r-J_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1639659216372658178/Dnn-Ysp-_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1554429112978120706/yr1hXl6R_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1615478486688272385/q2ECeZDX_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1638644441773748226/tNsA6RpG_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1607882836740120576/3Tg1mTYJ_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1401907430339002369/WKrP9Esn_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1523971278478131200/TMPzfvhE_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1626421539884204032/aj4tmzsk_400x400.png',
|
||||
'https://pbs.twimg.com/profile_images/1582771691779985408/C9MHYIgt_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1409612480465276931/38Vyx4e8_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1549826566787588098/MlduJCZO_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/539211568035004416/sBMjPR9q_400x400.jpeg',
|
||||
'https://pbs.twimg.com/profile_images/1548660003522887682/1QMHmles_400x400.jpg',
|
||||
'https://pbs.twimg.com/profile_images/1362497143999787013/KLUoN1Vn_400x400.png',
|
||||
'https://pbs.twimg.com/profile_images/1600434913240563713/AssmMGwf_400x400.jpg',
|
||||
"https://133332.xyz/p.jpg",
|
||||
"https://void.cat/d/3Bp6jSHURFNQ9u3pK8nwtq.webp",
|
||||
"https://i.imgur.com/f8SyhRL.jpg",
|
||||
"http://nostr.build/i/6369.jpg",
|
||||
"https://pbs.twimg.com/profile_images/1622010345589190656/mAPqsmtz_400x400.jpg",
|
||||
"https://media.tenor.com/l5arkXy9RfIAAAAd/thunder.gif",
|
||||
"https://nostr.build/i/p/nostr.build_0e412058980ed2ac4adf3de639304c9e970e2745ba9ca19c75f984f4f6da4971.jpeg",
|
||||
"https://nostr.build/i/nostr.build_864a019a6c1d3a90a17363553d32b71de618d250f02cf0a59ca19fb3029fd5bc.jpg",
|
||||
"https://void.cat/d/8zE9T8a39YfUVjrLM4xcpE.webp",
|
||||
"https://avatars.githubusercontent.com/u/89577423",
|
||||
"https://pbs.twimg.com/profile_images/1363180486080663554/iN-r_BiM_400x400.jpg",
|
||||
"https://void.cat/d/JUBBqXgCcGBEh7jUgJaayy",
|
||||
"https://phase1.attract-eu.com/wp-content/uploads/2020/03/ATTRACT_HPLM.png",
|
||||
"https://www.retro-synthwave.com/wp-content/uploads/2017/01/PowerGlove-23.jpg",
|
||||
"https://void.cat/d/KvAEMvYNmy1rfCH6a7HZzh.webp",
|
||||
"https://media.giphy.com/media/NqfMNCkyGwtXhKFlCR/giphy-downsized-large.gif",
|
||||
"https://i.imgur.com/VGpUNFS.jpg",
|
||||
"https://nostr.build/i/p/nostr.build_b39254db43d5557df99d1eb516f1c2f56a21a01b10c248f6eb66aa827c9a90f4.jpeg",
|
||||
"https://davidcoen.it/wp-content/uploads/2020/11/7004972-taglio.jpg",
|
||||
"https://pbs.twimg.com/profile_images/1570432066348515330/26PtCuwF_400x400.jpg",
|
||||
"https://nostr.build/i/nostr.build_9d33ee801aa08955be174554832952ab95a65d5e015176834c8aa9a4e2f2e3a5.jpg",
|
||||
"https://www.linkpicture.com/q/0FE78CFF-C931-4568-A7AA-DD8AEE889992.jpeg",
|
||||
"https://nostr.build/i/nostr.build_97d6e2d25dd92422eb3d6d645b7cee9ed9c614f331be7e6f7db9ccfdbc5ee260.png",
|
||||
"https://pbs.twimg.com/profile_images/1569570198348337152/-n1KD74u_400x400.jpg",
|
||||
"https://pbs.twimg.com/profile_images/1600149653898596354/5PVe-r-J_400x400.jpg",
|
||||
"https://pbs.twimg.com/profile_images/1639659216372658178/Dnn-Ysp-_400x400.jpg",
|
||||
"https://pbs.twimg.com/profile_images/1554429112978120706/yr1hXl6R_400x400.jpg",
|
||||
"https://pbs.twimg.com/profile_images/1615478486688272385/q2ECeZDX_400x400.jpg",
|
||||
"https://pbs.twimg.com/profile_images/1638644441773748226/tNsA6RpG_400x400.jpg",
|
||||
"https://pbs.twimg.com/profile_images/1607882836740120576/3Tg1mTYJ_400x400.jpg",
|
||||
"https://pbs.twimg.com/profile_images/1401907430339002369/WKrP9Esn_400x400.jpg",
|
||||
"https://pbs.twimg.com/profile_images/1523971278478131200/TMPzfvhE_400x400.jpg",
|
||||
"https://pbs.twimg.com/profile_images/1626421539884204032/aj4tmzsk_400x400.png",
|
||||
"https://pbs.twimg.com/profile_images/1582771691779985408/C9MHYIgt_400x400.jpg",
|
||||
"https://pbs.twimg.com/profile_images/1409612480465276931/38Vyx4e8_400x400.jpg",
|
||||
"https://pbs.twimg.com/profile_images/1549826566787588098/MlduJCZO_400x400.jpg",
|
||||
"https://pbs.twimg.com/profile_images/539211568035004416/sBMjPR9q_400x400.jpeg",
|
||||
"https://pbs.twimg.com/profile_images/1548660003522887682/1QMHmles_400x400.jpg",
|
||||
"https://pbs.twimg.com/profile_images/1362497143999787013/KLUoN1Vn_400x400.png",
|
||||
"https://pbs.twimg.com/profile_images/1600434913240563713/AssmMGwf_400x400.jpg",
|
||||
];
|
||||
|
||||
const DURATION = 50000;
|
||||
@@ -52,65 +52,80 @@ const PLEBS_PER_ROW = 20;
|
||||
const random = (min, max) => Math.floor(Math.random() * (max - min)) + min;
|
||||
const shuffle = (arr) => [...arr].sort(() => 0.5 - Math.random());
|
||||
|
||||
const InfiniteLoopSlider = ({ children, duration, reverse }: { children: any; duration: any; reverse: any }) => {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex w-fit"
|
||||
style={{
|
||||
animationName: 'loop',
|
||||
animationIterationCount: 'infinite',
|
||||
animationDirection: reverse ? 'reverse' : 'normal',
|
||||
animationDuration: duration + 'ms',
|
||||
animationTimingFunction: 'linear',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const InfiniteLoopSlider = ({
|
||||
children,
|
||||
duration,
|
||||
reverse,
|
||||
}: { children: any; duration: any; reverse: any }) => {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex w-fit"
|
||||
style={{
|
||||
animationName: "loop",
|
||||
animationIterationCount: "infinite",
|
||||
animationDirection: reverse ? "reverse" : "normal",
|
||||
animationDuration: `${duration}ms`,
|
||||
animationTimingFunction: "linear",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="grid h-full w-full grid-rows-5">
|
||||
<div className="row-span-3 overflow-hidden">
|
||||
<div className="relaive flex w-full max-w-full shrink-0 flex-col gap-4 overflow-hidden p-4">
|
||||
{[...new Array(ROWS)].map((_, i) => (
|
||||
<InfiniteLoopSlider key={i} duration={random(DURATION - 5000, DURATION + 20000)} reverse={i % 2}>
|
||||
{shuffle(PLEBS)
|
||||
.slice(0, PLEBS_PER_ROW)
|
||||
.map((tag) => (
|
||||
<div key={tag} className="relative mr-4 h-11 w-11 gap-2 rounded-md bg-zinc-900 shadow-xl">
|
||||
<Image src={tag} alt={tag} className="h-11 w-11 rounded-md border border-zinc-900" />
|
||||
</div>
|
||||
))}
|
||||
</InfiniteLoopSlider>
|
||||
))}
|
||||
<div className="pointer-events-none absolute inset-0 bg-fade" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="row-span-2 flex w-full flex-col items-center gap-4 overflow-hidden pt-6 min-[1050px]:gap-8 min-[1050px]:pt-10">
|
||||
<h1 className="animate-moveBg bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-5xl font-bold leading-none text-transparent">
|
||||
Let's start!
|
||||
</h1>
|
||||
<div className="mt-4 flex flex-col items-center gap-1.5">
|
||||
<a
|
||||
href="/auth/create"
|
||||
className="relative inline-flex h-14 w-64 items-center justify-center gap-2 rounded-full bg-zinc-900 px-6 text-lg font-medium ring-1 ring-zinc-800 hover:bg-zinc-800"
|
||||
>
|
||||
Create new key
|
||||
<ArrowRightIcon width={20} height={20} />
|
||||
</a>
|
||||
<a
|
||||
href="/auth/import"
|
||||
className="inline-flex h-14 w-64 items-center justify-center gap-2 rounded-full px-6 text-base font-medium text-zinc-300 hover:bg-zinc-800"
|
||||
>
|
||||
Login with private key
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="grid h-full w-full grid-rows-5">
|
||||
<div className="row-span-3 overflow-hidden">
|
||||
<div className="relaive flex w-full max-w-full shrink-0 flex-col gap-4 overflow-hidden p-4">
|
||||
{[...new Array(ROWS)].map((_, i) => (
|
||||
<InfiniteLoopSlider
|
||||
key={`item-${i}`}
|
||||
duration={random(DURATION - 5000, DURATION + 20000)}
|
||||
reverse={i % 2}
|
||||
>
|
||||
{shuffle(PLEBS)
|
||||
.slice(0, PLEBS_PER_ROW)
|
||||
.map((tag) => (
|
||||
<div
|
||||
key={tag}
|
||||
className="relative mr-4 h-11 w-11 gap-2 rounded-md bg-zinc-900 shadow-xl"
|
||||
>
|
||||
<Image
|
||||
src={tag}
|
||||
alt={tag}
|
||||
className="h-11 w-11 rounded-md border border-zinc-900"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</InfiniteLoopSlider>
|
||||
))}
|
||||
<div className="pointer-events-none absolute inset-0 bg-fade" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="row-span-2 flex w-full flex-col items-center gap-4 overflow-hidden pt-6 min-[1050px]:gap-8 min-[1050px]:pt-10">
|
||||
<h1 className="animate-moveBg bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-5xl font-bold leading-none text-transparent">
|
||||
Let's start!
|
||||
</h1>
|
||||
<div className="mt-4 flex flex-col items-center gap-1.5">
|
||||
<a
|
||||
href="/auth/create"
|
||||
className="relative inline-flex h-14 w-64 items-center justify-center gap-2 rounded-full bg-zinc-900 px-6 text-lg font-medium ring-1 ring-zinc-800 hover:bg-zinc-800"
|
||||
>
|
||||
Create new key
|
||||
<ArrowRightIcon width={20} height={20} />
|
||||
</a>
|
||||
<a
|
||||
href="/auth/import"
|
||||
className="inline-flex h-14 w-64 items-center justify-center gap-2 rounded-full px-6 text-base font-medium text-zinc-300 hover:bg-zinc-800"
|
||||
>
|
||||
Login with private key
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { LayoutChannel as Layout } from './layout';
|
||||
export { LayoutChannel as Layout } from "./layout";
|
||||
|
||||
@@ -1,53 +1,60 @@
|
||||
import MutedItem from '@app/channel/components/mutedItem';
|
||||
import MutedItem from "@app/channel/components/mutedItem";
|
||||
|
||||
import MuteIcon from '@icons/mute';
|
||||
import MuteIcon from "@icons/mute";
|
||||
|
||||
import { Popover, Transition } from '@headlessui/react';
|
||||
import { Fragment } from 'react';
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
import { Fragment } from "react";
|
||||
|
||||
export default function ChannelBlackList({ blacklist }: { blacklist: any }) {
|
||||
return (
|
||||
<Popover className="relative">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`group inline-flex h-8 w-8 items-center justify-center rounded-md ring-2 ring-zinc-950 focus:outline-none ${
|
||||
open ? 'bg-zinc-800 hover:bg-zinc-700' : 'bg-zinc-900 hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
<MuteIcon width={16} height={16} className="text-zinc-400 group-hover:text-zinc-200" />
|
||||
</Popover.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute right-0 z-10 mt-1 w-screen max-w-xs transform px-4 sm:px-0">
|
||||
<div className="flex flex-col gap-2 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900 shadow-popover">
|
||||
<div className="h-min w-full shrink-0 border-b border-zinc-800 p-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h3 className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text font-semibold leading-none text-transparent">
|
||||
Your muted list
|
||||
</h3>
|
||||
<p className="text-xs leading-tight text-zinc-400">
|
||||
Currently, unmute only affect locally, when you move to new client, muted list will loaded again
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 px-3 pb-3 pt-1">
|
||||
{blacklist.map((item: any) => (
|
||||
<MutedItem key={item.id} data={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
return (
|
||||
<Popover className="relative">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`group inline-flex h-8 w-8 items-center justify-center rounded-md ring-2 ring-zinc-950 focus:outline-none ${
|
||||
open
|
||||
? "bg-zinc-800 hover:bg-zinc-700"
|
||||
: "bg-zinc-900 hover:bg-zinc-800"
|
||||
}`}
|
||||
>
|
||||
<MuteIcon
|
||||
width={16}
|
||||
height={16}
|
||||
className="text-zinc-400 group-hover:text-zinc-200"
|
||||
/>
|
||||
</Popover.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute right-0 z-10 mt-1 w-screen max-w-xs transform px-4 sm:px-0">
|
||||
<div className="flex flex-col gap-2 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900 shadow-popover">
|
||||
<div className="h-min w-full shrink-0 border-b border-zinc-800 p-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h3 className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text font-semibold leading-none text-transparent">
|
||||
Your muted list
|
||||
</h3>
|
||||
<p className="text-xs leading-tight text-zinc-400">
|
||||
Currently, unmute only affect locally, when you move to
|
||||
new client, muted list will loaded again
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 px-3 pb-3 pt-1">
|
||||
{blacklist.map((item: any) => (
|
||||
<MutedItem key={item.id} data={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,254 +1,275 @@
|
||||
import { AvatarUploader } from '@shared/avatarUploader';
|
||||
import { Image } from '@shared/image';
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { AvatarUploader } from "@shared/avatarUploader";
|
||||
import { Image } from "@shared/image";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import CancelIcon from '@icons/cancel';
|
||||
import PlusIcon from '@icons/plus';
|
||||
import CancelIcon from "@icons/cancel";
|
||||
import PlusIcon from "@icons/plus";
|
||||
|
||||
import { DEFAULT_AVATAR, WRITEONLY_RELAYS } from '@stores/constants';
|
||||
import { DEFAULT_AVATAR, WRITEONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { dateToUnix } from '@utils/date';
|
||||
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
|
||||
import { createChannel } from '@utils/storage';
|
||||
import { dateToUnix } from "@utils/date";
|
||||
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
|
||||
import { createChannel } from "@utils/storage";
|
||||
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { Fragment, useContext, useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useSWRConfig } from 'swr';
|
||||
import { navigate } from 'vite-plugin-ssr/client/router';
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { getEventHash, signEvent } from "nostr-tools";
|
||||
import { Fragment, useContext, useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { navigate } from "vite-plugin-ssr/client/router";
|
||||
|
||||
export default function ChannelCreateModal() {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const { account, isError, isLoading } = useActiveAccount();
|
||||
const { mutate } = useSWRConfig();
|
||||
const { account, isError, isLoading } = useActiveAccount();
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [image, setImage] = useState(DEFAULT_AVATAR);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [image, setImage] = useState(DEFAULT_AVATAR);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const openModal = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
const openModal = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
formState: { isDirty, isValid },
|
||||
} = useForm();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
formState: { isDirty, isValid },
|
||||
} = useForm();
|
||||
|
||||
const onSubmit = (data: any) => {
|
||||
setLoading(true);
|
||||
const onSubmit = (data: any) => {
|
||||
setLoading(true);
|
||||
|
||||
if (!isError && !isLoading && account) {
|
||||
const event: any = {
|
||||
content: JSON.stringify(data),
|
||||
created_at: dateToUnix(),
|
||||
kind: 40,
|
||||
pubkey: account.pubkey,
|
||||
tags: [],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
if (!isError && !isLoading && account) {
|
||||
const event: any = {
|
||||
content: JSON.stringify(data),
|
||||
created_at: dateToUnix(),
|
||||
kind: 40,
|
||||
pubkey: account.pubkey,
|
||||
tags: [],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
|
||||
// publish channel
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// insert to database
|
||||
createChannel(event.id, event.pubkey, event.content, event.created_at);
|
||||
// update channe llist
|
||||
mutate('channels');
|
||||
// reset form
|
||||
reset();
|
||||
setTimeout(() => {
|
||||
// close modal
|
||||
setIsOpen(false);
|
||||
// redirect to channel page
|
||||
navigate(`/app/channel?id=${event.id}`);
|
||||
}, 2000);
|
||||
} else {
|
||||
console.log('error');
|
||||
}
|
||||
};
|
||||
// publish channel
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// insert to database
|
||||
createChannel(event.id, event.pubkey, event.content, event.created_at);
|
||||
// update channe llist
|
||||
mutate("channels");
|
||||
// reset form
|
||||
reset();
|
||||
setTimeout(() => {
|
||||
// close modal
|
||||
setIsOpen(false);
|
||||
// redirect to channel page
|
||||
navigate(`/app/channel?id=${event.id}`);
|
||||
}, 2000);
|
||||
} else {
|
||||
console.log("error");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setValue('picture', image);
|
||||
}, [setValue, image]);
|
||||
useEffect(() => {
|
||||
setValue("picture", image);
|
||||
}, [setValue, image]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openModal()}
|
||||
className="group inline-flex h-8 items-center gap-2.5 rounded-md px-2.5 hover:bg-zinc-900"
|
||||
>
|
||||
<div className="inline-flex h-5 w-5 shrink items-center justify-center rounded bg-zinc-900 group-hover:bg-zinc-800">
|
||||
<PlusIcon width={12} height={12} className="text-zinc-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="text-[13px] font-semibold text-zinc-500 group-hover:text-zinc-400">Add a new channel</h5>
|
||||
</div>
|
||||
</button>
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={closeModal}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
|
||||
</Transition.Child>
|
||||
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900">
|
||||
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-2xl font-semibold leading-none text-transparent"
|
||||
>
|
||||
Create channel
|
||||
</Dialog.Title>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
autoFocus={false}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
|
||||
>
|
||||
<CancelIcon width={20} height={20} className="text-zinc-300" />
|
||||
</button>
|
||||
</div>
|
||||
<Dialog.Description className="leading-tight text-zinc-400">
|
||||
Channels are freedom square, everyone can speech freely, no one can stop you or deceive what to
|
||||
speech
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex h-full w-full flex-col gap-4">
|
||||
<input
|
||||
type={'hidden'}
|
||||
{...register('picture')}
|
||||
value={image}
|
||||
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">Picture</label>
|
||||
<div className="relative inline-flex h-36 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950">
|
||||
<Image src={image} alt="channel picture" className="relative z-10 h-11 w-11 rounded-md" />
|
||||
<div className="absolute bottom-3 right-3 z-10">
|
||||
<AvatarUploader valueState={setImage} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
|
||||
Channel name *
|
||||
</label>
|
||||
<div className="relative w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
|
||||
<input
|
||||
type={'text'}
|
||||
{...register('name', { required: true, minLength: 4 })}
|
||||
spellCheck={false}
|
||||
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
|
||||
Description
|
||||
</label>
|
||||
<div className="relative h-20 w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
|
||||
<textarea
|
||||
{...register('about')}
|
||||
spellCheck={false}
|
||||
className="relative h-20 w-full resize-none rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-14 items-center justify-between gap-1 rounded-lg bg-zinc-800 px-4 py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<span className="text-sm font-bold leading-none text-zinc-200">Make Private</span>
|
||||
<div className="inline-flex items-center rounded-md bg-zinc-400/10 px-2 py-0.5 text-xs font-medium ring-1 ring-inset ring-zinc-400/20">
|
||||
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-transparent">
|
||||
Coming soon
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm leading-none text-zinc-400">
|
||||
Private channels can only be viewed by member
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
disabled
|
||||
className="relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent bg-zinc-900 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-fuchsia-600 focus:ring-offset-2"
|
||||
role="switch"
|
||||
aria-checked="false"
|
||||
>
|
||||
<span className="pointer-events-none inline-block h-5 w-5 translate-x-0 transform rounded-full bg-zinc-600 shadow ring-0 transition duration-200 ease-in-out"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isDirty || !isValid}
|
||||
className="inline-flex h-11 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 font-medium text-white shadow-button active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
{loading ? (
|
||||
<svg
|
||||
className="h-4 w-4 animate-spin text-black dark:text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
) : (
|
||||
'Create channel'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openModal()}
|
||||
className="group inline-flex h-8 items-center gap-2.5 rounded-md px-2.5 hover:bg-zinc-900"
|
||||
>
|
||||
<div className="inline-flex h-5 w-5 shrink items-center justify-center rounded bg-zinc-900 group-hover:bg-zinc-800">
|
||||
<PlusIcon width={12} height={12} className="text-zinc-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="text-[13px] font-semibold text-zinc-500 group-hover:text-zinc-400">
|
||||
Add a new channel
|
||||
</h5>
|
||||
</div>
|
||||
</button>
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={closeModal}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
|
||||
</Transition.Child>
|
||||
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900">
|
||||
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-2xl font-semibold leading-none text-transparent"
|
||||
>
|
||||
Create channel
|
||||
</Dialog.Title>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
|
||||
>
|
||||
<CancelIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-zinc-300"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<Dialog.Description className="leading-tight text-zinc-400">
|
||||
Channels are freedom square, everyone can speech freely,
|
||||
no one can stop you or deceive what to speech
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex h-full w-full flex-col gap-4"
|
||||
>
|
||||
<input
|
||||
type={"hidden"}
|
||||
{...register("picture")}
|
||||
value={image}
|
||||
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
|
||||
Picture
|
||||
</label>
|
||||
<div className="relative inline-flex h-36 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950">
|
||||
<Image
|
||||
src={image}
|
||||
alt="channel picture"
|
||||
className="relative z-10 h-11 w-11 rounded-md"
|
||||
/>
|
||||
<div className="absolute bottom-3 right-3 z-10">
|
||||
<AvatarUploader valueState={setImage} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
|
||||
Channel name *
|
||||
</label>
|
||||
<div className="relative w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
|
||||
<input
|
||||
type={"text"}
|
||||
{...register("name", {
|
||||
required: true,
|
||||
minLength: 4,
|
||||
})}
|
||||
spellCheck={false}
|
||||
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
|
||||
Description
|
||||
</label>
|
||||
<div className="relative h-20 w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
|
||||
<textarea
|
||||
{...register("about")}
|
||||
spellCheck={false}
|
||||
className="relative h-20 w-full resize-none rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-14 items-center justify-between gap-1 rounded-lg bg-zinc-800 px-4 py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<span className="text-sm font-bold leading-none text-zinc-200">
|
||||
Make Private
|
||||
</span>
|
||||
<div className="inline-flex items-center rounded-md bg-zinc-400/10 px-2 py-0.5 text-xs font-medium ring-1 ring-inset ring-zinc-400/20">
|
||||
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-transparent">
|
||||
Coming soon
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm leading-none text-zinc-400">
|
||||
Private channels can only be viewed by member
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent bg-zinc-900 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-fuchsia-600 focus:ring-offset-2"
|
||||
role="switch"
|
||||
aria-checked="false"
|
||||
>
|
||||
<span className="pointer-events-none inline-block h-5 w-5 translate-x-0 transform rounded-full bg-zinc-600 shadow ring-0 transition duration-200 ease-in-out" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isDirty || !isValid}
|
||||
className="inline-flex h-11 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 font-medium text-white shadow-button active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
{loading ? (
|
||||
<svg
|
||||
className="h-4 w-4 animate-spin text-black dark:text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<title id="loading">Loading</title>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
"Create channel"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,41 @@
|
||||
import { useChannelProfile } from '@app/channel/hooks/useChannelProfile';
|
||||
import { useChannelProfile } from "@app/channel/hooks/useChannelProfile";
|
||||
|
||||
import { usePageContext } from '@utils/hooks/usePageContext';
|
||||
import { usePageContext } from "@utils/hooks/usePageContext";
|
||||
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export default function ChannelsListItem({ data }: { data: any }) {
|
||||
const channel: any = useChannelProfile(data.event_id, data.pubkey);
|
||||
const pageContext = usePageContext();
|
||||
const channel: any = useChannelProfile(data.event_id, data.pubkey);
|
||||
const pageContext = usePageContext();
|
||||
|
||||
const searchParams: any = pageContext.urlParsed.search;
|
||||
const pageID = searchParams.id;
|
||||
const searchParams: any = pageContext.urlParsed.search;
|
||||
const pageID = searchParams.id;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`/app/channel?id=${data.event_id}&channelpub=${data.pubkey}`}
|
||||
className={twMerge(
|
||||
'group inline-flex h-8 items-center gap-2.5 rounded-md px-2.5 hover:bg-zinc-900',
|
||||
pageID === data.event_id ? 'dark:bg-zinc-900 dark:text-zinc-100 hover:dark:bg-zinc-800' : ''
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
'inline-flex h-5 w-5 items-center justify-center rounded bg-zinc-900 group-hover:bg-zinc-800',
|
||||
pageID === data.event_id ? 'dark:bg-zinc-800 group-hover:dark:bg-zinc-700' : ''
|
||||
)}
|
||||
>
|
||||
<span className="text-xs text-zinc-200">#</span>
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="truncate text-[13px] font-semibold text-zinc-400">{channel?.name}</h5>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
return (
|
||||
<a
|
||||
href={`/app/channel?id=${data.event_id}&channelpub=${data.pubkey}`}
|
||||
className={twMerge(
|
||||
"group inline-flex h-8 items-center gap-2.5 rounded-md px-2.5 hover:bg-zinc-900",
|
||||
pageID === data.event_id
|
||||
? "dark:bg-zinc-900 dark:text-zinc-100 hover:dark:bg-zinc-800"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
"inline-flex h-5 w-5 items-center justify-center rounded bg-zinc-900 group-hover:bg-zinc-800",
|
||||
pageID === data.event_id
|
||||
? "dark:bg-zinc-800 group-hover:dark:bg-zinc-700"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<span className="text-xs text-zinc-200">#</span>
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="truncate text-[13px] font-semibold text-zinc-400">
|
||||
{channel?.name}
|
||||
</h5>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,34 @@
|
||||
import ChannelCreateModal from '@app/channel/components/createModal';
|
||||
import ChannelsListItem from '@app/channel/components/item';
|
||||
import ChannelCreateModal from "@app/channel/components/createModal";
|
||||
import ChannelsListItem from "@app/channel/components/item";
|
||||
|
||||
import { getChannels } from '@utils/storage';
|
||||
import { getChannels } from "@utils/storage";
|
||||
|
||||
import useSWR from 'swr';
|
||||
import useSWR from "swr";
|
||||
|
||||
const fetcher = () => getChannels(10, 0);
|
||||
|
||||
export default function ChannelsList() {
|
||||
const { data, error }: any = useSWR('channels', fetcher);
|
||||
const { data, error }: any = useSWR("channels", fetcher);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-px">
|
||||
{!data || error ? (
|
||||
<>
|
||||
<div className="inline-flex h-8 items-center gap-2 rounded-md px-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800"></div>
|
||||
<div className="h-3 w-full animate-pulse rounded-sm bg-zinc-800"></div>
|
||||
</div>
|
||||
<div className="inline-flex h-8 items-center gap-2 rounded-md px-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800"></div>
|
||||
<div className="h-3 w-full animate-pulse rounded-sm bg-zinc-800"></div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
data.map((item: { event_id: string }) => <ChannelsListItem key={item.event_id} data={item} />)
|
||||
)}
|
||||
<ChannelCreateModal />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col gap-px">
|
||||
{!data || error ? (
|
||||
<>
|
||||
<div className="inline-flex h-8 items-center gap-2 rounded-md px-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800" />
|
||||
<div className="h-3 w-full animate-pulse rounded-sm bg-zinc-800" />
|
||||
</div>
|
||||
<div className="inline-flex h-8 items-center gap-2 rounded-md px-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800" />
|
||||
<div className="h-3 w-full animate-pulse rounded-sm bg-zinc-800" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
data.map((item: { event_id: string }) => (
|
||||
<ChannelsListItem key={item.event_id} data={item} />
|
||||
))
|
||||
)}
|
||||
<ChannelCreateModal />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,40 +1,44 @@
|
||||
import MiniMember from '@app/channel/components/miniMember';
|
||||
import MiniMember from "@app/channel/components/miniMember";
|
||||
|
||||
import { channelMembersAtom } from '@stores/channel';
|
||||
import { channelMembersAtom } from "@stores/channel";
|
||||
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useAtomValue } from "jotai";
|
||||
|
||||
export default function ChannelMembers() {
|
||||
const membersAsSet = useAtomValue(channelMembersAtom);
|
||||
const membersAsArray = [...membersAsSet];
|
||||
const miniMembersList = membersAsArray.slice(0, 4);
|
||||
const totalMembers =
|
||||
membersAsArray.length > 0
|
||||
? '+' +
|
||||
Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(membersAsArray.length)
|
||||
: 0;
|
||||
const membersAsSet = useAtomValue(channelMembersAtom);
|
||||
const membersAsArray = [...membersAsSet];
|
||||
const miniMembersList = membersAsArray.slice(0, 4);
|
||||
const totalMembers =
|
||||
membersAsArray.length > 0
|
||||
? `+${Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(membersAsArray.length)}`
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="group flex -space-x-2 overflow-hidden hover:-space-x-1">
|
||||
{miniMembersList.map((member, index) => (
|
||||
<MiniMember key={index} pubkey={member} />
|
||||
))}
|
||||
{totalMembers ? (
|
||||
<div className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-zinc-900 ring-2 ring-zinc-950 transition-all duration-150 ease-in-out group-hover:bg-zinc-800">
|
||||
<span className="text-xs font-medium text-zinc-400 group-hover:text-zinc-200">{totalMembers}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<button className="inline-flex h-8 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm text-white shadow-button">
|
||||
Invite
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<div className="group flex -space-x-2 overflow-hidden hover:-space-x-1">
|
||||
{miniMembersList.map((member, index) => (
|
||||
<MiniMember key={`item-${index}`} pubkey={member} />
|
||||
))}
|
||||
{totalMembers ? (
|
||||
<div className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-zinc-900 ring-2 ring-zinc-950 transition-all duration-150 ease-in-out group-hover:bg-zinc-800">
|
||||
<span className="text-xs font-medium text-zinc-400 group-hover:text-zinc-200">
|
||||
{totalMembers}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm text-white shadow-button"
|
||||
>
|
||||
Invite
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,71 +1,78 @@
|
||||
import ChannelMessageItem from '@app/channel/components/messages/item';
|
||||
import ChannelMessageItem from "@app/channel/components/messages/item";
|
||||
|
||||
import { sortedChannelMessagesAtom } from '@stores/channel';
|
||||
import { sortedChannelMessagesAtom } from "@stores/channel";
|
||||
|
||||
import { getHourAgo } from '@utils/date';
|
||||
import { getHourAgo } from "@utils/date";
|
||||
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
|
||||
export default function ChannelMessageList() {
|
||||
const now = useRef(new Date());
|
||||
const virtuosoRef = useRef(null);
|
||||
const data = useAtomValue(sortedChannelMessagesAtom);
|
||||
const now = useRef(new Date());
|
||||
const virtuosoRef = useRef(null);
|
||||
const data = useAtomValue(sortedChannelMessagesAtom);
|
||||
|
||||
const itemContent: any = useCallback(
|
||||
(index: string | number) => {
|
||||
return <ChannelMessageItem data={data[index]} />;
|
||||
},
|
||||
[data]
|
||||
);
|
||||
const itemContent: any = useCallback(
|
||||
(index: string | number) => {
|
||||
return <ChannelMessageItem data={data[index]} />;
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
const computeItemKey = useCallback(
|
||||
(index: string | number) => {
|
||||
return data[index].id;
|
||||
},
|
||||
[data]
|
||||
);
|
||||
const computeItemKey = useCallback(
|
||||
(index: string | number) => {
|
||||
return data[index].id;
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
data={data}
|
||||
itemContent={itemContent}
|
||||
components={{
|
||||
Header: () => (
|
||||
<div className="relative py-4">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="w-full border-t border-zinc-800" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<div className="inline-flex items-center gap-x-1.5 rounded-full bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-800">
|
||||
{getHourAgo(24, now.current).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
EmptyPlaceholder: () => (
|
||||
<div className="flex flex-col gap-1 text-center">
|
||||
<h3 className="text-sm font-semibold leading-none text-zinc-200">Nothing to see here yet</h3>
|
||||
<p className="text-sm leading-none text-zinc-400">Be the first to share a message in this channel.</p>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
computeItemKey={computeItemKey}
|
||||
initialTopMostItemIndex={data.length - 1}
|
||||
alignToBottom={true}
|
||||
followOutput={true}
|
||||
overscan={50}
|
||||
increaseViewportBy={{ top: 200, bottom: 200 }}
|
||||
className="scrollbar-hide h-full w-full overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
data={data}
|
||||
itemContent={itemContent}
|
||||
components={{
|
||||
Header: () => (
|
||||
<div className="relative py-4">
|
||||
<div
|
||||
className="absolute inset-0 flex items-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="w-full border-t border-zinc-800" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<div className="inline-flex items-center gap-x-1.5 rounded-full bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-800">
|
||||
{getHourAgo(24, now.current).toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
EmptyPlaceholder: () => (
|
||||
<div className="flex flex-col gap-1 text-center">
|
||||
<h3 className="text-sm font-semibold leading-none text-zinc-200">
|
||||
Nothing to see here yet
|
||||
</h3>
|
||||
<p className="text-sm leading-none text-zinc-400">
|
||||
Be the first to share a message in this channel.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
computeItemKey={computeItemKey}
|
||||
initialTopMostItemIndex={data.length - 1}
|
||||
alignToBottom={true}
|
||||
followOutput={true}
|
||||
overscan={50}
|
||||
increaseViewportBy={{ top: 200, bottom: 200 }}
|
||||
className="scrollbar-hide h-full w-full overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,128 +1,134 @@
|
||||
import UserReply from '@app/channel/components/messages/userReply';
|
||||
import UserReply from "@app/channel/components/messages/userReply";
|
||||
|
||||
import { ImagePicker } from '@shared/form/imagePicker';
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { ImagePicker } from "@shared/form/imagePicker";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import CancelIcon from '@icons/cancel';
|
||||
import CancelIcon from "@icons/cancel";
|
||||
|
||||
import { channelContentAtom, channelReplyAtom } from '@stores/channel';
|
||||
import { WRITEONLY_RELAYS } from '@stores/constants';
|
||||
import { channelContentAtom, channelReplyAtom } from "@stores/channel";
|
||||
import { WRITEONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { dateToUnix } from '@utils/date';
|
||||
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
|
||||
import { dateToUnix } from "@utils/date";
|
||||
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
|
||||
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { useResetAtom } from 'jotai/utils';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { useContext } from 'react';
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useResetAtom } from "jotai/utils";
|
||||
import { getEventHash, signEvent } from "nostr-tools";
|
||||
import { useContext } from "react";
|
||||
|
||||
export default function ChannelMessageForm({ channelID }: { channelID: string | string[] }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
export default function ChannelMessageForm({
|
||||
channelID,
|
||||
}: { channelID: string | string[] }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
|
||||
const [value, setValue] = useAtom(channelContentAtom);
|
||||
const resetValue = useResetAtom(channelContentAtom);
|
||||
const [value, setValue] = useAtom(channelContentAtom);
|
||||
const resetValue = useResetAtom(channelContentAtom);
|
||||
|
||||
const channelReply = useAtomValue(channelReplyAtom);
|
||||
const resetChannelReply = useResetAtom(channelReplyAtom);
|
||||
const channelReply = useAtomValue(channelReplyAtom);
|
||||
const resetChannelReply = useResetAtom(channelReplyAtom);
|
||||
|
||||
const submitEvent = () => {
|
||||
let tags: any[][];
|
||||
const submitEvent = () => {
|
||||
let tags: any[][];
|
||||
|
||||
if (channelReply.id !== null) {
|
||||
tags = [
|
||||
['e', channelID, '', 'root'],
|
||||
['e', channelReply.id, '', 'reply'],
|
||||
['p', channelReply.pubkey, ''],
|
||||
];
|
||||
} else {
|
||||
tags = [['e', channelID, '', 'root']];
|
||||
}
|
||||
if (channelReply.id !== null) {
|
||||
tags = [
|
||||
["e", channelID, "", "root"],
|
||||
["e", channelReply.id, "", "reply"],
|
||||
["p", channelReply.pubkey, ""],
|
||||
];
|
||||
} else {
|
||||
tags = [["e", channelID, "", "root"]];
|
||||
}
|
||||
|
||||
if (!isError && !isLoading && account) {
|
||||
const event: any = {
|
||||
content: value,
|
||||
created_at: dateToUnix(),
|
||||
kind: 42,
|
||||
pubkey: account.pubkey,
|
||||
tags: tags,
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
if (!isError && !isLoading && account) {
|
||||
const event: any = {
|
||||
content: value,
|
||||
created_at: dateToUnix(),
|
||||
kind: 42,
|
||||
pubkey: account.pubkey,
|
||||
tags: tags,
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
|
||||
// publish note
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// reset state
|
||||
resetValue();
|
||||
// reset channel reply
|
||||
resetChannelReply();
|
||||
} else {
|
||||
console.log('error');
|
||||
}
|
||||
};
|
||||
// publish note
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// reset state
|
||||
resetValue();
|
||||
// reset channel reply
|
||||
resetChannelReply();
|
||||
} else {
|
||||
console.log("error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnterPress = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submitEvent();
|
||||
}
|
||||
};
|
||||
const handleEnterPress = (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submitEvent();
|
||||
}
|
||||
};
|
||||
|
||||
const stopReply = () => {
|
||||
resetChannelReply();
|
||||
};
|
||||
const stopReply = () => {
|
||||
resetChannelReply();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative ${
|
||||
channelReply.id ? 'h-36' : 'h-24'
|
||||
} w-full overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20`}
|
||||
>
|
||||
{channelReply.id && (
|
||||
<div className="absolute left-0 top-0 z-10 h-14 w-full p-[2px]">
|
||||
<div className="flex h-full w-full items-center justify-between rounded-t-md border-b border-zinc-700/70 bg-zinc-900 px-3">
|
||||
<div className="flex w-full flex-col">
|
||||
<UserReply pubkey={channelReply.pubkey} />
|
||||
<div className="-mt-3.5 pl-[32px]">
|
||||
<div className="text-xs text-zinc-200">{channelReply.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => stopReply()}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-800"
|
||||
>
|
||||
<CancelIcon width={12} height={12} className="text-zinc-100" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleEnterPress}
|
||||
spellCheck={false}
|
||||
placeholder="Message"
|
||||
className={`relative ${
|
||||
channelReply.id ? 'h-36 pt-16' : 'h-24 pt-3'
|
||||
} w-full resize-none rounded-lg border border-black/5 px-3.5 pb-3 text-sm shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500`}
|
||||
/>
|
||||
<div className="absolute bottom-2 w-full px-2">
|
||||
<div className="flex w-full items-center justify-between bg-zinc-800">
|
||||
<div className="flex items-center gap-2 divide-x divide-zinc-700">
|
||||
<ImagePicker type="channel" />
|
||||
<div className="flex items-center gap-2 pl-2"></div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => submitEvent()}
|
||||
disabled={value.length === 0 ? true : false}
|
||||
className="inline-flex h-8 w-16 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm font-medium shadow-button hover:bg-fuchsia-600 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={`relative ${
|
||||
channelReply.id ? "h-36" : "h-24"
|
||||
} w-full overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20`}
|
||||
>
|
||||
{channelReply.id && (
|
||||
<div className="absolute left-0 top-0 z-10 h-14 w-full p-[2px]">
|
||||
<div className="flex h-full w-full items-center justify-between rounded-t-md border-b border-zinc-700/70 bg-zinc-900 px-3">
|
||||
<div className="flex w-full flex-col">
|
||||
<UserReply pubkey={channelReply.pubkey} />
|
||||
<div className="-mt-3.5 pl-[32px]">
|
||||
<div className="text-xs text-zinc-200">
|
||||
{channelReply.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => stopReply()}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-800"
|
||||
>
|
||||
<CancelIcon width={12} height={12} className="text-zinc-100" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleEnterPress}
|
||||
spellCheck={false}
|
||||
placeholder="Message"
|
||||
className={`relative ${
|
||||
channelReply.id ? "h-36 pt-16" : "h-24 pt-3"
|
||||
} w-full resize-none rounded-lg border border-black/5 px-3.5 pb-3 text-sm shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500`}
|
||||
/>
|
||||
<div className="absolute bottom-2 w-full px-2">
|
||||
<div className="flex w-full items-center justify-between bg-zinc-800">
|
||||
<div className="flex items-center gap-2 divide-x divide-zinc-700">
|
||||
<ImagePicker type="channel" />
|
||||
<div className="flex items-center gap-2 pl-2" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submitEvent()}
|
||||
disabled={value.length === 0 ? true : false}
|
||||
className="inline-flex h-8 w-16 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm font-medium shadow-button hover:bg-fuchsia-600 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,141 +1,147 @@
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { Tooltip } from '@shared/tooltip';
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
import { Tooltip } from "@shared/tooltip";
|
||||
|
||||
import CancelIcon from '@icons/cancel';
|
||||
import HideIcon from '@icons/hide';
|
||||
import CancelIcon from "@icons/cancel";
|
||||
import HideIcon from "@icons/hide";
|
||||
|
||||
import { channelMessagesAtom } from '@stores/channel';
|
||||
import { WRITEONLY_RELAYS } from '@stores/constants';
|
||||
import { channelMessagesAtom } from "@stores/channel";
|
||||
import { WRITEONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { dateToUnix } from '@utils/date';
|
||||
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
|
||||
import { dateToUnix } from "@utils/date";
|
||||
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
|
||||
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { Fragment, useContext, useState } from 'react';
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { useAtom } from "jotai";
|
||||
import { getEventHash, signEvent } from "nostr-tools";
|
||||
import { Fragment, useContext, useState } from "react";
|
||||
|
||||
export default function MessageHideButton({ id }: { id: string }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const { account, isError, isLoading } = useActiveAccount();
|
||||
const pool: any = useContext(RelayContext);
|
||||
const { account, isError, isLoading } = useActiveAccount();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [messages, setMessages] = useAtom(channelMessagesAtom);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [messages, setMessages] = useAtom(channelMessagesAtom);
|
||||
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const openModal = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
const openModal = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const hideMessage = () => {
|
||||
if (!isError && !isLoading && account) {
|
||||
const event: any = {
|
||||
content: '',
|
||||
created_at: dateToUnix(),
|
||||
kind: 43,
|
||||
pubkey: account.pubkey,
|
||||
tags: [['e', id]],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
const hideMessage = () => {
|
||||
if (!isError && !isLoading && account) {
|
||||
const event: any = {
|
||||
content: "",
|
||||
created_at: dateToUnix(),
|
||||
kind: 43,
|
||||
pubkey: account.pubkey,
|
||||
tags: [["e", id]],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
|
||||
// publish note
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// update local state
|
||||
const cloneMessages = [...messages];
|
||||
const targetMessage = cloneMessages.findIndex((message) => message.id === id);
|
||||
cloneMessages[targetMessage]['hide'] = true;
|
||||
setMessages(cloneMessages);
|
||||
// close modal
|
||||
closeModal();
|
||||
} else {
|
||||
console.log('error');
|
||||
}
|
||||
};
|
||||
// publish note
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// update local state
|
||||
const cloneMessages = [...messages];
|
||||
const targetMessage = cloneMessages.findIndex(
|
||||
(message) => message.id === id,
|
||||
);
|
||||
cloneMessages[targetMessage]["hide"] = true;
|
||||
setMessages(cloneMessages);
|
||||
// close modal
|
||||
closeModal();
|
||||
} else {
|
||||
console.log("error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip message="Hide this message">
|
||||
<button
|
||||
onClick={openModal}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800"
|
||||
>
|
||||
<HideIcon width={16} height={16} className="text-zinc-200" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={closeModal}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
|
||||
</Transition.Child>
|
||||
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col rounded-lg border border-zinc-800 bg-zinc-900">
|
||||
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-2xl font-semibold leading-none text-transparent"
|
||||
>
|
||||
Are you sure!
|
||||
</Dialog.Title>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
autoFocus={false}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
|
||||
>
|
||||
<CancelIcon width={20} height={20} className="text-zinc-300" />
|
||||
</button>
|
||||
</div>
|
||||
<Dialog.Description className="leading-tight text-zinc-400">
|
||||
This message will be hidden from your feed.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full w-full flex-col items-end justify-center overflow-y-auto px-5 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="inline-flex h-9 items-center justify-center rounded-md px-2 text-sm font-medium text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => hideMessage()}
|
||||
className="inline-flex h-9 items-center justify-center rounded-md bg-red-500 px-2 text-sm font-medium text-zinc-100 hover:bg-red-600"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Tooltip message="Hide this message">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openModal}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800"
|
||||
>
|
||||
<HideIcon width={16} height={16} className="text-zinc-200" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={closeModal}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
|
||||
</Transition.Child>
|
||||
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col rounded-lg border border-zinc-800 bg-zinc-900">
|
||||
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-2xl font-semibold leading-none text-transparent"
|
||||
>
|
||||
Are you sure!
|
||||
</Dialog.Title>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
|
||||
>
|
||||
<CancelIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-zinc-300"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<Dialog.Description className="leading-tight text-zinc-400">
|
||||
This message will be hidden from your feed.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full w-full flex-col items-end justify-center overflow-y-auto px-5 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="inline-flex h-9 items-center justify-center rounded-md px-2 text-sm font-medium text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => hideMessage()}
|
||||
className="inline-flex h-9 items-center justify-center rounded-md bg-red-500 px-2 text-sm font-medium text-zinc-100 hover:bg-red-600"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,42 @@
|
||||
import MessageHideButton from '@app/channel/components/messages/hideButton';
|
||||
import MessageMuteButton from '@app/channel/components/messages/muteButton';
|
||||
import MessageReplyButton from '@app/channel/components/messages/replyButton';
|
||||
import ChannelMessageUser from '@app/channel/components/messages/user';
|
||||
import MessageHideButton from "@app/channel/components/messages/hideButton";
|
||||
import MessageMuteButton from "@app/channel/components/messages/muteButton";
|
||||
import MessageReplyButton from "@app/channel/components/messages/replyButton";
|
||||
import ChannelMessageUser from "@app/channel/components/messages/user";
|
||||
|
||||
import { noteParser } from '@utils/parser';
|
||||
import { noteParser } from "@utils/parser";
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo } from "react";
|
||||
|
||||
export default function ChannelMessageItem({ data }: { data: any }) {
|
||||
const content = useMemo(() => noteParser(data), [data]);
|
||||
const content = useMemo(() => noteParser(data), [data]);
|
||||
|
||||
return (
|
||||
<div className="group relative flex h-min min-h-min w-full select-text flex-col px-5 py-2 hover:bg-black/20">
|
||||
<div className="flex flex-col">
|
||||
<ChannelMessageUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="-mt-[17px] pl-[48px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="whitespace-pre-line break-words text-sm leading-tight">
|
||||
{data.hide ? <span className="italic text-zinc-400">[hided message]</span> : content.parsed}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -top-4 right-4 z-10 hidden group-hover:inline-flex">
|
||||
<div className="inline-flex h-8 items-center justify-center gap-1.5 rounded bg-zinc-900 px-0.5 shadow-md shadow-black/20 ring-1 ring-zinc-800">
|
||||
<MessageReplyButton id={data.id} pubkey={data.pubkey} content={data.content} />
|
||||
<MessageHideButton id={data.id} />
|
||||
<MessageMuteButton pubkey={data.pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="group relative flex h-min min-h-min w-full select-text flex-col px-5 py-2 hover:bg-black/20">
|
||||
<div className="flex flex-col">
|
||||
<ChannelMessageUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="-mt-[17px] pl-[48px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="whitespace-pre-line break-words text-sm leading-tight">
|
||||
{data.hide ? (
|
||||
<span className="italic text-zinc-400">[hided message]</span>
|
||||
) : (
|
||||
content.parsed
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -top-4 right-4 z-10 hidden group-hover:inline-flex">
|
||||
<div className="inline-flex h-8 items-center justify-center gap-1.5 rounded bg-zinc-900 px-0.5 shadow-md shadow-black/20 ring-1 ring-zinc-800">
|
||||
<MessageReplyButton
|
||||
id={data.id}
|
||||
pubkey={data.pubkey}
|
||||
content={data.content}
|
||||
/>
|
||||
<MessageHideButton id={data.id} />
|
||||
<MessageMuteButton pubkey={data.pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,140 +1,146 @@
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { Tooltip } from '@shared/tooltip';
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
import { Tooltip } from "@shared/tooltip";
|
||||
|
||||
import CancelIcon from '@icons/cancel';
|
||||
import MuteIcon from '@icons/mute';
|
||||
import CancelIcon from "@icons/cancel";
|
||||
import MuteIcon from "@icons/mute";
|
||||
|
||||
import { channelMessagesAtom } from '@stores/channel';
|
||||
import { WRITEONLY_RELAYS } from '@stores/constants';
|
||||
import { channelMessagesAtom } from "@stores/channel";
|
||||
import { WRITEONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { dateToUnix } from '@utils/date';
|
||||
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
|
||||
import { dateToUnix } from "@utils/date";
|
||||
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
|
||||
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { Fragment, useContext, useState } from 'react';
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { useAtom } from "jotai";
|
||||
import { getEventHash, signEvent } from "nostr-tools";
|
||||
import { Fragment, useContext, useState } from "react";
|
||||
|
||||
export default function MessageMuteButton({ pubkey }: { pubkey: string }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const { account, isError, isLoading } = useActiveAccount();
|
||||
const pool: any = useContext(RelayContext);
|
||||
const { account, isError, isLoading } = useActiveAccount();
|
||||
|
||||
const [messages, setMessages] = useAtom(channelMessagesAtom);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [messages, setMessages] = useAtom(channelMessagesAtom);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const openModal = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
const openModal = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const muteUser = () => {
|
||||
if (!isError && !isLoading && account) {
|
||||
const event: any = {
|
||||
content: '',
|
||||
created_at: dateToUnix(),
|
||||
kind: 44,
|
||||
pubkey: account.pubkey,
|
||||
tags: [['p', pubkey]],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
const muteUser = () => {
|
||||
if (!isError && !isLoading && account) {
|
||||
const event: any = {
|
||||
content: "",
|
||||
created_at: dateToUnix(),
|
||||
kind: 44,
|
||||
pubkey: account.pubkey,
|
||||
tags: [["p", pubkey]],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
|
||||
// publish note
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// update local state
|
||||
const cloneMessages = [...messages];
|
||||
const finalMessages = cloneMessages.filter((message) => message.pubkey !== pubkey);
|
||||
setMessages(finalMessages);
|
||||
// close modal
|
||||
closeModal();
|
||||
} else {
|
||||
console.log('error');
|
||||
}
|
||||
};
|
||||
// publish note
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// update local state
|
||||
const cloneMessages = [...messages];
|
||||
const finalMessages = cloneMessages.filter(
|
||||
(message) => message.pubkey !== pubkey,
|
||||
);
|
||||
setMessages(finalMessages);
|
||||
// close modal
|
||||
closeModal();
|
||||
} else {
|
||||
console.log("error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip message="Mute this user">
|
||||
<button
|
||||
onClick={() => openModal()}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800"
|
||||
>
|
||||
<MuteIcon width={16} height={16} className="text-zinc-200" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={closeModal}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
|
||||
</Transition.Child>
|
||||
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col rounded-lg border border-zinc-800 bg-zinc-900">
|
||||
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-2xl font-semibold leading-none text-transparent"
|
||||
>
|
||||
Are you sure!
|
||||
</Dialog.Title>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
autoFocus={false}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
|
||||
>
|
||||
<CancelIcon width={20} height={20} className="text-zinc-300" />
|
||||
</button>
|
||||
</div>
|
||||
<Dialog.Description className="leading-tight text-zinc-400">
|
||||
You will no longer see messages from this user.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full w-full flex-col items-end justify-center overflow-y-auto px-5 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="inline-flex h-9 items-center justify-center rounded-md px-2 text-sm font-medium text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => muteUser()}
|
||||
className="inline-flex h-9 items-center justify-center rounded-md bg-red-500 px-2 text-sm font-medium text-zinc-100 hover:bg-red-600"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Tooltip message="Mute this user">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openModal()}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800"
|
||||
>
|
||||
<MuteIcon width={16} height={16} className="text-zinc-200" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={closeModal}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
|
||||
</Transition.Child>
|
||||
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col rounded-lg border border-zinc-800 bg-zinc-900">
|
||||
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-2xl font-semibold leading-none text-transparent"
|
||||
>
|
||||
Are you sure!
|
||||
</Dialog.Title>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
|
||||
>
|
||||
<CancelIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-zinc-300"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<Dialog.Description className="leading-tight text-zinc-400">
|
||||
You will no longer see messages from this user.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full w-full flex-col items-end justify-center overflow-y-auto px-5 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="inline-flex h-9 items-center justify-center rounded-md px-2 text-sm font-medium text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => muteUser()}
|
||||
className="inline-flex h-9 items-center justify-center rounded-md bg-red-500 px-2 text-sm font-medium text-zinc-100 hover:bg-red-600"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
import { Tooltip } from '@shared/tooltip';
|
||||
import { Tooltip } from "@shared/tooltip";
|
||||
|
||||
import ReplyMessageIcon from '@icons/replyMessage';
|
||||
import ReplyMessageIcon from "@icons/replyMessage";
|
||||
|
||||
import { channelReplyAtom } from '@stores/channel';
|
||||
import { channelReplyAtom } from "@stores/channel";
|
||||
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useSetAtom } from "jotai";
|
||||
|
||||
export default function MessageReplyButton({ id, pubkey, content }: { id: string; pubkey: string; content: string }) {
|
||||
const setChannelReplyAtom = useSetAtom(channelReplyAtom);
|
||||
export default function MessageReplyButton({
|
||||
id,
|
||||
pubkey,
|
||||
content,
|
||||
}: { id: string; pubkey: string; content: string }) {
|
||||
const setChannelReplyAtom = useSetAtom(channelReplyAtom);
|
||||
|
||||
const createReply = () => {
|
||||
setChannelReplyAtom({ id: id, pubkey: pubkey, content: content });
|
||||
};
|
||||
const createReply = () => {
|
||||
setChannelReplyAtom({ id: id, pubkey: pubkey, content: content });
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip message="Reply to message">
|
||||
<button
|
||||
onClick={() => createReply()}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800"
|
||||
>
|
||||
<ReplyMessageIcon width={16} height={16} className="text-zinc-200" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
return (
|
||||
<Tooltip message="Reply to message">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => createReply()}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800"
|
||||
>
|
||||
<ReplyMessageIcon width={16} height={16} className="text-zinc-200" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,49 +1,56 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
import { DEFAULT_AVATAR, IMGPROXY_URL } from '@stores/constants';
|
||||
import { DEFAULT_AVATAR, IMGPROXY_URL } from "@stores/constants";
|
||||
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { shortenKey } from '@utils/shortenKey';
|
||||
import { useProfile } from "@utils/hooks/useProfile";
|
||||
import { shortenKey } from "@utils/shortenKey";
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export default function ChannelMessageUser({ pubkey, time }: { pubkey: string; time: number }) {
|
||||
const { user, isError, isLoading } = useProfile(pubkey);
|
||||
export default function ChannelMessageUser({
|
||||
pubkey,
|
||||
time,
|
||||
}: { pubkey: string; time: number }) {
|
||||
const { user, isError, isLoading } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<div className="group flex items-start gap-3">
|
||||
{isError || isLoading ? (
|
||||
<>
|
||||
<div className="relative h-9 w-9 shrink animate-pulse rounded-md bg-zinc-800"></div>
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex items-baseline gap-2 text-sm">
|
||||
<div className="h-4 w-20 animate-pulse rounded bg-zinc-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative h-9 w-9 shrink rounded-md">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${user?.picture ? user.picture : DEFAULT_AVATAR}`}
|
||||
alt={pubkey}
|
||||
className="h-9 w-9 rounded-md object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex items-baseline gap-2 text-sm">
|
||||
<span className="font-semibold leading-none text-zinc-200 group-hover:underline">
|
||||
{user?.display_name || user?.name || shortenKey(pubkey)}
|
||||
</span>
|
||||
<span className="leading-none text-zinc-500">·</span>
|
||||
<span className="leading-none text-zinc-500">{dayjs().to(dayjs.unix(time))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="group flex items-start gap-3">
|
||||
{isError || isLoading ? (
|
||||
<>
|
||||
<div className="relative h-9 w-9 shrink animate-pulse rounded-md bg-zinc-800" />
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex items-baseline gap-2 text-sm">
|
||||
<div className="h-4 w-20 animate-pulse rounded bg-zinc-800" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative h-9 w-9 shrink rounded-md">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${
|
||||
user?.picture ? user.picture : DEFAULT_AVATAR
|
||||
}`}
|
||||
alt={pubkey}
|
||||
className="h-9 w-9 rounded-md object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex items-baseline gap-2 text-sm">
|
||||
<span className="font-semibold leading-none text-zinc-200 group-hover:underline">
|
||||
{user?.display_name || user?.name || shortenKey(pubkey)}
|
||||
</span>
|
||||
<span className="leading-none text-zinc-500">·</span>
|
||||
<span className="leading-none text-zinc-500">
|
||||
{dayjs().to(dayjs.unix(time))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,36 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
import { DEFAULT_AVATAR, IMGPROXY_URL } from '@stores/constants';
|
||||
import { DEFAULT_AVATAR, IMGPROXY_URL } from "@stores/constants";
|
||||
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { shortenKey } from '@utils/shortenKey';
|
||||
import { useProfile } from "@utils/hooks/useProfile";
|
||||
import { shortenKey } from "@utils/shortenKey";
|
||||
|
||||
export default function UserReply({ pubkey }: { pubkey: string }) {
|
||||
const { user, isError, isLoading } = useProfile(pubkey);
|
||||
const { user, isError, isLoading } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<div className="group flex items-start gap-1">
|
||||
{isError || isLoading ? (
|
||||
<>
|
||||
<div className="relative h-7 w-7 shrink animate-pulse overflow-hidden rounded bg-zinc-800"></div>
|
||||
<span className="h-2 w-10 animate-pulse rounded bg-zinc-800 text-xs font-medium leading-none text-zinc-500"></span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative h-7 w-7 shrink overflow-hidden rounded">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${user?.picture ? user.picture : DEFAULT_AVATAR}`}
|
||||
alt={pubkey}
|
||||
className="h-7 w-7 rounded object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-medium leading-none text-zinc-500">
|
||||
Replying to {user?.name || shortenKey(pubkey)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="group flex items-start gap-1">
|
||||
{isError || isLoading ? (
|
||||
<>
|
||||
<div className="relative h-7 w-7 shrink animate-pulse overflow-hidden rounded bg-zinc-800" />
|
||||
<span className="h-2 w-10 animate-pulse rounded bg-zinc-800 text-xs font-medium leading-none text-zinc-500" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative h-7 w-7 shrink overflow-hidden rounded">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${
|
||||
user?.picture ? user.picture : DEFAULT_AVATAR
|
||||
}`}
|
||||
alt={pubkey}
|
||||
className="h-7 w-7 rounded object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-medium leading-none text-zinc-500">
|
||||
Replying to {user?.name || shortenKey(pubkey)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,44 +1,49 @@
|
||||
import { useChannelProfile } from '@app/channel/hooks/useChannelProfile';
|
||||
import { useChannelProfile } from "@app/channel/hooks/useChannelProfile";
|
||||
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
import CopyIcon from '@icons/copy';
|
||||
import CopyIcon from "@icons/copy";
|
||||
|
||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||
import { DEFAULT_AVATAR } from "@stores/constants";
|
||||
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
export default function ChannelMetadata({ id, pubkey }: { id: string; pubkey: string }) {
|
||||
const metadata = useChannelProfile(id, pubkey);
|
||||
const noteID = id ? nip19.noteEncode(id) : null;
|
||||
export default function ChannelMetadata({
|
||||
id,
|
||||
pubkey,
|
||||
}: { id: string; pubkey: string }) {
|
||||
const metadata = useChannelProfile(id, pubkey);
|
||||
const noteID = id ? nip19.noteEncode(id) : null;
|
||||
|
||||
const copyNoteID = async () => {
|
||||
const { writeText } = await import('@tauri-apps/api/clipboard');
|
||||
if (noteID) {
|
||||
await writeText(noteID);
|
||||
}
|
||||
};
|
||||
const copyNoteID = async () => {
|
||||
const { writeText } = await import("@tauri-apps/api/clipboard");
|
||||
if (noteID) {
|
||||
await writeText(noteID);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<div className="relative shrink-0 rounded-md">
|
||||
<Image
|
||||
src={metadata?.picture || DEFAULT_AVATAR}
|
||||
alt={id}
|
||||
className="h-8 w-8 rounded bg-zinc-900 object-contain ring-2 ring-zinc-950"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<h5 className="truncate text-sm font-medium leading-none text-zinc-100">{metadata?.name}</h5>
|
||||
<button onClick={() => copyNoteID()}>
|
||||
<CopyIcon width={14} height={14} className="text-zinc-400" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs leading-none text-zinc-400">
|
||||
{metadata?.about || (noteID && noteID.substring(0, 24) + '...')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<div className="relative shrink-0 rounded-md">
|
||||
<Image
|
||||
src={metadata?.picture || DEFAULT_AVATAR}
|
||||
alt={id}
|
||||
className="h-8 w-8 rounded bg-zinc-900 object-contain ring-2 ring-zinc-950"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<h5 className="truncate text-sm font-medium leading-none text-zinc-100">
|
||||
{metadata?.name}
|
||||
</h5>
|
||||
<button type="button" onClick={() => copyNoteID()}>
|
||||
<CopyIcon width={14} height={14} className="text-zinc-400" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs leading-none text-zinc-400">
|
||||
{metadata?.about || (noteID && `${noteID.substring(0, 24)}...`)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||
import { DEFAULT_AVATAR } from "@stores/constants";
|
||||
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { useProfile } from "@utils/hooks/useProfile";
|
||||
|
||||
export default function MiniMember({ pubkey }: { pubkey: string }) {
|
||||
const { user, isError, isLoading } = useProfile(pubkey);
|
||||
const { user, isError, isLoading } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isError || isLoading ? (
|
||||
<div className="h-8 w-8 animate-pulse rounded-md bg-zinc-800"></div>
|
||||
) : (
|
||||
<Image
|
||||
className="inline-block h-8 w-8 rounded-md bg-white ring-2 ring-zinc-950 transition-all duration-150 ease-in-out"
|
||||
src={user?.picture || DEFAULT_AVATAR}
|
||||
alt={user?.pubkey || 'user avatar'}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{isError || isLoading ? (
|
||||
<div className="h-8 w-8 animate-pulse rounded-md bg-zinc-800" />
|
||||
) : (
|
||||
<Image
|
||||
className="inline-block h-8 w-8 rounded-md bg-white ring-2 ring-zinc-950 transition-all duration-150 ease-in-out"
|
||||
src={user?.picture || DEFAULT_AVATAR}
|
||||
alt={user?.pubkey || "user avatar"}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,80 +1,84 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||
import { DEFAULT_AVATAR } from "@stores/constants";
|
||||
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { shortenKey } from '@utils/shortenKey';
|
||||
import { useProfile } from "@utils/hooks/useProfile";
|
||||
import { shortenKey } from "@utils/shortenKey";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState } from "react";
|
||||
|
||||
export default function MutedItem({ data }: { data: any }) {
|
||||
const { user, isError, isLoading } = useProfile(data.content);
|
||||
const [status, setStatus] = useState(data.status);
|
||||
const { user, isError, isLoading } = useProfile(data.content);
|
||||
const [status, setStatus] = useState(data.status);
|
||||
|
||||
const unmute = async () => {
|
||||
const { updateItemInBlacklist } = await import('@utils/storage');
|
||||
const res = await updateItemInBlacklist(data.content, 0);
|
||||
if (res) {
|
||||
setStatus(0);
|
||||
}
|
||||
};
|
||||
const unmute = async () => {
|
||||
const { updateItemInBlacklist } = await import("@utils/storage");
|
||||
const res = await updateItemInBlacklist(data.content, 0);
|
||||
if (res) {
|
||||
setStatus(0);
|
||||
}
|
||||
};
|
||||
|
||||
const mute = async () => {
|
||||
const { updateItemInBlacklist } = await import('@utils/storage');
|
||||
const res = await updateItemInBlacklist(data.content, 1);
|
||||
if (res) {
|
||||
setStatus(1);
|
||||
}
|
||||
};
|
||||
const mute = async () => {
|
||||
const { updateItemInBlacklist } = await import("@utils/storage");
|
||||
const res = await updateItemInBlacklist(data.content, 1);
|
||||
if (res) {
|
||||
setStatus(1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
{isError || isLoading ? (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="relative h-9 w-9 shrink animate-pulse rounded-md bg-zinc-800"></div>
|
||||
<div className="flex w-full flex-1 flex-col items-start gap-0.5 text-start">
|
||||
<div className="h-3 w-16 animate-pulse bg-zinc-800"></div>
|
||||
<div className="h-2 w-10 animate-pulse bg-zinc-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="relative h-9 w-9 shrink rounded-md">
|
||||
<Image
|
||||
src={user?.picture || DEFAULT_AVATAR}
|
||||
alt={data.content}
|
||||
className="h-9 w-9 rounded-md object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 flex-col items-start gap-0.5 text-start">
|
||||
<span className="truncate text-sm font-medium leading-none text-zinc-200">
|
||||
{user?.display_name || user?.name || 'Pleb'}
|
||||
</span>
|
||||
<span className="text-xs leading-none text-zinc-400">{shortenKey(data.content)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{status === 1 ? (
|
||||
<button
|
||||
onClick={() => unmute()}
|
||||
className="inline-flex h-6 w-min items-center justify-center rounded px-1.5 text-xs font-medium leading-none text-zinc-400 hover:bg-zinc-800 hover:text-fuchsia-500"
|
||||
>
|
||||
Unmute
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => mute()}
|
||||
className="inline-flex h-6 w-min items-center justify-center rounded px-1.5 text-xs font-medium leading-none text-zinc-400 hover:bg-zinc-800 hover:text-fuchsia-500"
|
||||
>
|
||||
Mute
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
{isError || isLoading ? (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="relative h-9 w-9 shrink animate-pulse rounded-md bg-zinc-800" />
|
||||
<div className="flex w-full flex-1 flex-col items-start gap-0.5 text-start">
|
||||
<div className="h-3 w-16 animate-pulse bg-zinc-800" />
|
||||
<div className="h-2 w-10 animate-pulse bg-zinc-800" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="relative h-9 w-9 shrink rounded-md">
|
||||
<Image
|
||||
src={user?.picture || DEFAULT_AVATAR}
|
||||
alt={data.content}
|
||||
className="h-9 w-9 rounded-md object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 flex-col items-start gap-0.5 text-start">
|
||||
<span className="truncate text-sm font-medium leading-none text-zinc-200">
|
||||
{user?.display_name || user?.name || "Pleb"}
|
||||
</span>
|
||||
<span className="text-xs leading-none text-zinc-400">
|
||||
{shortenKey(data.content)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{status === 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => unmute()}
|
||||
className="inline-flex h-6 w-min items-center justify-center rounded px-1.5 text-xs font-medium leading-none text-zinc-400 hover:bg-zinc-800 hover:text-fuchsia-500"
|
||||
>
|
||||
Unmute
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => mute()}
|
||||
className="inline-flex h-6 w-min items-center justify-center rounded px-1.5 text-xs font-medium leading-none text-zinc-400 hover:bg-zinc-800 hover:text-fuchsia-500"
|
||||
>
|
||||
Mute
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,247 +1,270 @@
|
||||
import { AvatarUploader } from '@shared/avatarUploader';
|
||||
import { Image } from '@shared/image';
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { AvatarUploader } from "@shared/avatarUploader";
|
||||
import { Image } from "@shared/image";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import CancelIcon from '@icons/cancel';
|
||||
import EditIcon from '@icons/edit';
|
||||
import CancelIcon from "@icons/cancel";
|
||||
import EditIcon from "@icons/edit";
|
||||
|
||||
import { DEFAULT_AVATAR, WRITEONLY_RELAYS } from '@stores/constants';
|
||||
import { DEFAULT_AVATAR, WRITEONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { dateToUnix } from '@utils/date';
|
||||
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
|
||||
import { getChannel } from '@utils/storage';
|
||||
import { dateToUnix } from "@utils/date";
|
||||
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
|
||||
import { getChannel } from "@utils/storage";
|
||||
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { Fragment, useContext, useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { getEventHash, signEvent } from "nostr-tools";
|
||||
import { Fragment, useContext, useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
export default function ChannelUpdateModal({ id }: { id: string }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const { account, isError, isLoading } = useActiveAccount();
|
||||
const pool: any = useContext(RelayContext);
|
||||
const { account, isError, isLoading } = useActiveAccount();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [image, setImage] = useState(DEFAULT_AVATAR);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [image, setImage] = useState(DEFAULT_AVATAR);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const openModal = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
const openModal = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
formState: { isDirty, isValid },
|
||||
} = useForm({
|
||||
defaultValues: async () => {
|
||||
const channel = await getChannel(id);
|
||||
const metadata = JSON.parse(channel.metadata);
|
||||
// update image state
|
||||
setImage(metadata.picture);
|
||||
// set default values
|
||||
return metadata;
|
||||
},
|
||||
});
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
formState: { isDirty, isValid },
|
||||
} = useForm({
|
||||
defaultValues: async () => {
|
||||
const channel = await getChannel(id);
|
||||
const metadata = JSON.parse(channel.metadata);
|
||||
// update image state
|
||||
setImage(metadata.picture);
|
||||
// set default values
|
||||
return metadata;
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: any) => {
|
||||
setLoading(true);
|
||||
const onSubmit = (data: any) => {
|
||||
setLoading(true);
|
||||
|
||||
if (!isError && !isLoading && account) {
|
||||
const event: any = {
|
||||
content: JSON.stringify(data),
|
||||
created_at: dateToUnix(),
|
||||
kind: 41,
|
||||
pubkey: account.pubkey,
|
||||
tags: [['e', id]],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
if (!isError && !isLoading && account) {
|
||||
const event: any = {
|
||||
content: JSON.stringify(data),
|
||||
created_at: dateToUnix(),
|
||||
kind: 41,
|
||||
pubkey: account.pubkey,
|
||||
tags: [["e", id]],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
|
||||
// publish channel
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// reset form
|
||||
reset();
|
||||
// close modal
|
||||
setIsOpen(false);
|
||||
setLoading(false);
|
||||
} else {
|
||||
console.log('error');
|
||||
}
|
||||
};
|
||||
// publish channel
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// reset form
|
||||
reset();
|
||||
// close modal
|
||||
setIsOpen(false);
|
||||
setLoading(false);
|
||||
} else {
|
||||
console.log("error");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setValue('picture', image);
|
||||
}, [setValue, image]);
|
||||
useEffect(() => {
|
||||
setValue("picture", image);
|
||||
}, [setValue, image]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openModal()}
|
||||
className="group inline-flex h-8 w-8 items-center justify-center rounded-md bg-zinc-900 hover:bg-zinc-800 focus:outline-none"
|
||||
>
|
||||
<EditIcon width={16} height={16} className="text-zinc-400 group-hover:text-zinc-200" />
|
||||
</button>
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={closeModal}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
|
||||
</Transition.Child>
|
||||
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900">
|
||||
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-2xl font-semibold leading-none text-transparent"
|
||||
>
|
||||
Update channel
|
||||
</Dialog.Title>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
autoFocus={false}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
|
||||
>
|
||||
<CancelIcon width={20} height={20} className="text-zinc-300" />
|
||||
</button>
|
||||
</div>
|
||||
<Dialog.Description className="leading-tight text-zinc-400">
|
||||
New metadata will be published on all relays, and will be immediately available to all users, so
|
||||
please carefully.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex h-full w-full flex-col gap-4">
|
||||
<input
|
||||
type={'hidden'}
|
||||
{...register('picture')}
|
||||
value={image}
|
||||
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">Picture</label>
|
||||
<div className="relative inline-flex h-36 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950">
|
||||
<Image src={image} alt="channel picture" className="relative z-10 h-11 w-11 rounded-md" />
|
||||
<div className="absolute bottom-3 right-3 z-10">
|
||||
<AvatarUploader valueState={setImage} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
|
||||
Channel name *
|
||||
</label>
|
||||
<div className="relative w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
|
||||
<input
|
||||
type={'text'}
|
||||
{...register('name', { required: true, minLength: 4 })}
|
||||
spellCheck={false}
|
||||
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
|
||||
Description
|
||||
</label>
|
||||
<div className="relative h-20 w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
|
||||
<textarea
|
||||
{...register('about')}
|
||||
spellCheck={false}
|
||||
className="relative h-20 w-full resize-none rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-14 items-center justify-between gap-1 rounded-lg bg-zinc-800 px-4 py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<span className="text-sm font-bold leading-none text-zinc-200">Make Private</span>
|
||||
<div className="inline-flex items-center rounded-md bg-zinc-400/10 px-2 py-0.5 text-xs font-medium ring-1 ring-inset ring-zinc-400/20">
|
||||
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-transparent">
|
||||
Coming soon
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm leading-none text-zinc-400">
|
||||
Private channels can only be viewed by member
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
disabled
|
||||
className="relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent bg-zinc-900 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-fuchsia-600 focus:ring-offset-2"
|
||||
role="switch"
|
||||
aria-checked="false"
|
||||
>
|
||||
<span className="pointer-events-none inline-block h-5 w-5 translate-x-0 transform rounded-full bg-zinc-600 shadow ring-0 transition duration-200 ease-in-out"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isDirty || !isValid}
|
||||
className="inline-flex h-11 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 font-medium text-white shadow-button active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
{loading ? (
|
||||
<svg
|
||||
className="h-4 w-4 animate-spin text-black dark:text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
) : (
|
||||
'Update channel'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openModal()}
|
||||
className="group inline-flex h-8 w-8 items-center justify-center rounded-md bg-zinc-900 hover:bg-zinc-800 focus:outline-none"
|
||||
>
|
||||
<EditIcon
|
||||
width={16}
|
||||
height={16}
|
||||
className="text-zinc-400 group-hover:text-zinc-200"
|
||||
/>
|
||||
</button>
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={closeModal}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
|
||||
</Transition.Child>
|
||||
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900">
|
||||
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-2xl font-semibold leading-none text-transparent"
|
||||
>
|
||||
Update channel
|
||||
</Dialog.Title>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
|
||||
>
|
||||
<CancelIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-zinc-300"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<Dialog.Description className="leading-tight text-zinc-400">
|
||||
New metadata will be published on all relays, and will be
|
||||
immediately available to all users, so please carefully.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex h-full w-full flex-col gap-4"
|
||||
>
|
||||
<input
|
||||
type={"hidden"}
|
||||
{...register("picture")}
|
||||
value={image}
|
||||
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
|
||||
Picture
|
||||
</label>
|
||||
<div className="relative inline-flex h-36 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950">
|
||||
<Image
|
||||
src={image}
|
||||
alt="channel picture"
|
||||
className="relative z-10 h-11 w-11 rounded-md"
|
||||
/>
|
||||
<div className="absolute bottom-3 right-3 z-10">
|
||||
<AvatarUploader valueState={setImage} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
|
||||
Channel name *
|
||||
</label>
|
||||
<div className="relative w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
|
||||
<input
|
||||
type={"text"}
|
||||
{...register("name", {
|
||||
required: true,
|
||||
minLength: 4,
|
||||
})}
|
||||
spellCheck={false}
|
||||
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
|
||||
Description
|
||||
</label>
|
||||
<div className="relative h-20 w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
|
||||
<textarea
|
||||
{...register("about")}
|
||||
spellCheck={false}
|
||||
className="relative h-20 w-full resize-none rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-14 items-center justify-between gap-1 rounded-lg bg-zinc-800 px-4 py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<span className="text-sm font-bold leading-none text-zinc-200">
|
||||
Make Private
|
||||
</span>
|
||||
<div className="inline-flex items-center rounded-md bg-zinc-400/10 px-2 py-0.5 text-xs font-medium ring-1 ring-inset ring-zinc-400/20">
|
||||
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-transparent">
|
||||
Coming soon
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm leading-none text-zinc-400">
|
||||
Private channels can only be viewed by member
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent bg-zinc-900 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-fuchsia-600 focus:ring-offset-2"
|
||||
role="switch"
|
||||
aria-checked="false"
|
||||
>
|
||||
<span className="pointer-events-none inline-block h-5 w-5 translate-x-0 transform rounded-full bg-zinc-600 shadow ring-0 transition duration-200 ease-in-out" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isDirty || !isValid}
|
||||
className="inline-flex h-11 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 font-medium text-white shadow-button active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
{loading ? (
|
||||
<svg
|
||||
className="h-4 w-4 animate-spin text-black dark:text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<title id="loading">Loading</title>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
"Update channel"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,56 +1,59 @@
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import { READONLY_RELAYS } from '@stores/constants';
|
||||
import { READONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { getChannel, updateChannelMetadata } from '@utils/storage';
|
||||
import { getChannel, updateChannelMetadata } from "@utils/storage";
|
||||
|
||||
import { useContext } from 'react';
|
||||
import useSWR, { useSWRConfig } from 'swr';
|
||||
import useSWRSubscription from 'swr/subscription';
|
||||
import { useContext } from "react";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
import useSWRSubscription from "swr/subscription";
|
||||
|
||||
const fetcher = async ([, id]) => {
|
||||
const result = await getChannel(id);
|
||||
if (result) {
|
||||
return JSON.parse(result.metadata);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
const result = await getChannel(id);
|
||||
if (result) {
|
||||
return JSON.parse(result.metadata);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export function useChannelProfile(id: string, channelPubkey: string) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
const { data, isLoading } = useSWR(['channel-metadata', id], fetcher);
|
||||
const { mutate } = useSWRConfig();
|
||||
const { data, isLoading } = useSWR(["channel-metadata", id], fetcher);
|
||||
|
||||
useSWRSubscription(!isLoading && data ? ['channel-metadata', id] : null, ([, key], {}) => {
|
||||
// subscribe to channel
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
'#e': [key],
|
||||
authors: [channelPubkey],
|
||||
kinds: [41],
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: { content: string }) => {
|
||||
// update in local database
|
||||
updateChannelMetadata(key, event.content);
|
||||
// revaildate
|
||||
mutate(['channel-metadata', key]);
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
unsubscribeOnEose: true,
|
||||
}
|
||||
);
|
||||
useSWRSubscription(
|
||||
!isLoading && data ? ["channel-metadata", id] : null,
|
||||
([, key]) => {
|
||||
// subscribe to channel
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
"#e": [key],
|
||||
authors: [channelPubkey],
|
||||
kinds: [41],
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: { content: string }) => {
|
||||
// update in local database
|
||||
updateChannelMetadata(key, event.content);
|
||||
// revaildate
|
||||
mutate(["channel-metadata", key]);
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
unsubscribeOnEose: true,
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return data;
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
import AppHeader from '@shared/appHeader';
|
||||
import MultiAccounts from '@shared/multiAccounts';
|
||||
import Navigation from '@shared/navigation';
|
||||
import AppHeader from "@shared/appHeader";
|
||||
import MultiAccounts from "@shared/multiAccounts";
|
||||
import Navigation from "@shared/navigation";
|
||||
|
||||
export function LayoutChannel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white">
|
||||
<div className="flex h-screen w-full flex-col">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="relative h-9 shrink-0 border-b border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
|
||||
>
|
||||
<AppHeader />
|
||||
</div>
|
||||
<div className="relative flex min-h-0 w-full flex-1">
|
||||
<div className="relative w-[68px] shrink-0 border-r border-zinc-900">
|
||||
<MultiAccounts />
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-4 xl:grid-cols-5">
|
||||
<div className="scrollbar-hide col-span-1 overflow-y-auto overflow-x-hidden border-r border-zinc-900">
|
||||
<Navigation />
|
||||
</div>
|
||||
<div className="col-span-3 m-3 overflow-hidden xl:col-span-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white">
|
||||
<div className="flex h-screen w-full flex-col">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="relative h-9 shrink-0 border-b border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
|
||||
>
|
||||
<AppHeader />
|
||||
</div>
|
||||
<div className="relative flex min-h-0 w-full flex-1">
|
||||
<div className="relative w-[68px] shrink-0 border-r border-zinc-900">
|
||||
<MultiAccounts />
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-4 xl:grid-cols-5">
|
||||
<div className="scrollbar-hide col-span-1 overflow-y-auto overflow-x-hidden border-r border-zinc-900">
|
||||
<Navigation />
|
||||
</div>
|
||||
<div className="col-span-3 m-3 overflow-hidden xl:col-span-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,127 +1,140 @@
|
||||
import ChannelBlackList from '@app/channel/components/blacklist';
|
||||
import ChannelMembers from '@app/channel/components/members';
|
||||
import ChannelMessageForm from '@app/channel/components/messages/form';
|
||||
import ChannelMetadata from '@app/channel/components/metadata';
|
||||
import ChannelUpdateModal from '@app/channel/components/updateModal';
|
||||
import ChannelBlackList from "@app/channel/components/blacklist";
|
||||
import ChannelMembers from "@app/channel/components/members";
|
||||
import ChannelMessageForm from "@app/channel/components/messages/form";
|
||||
import ChannelMetadata from "@app/channel/components/metadata";
|
||||
import ChannelUpdateModal from "@app/channel/components/updateModal";
|
||||
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import { channelMessagesAtom, channelReplyAtom } from '@stores/channel';
|
||||
import { READONLY_RELAYS } from '@stores/constants';
|
||||
import { channelMessagesAtom, channelReplyAtom } from "@stores/channel";
|
||||
import { READONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { dateToUnix, getHourAgo } from '@utils/date';
|
||||
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
|
||||
import { usePageContext } from '@utils/hooks/usePageContext';
|
||||
import { getActiveBlacklist, getBlacklist } from '@utils/storage';
|
||||
import { arrayObjToPureArr } from '@utils/transform';
|
||||
import { dateToUnix, getHourAgo } from "@utils/date";
|
||||
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
|
||||
import { usePageContext } from "@utils/hooks/usePageContext";
|
||||
import { getActiveBlacklist, getBlacklist } from "@utils/storage";
|
||||
import { arrayObjToPureArr } from "@utils/transform";
|
||||
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useResetAtom } from 'jotai/utils';
|
||||
import { Suspense, lazy, useContext, useEffect, useRef } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import useSWRSubscription from 'swr/subscription';
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useResetAtom } from "jotai/utils";
|
||||
import { Suspense, lazy, useContext, useEffect, useRef } from "react";
|
||||
import useSWR from "swr";
|
||||
import useSWRSubscription from "swr/subscription";
|
||||
|
||||
const fetchMuted = async ([, id]) => {
|
||||
const res = await getBlacklist(id, 44);
|
||||
const array = arrayObjToPureArr(res);
|
||||
return { original: res, array: array };
|
||||
const res = await getBlacklist(id, 44);
|
||||
const array = arrayObjToPureArr(res);
|
||||
return { original: res, array: array };
|
||||
};
|
||||
|
||||
const fetchHided = async ([, id]) => {
|
||||
const res = await getActiveBlacklist(id, 43);
|
||||
const array = arrayObjToPureArr(res);
|
||||
return array;
|
||||
const res = await getActiveBlacklist(id, 43);
|
||||
const array = arrayObjToPureArr(res);
|
||||
return array;
|
||||
};
|
||||
|
||||
const ChannelMessageList = lazy(() => import('@app/channel/components/messageList'));
|
||||
const ChannelMessageList = lazy(
|
||||
() => import("@app/channel/components/messageList"),
|
||||
);
|
||||
|
||||
export function Page() {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const pageContext = usePageContext();
|
||||
const searchParams: any = pageContext.urlParsed.search;
|
||||
const pool: any = useContext(RelayContext);
|
||||
const pageContext = usePageContext();
|
||||
const searchParams: any = pageContext.urlParsed.search;
|
||||
|
||||
const channelID = searchParams.id;
|
||||
const channelPubkey = searchParams.channelpub;
|
||||
const channelID = searchParams.id;
|
||||
const channelPubkey = searchParams.channelpub;
|
||||
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
const { data: muted } = useSWR(!isLoading && !isError && account ? ['muted', account.id] : null, fetchMuted);
|
||||
const { data: hided } = useSWR(!isLoading && !isError && account ? ['hided', account.id] : null, fetchHided);
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
const { data: muted } = useSWR(
|
||||
!isLoading && !isError && account ? ["muted", account.id] : null,
|
||||
fetchMuted,
|
||||
);
|
||||
const { data: hided } = useSWR(
|
||||
!isLoading && !isError && account ? ["hided", account.id] : null,
|
||||
fetchHided,
|
||||
);
|
||||
|
||||
const setChannelMessages = useSetAtom(channelMessagesAtom);
|
||||
const resetChannelMessages = useResetAtom(channelMessagesAtom);
|
||||
const resetChannelReply = useResetAtom(channelReplyAtom);
|
||||
const setChannelMessages = useSetAtom(channelMessagesAtom);
|
||||
const resetChannelMessages = useResetAtom(channelMessagesAtom);
|
||||
const resetChannelReply = useResetAtom(channelReplyAtom);
|
||||
|
||||
const now = useRef(new Date());
|
||||
const now = useRef(new Date());
|
||||
|
||||
useSWRSubscription(account && channelID && muted && hided ? ['channel', channelID] : null, ([, key], {}: any) => {
|
||||
// subscribe to channel
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
'#e': [key],
|
||||
kinds: [42],
|
||||
since: dateToUnix(getHourAgo(24, now.current)),
|
||||
limit: 20,
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: { id: string; pubkey: string }) => {
|
||||
const message: any = event;
|
||||
if (hided.includes(event.id)) {
|
||||
message['hide'] = true;
|
||||
} else {
|
||||
message['hide'] = false;
|
||||
}
|
||||
if (!muted.array.includes(event.pubkey)) {
|
||||
setChannelMessages((prev) => [...prev, message]);
|
||||
}
|
||||
}
|
||||
);
|
||||
useSWRSubscription(
|
||||
account && channelID && muted && hided ? ["channel", channelID] : null,
|
||||
([, key]) => {
|
||||
// subscribe to channel
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
"#e": [key],
|
||||
kinds: [42],
|
||||
since: dateToUnix(getHourAgo(24, now.current)),
|
||||
limit: 20,
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: { id: string; pubkey: string }) => {
|
||||
const message: any = event;
|
||||
if (hided.includes(event.id)) {
|
||||
message["hide"] = true;
|
||||
} else {
|
||||
message["hide"] = false;
|
||||
}
|
||||
if (!muted.array.includes(event.pubkey)) {
|
||||
setChannelMessages((prev) => [...prev, message]);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
|
||||
if (!ignore) {
|
||||
// reset channel reply
|
||||
resetChannelReply();
|
||||
// reset channel messages
|
||||
resetChannelMessages();
|
||||
}
|
||||
if (!ignore) {
|
||||
// reset channel reply
|
||||
resetChannelReply();
|
||||
// reset channel messages
|
||||
resetChannelMessages();
|
||||
}
|
||||
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col justify-between gap-2">
|
||||
<div className="flex h-11 w-full shrink-0 items-center justify-between">
|
||||
<div>
|
||||
<ChannelMetadata id={channelID} pubkey={channelPubkey} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ChannelMembers />
|
||||
{!muted ? <></> : <ChannelBlackList blacklist={muted.original} />}
|
||||
{!isLoading && !isError && account ? (
|
||||
account.pubkey === channelPubkey && <ChannelUpdateModal id={channelID} />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex w-full flex-1 flex-col justify-between rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
|
||||
<Suspense fallback={<p>Loading...</p>}>
|
||||
<ChannelMessageList />
|
||||
</Suspense>
|
||||
<div className="inline-flex shrink-0 p-3">
|
||||
<ChannelMessageForm channelID={channelID} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex h-full flex-col justify-between gap-2">
|
||||
<div className="flex h-11 w-full shrink-0 items-center justify-between">
|
||||
<div>
|
||||
<ChannelMetadata id={channelID} pubkey={channelPubkey} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ChannelMembers />
|
||||
{!muted ? <></> : <ChannelBlackList blacklist={muted.original} />}
|
||||
{!isLoading && !isError && account ? (
|
||||
account.pubkey === channelPubkey && (
|
||||
<ChannelUpdateModal id={channelID} />
|
||||
)
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex w-full flex-1 flex-col justify-between rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
|
||||
<Suspense fallback={<p>Loading...</p>}>
|
||||
<ChannelMessageList />
|
||||
</Suspense>
|
||||
<div className="inline-flex shrink-0 p-3">
|
||||
<ChannelMessageForm channelID={channelID} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { LayoutChat as Layout } from './layout';
|
||||
export { LayoutChat as Layout } from "./layout";
|
||||
|
||||
@@ -1,53 +1,55 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||
import { DEFAULT_AVATAR } from "@stores/constants";
|
||||
|
||||
import { usePageContext } from '@utils/hooks/usePageContext';
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { shortenKey } from '@utils/shortenKey';
|
||||
import { usePageContext } from "@utils/hooks/usePageContext";
|
||||
import { useProfile } from "@utils/hooks/useProfile";
|
||||
import { shortenKey } from "@utils/shortenKey";
|
||||
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export default function ChatsListItem({ pubkey }: { pubkey: string }) {
|
||||
const pageContext = usePageContext();
|
||||
const pageContext = usePageContext();
|
||||
|
||||
const searchParams: any = pageContext.urlParsed.search;
|
||||
const pagePubkey = searchParams.pubkey;
|
||||
const searchParams: any = pageContext.urlParsed.search;
|
||||
const pagePubkey = searchParams.pubkey;
|
||||
|
||||
const { user, isError, isLoading } = useProfile(pubkey);
|
||||
const { user, isError, isLoading } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isError && <div>error</div>}
|
||||
{isLoading && !user ? (
|
||||
<div className="inline-flex h-8 items-center gap-2.5 rounded-md px-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800"></div>
|
||||
<div>
|
||||
<div className="h-2.5 w-full animate-pulse truncate rounded bg-zinc-800 text-sm font-medium"></div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<a
|
||||
href={`/app/chat?pubkey=${pubkey}`}
|
||||
className={twMerge(
|
||||
'group inline-flex h-8 items-center gap-2.5 rounded-md px-2.5 hover:bg-zinc-900',
|
||||
pagePubkey === pubkey ? 'dark:bg-zinc-900 dark:text-zinc-100 hover:dark:bg-zinc-800' : ''
|
||||
)}
|
||||
>
|
||||
<div className="relative h-5 w-5 shrink-0 rounded">
|
||||
<Image
|
||||
src={user.picture || DEFAULT_AVATAR}
|
||||
alt={pubkey}
|
||||
className="h-5 w-5 rounded bg-white object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="truncate text-[13px] font-semibold text-zinc-400 group-hover:text-zinc-200">
|
||||
{user.display_name || user.name || shortenKey(pubkey)}
|
||||
</h5>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{isError && <div>error</div>}
|
||||
{isLoading && !user ? (
|
||||
<div className="inline-flex h-8 items-center gap-2.5 rounded-md px-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800" />
|
||||
<div>
|
||||
<div className="h-2.5 w-full animate-pulse truncate rounded bg-zinc-800 text-sm font-medium" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<a
|
||||
href={`/app/chat?pubkey=${pubkey}`}
|
||||
className={twMerge(
|
||||
"group inline-flex h-8 items-center gap-2.5 rounded-md px-2.5 hover:bg-zinc-900",
|
||||
pagePubkey === pubkey
|
||||
? "dark:bg-zinc-900 dark:text-zinc-100 hover:dark:bg-zinc-800"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<div className="relative h-5 w-5 shrink-0 rounded">
|
||||
<Image
|
||||
src={user.picture || DEFAULT_AVATAR}
|
||||
alt={pubkey}
|
||||
className="h-5 w-5 rounded bg-white object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="truncate text-[13px] font-semibold text-zinc-400 group-hover:text-zinc-200">
|
||||
{user.display_name || user.name || shortenKey(pubkey)}
|
||||
</h5>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,39 @@
|
||||
import ChatsListItem from '@app/chat/components/item';
|
||||
import ChatsListSelfItem from '@app/chat/components/self';
|
||||
import ChatsListItem from "@app/chat/components/item";
|
||||
import ChatsListSelfItem from "@app/chat/components/self";
|
||||
|
||||
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
|
||||
import { getChats } from '@utils/storage';
|
||||
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
|
||||
import { getChats } from "@utils/storage";
|
||||
|
||||
import useSWR from 'swr';
|
||||
import useSWR from "swr";
|
||||
|
||||
const fetcher = ([, account]) => getChats(account);
|
||||
|
||||
export default function ChatsList() {
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
const { data: chats, error }: any = useSWR(!isLoading && !isError && account ? ['chats', account] : null, fetcher);
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
const { data: chats, error }: any = useSWR(
|
||||
!isLoading && !isError && account ? ["chats", account] : null,
|
||||
fetcher,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-px">
|
||||
<ChatsListSelfItem />
|
||||
{!chats || error ? (
|
||||
<>
|
||||
<div className="inline-flex h-8 items-center gap-2 rounded-md px-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800"></div>
|
||||
<div className="h-3 w-full animate-pulse bg-zinc-800"></div>
|
||||
</div>
|
||||
<div className="inline-flex h-8 items-center gap-2 rounded-md px-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800"></div>
|
||||
<div className="h-3 w-full animate-pulse bg-zinc-800"></div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
chats.map((item: { pubkey: string }) => <ChatsListItem key={item.pubkey} pubkey={item.pubkey} />)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col gap-px">
|
||||
<ChatsListSelfItem />
|
||||
{!chats || error ? (
|
||||
<>
|
||||
<div className="inline-flex h-8 items-center gap-2 rounded-md px-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800" />
|
||||
<div className="h-3 w-full animate-pulse bg-zinc-800" />
|
||||
</div>
|
||||
<div className="inline-flex h-8 items-center gap-2 rounded-md px-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800" />
|
||||
<div className="h-3 w-full animate-pulse bg-zinc-800" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
chats.map((item: { pubkey: string }) => (
|
||||
<ChatsListItem key={item.pubkey} pubkey={item.pubkey} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,53 @@
|
||||
import { ChatMessageItem } from '@app/chat/components/messages/item';
|
||||
import { ChatMessageItem } from "@app/chat/components/messages/item";
|
||||
|
||||
import { sortedChatMessagesAtom } from '@stores/chat';
|
||||
import { sortedChatMessagesAtom } from "@stores/chat";
|
||||
|
||||
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
|
||||
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
|
||||
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
|
||||
export default function ChatMessageList() {
|
||||
const { account } = useActiveAccount();
|
||||
const { account } = useActiveAccount();
|
||||
|
||||
const virtuosoRef = useRef(null);
|
||||
const data = useAtomValue(sortedChatMessagesAtom);
|
||||
const virtuosoRef = useRef(null);
|
||||
const data = useAtomValue(sortedChatMessagesAtom);
|
||||
|
||||
const itemContent: any = useCallback(
|
||||
(index: string | number) => {
|
||||
return <ChatMessageItem data={data[index]} userPubkey={account.pubkey} userPrivkey={account.privkey} />;
|
||||
},
|
||||
[account.privkey, account.pubkey, data]
|
||||
);
|
||||
const itemContent: any = useCallback(
|
||||
(index: string | number) => {
|
||||
return (
|
||||
<ChatMessageItem
|
||||
data={data[index]}
|
||||
userPubkey={account.pubkey}
|
||||
userPrivkey={account.privkey}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[account.privkey, account.pubkey, data],
|
||||
);
|
||||
|
||||
const computeItemKey = useCallback(
|
||||
(index: string | number) => {
|
||||
return data[index].id;
|
||||
},
|
||||
[data]
|
||||
);
|
||||
const computeItemKey = useCallback(
|
||||
(index: string | number) => {
|
||||
return data[index].id;
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
data={data}
|
||||
itemContent={itemContent}
|
||||
computeItemKey={computeItemKey}
|
||||
initialTopMostItemIndex={data.length - 1}
|
||||
alignToBottom={true}
|
||||
followOutput={true}
|
||||
overscan={50}
|
||||
increaseViewportBy={{ top: 200, bottom: 200 }}
|
||||
className="scrollbar-hide h-full w-full overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
data={data}
|
||||
itemContent={itemContent}
|
||||
computeItemKey={computeItemKey}
|
||||
initialTopMostItemIndex={data.length - 1}
|
||||
alignToBottom={true}
|
||||
followOutput={true}
|
||||
overscan={50}
|
||||
increaseViewportBy={{ top: 200, bottom: 200 }}
|
||||
className="scrollbar-hide h-full w-full overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,89 +1,92 @@
|
||||
import { ImagePicker } from '@shared/form/imagePicker';
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { ImagePicker } from "@shared/form/imagePicker";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import { chatContentAtom } from '@stores/chat';
|
||||
import { WRITEONLY_RELAYS } from '@stores/constants';
|
||||
import { chatContentAtom } from "@stores/chat";
|
||||
import { WRITEONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { dateToUnix } from '@utils/date';
|
||||
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
|
||||
import { dateToUnix } from "@utils/date";
|
||||
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
|
||||
|
||||
import { useAtom } from 'jotai';
|
||||
import { useResetAtom } from 'jotai/utils';
|
||||
import { getEventHash, nip04, signEvent } from 'nostr-tools';
|
||||
import { useCallback, useContext } from 'react';
|
||||
import { useAtom } from "jotai";
|
||||
import { useResetAtom } from "jotai/utils";
|
||||
import { getEventHash, nip04, signEvent } from "nostr-tools";
|
||||
import { useCallback, useContext } from "react";
|
||||
|
||||
export default function ChatMessageForm({ receiverPubkey }: { receiverPubkey: string }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
export default function ChatMessageForm({
|
||||
receiverPubkey,
|
||||
}: { receiverPubkey: string }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
|
||||
const [value, setValue] = useAtom(chatContentAtom);
|
||||
const resetValue = useResetAtom(chatContentAtom);
|
||||
const [value, setValue] = useAtom(chatContentAtom);
|
||||
const resetValue = useResetAtom(chatContentAtom);
|
||||
|
||||
const encryptMessage = useCallback(
|
||||
async (privkey: string) => {
|
||||
return await nip04.encrypt(privkey, receiverPubkey, value);
|
||||
},
|
||||
[receiverPubkey, value]
|
||||
);
|
||||
const encryptMessage = useCallback(
|
||||
async (privkey: string) => {
|
||||
return await nip04.encrypt(privkey, receiverPubkey, value);
|
||||
},
|
||||
[receiverPubkey, value],
|
||||
);
|
||||
|
||||
const submitEvent = () => {
|
||||
if (!isError && !isLoading && account) {
|
||||
encryptMessage(account.privkey)
|
||||
.then((encryptedContent) => {
|
||||
const event: any = {
|
||||
content: encryptedContent,
|
||||
created_at: dateToUnix(),
|
||||
kind: 4,
|
||||
pubkey: account.pubkey,
|
||||
tags: [['p', receiverPubkey]],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
// publish note
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// reset state
|
||||
resetValue();
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
};
|
||||
const submitEvent = () => {
|
||||
if (!isError && !isLoading && account) {
|
||||
encryptMessage(account.privkey)
|
||||
.then((encryptedContent) => {
|
||||
const event: any = {
|
||||
content: encryptedContent,
|
||||
created_at: dateToUnix(),
|
||||
kind: 4,
|
||||
pubkey: account.pubkey,
|
||||
tags: [["p", receiverPubkey]],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
// publish note
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// reset state
|
||||
resetValue();
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnterPress = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submitEvent();
|
||||
}
|
||||
};
|
||||
const handleEnterPress = (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submitEvent();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-24 w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
|
||||
<div>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleEnterPress}
|
||||
spellCheck={false}
|
||||
placeholder="Message"
|
||||
className="relative h-24 w-full resize-none rounded-lg border border-black/5 px-3.5 py-3 text-sm shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-2 w-full px-2">
|
||||
<div className="flex w-full items-center justify-between bg-zinc-800">
|
||||
<div className="flex items-center gap-2 divide-x divide-zinc-700">
|
||||
<ImagePicker type="chat" />
|
||||
<div className="flex items-center gap-2 pl-2"></div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => submitEvent()}
|
||||
disabled={value.length === 0 ? true : false}
|
||||
className="inline-flex h-8 w-16 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm font-medium shadow-button hover:bg-fuchsia-600 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="relative h-24 w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
|
||||
<div>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleEnterPress}
|
||||
spellCheck={false}
|
||||
placeholder="Message"
|
||||
className="relative h-24 w-full resize-none rounded-lg border border-black/5 px-3.5 py-3 text-sm shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-2 w-full px-2">
|
||||
<div className="flex w-full items-center justify-between bg-zinc-800">
|
||||
<div className="flex items-center gap-2 divide-x divide-zinc-700">
|
||||
<ImagePicker type="chat" />
|
||||
<div className="flex items-center gap-2 pl-2" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submitEvent()}
|
||||
disabled={value.length === 0 ? true : false}
|
||||
className="inline-flex h-8 w-16 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm font-medium shadow-button hover:bg-fuchsia-600 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,39 +1,49 @@
|
||||
import ChatMessageUser from '@app/chat/components/messages/user';
|
||||
import { useDecryptMessage } from '@app/chat/hooks/useDecryptMessage';
|
||||
import ImagePreview from '@app/note/components/preview/image';
|
||||
import VideoPreview from '@app/note/components/preview/video';
|
||||
import ChatMessageUser from "@app/chat/components/messages/user";
|
||||
import { useDecryptMessage } from "@app/chat/hooks/useDecryptMessage";
|
||||
import ImagePreview from "@app/note/components/preview/image";
|
||||
import VideoPreview from "@app/note/components/preview/video";
|
||||
|
||||
import { noteParser } from '@utils/parser';
|
||||
import { noteParser } from "@utils/parser";
|
||||
|
||||
import { memo } from 'react';
|
||||
import { memo } from "react";
|
||||
|
||||
export const ChatMessageItem = memo(function MessageListItem({
|
||||
data,
|
||||
userPubkey,
|
||||
userPrivkey,
|
||||
data,
|
||||
userPubkey,
|
||||
userPrivkey,
|
||||
}: {
|
||||
data: any;
|
||||
userPubkey: string;
|
||||
userPrivkey: string;
|
||||
data: any;
|
||||
userPubkey: string;
|
||||
userPrivkey: string;
|
||||
}) {
|
||||
const decryptedContent = useDecryptMessage(userPubkey, userPrivkey, data);
|
||||
// if we have decrypted content, use it instead of the encrypted content
|
||||
if (decryptedContent) {
|
||||
data['content'] = decryptedContent;
|
||||
}
|
||||
// parse the note content
|
||||
const content = noteParser(data);
|
||||
const decryptedContent = useDecryptMessage(userPubkey, userPrivkey, data);
|
||||
// if we have decrypted content, use it instead of the encrypted content
|
||||
if (decryptedContent) {
|
||||
data["content"] = decryptedContent;
|
||||
}
|
||||
// parse the note content
|
||||
const content = noteParser(data);
|
||||
|
||||
return (
|
||||
<div className="flex h-min min-h-min w-full select-text flex-col px-5 py-2 hover:bg-black/20">
|
||||
<div className="flex flex-col">
|
||||
<ChatMessageUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="-mt-[17px] pl-[48px]">
|
||||
<div className="whitespace-pre-line break-words text-sm leading-tight">{content.parsed}</div>
|
||||
{Array.isArray(content.images) && content.images.length ? <ImagePreview urls={content.images} /> : <></>}
|
||||
{Array.isArray(content.videos) && content.videos.length ? <VideoPreview urls={content.videos} /> : <></>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex h-min min-h-min w-full select-text flex-col px-5 py-2 hover:bg-black/20">
|
||||
<div className="flex flex-col">
|
||||
<ChatMessageUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="-mt-[17px] pl-[48px]">
|
||||
<div className="whitespace-pre-line break-words text-sm leading-tight">
|
||||
{content.parsed}
|
||||
</div>
|
||||
{Array.isArray(content.images) && content.images.length ? (
|
||||
<ImagePreview urls={content.images} />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{Array.isArray(content.videos) && content.videos.length ? (
|
||||
<VideoPreview urls={content.videos} />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,49 +1,56 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
import { DEFAULT_AVATAR, IMGPROXY_URL } from '@stores/constants';
|
||||
import { DEFAULT_AVATAR, IMGPROXY_URL } from "@stores/constants";
|
||||
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { shortenKey } from '@utils/shortenKey';
|
||||
import { useProfile } from "@utils/hooks/useProfile";
|
||||
import { shortenKey } from "@utils/shortenKey";
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export default function ChatMessageUser({ pubkey, time }: { pubkey: string; time: number }) {
|
||||
const { user, isError, isLoading } = useProfile(pubkey);
|
||||
export default function ChatMessageUser({
|
||||
pubkey,
|
||||
time,
|
||||
}: { pubkey: string; time: number }) {
|
||||
const { user, isError, isLoading } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<div className="group flex items-start gap-3">
|
||||
{isError || isLoading ? (
|
||||
<>
|
||||
<div className="relative h-9 w-9 shrink animate-pulse rounded-md bg-zinc-800"></div>
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex items-baseline gap-2 text-sm">
|
||||
<div className="h-4 w-20 animate-pulse rounded bg-zinc-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative h-9 w-9 shrink rounded-md">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${user?.picture ? user.picture : DEFAULT_AVATAR}`}
|
||||
alt={pubkey}
|
||||
className="h-9 w-9 rounded-md object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex items-baseline gap-2 text-sm">
|
||||
<span className="font-semibold leading-none text-zinc-200 group-hover:underline">
|
||||
{user?.display_name || user?.name || shortenKey(pubkey)}
|
||||
</span>
|
||||
<span className="leading-none text-zinc-500">·</span>
|
||||
<span className="leading-none text-zinc-500">{dayjs().to(dayjs.unix(time))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="group flex items-start gap-3">
|
||||
{isError || isLoading ? (
|
||||
<>
|
||||
<div className="relative h-9 w-9 shrink animate-pulse rounded-md bg-zinc-800" />
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex items-baseline gap-2 text-sm">
|
||||
<div className="h-4 w-20 animate-pulse rounded bg-zinc-800" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative h-9 w-9 shrink rounded-md">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${
|
||||
user?.picture ? user.picture : DEFAULT_AVATAR
|
||||
}`}
|
||||
alt={pubkey}
|
||||
className="h-9 w-9 rounded-md object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex items-baseline gap-2 text-sm">
|
||||
<span className="font-semibold leading-none text-zinc-200 group-hover:underline">
|
||||
{user?.display_name || user?.name || shortenKey(pubkey)}
|
||||
</span>
|
||||
<span className="leading-none text-zinc-500">·</span>
|
||||
<span className="leading-none text-zinc-500">
|
||||
{dayjs().to(dayjs.unix(time))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,55 +1,59 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||
import { DEFAULT_AVATAR } from "@stores/constants";
|
||||
|
||||
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
|
||||
import { usePageContext } from '@utils/hooks/usePageContext';
|
||||
import { shortenKey } from '@utils/shortenKey';
|
||||
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
|
||||
import { usePageContext } from "@utils/hooks/usePageContext";
|
||||
import { shortenKey } from "@utils/shortenKey";
|
||||
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export default function ChatsListSelfItem() {
|
||||
const pageContext = usePageContext();
|
||||
const pageContext = usePageContext();
|
||||
|
||||
const searchParams: any = pageContext.urlParsed.search;
|
||||
const pagePubkey = searchParams.pubkey;
|
||||
const searchParams: any = pageContext.urlParsed.search;
|
||||
const pagePubkey = searchParams.pubkey;
|
||||
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
const profile = account ? JSON.parse(account.metadata) : null;
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
const profile = account ? JSON.parse(account.metadata) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isError && <div>error</div>}
|
||||
{isLoading && !account ? (
|
||||
<div className="inline-flex h-8 items-center gap-2.5 rounded-md px-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800"></div>
|
||||
<div>
|
||||
<div className="h-2.5 w-full animate-pulse truncate rounded bg-zinc-800 text-sm font-medium"></div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<a
|
||||
href={`/app/chat?pubkey=${account.pubkey}`}
|
||||
className={twMerge(
|
||||
'inline-flex h-8 items-center gap-2.5 rounded-md px-2.5 hover:bg-zinc-900',
|
||||
pagePubkey === account.pubkey ? 'dark:bg-zinc-900 dark:text-zinc-100 hover:dark:bg-zinc-800' : ''
|
||||
)}
|
||||
>
|
||||
<div className="relative h-5 w-5 shrink-0 rounded">
|
||||
<Image
|
||||
src={profile?.picture || DEFAULT_AVATAR}
|
||||
alt={account.pubkey}
|
||||
className="h-5 w-5 rounded bg-white object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="truncate text-[13px] font-semibold text-zinc-400">
|
||||
{profile?.display_name || profile?.name || shortenKey(account.pubkey)}{' '}
|
||||
<span className="text-zinc-500">(you)</span>
|
||||
</h5>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{isError && <div>error</div>}
|
||||
{isLoading && !account ? (
|
||||
<div className="inline-flex h-8 items-center gap-2.5 rounded-md px-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800" />
|
||||
<div>
|
||||
<div className="h-2.5 w-full animate-pulse truncate rounded bg-zinc-800 text-sm font-medium" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<a
|
||||
href={`/app/chat?pubkey=${account.pubkey}`}
|
||||
className={twMerge(
|
||||
"inline-flex h-8 items-center gap-2.5 rounded-md px-2.5 hover:bg-zinc-900",
|
||||
pagePubkey === account.pubkey
|
||||
? "dark:bg-zinc-900 dark:text-zinc-100 hover:dark:bg-zinc-800"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<div className="relative h-5 w-5 shrink-0 rounded">
|
||||
<Image
|
||||
src={profile?.picture || DEFAULT_AVATAR}
|
||||
alt={account.pubkey}
|
||||
className="h-5 w-5 rounded bg-white object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="truncate text-[13px] font-semibold text-zinc-400">
|
||||
{profile?.display_name ||
|
||||
profile?.name ||
|
||||
shortenKey(account.pubkey)}{" "}
|
||||
<span className="text-zinc-500">(you)</span>
|
||||
</h5>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
import { nip04 } from 'nostr-tools';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { nip04 } from "nostr-tools";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export function useDecryptMessage(userKey: string, userPriv: string, data: any) {
|
||||
const [content, setContent] = useState(null);
|
||||
export function useDecryptMessage(
|
||||
userKey: string,
|
||||
userPriv: string,
|
||||
data: any,
|
||||
) {
|
||||
const [content, setContent] = useState(null);
|
||||
|
||||
const extractSenderKey = useCallback(() => {
|
||||
const keyInTags = data.tags.find(([k, v]) => k === 'p' && v && v !== '')[1];
|
||||
if (keyInTags === userKey) {
|
||||
return data.pubkey;
|
||||
} else {
|
||||
return keyInTags;
|
||||
}
|
||||
}, [data.pubkey, data.tags, userKey]);
|
||||
const extractSenderKey = useCallback(() => {
|
||||
const keyInTags = data.tags.find(([k, v]) => k === "p" && v && v !== "")[1];
|
||||
if (keyInTags === userKey) {
|
||||
return data.pubkey;
|
||||
} else {
|
||||
return keyInTags;
|
||||
}
|
||||
}, [data.pubkey, data.tags, userKey]);
|
||||
|
||||
const decrypt = useCallback(async () => {
|
||||
const senderKey = extractSenderKey();
|
||||
const result = await nip04.decrypt(userPriv, senderKey, data.content);
|
||||
// update state with decrypt content
|
||||
setContent(result);
|
||||
}, [extractSenderKey, userPriv, data.content]);
|
||||
const decrypt = useCallback(async () => {
|
||||
const senderKey = extractSenderKey();
|
||||
const result = await nip04.decrypt(userPriv, senderKey, data.content);
|
||||
// update state with decrypt content
|
||||
setContent(result);
|
||||
}, [extractSenderKey, userPriv, data.content]);
|
||||
|
||||
useEffect(() => {
|
||||
decrypt().catch(console.error);
|
||||
}, [decrypt]);
|
||||
useEffect(() => {
|
||||
decrypt().catch(console.error);
|
||||
}, [decrypt]);
|
||||
|
||||
return content ? content : null;
|
||||
return content ? content : null;
|
||||
}
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
import AppHeader from '@shared/appHeader';
|
||||
import MultiAccounts from '@shared/multiAccounts';
|
||||
import Navigation from '@shared/navigation';
|
||||
import AppHeader from "@shared/appHeader";
|
||||
import MultiAccounts from "@shared/multiAccounts";
|
||||
import Navigation from "@shared/navigation";
|
||||
|
||||
export function LayoutChat({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white">
|
||||
<div className="flex h-screen w-full flex-col">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="relative h-9 shrink-0 border-b border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
|
||||
>
|
||||
<AppHeader />
|
||||
</div>
|
||||
<div className="relative flex min-h-0 w-full flex-1">
|
||||
<div className="relative w-[68px] shrink-0 border-r border-zinc-900">
|
||||
<MultiAccounts />
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-4 xl:grid-cols-5">
|
||||
<div className="scrollbar-hide col-span-1 overflow-y-auto overflow-x-hidden border-r border-zinc-900">
|
||||
<Navigation />
|
||||
</div>
|
||||
<div className="col-span-3 m-3 overflow-hidden xl:col-span-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white">
|
||||
<div className="flex h-screen w-full flex-col">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="relative h-9 shrink-0 border-b border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
|
||||
>
|
||||
<AppHeader />
|
||||
</div>
|
||||
<div className="relative flex min-h-0 w-full flex-1">
|
||||
<div className="relative w-[68px] shrink-0 border-r border-zinc-900">
|
||||
<MultiAccounts />
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-4 xl:grid-cols-5">
|
||||
<div className="scrollbar-hide col-span-1 overflow-y-auto overflow-x-hidden border-r border-zinc-900">
|
||||
<Navigation />
|
||||
</div>
|
||||
<div className="col-span-3 m-3 overflow-hidden xl:col-span-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,80 +1,80 @@
|
||||
import ChatMessageForm from '@app/chat/components/messages/form';
|
||||
import ChatMessageForm from "@app/chat/components/messages/form";
|
||||
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import { chatMessagesAtom } from '@stores/chat';
|
||||
import { READONLY_RELAYS } from '@stores/constants';
|
||||
import { chatMessagesAtom } from "@stores/chat";
|
||||
import { READONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
|
||||
import { usePageContext } from '@utils/hooks/usePageContext';
|
||||
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
|
||||
import { usePageContext } from "@utils/hooks/usePageContext";
|
||||
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useResetAtom } from 'jotai/utils';
|
||||
import { Suspense, lazy, useContext, useEffect } from 'react';
|
||||
import useSWRSubscription from 'swr/subscription';
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useResetAtom } from "jotai/utils";
|
||||
import { Suspense, lazy, useContext, useEffect } from "react";
|
||||
import useSWRSubscription from "swr/subscription";
|
||||
|
||||
const ChatMessageList = lazy(() => import('@app/chat/components/messageList'));
|
||||
const ChatMessageList = lazy(() => import("@app/chat/components/messageList"));
|
||||
|
||||
export function Page() {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const pageContext = usePageContext();
|
||||
const searchParams: any = pageContext.urlParsed.search;
|
||||
const pool: any = useContext(RelayContext);
|
||||
const pageContext = usePageContext();
|
||||
const searchParams: any = pageContext.urlParsed.search;
|
||||
|
||||
const pubkey = searchParams.pubkey;
|
||||
const pubkey = searchParams.pubkey;
|
||||
|
||||
const { account } = useActiveAccount();
|
||||
const { account } = useActiveAccount();
|
||||
|
||||
const setChatMessages = useSetAtom(chatMessagesAtom);
|
||||
const resetChatMessages = useResetAtom(chatMessagesAtom);
|
||||
const setChatMessages = useSetAtom(chatMessagesAtom);
|
||||
const resetChatMessages = useResetAtom(chatMessagesAtom);
|
||||
|
||||
useSWRSubscription(account ? ['chat', pubkey] : null, ([, key], {}: any) => {
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [4],
|
||||
authors: [key],
|
||||
'#p': [account.pubkey],
|
||||
limit: 20,
|
||||
},
|
||||
{
|
||||
kinds: [4],
|
||||
authors: [account.pubkey],
|
||||
'#p': [key],
|
||||
limit: 20,
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
setChatMessages((prev) => [...prev, event]);
|
||||
}
|
||||
);
|
||||
useSWRSubscription(account ? ["chat", pubkey] : null, ([, key]) => {
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [4],
|
||||
authors: [key],
|
||||
"#p": [account.pubkey],
|
||||
limit: 20,
|
||||
},
|
||||
{
|
||||
kinds: [4],
|
||||
authors: [account.pubkey],
|
||||
"#p": [key],
|
||||
limit: 20,
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
setChatMessages((prev) => [...prev, event]);
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
|
||||
if (!ignore) {
|
||||
// reset chat messages
|
||||
resetChatMessages();
|
||||
}
|
||||
if (!ignore) {
|
||||
// reset chat messages
|
||||
resetChatMessages();
|
||||
}
|
||||
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col justify-between rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
|
||||
<Suspense fallback={<p>Loading...</p>}>
|
||||
<ChatMessageList />
|
||||
</Suspense>
|
||||
<div className="shrink-0 p-3">
|
||||
<ChatMessageForm receiverPubkey={pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col justify-between rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
|
||||
<Suspense fallback={<p>Loading...</p>}>
|
||||
<ChatMessageList />
|
||||
</Suspense>
|
||||
<div className="shrink-0 p-3">
|
||||
<ChatMessageForm receiverPubkey={pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const filesystemRoutingRoot = '/';
|
||||
export const filesystemRoutingRoot = "/";
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import { getActiveAccount } from '@utils/storage';
|
||||
import { getActiveAccount } from "@utils/storage";
|
||||
|
||||
import useSWR from 'swr';
|
||||
import { navigate } from 'vite-plugin-ssr/client/router';
|
||||
import useSWR from "swr";
|
||||
import { navigate } from "vite-plugin-ssr/client/router";
|
||||
|
||||
const fetcher = () => getActiveAccount();
|
||||
|
||||
export function Page() {
|
||||
const { data, isLoading } = useSWR('account', fetcher, {
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
});
|
||||
const { data, isLoading } = useSWR("account", fetcher, {
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
});
|
||||
|
||||
if (!isLoading && !data) {
|
||||
navigate('/auth', { overwriteLastHistoryEntry: true });
|
||||
}
|
||||
if (!isLoading && !data) {
|
||||
navigate("/auth", { overwriteLastHistoryEntry: true });
|
||||
}
|
||||
|
||||
if (!isLoading && data) {
|
||||
navigate('/app/inital-data', { overwriteLastHistoryEntry: true });
|
||||
}
|
||||
if (!isLoading && data) {
|
||||
navigate("/app/inital-data", { overwriteLastHistoryEntry: true });
|
||||
}
|
||||
|
||||
return <div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-white"></div>;
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-white" />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,233 +1,246 @@
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import LumeIcon from '@icons/lume';
|
||||
import LumeIcon from "@icons/lume";
|
||||
|
||||
import { READONLY_RELAYS } from '@stores/constants';
|
||||
import { READONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { dateToUnix, getHourAgo } from '@utils/date';
|
||||
import { dateToUnix, getHourAgo } from "@utils/date";
|
||||
import {
|
||||
addToBlacklist,
|
||||
countTotalLongNotes,
|
||||
countTotalNotes,
|
||||
createChat,
|
||||
createNote,
|
||||
getActiveAccount,
|
||||
getLastLogin,
|
||||
updateLastLogin,
|
||||
} from '@utils/storage';
|
||||
import { getParentID, nip02ToArray } from '@utils/transform';
|
||||
addToBlacklist,
|
||||
countTotalLongNotes,
|
||||
countTotalNotes,
|
||||
createChat,
|
||||
createNote,
|
||||
getActiveAccount,
|
||||
getLastLogin,
|
||||
updateLastLogin,
|
||||
} from "@utils/storage";
|
||||
import { getParentID, nip02ToArray } from "@utils/transform";
|
||||
|
||||
import { useContext, useEffect, useRef } from 'react';
|
||||
import { navigate } from 'vite-plugin-ssr/client/router';
|
||||
import { useContext, useEffect, useRef } from "react";
|
||||
import { navigate } from "vite-plugin-ssr/client/router";
|
||||
|
||||
export function Page() {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const now = useRef(new Date());
|
||||
const pool: any = useContext(RelayContext);
|
||||
const now = useRef(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
let unsubscribe: () => void;
|
||||
let timeout: any;
|
||||
useEffect(() => {
|
||||
let unsubscribe: () => void;
|
||||
let timeout: any;
|
||||
|
||||
const fetchInitalData = async () => {
|
||||
const account = await getActiveAccount();
|
||||
const lastLogin = await getLastLogin();
|
||||
const notes = await countTotalNotes();
|
||||
const longNotes = await countTotalLongNotes();
|
||||
const fetchInitalData = async () => {
|
||||
const account = await getActiveAccount();
|
||||
const lastLogin = await getLastLogin();
|
||||
const notes = await countTotalNotes();
|
||||
const longNotes = await countTotalLongNotes();
|
||||
|
||||
const follows = nip02ToArray(JSON.parse(account.follows));
|
||||
const query = [];
|
||||
const follows = nip02ToArray(JSON.parse(account.follows));
|
||||
const query = [];
|
||||
|
||||
let sinceNotes: number;
|
||||
let sinceLongNotes: number;
|
||||
let sinceNotes: number;
|
||||
let sinceLongNotes: number;
|
||||
|
||||
if (notes === 0) {
|
||||
sinceNotes = dateToUnix(getHourAgo(48, now.current));
|
||||
} else {
|
||||
if (parseInt(lastLogin) > 0) {
|
||||
sinceNotes = parseInt(lastLogin);
|
||||
} else {
|
||||
sinceNotes = dateToUnix(getHourAgo(48, now.current));
|
||||
}
|
||||
}
|
||||
if (notes === 0) {
|
||||
sinceNotes = dateToUnix(getHourAgo(48, now.current));
|
||||
} else {
|
||||
if (parseInt(lastLogin) > 0) {
|
||||
sinceNotes = parseInt(lastLogin);
|
||||
} else {
|
||||
sinceNotes = dateToUnix(getHourAgo(48, now.current));
|
||||
}
|
||||
}
|
||||
|
||||
if (longNotes === 0) {
|
||||
sinceLongNotes = 0;
|
||||
} else {
|
||||
if (parseInt(lastLogin) > 0) {
|
||||
sinceLongNotes = parseInt(lastLogin);
|
||||
} else {
|
||||
sinceLongNotes = 0;
|
||||
}
|
||||
}
|
||||
if (longNotes === 0) {
|
||||
sinceLongNotes = 0;
|
||||
} else {
|
||||
if (parseInt(lastLogin) > 0) {
|
||||
sinceLongNotes = parseInt(lastLogin);
|
||||
} else {
|
||||
sinceLongNotes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// kind 1 (notes) query
|
||||
query.push({
|
||||
kinds: [1, 6, 1063],
|
||||
authors: follows,
|
||||
since: sinceNotes,
|
||||
until: dateToUnix(now.current),
|
||||
});
|
||||
// kind 1 (notes) query
|
||||
query.push({
|
||||
kinds: [1, 6, 1063],
|
||||
authors: follows,
|
||||
since: sinceNotes,
|
||||
until: dateToUnix(now.current),
|
||||
});
|
||||
|
||||
// kind 4 (chats) query
|
||||
query.push({
|
||||
kinds: [4],
|
||||
'#p': [account.pubkey],
|
||||
since: 0,
|
||||
until: dateToUnix(now.current),
|
||||
});
|
||||
// kind 4 (chats) query
|
||||
query.push({
|
||||
kinds: [4],
|
||||
"#p": [account.pubkey],
|
||||
since: 0,
|
||||
until: dateToUnix(now.current),
|
||||
});
|
||||
|
||||
// kind 43, 43 (mute user, hide message) query
|
||||
query.push({
|
||||
authors: [account.pubkey],
|
||||
kinds: [43, 44],
|
||||
since: 0,
|
||||
until: dateToUnix(now.current),
|
||||
});
|
||||
// kind 43, 43 (mute user, hide message) query
|
||||
query.push({
|
||||
authors: [account.pubkey],
|
||||
kinds: [43, 44],
|
||||
since: 0,
|
||||
until: dateToUnix(now.current),
|
||||
});
|
||||
|
||||
// kind 30023 (long post) query
|
||||
query.push({
|
||||
kinds: [30023],
|
||||
since: sinceLongNotes,
|
||||
until: dateToUnix(now.current),
|
||||
});
|
||||
// kind 30023 (long post) query
|
||||
query.push({
|
||||
kinds: [30023],
|
||||
since: sinceLongNotes,
|
||||
until: dateToUnix(now.current),
|
||||
});
|
||||
|
||||
// subscribe relays
|
||||
unsubscribe = pool.subscribe(
|
||||
query,
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
switch (event.kind) {
|
||||
// short text note
|
||||
case 1:
|
||||
const parentID = getParentID(event.tags, event.id);
|
||||
// insert event to local database
|
||||
createNote(
|
||||
event.id,
|
||||
account.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at,
|
||||
parentID
|
||||
);
|
||||
break;
|
||||
// chat
|
||||
case 4:
|
||||
if (event.pubkey !== account.pubkey) {
|
||||
createChat(account.id, event.pubkey, event.created_at);
|
||||
}
|
||||
break;
|
||||
// repost
|
||||
case 6:
|
||||
createNote(
|
||||
event.id,
|
||||
account.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at,
|
||||
event.id
|
||||
);
|
||||
break;
|
||||
// hide message (channel only)
|
||||
case 43:
|
||||
if (event.tags[0][0] === 'e') {
|
||||
addToBlacklist(account.id, event.tags[0][1], 43, 1);
|
||||
}
|
||||
break;
|
||||
// mute user (channel only)
|
||||
case 44:
|
||||
if (event.tags[0][0] === 'p') {
|
||||
addToBlacklist(account.id, event.tags[0][1], 44, 1);
|
||||
}
|
||||
break;
|
||||
case 1063:
|
||||
createNote(
|
||||
event.id,
|
||||
account.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at,
|
||||
''
|
||||
);
|
||||
break;
|
||||
// long post
|
||||
case 30023:
|
||||
// insert event to local database
|
||||
createNote(
|
||||
event.id,
|
||||
account.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at,
|
||||
''
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
() => {
|
||||
updateLastLogin(dateToUnix(now.current));
|
||||
timeout = setTimeout(() => {
|
||||
navigate('/app/today', { overwriteLastHistoryEntry: true });
|
||||
}, 5000);
|
||||
}
|
||||
);
|
||||
};
|
||||
// subscribe relays
|
||||
unsubscribe = pool.subscribe(
|
||||
query,
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
switch (event.kind) {
|
||||
// short text note
|
||||
case 1: {
|
||||
const parentID = getParentID(event.tags, event.id);
|
||||
// insert event to local database
|
||||
createNote(
|
||||
event.id,
|
||||
account.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at,
|
||||
parentID,
|
||||
);
|
||||
break;
|
||||
}
|
||||
// chat
|
||||
case 4:
|
||||
if (event.pubkey !== account.pubkey) {
|
||||
createChat(account.id, event.pubkey, event.created_at);
|
||||
}
|
||||
break;
|
||||
// repost
|
||||
case 6:
|
||||
createNote(
|
||||
event.id,
|
||||
account.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at,
|
||||
event.id,
|
||||
);
|
||||
break;
|
||||
// hide message (channel only)
|
||||
case 43:
|
||||
if (event.tags[0][0] === "e") {
|
||||
addToBlacklist(account.id, event.tags[0][1], 43, 1);
|
||||
}
|
||||
break;
|
||||
// mute user (channel only)
|
||||
case 44:
|
||||
if (event.tags[0][0] === "p") {
|
||||
addToBlacklist(account.id, event.tags[0][1], 44, 1);
|
||||
}
|
||||
break;
|
||||
case 1063:
|
||||
createNote(
|
||||
event.id,
|
||||
account.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at,
|
||||
"",
|
||||
);
|
||||
break;
|
||||
// long post
|
||||
case 30023:
|
||||
// insert event to local database
|
||||
createNote(
|
||||
event.id,
|
||||
account.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at,
|
||||
"",
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
() => {
|
||||
updateLastLogin(dateToUnix(now.current));
|
||||
timeout = setTimeout(() => {
|
||||
navigate("/app/today", { overwriteLastHistoryEntry: true });
|
||||
}, 5000);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
fetchInitalData().catch(console.error);
|
||||
fetchInitalData().catch(console.error);
|
||||
|
||||
return () => {
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [pool]);
|
||||
return () => {
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [pool]);
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-white">
|
||||
<div className="relative h-full overflow-hidden">
|
||||
{/* dragging area */}
|
||||
<div data-tauri-drag-region className="absolute left-0 top-0 z-20 h-16 w-full bg-transparent" />
|
||||
{/* end dragging area */}
|
||||
<div className="relative flex h-full flex-col items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<LumeIcon className="h-16 w-16 text-black dark:text-white" />
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold leading-tight text-zinc-900 dark:text-zinc-100">
|
||||
Here's an interesting fact:
|
||||
</h3>
|
||||
<p className="font-medium text-zinc-300 dark:text-zinc-600">
|
||||
Bitcoin and Nostr can be used by anyone, and no one can stop you!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-16 left-1/2 -translate-x-1/2 transform">
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-black dark:text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-white">
|
||||
<div className="relative h-full overflow-hidden">
|
||||
{/* dragging area */}
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="absolute left-0 top-0 z-20 h-16 w-full bg-transparent"
|
||||
/>
|
||||
{/* end dragging area */}
|
||||
<div className="relative flex h-full flex-col items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<LumeIcon className="h-16 w-16 text-black dark:text-white" />
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold leading-tight text-zinc-900 dark:text-zinc-100">
|
||||
Here's an interesting fact:
|
||||
</h3>
|
||||
<p className="font-medium text-zinc-300 dark:text-zinc-600">
|
||||
Bitcoin and Nostr can be used by anyone, and no one can stop
|
||||
you!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-16 left-1/2 -translate-x-1/2 transform">
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-black dark:text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<title id="loading">Loading</title>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { LayoutNewsfeed as Layout } from './layout';
|
||||
export { LayoutNewsfeed as Layout } from "./layout";
|
||||
|
||||
@@ -1,38 +1,41 @@
|
||||
import { Kind1 } from '@app/note/components/kind1';
|
||||
import { Kind1063 } from '@app/note/components/kind1063';
|
||||
import NoteMetadata from '@app/note/components/metadata';
|
||||
import { NoteParent } from '@app/note/components/parent';
|
||||
import { NoteDefaultUser } from '@app/note/components/user/default';
|
||||
import { NoteWrapper } from '@app/note/components/wrapper';
|
||||
import { Kind1 } from "@app/note/components/kind1";
|
||||
import { Kind1063 } from "@app/note/components/kind1063";
|
||||
import NoteMetadata from "@app/note/components/metadata";
|
||||
import { NoteParent } from "@app/note/components/parent";
|
||||
import { NoteDefaultUser } from "@app/note/components/user/default";
|
||||
import { NoteWrapper } from "@app/note/components/wrapper";
|
||||
|
||||
import { noteParser } from '@utils/parser';
|
||||
import { isTagsIncludeID } from '@utils/transform';
|
||||
import { noteParser } from "@utils/parser";
|
||||
import { isTagsIncludeID } from "@utils/transform";
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function NoteBase({ event }: { event: any }) {
|
||||
const content = useMemo(() => noteParser(event), [event]);
|
||||
const checkParentID = isTagsIncludeID(event.parent_id, event.tags);
|
||||
const content = useMemo(() => noteParser(event), [event]);
|
||||
const checkParentID = isTagsIncludeID(event.parent_id, event.tags);
|
||||
|
||||
const href = event.parent_id ? `/app/note?id=${event.parent_id}` : `/app/note?id=${event.event_id}`;
|
||||
const href = event.parent_id
|
||||
? `/app/note?id=${event.parent_id}`
|
||||
: `/app/note?id=${event.event_id}`;
|
||||
|
||||
return (
|
||||
<NoteWrapper href={href} className="h-min w-full px-3 py-1.5">
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900 px-3 pt-3 shadow-input shadow-black/20">
|
||||
{event.parent_id && (event.parent_id !== event.event_id || checkParentID) ? (
|
||||
<NoteParent id={event.parent_id} />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<NoteDefaultUser pubkey={event.pubkey} time={event.created_at} />
|
||||
<div className="mt-3 pl-[46px]">
|
||||
{event.kind === 1 && <Kind1 content={content} />}
|
||||
{event.kind === 1063 && <Kind1063 metadata={event.tags} />}
|
||||
<NoteMetadata id={event.event_id} eventPubkey={event.pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NoteWrapper>
|
||||
);
|
||||
return (
|
||||
<NoteWrapper href={href} className="h-min w-full px-3 py-1.5">
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900 px-3 pt-3 shadow-input shadow-black/20">
|
||||
{event.parent_id &&
|
||||
(event.parent_id !== event.event_id || checkParentID) ? (
|
||||
<NoteParent id={event.parent_id} />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<NoteDefaultUser pubkey={event.pubkey} time={event.created_at} />
|
||||
<div className="mt-3 pl-[46px]">
|
||||
{event.kind === 1 && <Kind1 content={content} />}
|
||||
{event.kind === 1063 && <Kind1063 metadata={event.tags} />}
|
||||
<NoteMetadata id={event.event_id} eventPubkey={event.pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NoteWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,41 @@
|
||||
import { MentionNote } from '@app/note/components/mentions/note';
|
||||
import { MentionUser } from '@app/note/components/mentions/user';
|
||||
import ImagePreview from '@app/note/components/preview/image';
|
||||
import VideoPreview from '@app/note/components/preview/video';
|
||||
import { MentionNote } from "@app/note/components/mentions/note";
|
||||
import { MentionUser } from "@app/note/components/mentions/user";
|
||||
import ImagePreview from "@app/note/components/preview/image";
|
||||
import VideoPreview from "@app/note/components/preview/video";
|
||||
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
export function Kind1({ content }: { content: any }) {
|
||||
return (
|
||||
<>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[[remarkGfm]]}
|
||||
linkTarget="_blank"
|
||||
className="prose prose-zinc max-w-none select-text break-words dark:prose-invert prose-p:text-[15px] prose-p:leading-tight prose-a:text-[15px] prose-a:font-normal prose-a:leading-tight prose-a:text-fuchsia-500 prose-a:no-underline hover:prose-a:text-fuchsia-600 hover:prose-a:underline prose-ol:mb-1 prose-ul:mb-1 prose-li:text-[15px] prose-li:leading-tight"
|
||||
components={{
|
||||
em: ({ ...props }) => <MentionUser {...props} />,
|
||||
}}
|
||||
>
|
||||
{content.parsed}
|
||||
</ReactMarkdown>
|
||||
{Array.isArray(content.images) && content.images.length ? <ImagePreview urls={content.images} /> : <></>}
|
||||
{Array.isArray(content.videos) && content.videos.length ? <VideoPreview urls={content.videos} /> : <></>}
|
||||
{Array.isArray(content.notes) && content.notes.length ? (
|
||||
content.notes.map((note: string) => <MentionNote key={note} id={note} />)
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[[remarkGfm]]}
|
||||
linkTarget="_blank"
|
||||
className="prose prose-zinc max-w-none select-text break-words dark:prose-invert prose-p:text-[15px] prose-p:leading-tight prose-a:text-[15px] prose-a:font-normal prose-a:leading-tight prose-a:text-fuchsia-500 prose-a:no-underline hover:prose-a:text-fuchsia-600 hover:prose-a:underline prose-ol:mb-1 prose-ul:mb-1 prose-li:text-[15px] prose-li:leading-tight"
|
||||
components={{
|
||||
em: ({ ...props }) => <MentionUser {...props} />,
|
||||
}}
|
||||
>
|
||||
{content.parsed}
|
||||
</ReactMarkdown>
|
||||
{Array.isArray(content.images) && content.images.length ? (
|
||||
<ImagePreview urls={content.images} />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{Array.isArray(content.videos) && content.videos.length ? (
|
||||
<VideoPreview urls={content.videos} />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{Array.isArray(content.notes) && content.notes.length ? (
|
||||
content.notes.map((note: string) => (
|
||||
<MentionNote key={note} id={note} />
|
||||
))
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
function isImage(url: string) {
|
||||
return /\.(jpg|jpeg|gif|png|webp|avif)$/.test(url);
|
||||
return /\.(jpg|jpeg|gif|png|webp|avif)$/.test(url);
|
||||
}
|
||||
|
||||
export function Kind1063({ metadata }: { metadata: string[] }) {
|
||||
const url = metadata[0][1];
|
||||
const url = metadata[0][1];
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
{isImage(url) && <Image src={url} alt="image" className="h-auto w-full rounded-lg object-cover" />}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="mt-3">
|
||||
{isImage(url) && (
|
||||
<Image
|
||||
src={url}
|
||||
alt="image"
|
||||
className="h-auto w-full rounded-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,60 +1,66 @@
|
||||
import { Kind1 } from '@app/note/components/kind1';
|
||||
import { Kind1063 } from '@app/note/components/kind1063';
|
||||
import { NoteSkeleton } from '@app/note/components/skeleton';
|
||||
import { NoteDefaultUser } from '@app/note/components/user/default';
|
||||
import { NoteWrapper } from '@app/note/components/wrapper';
|
||||
import { Kind1 } from "@app/note/components/kind1";
|
||||
import { Kind1063 } from "@app/note/components/kind1063";
|
||||
import { NoteSkeleton } from "@app/note/components/skeleton";
|
||||
import { NoteDefaultUser } from "@app/note/components/user/default";
|
||||
import { NoteWrapper } from "@app/note/components/wrapper";
|
||||
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import { READONLY_RELAYS } from '@stores/constants';
|
||||
import { READONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { noteParser } from '@utils/parser';
|
||||
import { noteParser } from "@utils/parser";
|
||||
|
||||
import { memo, useContext } from 'react';
|
||||
import useSWRSubscription from 'swr/subscription';
|
||||
import { memo, useContext } from "react";
|
||||
import useSWRSubscription from "swr/subscription";
|
||||
|
||||
export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const { data, error } = useSWRSubscription(id ? id : null, (key, { next }) => {
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
ids: [key],
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
next(null, event);
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
unsubscribeOnEose: true,
|
||||
}
|
||||
);
|
||||
const { data, error } = useSWRSubscription(
|
||||
id ? id : null,
|
||||
(key, { next }) => {
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
ids: [key],
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
next(null, event);
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
unsubscribeOnEose: true,
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const kind1 = !error && data?.kind === 1 ? noteParser(data) : null;
|
||||
const kind1063 = !error && data?.kind === 1063 ? data.tags : null;
|
||||
const kind1 = !error && data?.kind === 1 ? noteParser(data) : null;
|
||||
const kind1063 = !error && data?.kind === 1063 ? data.tags : null;
|
||||
|
||||
return (
|
||||
<NoteWrapper href={`/app/note?id=${id}`} className="mt-3 rounded-lg border border-zinc-800 px-3 py-3">
|
||||
{data ? (
|
||||
<>
|
||||
<NoteDefaultUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="mt-1 pl-[46px]">
|
||||
{kind1 && <Kind1 content={kind1} />}
|
||||
{kind1063 && <Kind1063 metadata={kind1063} />}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<NoteSkeleton />
|
||||
)}
|
||||
</NoteWrapper>
|
||||
);
|
||||
return (
|
||||
<NoteWrapper
|
||||
href={`/app/note?id=${id}`}
|
||||
className="mt-3 rounded-lg border border-zinc-800 px-3 py-3"
|
||||
>
|
||||
{data ? (
|
||||
<>
|
||||
<NoteDefaultUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="mt-1 pl-[46px]">
|
||||
{kind1 && <Kind1 content={kind1} />}
|
||||
{kind1063 && <Kind1063 metadata={kind1063} />}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<NoteSkeleton />
|
||||
)}
|
||||
</NoteWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { shortenKey } from '@utils/shortenKey';
|
||||
import { useProfile } from "@utils/hooks/useProfile";
|
||||
import { shortenKey } from "@utils/shortenKey";
|
||||
|
||||
export function MentionUser(props: { children: any[] }) {
|
||||
const pubkey = props.children[0];
|
||||
const { user } = useProfile(pubkey);
|
||||
const pubkey = props.children[0];
|
||||
const { user } = useProfile(pubkey);
|
||||
|
||||
return <span className="text-fuchsia-500">@{user?.name || user?.display_name || shortenKey(pubkey)}</span>;
|
||||
return (
|
||||
<span className="text-fuchsia-500">
|
||||
@{user?.name || user?.display_name || shortenKey(pubkey)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,67 +1,79 @@
|
||||
import NoteLike from '@app/note/components/metadata/like';
|
||||
import NoteReply from '@app/note/components/metadata/reply';
|
||||
import NoteRepost from '@app/note/components/metadata/repost';
|
||||
import NoteLike from "@app/note/components/metadata/like";
|
||||
import NoteReply from "@app/note/components/metadata/reply";
|
||||
import NoteRepost from "@app/note/components/metadata/repost";
|
||||
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import ZapIcon from '@icons/zap';
|
||||
import ZapIcon from "@icons/zap";
|
||||
|
||||
import { READONLY_RELAYS } from '@stores/constants';
|
||||
import { READONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { useContext, useState } from 'react';
|
||||
import useSWRSubscription from 'swr/subscription';
|
||||
import { useContext, useState } from "react";
|
||||
import useSWRSubscription from "swr/subscription";
|
||||
|
||||
export default function NoteMetadata({ id, eventPubkey }: { id: string; eventPubkey: string }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
export default function NoteMetadata({
|
||||
id,
|
||||
eventPubkey,
|
||||
}: { id: string; eventPubkey: string }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const [replies, setReplies] = useState(0);
|
||||
const [reposts, setReposts] = useState(0);
|
||||
const [likes, setLikes] = useState(0);
|
||||
const [replies, setReplies] = useState(0);
|
||||
const [reposts, setReposts] = useState(0);
|
||||
const [likes, setLikes] = useState(0);
|
||||
|
||||
useSWRSubscription(id ? ['note-metadata', id] : null, ([, key], {}) => {
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
'#e': [key],
|
||||
since: 0,
|
||||
kinds: [1, 6, 7],
|
||||
limit: 20,
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
switch (event.kind) {
|
||||
case 1:
|
||||
setReplies((replies) => replies + 1);
|
||||
break;
|
||||
case 6:
|
||||
setReposts((reposts) => reposts + 1);
|
||||
break;
|
||||
case 7:
|
||||
if (event.content === '🤙' || event.content === '+') {
|
||||
setLikes((likes) => likes + 1);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
);
|
||||
useSWRSubscription(id ? ["note-metadata", id] : null, ([, key]) => {
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
"#e": [key],
|
||||
since: 0,
|
||||
kinds: [1, 6, 7],
|
||||
limit: 20,
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
switch (event.kind) {
|
||||
case 1:
|
||||
setReplies((replies) => replies + 1);
|
||||
break;
|
||||
case 6:
|
||||
setReposts((reposts) => reposts + 1);
|
||||
break;
|
||||
case 7:
|
||||
if (event.content === "🤙" || event.content === "+") {
|
||||
setLikes((likes) => likes + 1);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex h-12 items-center gap-16 border-t border-zinc-800/50">
|
||||
<NoteReply id={id} replies={replies} />
|
||||
<NoteLike id={id} pubkey={eventPubkey} likes={likes} />
|
||||
<NoteRepost id={id} pubkey={eventPubkey} reposts={reposts} />
|
||||
<button className="group inline-flex w-min items-center gap-1.5">
|
||||
<ZapIcon width={20} height={20} className="text-zinc-400 group-hover:text-orange-400" />
|
||||
<span className="text-sm leading-none text-zinc-400 group-hover:text-zinc-200">{0}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="mt-4 flex h-12 items-center gap-16 border-t border-zinc-800/50">
|
||||
<NoteReply id={id} replies={replies} />
|
||||
<NoteLike id={id} pubkey={eventPubkey} likes={likes} />
|
||||
<NoteRepost id={id} pubkey={eventPubkey} reposts={reposts} />
|
||||
<button
|
||||
type="button"
|
||||
className="group inline-flex w-min items-center gap-1.5"
|
||||
>
|
||||
<ZapIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-zinc-400 group-hover:text-orange-400"
|
||||
/>
|
||||
<span className="text-sm leading-none text-zinc-400 group-hover:text-zinc-200">
|
||||
{0}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,54 +1,68 @@
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import LikeIcon from '@icons/like';
|
||||
import LikeIcon from "@icons/like";
|
||||
|
||||
import { WRITEONLY_RELAYS } from '@stores/constants';
|
||||
import { WRITEONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { dateToUnix } from '@utils/date';
|
||||
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
|
||||
import { dateToUnix } from "@utils/date";
|
||||
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
|
||||
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { getEventHash, signEvent } from "nostr-tools";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
|
||||
export default function NoteLike({ id, pubkey, likes }: { id: string; pubkey: string; likes: number }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
export default function NoteLike({
|
||||
id,
|
||||
pubkey,
|
||||
likes,
|
||||
}: { id: string; pubkey: string; likes: number }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
|
||||
const [count, setCount] = useState(0);
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
const submitEvent = (e: any) => {
|
||||
e.stopPropagation();
|
||||
const submitEvent = (e: any) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!isLoading && !isError && account) {
|
||||
const event: any = {
|
||||
content: '+',
|
||||
kind: 7,
|
||||
tags: [
|
||||
['e', id],
|
||||
['p', pubkey],
|
||||
],
|
||||
created_at: dateToUnix(),
|
||||
pubkey: account.pubkey,
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
// publish event to all relays
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// update state
|
||||
setCount(count + 1);
|
||||
} else {
|
||||
console.log('error');
|
||||
}
|
||||
};
|
||||
if (!isLoading && !isError && account) {
|
||||
const event: any = {
|
||||
content: "+",
|
||||
kind: 7,
|
||||
tags: [
|
||||
["e", id],
|
||||
["p", pubkey],
|
||||
],
|
||||
created_at: dateToUnix(),
|
||||
pubkey: account.pubkey,
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
// publish event to all relays
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// update state
|
||||
setCount(count + 1);
|
||||
} else {
|
||||
console.log("error");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setCount(likes);
|
||||
}, [likes]);
|
||||
useEffect(() => {
|
||||
setCount(likes);
|
||||
}, [likes]);
|
||||
|
||||
return (
|
||||
<button type="button" onClick={(e) => submitEvent(e)} className="group inline-flex w-min items-center gap-1.5">
|
||||
<LikeIcon width={16} height={16} className="text-zinc-400 group-hover:text-rose-400" />
|
||||
<span className="text-sm leading-none text-zinc-400 group-hover:text-zinc-200">{count}</span>
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => submitEvent(e)}
|
||||
className="group inline-flex w-min items-center gap-1.5"
|
||||
>
|
||||
<LikeIcon
|
||||
width={16}
|
||||
height={16}
|
||||
className="text-zinc-400 group-hover:text-rose-400"
|
||||
/>
|
||||
<span className="text-sm leading-none text-zinc-400 group-hover:text-zinc-200">
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,132 +1,150 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { Image } from "@shared/image";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import ReplyIcon from '@icons/reply';
|
||||
import ReplyIcon from "@icons/reply";
|
||||
|
||||
import { WRITEONLY_RELAYS } from '@stores/constants';
|
||||
import { WRITEONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { dateToUnix } from '@utils/date';
|
||||
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
|
||||
import { dateToUnix } from "@utils/date";
|
||||
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
|
||||
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { Fragment, useContext, useEffect, useState } from 'react';
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { getEventHash, signEvent } from "nostr-tools";
|
||||
import { Fragment, useContext, useEffect, useState } from "react";
|
||||
|
||||
export default function NoteReply({ id, replies }: { id: string; replies: number }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
export default function NoteReply({
|
||||
id,
|
||||
replies,
|
||||
}: { id: string; replies: number }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const [count, setCount] = useState(0);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [value, setValue] = useState('');
|
||||
const [count, setCount] = useState(0);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
const profile = account ? JSON.parse(account.metadata) : null;
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
const profile = account ? JSON.parse(account.metadata) : null;
|
||||
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const openModal = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
const openModal = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const submitEvent = () => {
|
||||
if (!isLoading && !isError && account) {
|
||||
const event: any = {
|
||||
content: value,
|
||||
created_at: dateToUnix(),
|
||||
kind: 1,
|
||||
pubkey: account.pubkey,
|
||||
tags: [['e', id]],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
const submitEvent = () => {
|
||||
if (!isLoading && !isError && account) {
|
||||
const event: any = {
|
||||
content: value,
|
||||
created_at: dateToUnix(),
|
||||
kind: 1,
|
||||
pubkey: account.pubkey,
|
||||
tags: [["e", id]],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
|
||||
// publish event
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// close modal
|
||||
setIsOpen(false);
|
||||
setCount(count + 1);
|
||||
} else {
|
||||
console.log('error');
|
||||
}
|
||||
};
|
||||
// publish event
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// close modal
|
||||
setIsOpen(false);
|
||||
setCount(count + 1);
|
||||
} else {
|
||||
console.log("error");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setCount(replies);
|
||||
}, [replies]);
|
||||
useEffect(() => {
|
||||
setCount(replies);
|
||||
}, [replies]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => openModal()} className="group inline-flex w-min items-center gap-1.5">
|
||||
<ReplyIcon width={16} height={16} className="text-zinc-400 group-hover:text-green-400" />
|
||||
<span className="text-sm leading-none text-zinc-400 group-hover:text-zinc-200">{count}</span>
|
||||
</button>
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={closeModal}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
|
||||
</Transition.Child>
|
||||
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900 p-3">
|
||||
{/* root note */}
|
||||
{/* comment form */}
|
||||
<div className="flex gap-2">
|
||||
<div>
|
||||
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-md border border-white/10">
|
||||
<Image src={profile?.picture} alt="user's avatar" className="h-11 w-11 rounded-md object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative h-24 w-full flex-1 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<div>
|
||||
<textarea
|
||||
name="content"
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="Send your comment"
|
||||
className="relative h-24 w-full resize-none rounded-md border border-black/5 px-3.5 py-3 text-sm shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-2 w-full px-2">
|
||||
<div className="flex w-full items-center justify-between bg-zinc-800">
|
||||
<div className="flex items-center gap-2 divide-x divide-zinc-700">
|
||||
<div className="flex items-center gap-2 pl-2"></div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => submitEvent()}
|
||||
disabled={value.length === 0 ? true : false}
|
||||
className="inline-flex h-8 w-16 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm font-medium shadow-md shadow-fuchsia-900/50 hover:bg-fuchsia-600"
|
||||
>
|
||||
<span className="text-white drop-shadow">Send</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openModal()}
|
||||
className="group inline-flex w-min items-center gap-1.5"
|
||||
>
|
||||
<ReplyIcon
|
||||
width={16}
|
||||
height={16}
|
||||
className="text-zinc-400 group-hover:text-green-400"
|
||||
/>
|
||||
<span className="text-sm leading-none text-zinc-400 group-hover:text-zinc-200">
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={closeModal}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
|
||||
</Transition.Child>
|
||||
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900 p-3">
|
||||
{/* root note */}
|
||||
{/* comment form */}
|
||||
<div className="flex gap-2">
|
||||
<div>
|
||||
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-md border border-white/10">
|
||||
<Image
|
||||
src={profile?.picture}
|
||||
alt="user's avatar"
|
||||
className="h-11 w-11 rounded-md object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative h-24 w-full flex-1 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<div>
|
||||
<textarea
|
||||
name="content"
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="Send your comment"
|
||||
className="relative h-24 w-full resize-none rounded-md border border-black/5 px-3.5 py-3 text-sm shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-2 w-full px-2">
|
||||
<div className="flex w-full items-center justify-between bg-zinc-800">
|
||||
<div className="flex items-center gap-2 divide-x divide-zinc-700">
|
||||
<div className="flex items-center gap-2 pl-2" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submitEvent()}
|
||||
disabled={value.length === 0 ? true : false}
|
||||
className="inline-flex h-8 w-16 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm font-medium shadow-md shadow-fuchsia-900/50 hover:bg-fuchsia-600"
|
||||
>
|
||||
<span className="text-white drop-shadow">Send</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,54 +1,68 @@
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import RepostIcon from '@icons/repost';
|
||||
import RepostIcon from "@icons/repost";
|
||||
|
||||
import { WRITEONLY_RELAYS } from '@stores/constants';
|
||||
import { WRITEONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { dateToUnix } from '@utils/date';
|
||||
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
|
||||
import { dateToUnix } from "@utils/date";
|
||||
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
|
||||
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { getEventHash, signEvent } from "nostr-tools";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
|
||||
export default function NoteRepost({ id, pubkey, reposts }: { id: string; pubkey: string; reposts: number }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
export default function NoteRepost({
|
||||
id,
|
||||
pubkey,
|
||||
reposts,
|
||||
}: { id: string; pubkey: string; reposts: number }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
|
||||
const [count, setCount] = useState(0);
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
const submitEvent = (e: any) => {
|
||||
e.stopPropagation();
|
||||
const submitEvent = (e: any) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!isLoading && !isError && account) {
|
||||
const event: any = {
|
||||
content: '',
|
||||
kind: 6,
|
||||
tags: [
|
||||
['e', id],
|
||||
['p', pubkey],
|
||||
],
|
||||
created_at: dateToUnix(),
|
||||
pubkey: account.pubkey,
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
// publish event to all relays
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// update state
|
||||
setCount(count + 1);
|
||||
} else {
|
||||
console.log('error');
|
||||
}
|
||||
};
|
||||
if (!isLoading && !isError && account) {
|
||||
const event: any = {
|
||||
content: "",
|
||||
kind: 6,
|
||||
tags: [
|
||||
["e", id],
|
||||
["p", pubkey],
|
||||
],
|
||||
created_at: dateToUnix(),
|
||||
pubkey: account.pubkey,
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
// publish event to all relays
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// update state
|
||||
setCount(count + 1);
|
||||
} else {
|
||||
console.log("error");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setCount(reposts);
|
||||
}, [reposts]);
|
||||
useEffect(() => {
|
||||
setCount(reposts);
|
||||
}, [reposts]);
|
||||
|
||||
return (
|
||||
<button type="button" onClick={(e) => submitEvent(e)} className="group inline-flex w-min items-center gap-1.5">
|
||||
<RepostIcon width={16} height={16} className="text-zinc-400 group-hover:text-blue-400" />
|
||||
<span className="text-sm leading-none text-zinc-400 group-hover:text-zinc-200">{count}</span>
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => submitEvent(e)}
|
||||
className="group inline-flex w-min items-center gap-1.5"
|
||||
>
|
||||
<RepostIcon
|
||||
width={16}
|
||||
height={16}
|
||||
className="text-zinc-400 group-hover:text-blue-400"
|
||||
/>
|
||||
<span className="text-sm leading-none text-zinc-400 group-hover:text-zinc-200">
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,62 +1,65 @@
|
||||
import { Kind1 } from '@app/note/components/kind1';
|
||||
import { Kind1063 } from '@app/note/components/kind1063';
|
||||
import NoteMetadata from '@app/note/components/metadata';
|
||||
import { NoteSkeleton } from '@app/note/components/skeleton';
|
||||
import { NoteDefaultUser } from '@app/note/components/user/default';
|
||||
import { Kind1 } from "@app/note/components/kind1";
|
||||
import { Kind1063 } from "@app/note/components/kind1063";
|
||||
import NoteMetadata from "@app/note/components/metadata";
|
||||
import { NoteSkeleton } from "@app/note/components/skeleton";
|
||||
import { NoteDefaultUser } from "@app/note/components/user/default";
|
||||
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import { READONLY_RELAYS } from '@stores/constants';
|
||||
import { READONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { noteParser } from '@utils/parser';
|
||||
import { noteParser } from "@utils/parser";
|
||||
|
||||
import { memo, useContext } from 'react';
|
||||
import useSWRSubscription from 'swr/subscription';
|
||||
import { memo, useContext } from "react";
|
||||
import useSWRSubscription from "swr/subscription";
|
||||
|
||||
export const NoteParent = memo(function NoteParent({ id }: { id: string }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const { data, error } = useSWRSubscription(id ? id : null, (key, { next }) => {
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
ids: [key],
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
next(null, event);
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
unsubscribeOnEose: true,
|
||||
}
|
||||
);
|
||||
const { data, error } = useSWRSubscription(
|
||||
id ? id : null,
|
||||
(key, { next }) => {
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
ids: [key],
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
next(null, event);
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
unsubscribeOnEose: true,
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const kind1 = !error && data?.kind === 1 ? noteParser(data) : null;
|
||||
const kind1063 = !error && data?.kind === 1063 ? data.tags : null;
|
||||
const kind1 = !error && data?.kind === 1 ? noteParser(data) : null;
|
||||
const kind1063 = !error && data?.kind === 1063 ? data.tags : null;
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col pb-6">
|
||||
<div className="absolute left-[16px] top-0 h-full w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600"></div>
|
||||
{data ? (
|
||||
<>
|
||||
<NoteDefaultUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="mt-3 pl-[46px]">
|
||||
{kind1 && <Kind1 content={kind1} />}
|
||||
{kind1063 && <Kind1063 metadata={kind1063} />}
|
||||
<NoteMetadata id={data.id} eventPubkey={data.pubkey} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<NoteSkeleton />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="relative flex flex-col pb-6">
|
||||
<div className="absolute left-[16px] top-0 h-full w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600" />
|
||||
{data ? (
|
||||
<>
|
||||
<NoteDefaultUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="mt-3 pl-[46px]">
|
||||
{kind1 && <Kind1 content={kind1} />}
|
||||
{kind1063 && <Kind1063 metadata={kind1063} />}
|
||||
<NoteMetadata id={data.id} eventPubkey={data.pubkey} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<NoteSkeleton />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
export default function ImagePreview({ urls }: { urls: string[] }) {
|
||||
return (
|
||||
<div className="mt-3 grid h-full w-full grid-cols-3">
|
||||
<div className="col-span-3">
|
||||
<Image src={urls[0]} alt="image" className="h-auto w-full rounded-lg object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="mt-3 grid h-full w-full grid-cols-3">
|
||||
<div className="col-span-3">
|
||||
<Image
|
||||
src={urls[0]}
|
||||
alt="image"
|
||||
className="h-auto w-full rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { MediaOutlet, MediaPlayer } from '@vidstack/react';
|
||||
import { MediaOutlet, MediaPlayer } from "@vidstack/react";
|
||||
|
||||
export default function VideoPreview({ urls }: { urls: string[] }) {
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="relative mt-2 flex w-full flex-col overflow-hidden rounded-lg bg-zinc-950"
|
||||
>
|
||||
<MediaPlayer src={urls[0]} poster="" controls>
|
||||
<MediaOutlet />
|
||||
</MediaPlayer>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="relative mt-2 flex w-full flex-col overflow-hidden rounded-lg bg-zinc-950"
|
||||
>
|
||||
<MediaPlayer src={urls[0]} poster="" controls>
|
||||
<MediaOutlet />
|
||||
</MediaPlayer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
import { RootNote } from '@app/note/components/rootNote';
|
||||
import { NoteRepostUser } from '@app/note/components/user/repost';
|
||||
import { NoteWrapper } from '@app/note/components/wrapper';
|
||||
import { RootNote } from "@app/note/components/rootNote";
|
||||
import { NoteRepostUser } from "@app/note/components/user/repost";
|
||||
import { NoteWrapper } from "@app/note/components/wrapper";
|
||||
|
||||
import { getQuoteID } from '@utils/transform';
|
||||
import { getQuoteID } from "@utils/transform";
|
||||
|
||||
export function NoteQuoteRepost({ event }: { event: any }) {
|
||||
const rootID = getQuoteID(event.tags);
|
||||
const rootID = getQuoteID(event.tags);
|
||||
|
||||
return (
|
||||
<NoteWrapper href={`/app/note?id=${rootID}`} className="h-min w-full px-3 py-1.5">
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
|
||||
<div className="relative px-3 pb-5 pt-3">
|
||||
<div className="absolute left-[29px] top-[20px] h-[70px] w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600"></div>
|
||||
<NoteRepostUser pubkey={event.pubkey} time={event.created_at} />
|
||||
</div>
|
||||
<RootNote id={rootID} fallback={event.content} />
|
||||
</div>
|
||||
</NoteWrapper>
|
||||
);
|
||||
return (
|
||||
<NoteWrapper
|
||||
href={`/app/note?id=${rootID}`}
|
||||
className="h-min w-full px-3 py-1.5"
|
||||
>
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
|
||||
<div className="relative px-3 pb-5 pt-3">
|
||||
<div className="absolute left-[29px] top-[20px] h-[70px] w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600" />
|
||||
<NoteRepostUser pubkey={event.pubkey} time={event.created_at} />
|
||||
</div>
|
||||
<RootNote id={rootID} fallback={event.content} />
|
||||
</div>
|
||||
</NoteWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,74 +1,79 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { Image } from "@shared/image";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import { WRITEONLY_RELAYS } from '@stores/constants';
|
||||
import { WRITEONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { dateToUnix } from '@utils/date';
|
||||
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
|
||||
import { dateToUnix } from "@utils/date";
|
||||
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
|
||||
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { useContext, useState } from 'react';
|
||||
import { getEventHash, signEvent } from "nostr-tools";
|
||||
import { useContext, useState } from "react";
|
||||
|
||||
export default function NoteReplyForm({ id }: { id: string }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
const pool: any = useContext(RelayContext);
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
|
||||
const [value, setValue] = useState('');
|
||||
const profile = account ? JSON.parse(account.metadata) : null;
|
||||
const [value, setValue] = useState("");
|
||||
const profile = account ? JSON.parse(account.metadata) : null;
|
||||
|
||||
const submitEvent = () => {
|
||||
if (!isLoading && !isError && account) {
|
||||
const event: any = {
|
||||
content: value,
|
||||
created_at: dateToUnix(),
|
||||
kind: 1,
|
||||
pubkey: account.pubkey,
|
||||
tags: [['e', id]],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
const submitEvent = () => {
|
||||
if (!isLoading && !isError && account) {
|
||||
const event: any = {
|
||||
content: value,
|
||||
created_at: dateToUnix(),
|
||||
kind: 1,
|
||||
pubkey: account.pubkey,
|
||||
tags: [["e", id]],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
|
||||
// publish note
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// reset form
|
||||
setValue('');
|
||||
} else {
|
||||
console.log('error');
|
||||
}
|
||||
};
|
||||
// publish note
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// reset form
|
||||
setValue("");
|
||||
} else {
|
||||
console.log("error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2.5 px-3 py-4">
|
||||
<div>
|
||||
<div className="relative h-9 w-9 shrink-0 overflow-hidden rounded-md">
|
||||
<Image src={profile?.picture} alt={account?.pubkey} className="h-9 w-9 rounded-md object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative h-24 w-full flex-1 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
|
||||
<div>
|
||||
<textarea
|
||||
name="content"
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="Reply to this thread..."
|
||||
className="relative h-24 w-full resize-none rounded-md border border-black/5 px-3.5 py-3 text-sm shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-2 w-full px-2">
|
||||
<div className="flex w-full items-center justify-between bg-zinc-800">
|
||||
<div className="flex items-center gap-2 divide-x divide-zinc-700"></div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => submitEvent()}
|
||||
disabled={value.length === 0 ? true : false}
|
||||
className="inline-flex h-8 w-16 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm font-medium shadow-button hover:bg-fuchsia-600 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Reply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex gap-2.5 px-3 py-4">
|
||||
<div>
|
||||
<div className="relative h-9 w-9 shrink-0 overflow-hidden rounded-md">
|
||||
<Image
|
||||
src={profile?.picture}
|
||||
alt={account?.pubkey}
|
||||
className="h-9 w-9 rounded-md object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative h-24 w-full flex-1 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
|
||||
<div>
|
||||
<textarea
|
||||
name="content"
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="Reply to this thread..."
|
||||
className="relative h-24 w-full resize-none rounded-md border border-black/5 px-3.5 py-3 text-sm shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-2 w-full px-2">
|
||||
<div className="flex w-full items-center justify-between bg-zinc-800">
|
||||
<div className="flex items-center gap-2 divide-x divide-zinc-700" />
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submitEvent()}
|
||||
disabled={value.length === 0 ? true : false}
|
||||
className="inline-flex h-8 w-16 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm font-medium shadow-button hover:bg-fuchsia-600 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Reply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { Kind1 } from '@app/note/components/kind1';
|
||||
import NoteReplyUser from '@app/note/components/user/reply';
|
||||
import { Kind1 } from "@app/note/components/kind1";
|
||||
import NoteReplyUser from "@app/note/components/user/reply";
|
||||
|
||||
import { noteParser } from '@utils/parser';
|
||||
import { noteParser } from "@utils/parser";
|
||||
|
||||
export default function Reply({ data }: { data: any }) {
|
||||
const content = noteParser(data);
|
||||
const content = noteParser(data);
|
||||
|
||||
return (
|
||||
<div className="flex h-min min-h-min w-full select-text flex-col px-3 py-3">
|
||||
<div className="flex flex-col">
|
||||
<NoteReplyUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="-mt-[18px] pl-[46px]">
|
||||
<Kind1 content={content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex h-min min-h-min w-full select-text flex-col px-3 py-3">
|
||||
<div className="flex flex-col">
|
||||
<NoteReplyUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="-mt-[18px] pl-[46px]">
|
||||
<Kind1 content={content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,66 +1,69 @@
|
||||
import NoteReplyForm from '@app/note/components/replies/form';
|
||||
import Reply from '@app/note/components/replies/item';
|
||||
import NoteReplyForm from "@app/note/components/replies/form";
|
||||
import Reply from "@app/note/components/replies/item";
|
||||
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import { READONLY_RELAYS } from '@stores/constants';
|
||||
import { READONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { sortEvents } from '@utils/transform';
|
||||
import { sortEvents } from "@utils/transform";
|
||||
|
||||
import { useContext } from 'react';
|
||||
import useSWRSubscription from 'swr/subscription';
|
||||
import { useContext } from "react";
|
||||
import useSWRSubscription from "swr/subscription";
|
||||
|
||||
export default function RepliesList({ id }: { id: string }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const { data, error } = useSWRSubscription(id ? ['note-replies', id] : null, ([, key], { next }) => {
|
||||
// subscribe to note
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
'#e': [key],
|
||||
since: 0,
|
||||
kinds: [1, 1063],
|
||||
limit: 20,
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
next(null, (prev: any) => (prev ? [...prev, event] : [event]));
|
||||
}
|
||||
);
|
||||
const { data, error } = useSWRSubscription(
|
||||
id ? ["note-replies", id] : null,
|
||||
([, key], { next }) => {
|
||||
// subscribe to note
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
"#e": [key],
|
||||
since: 0,
|
||||
kinds: [1, 1063],
|
||||
limit: 20,
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
next(null, (prev: any) => (prev ? [...prev, event] : [event]));
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-5">
|
||||
<div className="mb-2">
|
||||
<h5 className="text-lg font-semibold text-zinc-300">Replies</h5>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
|
||||
<div className="flex flex-col divide-y divide-zinc-800">
|
||||
<NoteReplyForm id={id} />
|
||||
{error && <div>failed to load</div>}
|
||||
{!data ? (
|
||||
<div className="flex gap-2 px-3 py-4">
|
||||
<div className="relative h-9 w-9 shrink animate-pulse rounded-md bg-zinc-800"></div>
|
||||
<div className="flex w-full flex-1 flex-col justify-center gap-1">
|
||||
<div className="flex items-baseline gap-2 text-sm">
|
||||
<div className="h-2.5 w-20 animate-pulse rounded-sm bg-zinc-800"></div>
|
||||
</div>
|
||||
<div className="h-4 w-44 animate-pulse rounded-sm bg-zinc-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
sortEvents(data).map((event: any) => {
|
||||
return <Reply key={event.id} data={event} />;
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="mt-5">
|
||||
<div className="mb-2">
|
||||
<h5 className="text-lg font-semibold text-zinc-300">Replies</h5>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
|
||||
<div className="flex flex-col divide-y divide-zinc-800">
|
||||
<NoteReplyForm id={id} />
|
||||
{error && <div>failed to load</div>}
|
||||
{!data ? (
|
||||
<div className="flex gap-2 px-3 py-4">
|
||||
<div className="relative h-9 w-9 shrink animate-pulse rounded-md bg-zinc-800" />
|
||||
<div className="flex w-full flex-1 flex-col justify-center gap-1">
|
||||
<div className="flex items-baseline gap-2 text-sm">
|
||||
<div className="h-2.5 w-20 animate-pulse rounded-sm bg-zinc-800" />
|
||||
</div>
|
||||
<div className="h-4 w-44 animate-pulse rounded-sm bg-zinc-800" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
sortEvents(data).map((event: any) => {
|
||||
return <Reply key={event.id} data={event} />;
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,95 +1,107 @@
|
||||
import { Kind1 } from '@app/note/components/kind1';
|
||||
import { Kind1063 } from '@app/note/components/kind1063';
|
||||
import NoteMetadata from '@app/note/components/metadata';
|
||||
import { NoteSkeleton } from '@app/note/components/skeleton';
|
||||
import { NoteDefaultUser } from '@app/note/components/user/default';
|
||||
import { Kind1 } from "@app/note/components/kind1";
|
||||
import { Kind1063 } from "@app/note/components/kind1063";
|
||||
import NoteMetadata from "@app/note/components/metadata";
|
||||
import { NoteSkeleton } from "@app/note/components/skeleton";
|
||||
import { NoteDefaultUser } from "@app/note/components/user/default";
|
||||
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import { READONLY_RELAYS } from '@stores/constants';
|
||||
import { READONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { noteParser } from '@utils/parser';
|
||||
import { noteParser } from "@utils/parser";
|
||||
|
||||
import { memo, useContext } from 'react';
|
||||
import useSWRSubscription from 'swr/subscription';
|
||||
import { navigate } from 'vite-plugin-ssr/client/router';
|
||||
import { memo, useContext } from "react";
|
||||
import useSWRSubscription from "swr/subscription";
|
||||
import { navigate } from "vite-plugin-ssr/client/router";
|
||||
|
||||
function isJSON(str: string) {
|
||||
try {
|
||||
JSON.parse(str);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
try {
|
||||
JSON.parse(str);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export const RootNote = memo(function RootNote({ id, fallback }: { id: string; fallback?: any }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const parseFallback = isJSON(fallback) ? JSON.parse(fallback) : null;
|
||||
export const RootNote = memo(function RootNote({
|
||||
id,
|
||||
fallback,
|
||||
}: { id: string; fallback?: any }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const parseFallback = isJSON(fallback) ? JSON.parse(fallback) : null;
|
||||
|
||||
const { data, error } = useSWRSubscription(parseFallback ? null : id, (key, { next }) => {
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
ids: [key],
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
next(null, event);
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
unsubscribeOnEose: true,
|
||||
}
|
||||
);
|
||||
const { data, error } = useSWRSubscription(
|
||||
parseFallback ? null : id,
|
||||
(key, { next }) => {
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
ids: [key],
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
next(null, event);
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
unsubscribeOnEose: true,
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const openNote = (e) => {
|
||||
const selection = window.getSelection();
|
||||
if (selection.toString().length === 0) {
|
||||
navigate(`/app/note?id=${id}`);
|
||||
} else {
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
const openNote = (e) => {
|
||||
const selection = window.getSelection();
|
||||
if (selection.toString().length === 0) {
|
||||
navigate(`/app/note?id=${id}`);
|
||||
} else {
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const kind1 = !error && data?.kind === 1 ? noteParser(data) : null;
|
||||
const kind1063 = !error && data?.kind === 1063 ? data.tags : null;
|
||||
const kind1 = !error && data?.kind === 1 ? noteParser(data) : null;
|
||||
const kind1063 = !error && data?.kind === 1063 ? data.tags : null;
|
||||
|
||||
if (parseFallback) {
|
||||
const contentFallback = noteParser(parseFallback);
|
||||
if (parseFallback) {
|
||||
const contentFallback = noteParser(parseFallback);
|
||||
|
||||
return (
|
||||
<div onClick={(e) => openNote(e)} className="flex flex-col px-3">
|
||||
<NoteDefaultUser pubkey={parseFallback.pubkey} time={parseFallback.created_at} />
|
||||
<div className="mt-3 pl-[46px]">
|
||||
<Kind1 content={contentFallback} />
|
||||
<NoteMetadata id={parseFallback.id} eventPubkey={parseFallback.pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div onKeyDown={(e) => openNote(e)} className="flex flex-col px-3">
|
||||
<NoteDefaultUser
|
||||
pubkey={parseFallback.pubkey}
|
||||
time={parseFallback.created_at}
|
||||
/>
|
||||
<div className="mt-3 pl-[46px]">
|
||||
<Kind1 content={contentFallback} />
|
||||
<NoteMetadata
|
||||
id={parseFallback.id}
|
||||
eventPubkey={parseFallback.pubkey}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div onClick={(e) => openNote(e)} className="flex flex-col px-3">
|
||||
{data ? (
|
||||
<>
|
||||
<NoteDefaultUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="mt-3 pl-[46px]">
|
||||
{kind1 && <Kind1 content={kind1} />}
|
||||
{kind1063 && <Kind1063 metadata={kind1063} />}
|
||||
<NoteMetadata id={data.id} eventPubkey={data.pubkey} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<NoteSkeleton />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div onKeyDown={(e) => openNote(e)} className="flex flex-col px-3">
|
||||
{data ? (
|
||||
<>
|
||||
<NoteDefaultUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="mt-3 pl-[46px]">
|
||||
{kind1 && <Kind1 content={kind1} />}
|
||||
{kind1063 && <Kind1063 metadata={kind1063} />}
|
||||
<NoteMetadata id={data.id} eventPubkey={data.pubkey} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<NoteSkeleton />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
export function NoteSkeleton() {
|
||||
return (
|
||||
<div className="flex h-min flex-col pb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="relative h-9 w-9 shrink overflow-hidden rounded-md bg-zinc-700" />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="h-3 w-20 rounded-sm bg-zinc-700" />
|
||||
<div className="h-2 w-12 rounded-sm bg-zinc-700" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 animate-pulse pl-[46px]">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="h-4 w-full rounded-sm bg-zinc-700" />
|
||||
<div className="h-4 w-2/3 rounded-sm bg-zinc-700" />
|
||||
<div className="h-4 w-1/2 rounded-sm bg-zinc-700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex h-min flex-col pb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="relative h-9 w-9 shrink overflow-hidden rounded-md bg-zinc-700" />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="h-3 w-20 rounded-sm bg-zinc-700" />
|
||||
<div className="h-2 w-12 rounded-sm bg-zinc-700" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 animate-pulse pl-[46px]">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="h-4 w-full rounded-sm bg-zinc-700" />
|
||||
<div className="h-4 w-2/3 rounded-sm bg-zinc-700" />
|
||||
<div className="h-4 w-1/2 rounded-sm bg-zinc-700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,94 +1,105 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
import { DEFAULT_AVATAR, IMGPROXY_URL } from '@stores/constants';
|
||||
import { DEFAULT_AVATAR, IMGPROXY_URL } from "@stores/constants";
|
||||
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { shortenKey } from '@utils/shortenKey';
|
||||
import { useProfile } from "@utils/hooks/useProfile";
|
||||
import { shortenKey } from "@utils/shortenKey";
|
||||
|
||||
import { Popover, Transition } from '@headlessui/react';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import { Fragment } from 'react';
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { Fragment } from "react";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export function NoteDefaultUser({ pubkey, time }: { pubkey: string; time: number }) {
|
||||
const { user } = useProfile(pubkey);
|
||||
export function NoteDefaultUser({
|
||||
pubkey,
|
||||
time,
|
||||
}: { pubkey: string; time: number }) {
|
||||
const { user } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<Popover className="relative flex items-center gap-2.5">
|
||||
<Popover.Button className="h-9 w-9 shrink-0 overflow-hidden rounded-md bg-zinc-900">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${user?.picture ? user.picture : DEFAULT_AVATAR}`}
|
||||
alt={pubkey}
|
||||
className="h-9 w-9 object-cover"
|
||||
/>
|
||||
</Popover.Button>
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h5 className="text-sm font-semibold leading-none">
|
||||
{user?.display_name || user?.name || <div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700"></div>}
|
||||
</h5>
|
||||
<div className="flex items-baseline gap-1.5 text-sm leading-none text-zinc-500">
|
||||
<span>{user?.nip05 || shortenKey(pubkey)}</span>
|
||||
<span>•</span>
|
||||
<span>{dayjs().to(dayjs.unix(time), true)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute left-0 top-8 z-10 mt-3 w-screen max-w-sm px-4 sm:px-0 lg:max-w-3xl">
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full max-w-xs overflow-hidden rounded-lg border border-zinc-700 bg-zinc-900 shadow-input ring-1 ring-black ring-opacity-5"
|
||||
>
|
||||
<div className="flex items-start gap-2.5 border-b border-zinc-800 px-3 py-3">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:200:200/plain/${user?.picture ? user.picture : DEFAULT_AVATAR}`}
|
||||
alt={pubkey}
|
||||
className="h-14 w-14 shrink-0 rounded-lg object-cover"
|
||||
/>
|
||||
<div className="flex w-full flex-1 flex-col gap-2">
|
||||
<div className="inline-flex w-2/3 flex-col gap-0.5">
|
||||
<h5 className="text-sm font-semibold leading-none">
|
||||
{user?.display_name || user?.name || (
|
||||
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700"></div>
|
||||
)}
|
||||
</h5>
|
||||
<span className="truncate text-sm leading-none text-zinc-500">
|
||||
{user?.nip05 || shortenKey(pubkey)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="line-clamp-3 text-sm leading-tight text-zinc-100">{user?.about}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-3">
|
||||
<a
|
||||
href={`/app/user?pubkey=${pubkey}`}
|
||||
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-800 text-sm font-medium"
|
||||
>
|
||||
View full profile
|
||||
</a>
|
||||
<a
|
||||
href={`/app/chat?pubkey=${pubkey}`}
|
||||
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-800 text-sm font-medium"
|
||||
>
|
||||
Message
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</Popover>
|
||||
);
|
||||
return (
|
||||
<Popover className="relative flex items-center gap-2.5">
|
||||
<Popover.Button className="h-9 w-9 shrink-0 overflow-hidden rounded-md bg-zinc-900">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${
|
||||
user?.picture ? user.picture : DEFAULT_AVATAR
|
||||
}`}
|
||||
alt={pubkey}
|
||||
className="h-9 w-9 object-cover"
|
||||
/>
|
||||
</Popover.Button>
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h5 className="text-sm font-semibold leading-none">
|
||||
{user?.display_name || user?.name || (
|
||||
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700" />
|
||||
)}
|
||||
</h5>
|
||||
<div className="flex items-baseline gap-1.5 text-sm leading-none text-zinc-500">
|
||||
<span>{user?.nip05 || shortenKey(pubkey)}</span>
|
||||
<span>•</span>
|
||||
<span>{dayjs().to(dayjs.unix(time), true)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute left-0 top-8 z-10 mt-3 w-screen max-w-sm px-4 sm:px-0 lg:max-w-3xl">
|
||||
<div
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="w-full max-w-xs overflow-hidden rounded-lg border border-zinc-700 bg-zinc-900 shadow-input ring-1 ring-black ring-opacity-5"
|
||||
>
|
||||
<div className="flex items-start gap-2.5 border-b border-zinc-800 px-3 py-3">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:200:200/plain/${
|
||||
user?.picture ? user.picture : DEFAULT_AVATAR
|
||||
}`}
|
||||
alt={pubkey}
|
||||
className="h-14 w-14 shrink-0 rounded-lg object-cover"
|
||||
/>
|
||||
<div className="flex w-full flex-1 flex-col gap-2">
|
||||
<div className="inline-flex w-2/3 flex-col gap-0.5">
|
||||
<h5 className="text-sm font-semibold leading-none">
|
||||
{user?.display_name || user?.name || (
|
||||
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700" />
|
||||
)}
|
||||
</h5>
|
||||
<span className="truncate text-sm leading-none text-zinc-500">
|
||||
{user?.nip05 || shortenKey(pubkey)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="line-clamp-3 text-sm leading-tight text-zinc-100">
|
||||
{user?.about}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-3">
|
||||
<a
|
||||
href={`/app/user?pubkey=${pubkey}`}
|
||||
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-800 text-sm font-medium"
|
||||
>
|
||||
View full profile
|
||||
</a>
|
||||
<a
|
||||
href={`/app/chat?pubkey=${pubkey}`}
|
||||
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-800 text-sm font-medium"
|
||||
>
|
||||
Message
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,36 +1,43 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
import { DEFAULT_AVATAR, IMGPROXY_URL } from '@stores/constants';
|
||||
import { DEFAULT_AVATAR, IMGPROXY_URL } from "@stores/constants";
|
||||
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { shortenKey } from '@utils/shortenKey';
|
||||
import { useProfile } from "@utils/hooks/useProfile";
|
||||
import { shortenKey } from "@utils/shortenKey";
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export default function NoteReplyUser({ pubkey, time }: { pubkey: string; time: number }) {
|
||||
const { user } = useProfile(pubkey);
|
||||
export default function NoteReplyUser({
|
||||
pubkey,
|
||||
time,
|
||||
}: { pubkey: string; time: number }) {
|
||||
const { user } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<div className="group flex items-start gap-2.5">
|
||||
<div className="relative h-9 w-9 shrink-0 rounded-md">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${user?.picture ? user.picture : DEFAULT_AVATAR}`}
|
||||
alt={pubkey}
|
||||
className="h-9 w-9 rounded-md object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex items-baseline gap-2 text-sm">
|
||||
<span className="font-semibold leading-none text-zinc-200 group-hover:underline">
|
||||
{user?.display_name || user?.name || shortenKey(pubkey)}
|
||||
</span>
|
||||
<span className="leading-none text-zinc-500">·</span>
|
||||
<span className="leading-none text-zinc-500">{dayjs().to(dayjs.unix(time), true)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="group flex items-start gap-2.5">
|
||||
<div className="relative h-9 w-9 shrink-0 rounded-md">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${
|
||||
user?.picture ? user.picture : DEFAULT_AVATAR
|
||||
}`}
|
||||
alt={pubkey}
|
||||
className="h-9 w-9 rounded-md object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex items-baseline gap-2 text-sm">
|
||||
<span className="font-semibold leading-none text-zinc-200 group-hover:underline">
|
||||
{user?.display_name || user?.name || shortenKey(pubkey)}
|
||||
</span>
|
||||
<span className="leading-none text-zinc-500">·</span>
|
||||
<span className="leading-none text-zinc-500">
|
||||
{dayjs().to(dayjs.unix(time), true)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,93 +1,106 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
import { DEFAULT_AVATAR, IMGPROXY_URL } from '@stores/constants';
|
||||
import { DEFAULT_AVATAR, IMGPROXY_URL } from "@stores/constants";
|
||||
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { shortenKey } from '@utils/shortenKey';
|
||||
import { useProfile } from "@utils/hooks/useProfile";
|
||||
import { shortenKey } from "@utils/shortenKey";
|
||||
|
||||
import { Popover, Transition } from '@headlessui/react';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import { Fragment } from 'react';
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { Fragment } from "react";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export function NoteRepostUser({ pubkey, time }: { pubkey: string; time: number }) {
|
||||
const { user } = useProfile(pubkey);
|
||||
export function NoteRepostUser({
|
||||
pubkey,
|
||||
time,
|
||||
}: { pubkey: string; time: number }) {
|
||||
const { user } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<Popover className="relative flex items-center gap-2.5">
|
||||
<Popover.Button className="h-9 w-9 shrink-0 overflow-hidden rounded-md bg-zinc-900">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${user?.picture ? user.picture : DEFAULT_AVATAR}`}
|
||||
alt={pubkey}
|
||||
className="h-9 w-9 rounded-md object-cover"
|
||||
/>
|
||||
</Popover.Button>
|
||||
<div className="flex items-baseline gap-1.5 text-sm">
|
||||
<h5 className="font-semibold leading-tight group-hover:underline">
|
||||
{user?.display_name || user?.name || <div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700"></div>}
|
||||
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-transparent">
|
||||
{' '}
|
||||
reposted
|
||||
</span>
|
||||
</h5>
|
||||
<span className="leading-tight text-zinc-500">·</span>
|
||||
<span className="text-zinc-500">{dayjs().to(dayjs.unix(time), true)}</span>
|
||||
</div>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute left-0 top-8 z-10 mt-3 w-screen max-w-sm px-4 sm:px-0 lg:max-w-3xl">
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full max-w-xs overflow-hidden rounded-lg border border-zinc-700 bg-zinc-900 shadow-input ring-1 ring-black ring-opacity-5"
|
||||
>
|
||||
<div className="flex items-start gap-2.5 border-b border-zinc-800 px-3 py-3">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:200:200/plain/${user?.picture ? user.picture : DEFAULT_AVATAR}`}
|
||||
alt={pubkey}
|
||||
className="h-14 w-14 shrink-0 rounded-lg object-cover"
|
||||
/>
|
||||
<div className="flex w-full flex-1 flex-col gap-2">
|
||||
<div className="inline-flex w-2/3 flex-col gap-0.5">
|
||||
<h5 className="text-sm font-semibold leading-none">
|
||||
{user?.display_name || user?.name || (
|
||||
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700"></div>
|
||||
)}
|
||||
</h5>
|
||||
<span className="truncate text-sm leading-none text-zinc-500">
|
||||
{user?.nip05 || shortenKey(pubkey)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="line-clamp-3 text-sm leading-tight text-zinc-100">{user?.about}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-3">
|
||||
<a
|
||||
href={`/app/user?pubkey=${pubkey}`}
|
||||
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-800 text-sm font-medium"
|
||||
>
|
||||
View full profile
|
||||
</a>
|
||||
<a
|
||||
href={`/app/chat?pubkey=${pubkey}`}
|
||||
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-800 text-sm font-medium"
|
||||
>
|
||||
Message
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</Popover>
|
||||
);
|
||||
return (
|
||||
<Popover className="relative flex items-center gap-2.5">
|
||||
<Popover.Button className="h-9 w-9 shrink-0 overflow-hidden rounded-md bg-zinc-900">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${
|
||||
user?.picture ? user.picture : DEFAULT_AVATAR
|
||||
}`}
|
||||
alt={pubkey}
|
||||
className="h-9 w-9 rounded-md object-cover"
|
||||
/>
|
||||
</Popover.Button>
|
||||
<div className="flex items-baseline gap-1.5 text-sm">
|
||||
<h5 className="font-semibold leading-tight group-hover:underline">
|
||||
{user?.display_name || user?.name || (
|
||||
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700" />
|
||||
)}
|
||||
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-transparent">
|
||||
{" "}
|
||||
reposted
|
||||
</span>
|
||||
</h5>
|
||||
<span className="leading-tight text-zinc-500">·</span>
|
||||
<span className="text-zinc-500">
|
||||
{dayjs().to(dayjs.unix(time), true)}
|
||||
</span>
|
||||
</div>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute left-0 top-8 z-10 mt-3 w-screen max-w-sm px-4 sm:px-0 lg:max-w-3xl">
|
||||
<div
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="w-full max-w-xs overflow-hidden rounded-lg border border-zinc-700 bg-zinc-900 shadow-input ring-1 ring-black ring-opacity-5"
|
||||
>
|
||||
<div className="flex items-start gap-2.5 border-b border-zinc-800 px-3 py-3">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:200:200/plain/${
|
||||
user?.picture ? user.picture : DEFAULT_AVATAR
|
||||
}`}
|
||||
alt={pubkey}
|
||||
className="h-14 w-14 shrink-0 rounded-lg object-cover"
|
||||
/>
|
||||
<div className="flex w-full flex-1 flex-col gap-2">
|
||||
<div className="inline-flex w-2/3 flex-col gap-0.5">
|
||||
<h5 className="text-sm font-semibold leading-none">
|
||||
{user?.display_name || user?.name || (
|
||||
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700" />
|
||||
)}
|
||||
</h5>
|
||||
<span className="truncate text-sm leading-none text-zinc-500">
|
||||
{user?.nip05 || shortenKey(pubkey)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="line-clamp-3 text-sm leading-tight text-zinc-100">
|
||||
{user?.about}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-3">
|
||||
<a
|
||||
href={`/app/user?pubkey=${pubkey}`}
|
||||
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-800 text-sm font-medium"
|
||||
>
|
||||
View full profile
|
||||
</a>
|
||||
<a
|
||||
href={`/app/chat?pubkey=${pubkey}`}
|
||||
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-800 text-sm font-medium"
|
||||
>
|
||||
Message
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import { navigate } from 'vite-plugin-ssr/client/router';
|
||||
import { navigate } from "vite-plugin-ssr/client/router";
|
||||
|
||||
export function NoteWrapper({
|
||||
children,
|
||||
href,
|
||||
className,
|
||||
children,
|
||||
href,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
href: string;
|
||||
className: string;
|
||||
children: React.ReactNode;
|
||||
href: string;
|
||||
className: string;
|
||||
}) {
|
||||
const openThread = (event: any, href: string) => {
|
||||
const selection = window.getSelection();
|
||||
if (selection.toString().length === 0) {
|
||||
navigate(href, { keepScrollPosition: true });
|
||||
} else {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
const openThread = (event: any, href: string) => {
|
||||
const selection = window.getSelection();
|
||||
if (selection.toString().length === 0) {
|
||||
navigate(href, { keepScrollPosition: true });
|
||||
} else {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div onClick={(event) => openThread(event, href)} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div onKeyDown={(event) => openThread(event, href)} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
import AppHeader from '@shared/appHeader';
|
||||
import MultiAccounts from '@shared/multiAccounts';
|
||||
import Navigation from '@shared/navigation';
|
||||
import AppHeader from "@shared/appHeader";
|
||||
import MultiAccounts from "@shared/multiAccounts";
|
||||
import Navigation from "@shared/navigation";
|
||||
|
||||
export function LayoutNewsfeed({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white">
|
||||
<div className="flex h-screen w-full flex-col">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="relative h-9 shrink-0 border-b border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
|
||||
>
|
||||
<AppHeader />
|
||||
</div>
|
||||
<div className="relative flex min-h-0 w-full flex-1">
|
||||
<div className="relative w-[68px] shrink-0 border-r border-zinc-900">
|
||||
<MultiAccounts />
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-4 xl:grid-cols-5">
|
||||
<div className="scrollbar-hide col-span-1 overflow-y-auto overflow-x-hidden border-r border-zinc-900">
|
||||
<Navigation />
|
||||
</div>
|
||||
<div className="col-span-3 overflow-hidden xl:col-span-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white">
|
||||
<div className="flex h-screen w-full flex-col">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="relative h-9 shrink-0 border-b border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
|
||||
>
|
||||
<AppHeader />
|
||||
</div>
|
||||
<div className="relative flex min-h-0 w-full flex-1">
|
||||
<div className="relative w-[68px] shrink-0 border-r border-zinc-900">
|
||||
<MultiAccounts />
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-4 xl:grid-cols-5">
|
||||
<div className="scrollbar-hide col-span-1 overflow-y-auto overflow-x-hidden border-r border-zinc-900">
|
||||
<Navigation />
|
||||
</div>
|
||||
<div className="col-span-3 overflow-hidden xl:col-span-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,86 +1,89 @@
|
||||
import { Kind1 } from '@app/note/components/kind1';
|
||||
import NoteMetadata from '@app/note/components/metadata';
|
||||
import RepliesList from '@app/note/components/replies/list';
|
||||
import { NoteDefaultUser } from '@app/note/components/user/default';
|
||||
import { Kind1 } from "@app/note/components/kind1";
|
||||
import NoteMetadata from "@app/note/components/metadata";
|
||||
import RepliesList from "@app/note/components/replies/list";
|
||||
import { NoteDefaultUser } from "@app/note/components/user/default";
|
||||
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import { READONLY_RELAYS } from '@stores/constants';
|
||||
import { READONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { usePageContext } from '@utils/hooks/usePageContext';
|
||||
import { noteParser } from '@utils/parser';
|
||||
import { usePageContext } from "@utils/hooks/usePageContext";
|
||||
import { noteParser } from "@utils/parser";
|
||||
|
||||
import { useContext } from 'react';
|
||||
import useSWRSubscription from 'swr/subscription';
|
||||
import { useContext } from "react";
|
||||
import useSWRSubscription from "swr/subscription";
|
||||
|
||||
export function Page() {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const pageContext = usePageContext();
|
||||
const searchParams: any = pageContext.urlParsed.search;
|
||||
const pool: any = useContext(RelayContext);
|
||||
const pageContext = usePageContext();
|
||||
const searchParams: any = pageContext.urlParsed.search;
|
||||
|
||||
const noteID = searchParams.id;
|
||||
const noteID = searchParams.id;
|
||||
|
||||
const { data, error } = useSWRSubscription(noteID ? ['note', noteID] : null, ([, key], { next }) => {
|
||||
// subscribe to note
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
ids: [key],
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
next(null, event);
|
||||
}
|
||||
);
|
||||
const { data, error } = useSWRSubscription(
|
||||
noteID ? ["note", noteID] : null,
|
||||
([, key], { next }) => {
|
||||
// subscribe to note
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
ids: [key],
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
next(null, event);
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const content = !error && data ? noteParser(data) : null;
|
||||
const content = !error && data ? noteParser(data) : null;
|
||||
|
||||
return (
|
||||
<div className="scrollbar-hide h-full w-full overflow-y-auto">
|
||||
<div className="p-3">
|
||||
<div className="relative w-full rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
|
||||
{!data || error ? (
|
||||
<div className="animated-pulse p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-zinc-700" />
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="h-4 w-16 rounded bg-zinc-700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="h-16 w-full rounded bg-zinc-700" />
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="h-4 w-12 rounded bg-zinc-700" />
|
||||
<div className="h-4 w-12 rounded bg-zinc-700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative z-10 flex flex-col">
|
||||
<div className="px-3 pt-3">
|
||||
<NoteDefaultUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="mt-3">
|
||||
<Kind1 content={content} />
|
||||
<NoteMetadata id={noteID} eventPubkey={data.pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<RepliesList id={noteID} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="scrollbar-hide h-full w-full overflow-y-auto">
|
||||
<div className="p-3">
|
||||
<div className="relative w-full rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
|
||||
{!data || error ? (
|
||||
<div className="animated-pulse p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-zinc-700" />
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="h-4 w-16 rounded bg-zinc-700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="h-16 w-full rounded bg-zinc-700" />
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="h-4 w-12 rounded bg-zinc-700" />
|
||||
<div className="h-4 w-12 rounded bg-zinc-700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative z-10 flex flex-col">
|
||||
<div className="px-3 pt-3">
|
||||
<NoteDefaultUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="mt-3">
|
||||
<Kind1 content={content} />
|
||||
<NoteMetadata id={noteID} eventPubkey={data.pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<RepliesList id={noteID} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export function Page() {
|
||||
return (
|
||||
<div>
|
||||
<p>Space</p>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<p>Space</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export function Page() {
|
||||
return (
|
||||
<div>
|
||||
<p>MySpace</p>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<p>MySpace</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { LayoutNewsfeed as Layout } from './layout';
|
||||
export { LayoutNewsfeed as Layout } from "./layout";
|
||||
|
||||
@@ -1,45 +1,58 @@
|
||||
import { CreateViewModal } from '@app/today/components/views/createModal';
|
||||
import { CreateViewModal } from "@app/today/components/views/createModal";
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
<div className="flex w-full gap-4">
|
||||
<button className="from-zinc-90 inline-flex h-11 items-center overflow-hidden border-b border-fuchsia-500 hover:bg-zinc-900">
|
||||
<span className="px-2 text-sm font-semibold text-zinc-300">Following</span>
|
||||
</button>
|
||||
<div className="flex h-11 items-center -space-x-1 overflow-hidden">
|
||||
<img className="inline-block h-6 w-6 rounded ring-2 ring-zinc-950" src="https://133332.xyz/p.jpg" alt="" />
|
||||
<img
|
||||
className="inline-block h-6 w-6 rounded ring-2 ring-zinc-950"
|
||||
src="https://void.cat/d/3Bp6jSHURFNQ9u3pK8nwtq.webp"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
className="inline-block h-6 w-6 rounded ring-2 ring-zinc-950"
|
||||
src="https://void.cat/d/8zE9T8a39YfUVjrLM4xcpE.webp"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
className="ring-zinc-95 ring-20 inline-block h-6 w-6 rounded"
|
||||
src="https://nostr.build/i/p/nostr.build_0e412058980ed2ac4adf3de639304c9e970e2745ba9ca19c75f984f4f6da4971.jpeg"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
className="ring-zinc-95 ring-20 inline-block h-6 w-6 rounded"
|
||||
src="https://davidcoen.it/wp-content/uploads/2020/11/7004972-taglio.jpg"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-11 items-center overflow-hidden">
|
||||
<img
|
||||
className="ring-zinc-95 ring-20 inline-block h-6 w-6 rounded"
|
||||
src="https://void.cat/d/KvAEMvYNmy1rfCH6a7HZzh.webp"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-11 items-center overflow-hidden">
|
||||
<img className="ring-zinc-95 ring-20 inline-block h-6 w-6 rounded" src="http://nostr.build/i/6369.jpg" alt="" />
|
||||
</div>
|
||||
<CreateViewModal />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex w-full gap-4">
|
||||
<button
|
||||
type="button"
|
||||
className="from-zinc-90 inline-flex h-11 items-center overflow-hidden border-b border-fuchsia-500 hover:bg-zinc-900"
|
||||
>
|
||||
<span className="px-2 text-sm font-semibold text-zinc-300">
|
||||
Following
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex h-11 items-center -space-x-1 overflow-hidden">
|
||||
<img
|
||||
className="inline-block h-6 w-6 rounded ring-2 ring-zinc-950"
|
||||
src="https://133332.xyz/p.jpg"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
className="inline-block h-6 w-6 rounded ring-2 ring-zinc-950"
|
||||
src="https://void.cat/d/3Bp6jSHURFNQ9u3pK8nwtq.webp"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
className="inline-block h-6 w-6 rounded ring-2 ring-zinc-950"
|
||||
src="https://void.cat/d/8zE9T8a39YfUVjrLM4xcpE.webp"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
className="ring-zinc-95 ring-20 inline-block h-6 w-6 rounded"
|
||||
src="https://nostr.build/i/p/nostr.build_0e412058980ed2ac4adf3de639304c9e970e2745ba9ca19c75f984f4f6da4971.jpeg"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
className="ring-zinc-95 ring-20 inline-block h-6 w-6 rounded"
|
||||
src="https://davidcoen.it/wp-content/uploads/2020/11/7004972-taglio.jpg"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-11 items-center overflow-hidden">
|
||||
<img
|
||||
className="ring-zinc-95 ring-20 inline-block h-6 w-6 rounded"
|
||||
src="https://void.cat/d/KvAEMvYNmy1rfCH6a7HZzh.webp"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-11 items-center overflow-hidden">
|
||||
<img
|
||||
className="ring-zinc-95 ring-20 inline-block h-6 w-6 rounded"
|
||||
src="http://nostr.build/i/6369.jpg"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<CreateViewModal />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,86 +1,89 @@
|
||||
import CancelIcon from '@icons/cancel';
|
||||
import PlusIcon from '@icons/plus';
|
||||
import CancelIcon from "@icons/cancel";
|
||||
import PlusIcon from "@icons/plus";
|
||||
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { Fragment, useState } from 'react';
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { Fragment, useState } from "react";
|
||||
|
||||
export function CreateViewModal() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const openModal = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
const openModal = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openModal}
|
||||
className="inline-flex h-11 items-center overflow-hidden border-b border-transparent hover:bg-zinc-900"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1 px-2 text-sm font-medium text-zinc-500">
|
||||
<PlusIcon width={14} height={14} />
|
||||
View
|
||||
</span>
|
||||
</button>
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={closeModal}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
|
||||
</Transition.Child>
|
||||
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900">
|
||||
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-xl font-semibold leading-none text-transparent"
|
||||
>
|
||||
Create a view
|
||||
</Dialog.Title>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
autoFocus={false}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
|
||||
>
|
||||
<CancelIcon width={16} height={16} className="text-zinc-400" />
|
||||
</button>
|
||||
</div>
|
||||
<Dialog.Description className="text-sm leading-tight text-zinc-400">
|
||||
View is specific feature help you pin who you want to see in your feed. You can add maximum 5
|
||||
people in a view.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto pb-5 pt-3"></div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openModal}
|
||||
className="inline-flex h-11 items-center overflow-hidden border-b border-transparent hover:bg-zinc-900"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1 px-2 text-sm font-medium text-zinc-500">
|
||||
<PlusIcon width={14} height={14} />
|
||||
View
|
||||
</span>
|
||||
</button>
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={closeModal}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
|
||||
</Transition.Child>
|
||||
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900">
|
||||
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-xl font-semibold leading-none text-transparent"
|
||||
>
|
||||
Create a view
|
||||
</Dialog.Title>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
|
||||
>
|
||||
<CancelIcon
|
||||
width={16}
|
||||
height={16}
|
||||
className="text-zinc-400"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<Dialog.Description className="text-sm leading-tight text-zinc-400">
|
||||
View is specific feature help you pin who you want to see
|
||||
in your feed. You can add maximum 5 people in a view.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto pb-5 pt-3" />
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
import AppHeader from '@shared/appHeader';
|
||||
import MultiAccounts from '@shared/multiAccounts';
|
||||
import Navigation from '@shared/navigation';
|
||||
import AppHeader from "@shared/appHeader";
|
||||
import MultiAccounts from "@shared/multiAccounts";
|
||||
import Navigation from "@shared/navigation";
|
||||
|
||||
export function LayoutNewsfeed({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white">
|
||||
<div className="flex h-screen w-full flex-col">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="relative h-9 shrink-0 border-b border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
|
||||
>
|
||||
<AppHeader />
|
||||
</div>
|
||||
<div className="relative flex min-h-0 w-full flex-1">
|
||||
<div className="relative w-[68px] shrink-0 border-r border-zinc-900">
|
||||
<MultiAccounts />
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-4 xl:grid-cols-5">
|
||||
<div className="scrollbar-hide col-span-1 overflow-y-auto overflow-x-hidden border-r border-zinc-900">
|
||||
<Navigation />
|
||||
</div>
|
||||
<div className="col-span-3 overflow-hidden xl:col-span-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white">
|
||||
<div className="flex h-screen w-full flex-col">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="relative h-9 shrink-0 border-b border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
|
||||
>
|
||||
<AppHeader />
|
||||
</div>
|
||||
<div className="relative flex min-h-0 w-full flex-1">
|
||||
<div className="relative w-[68px] shrink-0 border-r border-zinc-900">
|
||||
<MultiAccounts />
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-4 xl:grid-cols-5">
|
||||
<div className="scrollbar-hide col-span-1 overflow-y-auto overflow-x-hidden border-r border-zinc-900">
|
||||
<Navigation />
|
||||
</div>
|
||||
<div className="col-span-3 overflow-hidden xl:col-span-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,113 +1,136 @@
|
||||
import { NoteBase } from '@app/note/components/base';
|
||||
import { NoteQuoteRepost } from '@app/note/components/quoteRepost';
|
||||
import { NoteSkeleton } from '@app/note/components/skeleton';
|
||||
import { Header } from '@app/today/components/header';
|
||||
import { NoteBase } from "@app/note/components/base";
|
||||
import { NoteQuoteRepost } from "@app/note/components/quoteRepost";
|
||||
import { NoteSkeleton } from "@app/note/components/skeleton";
|
||||
import { Header } from "@app/today/components/header";
|
||||
|
||||
import { getNotes } from '@utils/storage';
|
||||
import { getNotes } from "@utils/storage";
|
||||
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const ITEM_PER_PAGE = 10;
|
||||
const TIME = Math.floor(Date.now() / 1000);
|
||||
|
||||
export function Page() {
|
||||
const { status, error, data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage }: any = useInfiniteQuery({
|
||||
queryKey: ['following'],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
return await getNotes(TIME, ITEM_PER_PAGE, pageParam);
|
||||
},
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
});
|
||||
const {
|
||||
status,
|
||||
error,
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
}: any = useInfiniteQuery({
|
||||
queryKey: ["following"],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
return await getNotes(TIME, ITEM_PER_PAGE, pageParam);
|
||||
},
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
});
|
||||
|
||||
const allRows = data ? data.pages.flatMap((d: { data: any }) => d.data) : [];
|
||||
const parentRef = useRef();
|
||||
const allRows = data ? data.pages.flatMap((d: { data: any }) => d.data) : [];
|
||||
const parentRef = useRef();
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: hasNextPage ? allRows.length + 1 : allRows.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 400,
|
||||
overscan: 2,
|
||||
});
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: hasNextPage ? allRows.length + 1 : allRows.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 400,
|
||||
overscan: 2,
|
||||
});
|
||||
|
||||
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
|
||||
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
|
||||
|
||||
useEffect(() => {
|
||||
const [lastItem] = [...rowVirtualizer.getVirtualItems()].reverse();
|
||||
useEffect(() => {
|
||||
const [lastItem] = [...rowVirtualizer.getVirtualItems()].reverse();
|
||||
|
||||
if (!lastItem) {
|
||||
return;
|
||||
}
|
||||
if (!lastItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastItem.index >= allRows.length - 1 && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fetchNextPage, allRows.length, rowVirtualizer.getVirtualItems()]);
|
||||
if (
|
||||
lastItem.index >= allRows.length - 1 &&
|
||||
hasNextPage &&
|
||||
!isFetchingNextPage
|
||||
) {
|
||||
fetchNextPage();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fetchNextPage, allRows.length, rowVirtualizer.getVirtualItems()]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="scrollbar-hide flex h-full flex-col justify-between gap-1.5 overflow-y-auto"
|
||||
style={{ contain: 'strict' }}
|
||||
>
|
||||
<div className="flex h-11 w-full shrink-0 items-center justify-between border-b border-zinc-900 px-3">
|
||||
<Header />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{status === 'loading' ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-3 shadow-input shadow-black/20">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
) : status === 'error' ? (
|
||||
<div>{error.message}</div>
|
||||
) : (
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{
|
||||
transform: `translateY(${itemsVirtualizer[0].start - rowVirtualizer.options.scrollMargin}px)`,
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const note = allRows[virtualRow.index];
|
||||
if (note) {
|
||||
if (note.kind === 1) {
|
||||
return (
|
||||
<div key={virtualRow.index} data-index={virtualRow.index} ref={rowVirtualizer.measureElement}>
|
||||
<NoteBase key={note.event_id} event={note} />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div key={virtualRow.index} data-index={virtualRow.index} ref={rowVirtualizer.measureElement}>
|
||||
<NoteQuoteRepost key={note.event_id} event={note} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{isFetching && !isFetchingNextPage ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-3 shadow-input shadow-black/20">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="scrollbar-hide flex h-full flex-col justify-between gap-1.5 overflow-y-auto"
|
||||
style={{ contain: "strict" }}
|
||||
>
|
||||
<div className="flex h-11 w-full shrink-0 items-center justify-between border-b border-zinc-900 px-3">
|
||||
<Header />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{status === "loading" ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-3 shadow-input shadow-black/20">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
) : status === "error" ? (
|
||||
<div>{error.message}</div>
|
||||
) : (
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{
|
||||
transform: `translateY(${
|
||||
itemsVirtualizer[0].start -
|
||||
rowVirtualizer.options.scrollMargin
|
||||
}px)`,
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const note = allRows[virtualRow.index];
|
||||
if (note) {
|
||||
if (note.kind === 1) {
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.index}
|
||||
data-index={virtualRow.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
>
|
||||
<NoteBase key={note.event_id} event={note} />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.index}
|
||||
data-index={virtualRow.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
>
|
||||
<NoteQuoteRepost key={note.event_id} event={note} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{isFetching && !isFetchingNextPage ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-3 shadow-input shadow-black/20">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { Root, createRoot, hydrateRoot } from 'react-dom/client';
|
||||
import 'vidstack/styles/defaults.css';
|
||||
import { StrictMode } from "react";
|
||||
import { Root, createRoot, hydrateRoot } from "react-dom/client";
|
||||
import "vidstack/styles/defaults.css";
|
||||
|
||||
import './index.css';
|
||||
import { Shell } from './shell';
|
||||
import { PageContextClient } from './types';
|
||||
import "./index.css";
|
||||
import { Shell } from "./shell";
|
||||
import { PageContextClient } from "./types";
|
||||
|
||||
export const clientRouting = true;
|
||||
export const hydrationCanBeAborted = true;
|
||||
@@ -12,27 +12,30 @@ export const hydrationCanBeAborted = true;
|
||||
let root: Root;
|
||||
|
||||
export async function render(pageContext: PageContextClient) {
|
||||
const { Page, pageProps } = pageContext;
|
||||
const { Page, pageProps } = pageContext;
|
||||
|
||||
if (!Page) throw new Error('Client-side render() hook expects pageContext.Page to be defined');
|
||||
if (!Page)
|
||||
throw new Error(
|
||||
"Client-side render() hook expects pageContext.Page to be defined",
|
||||
);
|
||||
|
||||
const page = (
|
||||
<StrictMode>
|
||||
<Shell pageContext={pageContext}>
|
||||
<Page {...pageProps} />
|
||||
</Shell>
|
||||
</StrictMode>
|
||||
);
|
||||
const page = (
|
||||
<StrictMode>
|
||||
<Shell pageContext={pageContext}>
|
||||
<Page {...pageProps} />
|
||||
</Shell>
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
const container = document.getElementById('app');
|
||||
// SPA
|
||||
if (container.innerHTML === '' || !pageContext.isHydration) {
|
||||
if (!root) {
|
||||
root = createRoot(container);
|
||||
}
|
||||
root.render(page);
|
||||
// SSR
|
||||
} else {
|
||||
root = hydrateRoot(container, page);
|
||||
}
|
||||
const container = document.getElementById("app");
|
||||
// SPA
|
||||
if (container.innerHTML === "" || !pageContext.isHydration) {
|
||||
if (!root) {
|
||||
root = createRoot(container);
|
||||
}
|
||||
root.render(page);
|
||||
// SSR
|
||||
} else {
|
||||
root = hydrateRoot(container, page);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,36 @@
|
||||
import { StrictMode } from 'react';
|
||||
import ReactDOMServer from 'react-dom/server';
|
||||
import { dangerouslySkipEscape, escapeInject } from 'vite-plugin-ssr/server';
|
||||
import { StrictMode } from "react";
|
||||
import ReactDOMServer from "react-dom/server";
|
||||
import { dangerouslySkipEscape, escapeInject } from "vite-plugin-ssr/server";
|
||||
|
||||
import { Shell } from './shell';
|
||||
import { PageContextServer } from './types';
|
||||
import { Shell } from "./shell";
|
||||
import { PageContextServer } from "./types";
|
||||
|
||||
export const passToClient = ['pageProps'];
|
||||
export const passToClient = ["pageProps"];
|
||||
|
||||
export function render(pageContext: PageContextServer) {
|
||||
let pageHtml: string;
|
||||
let pageHtml: string;
|
||||
|
||||
if (!pageContext.Page) {
|
||||
// SPA
|
||||
pageHtml = '';
|
||||
} else {
|
||||
// SSR / HTML-only
|
||||
const { Page, pageProps } = pageContext;
|
||||
if (!Page) throw new Error('My render() hook expects pageContext.Page to be defined');
|
||||
if (!pageContext.Page) {
|
||||
// SPA
|
||||
pageHtml = "";
|
||||
} else {
|
||||
// SSR / HTML-only
|
||||
const { Page, pageProps } = pageContext;
|
||||
if (!Page)
|
||||
throw new Error(
|
||||
"My render() hook expects pageContext.Page to be defined",
|
||||
);
|
||||
|
||||
pageHtml = ReactDOMServer.renderToString(
|
||||
<StrictMode>
|
||||
<Shell pageContext={pageContext}>
|
||||
<Page {...pageProps} />
|
||||
</Shell>
|
||||
</StrictMode>
|
||||
);
|
||||
}
|
||||
pageHtml = ReactDOMServer.renderToString(
|
||||
<StrictMode>
|
||||
<Shell pageContext={pageContext}>
|
||||
<Page {...pageProps} />
|
||||
</Shell>
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
|
||||
return escapeInject`<!DOCTYPE html>
|
||||
return escapeInject`<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
export function Page({ is404 }: { is404: boolean }) {
|
||||
if (is404) {
|
||||
return (
|
||||
<>
|
||||
<h1>404 Page Not Found</h1>
|
||||
<p>This page could not be found.</p>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<h1>500 Internal Server Error</h1>
|
||||
<p>Something went wrong.</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (is404) {
|
||||
return (
|
||||
<>
|
||||
<h1>404 Page Not Found</h1>
|
||||
<p>This page could not be found.</p>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<h1>500 Internal Server Error</h1>
|
||||
<p>Something went wrong.</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
export function LayoutDefault({ children }: { children: React.ReactNode }) {
|
||||
return <div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-white">{children}</div>;
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-white">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
import { RelayProvider } from '@shared/relayProvider';
|
||||
import { RelayProvider } from "@shared/relayProvider";
|
||||
|
||||
import { PageContextProvider } from '@utils/hooks/usePageContext';
|
||||
import { PageContextProvider } from "@utils/hooks/usePageContext";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { LayoutDefault } from './layoutDefault';
|
||||
import { PageContext } from './types';
|
||||
import { LayoutDefault } from "./layoutDefault";
|
||||
import { PageContext } from "./types";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export function Shell({ children, pageContext }: { children: React.ReactNode; pageContext: PageContext }) {
|
||||
const Layout = (pageContext.exports.Layout as React.ElementType) || (LayoutDefault as React.ElementType);
|
||||
export function Shell({
|
||||
children,
|
||||
pageContext,
|
||||
}: { children: React.ReactNode; pageContext: PageContext }) {
|
||||
const Layout =
|
||||
(pageContext.exports.Layout as React.ElementType) ||
|
||||
(LayoutDefault as React.ElementType);
|
||||
|
||||
return (
|
||||
<PageContextProvider pageContext={pageContext}>
|
||||
<RelayProvider>
|
||||
<Layout>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</Layout>
|
||||
</RelayProvider>
|
||||
</PageContextProvider>
|
||||
);
|
||||
return (
|
||||
<PageContextProvider pageContext={pageContext}>
|
||||
<RelayProvider>
|
||||
<Layout>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</Layout>
|
||||
</RelayProvider>
|
||||
</PageContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type {
|
||||
PageContextBuiltIn,
|
||||
/*
|
||||
PageContextBuiltIn,
|
||||
/*
|
||||
// When using Client Routing https://vite-plugin-ssr.com/clientRouting
|
||||
PageContextBuiltInClientWithClientRouting as PageContextBuiltInClient
|
||||
/*/
|
||||
// When using Server Routing
|
||||
PageContextBuiltInClientWithServerRouting as PageContextBuiltInClient, //*/
|
||||
} from 'vite-plugin-ssr/types';
|
||||
// When using Server Routing
|
||||
PageContextBuiltInClientWithServerRouting as PageContextBuiltInClient, //*/
|
||||
} from "vite-plugin-ssr/types";
|
||||
|
||||
export type { PageContextServer };
|
||||
export type { PageContextClient };
|
||||
@@ -17,16 +17,16 @@ type Page = (pageProps: PageProps) => React.ReactElement;
|
||||
type PageProps = Record<string, never>;
|
||||
|
||||
export type PageContextCustom = {
|
||||
Page: Page;
|
||||
pageProps?: PageProps;
|
||||
redirectTo?: string;
|
||||
urlPathname: string;
|
||||
exports: {
|
||||
documentProps?: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
};
|
||||
Page: Page;
|
||||
pageProps?: PageProps;
|
||||
redirectTo?: string;
|
||||
urlPathname: string;
|
||||
exports: {
|
||||
documentProps?: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type PageContextServer = PageContextBuiltIn<Page> & PageContextCustom;
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||
import { DEFAULT_AVATAR } from "@stores/constants";
|
||||
|
||||
export default function ActiveAccount({ user }: { user: any }) {
|
||||
const userData = JSON.parse(user.metadata);
|
||||
const userData = JSON.parse(user.metadata);
|
||||
|
||||
return (
|
||||
<button className="relative h-10 w-10 overflow-hidden rounded-lg">
|
||||
<Image
|
||||
src={userData.picture || DEFAULT_AVATAR}
|
||||
alt="user's avatar"
|
||||
loading="auto"
|
||||
className="h-10 w-10 object-cover"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="relative h-10 w-10 overflow-hidden rounded-lg"
|
||||
>
|
||||
<Image
|
||||
src={userData.picture || DEFAULT_AVATAR}
|
||||
alt="user's avatar"
|
||||
loading="auto"
|
||||
className="h-10 w-10 object-cover"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||
import { DEFAULT_AVATAR } from "@stores/constants";
|
||||
|
||||
export default function InactiveAccount({ user }: { user: any }) {
|
||||
const userData = JSON.parse(user.metadata);
|
||||
const userData = JSON.parse(user.metadata);
|
||||
|
||||
return (
|
||||
<div className="relative h-10 w-10 shrink rounded-lg">
|
||||
<Image
|
||||
src={userData.picture || DEFAULT_AVATAR}
|
||||
alt="user's avatar"
|
||||
className="h-10 w-10 rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="relative h-10 w-10 shrink rounded-lg">
|
||||
<Image
|
||||
src={userData.picture || DEFAULT_AVATAR}
|
||||
alt="user's avatar"
|
||||
className="h-10 w-10 rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
import { usePageContext } from '@utils/hooks/usePageContext';
|
||||
import { usePageContext } from "@utils/hooks/usePageContext";
|
||||
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export default function ActiveLink({
|
||||
href,
|
||||
className,
|
||||
activeClassName,
|
||||
children,
|
||||
href,
|
||||
className,
|
||||
activeClassName,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
className: string;
|
||||
activeClassName: string;
|
||||
children: React.ReactNode;
|
||||
href: string;
|
||||
className: string;
|
||||
activeClassName: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const pageContext = usePageContext();
|
||||
const pathName = pageContext.urlPathname;
|
||||
const pageContext = usePageContext();
|
||||
const pathName = pageContext.urlPathname;
|
||||
|
||||
return (
|
||||
<a href={href} className={twMerge(className, href === pathName ? activeClassName : '')}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={twMerge(className, href === pathName ? activeClassName : "")}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,55 +1,75 @@
|
||||
import ArrowLeftIcon from '@icons/arrowLeft';
|
||||
import ArrowRightIcon from '@icons/arrowRight';
|
||||
import RefreshIcon from '@icons/refresh';
|
||||
import ArrowLeftIcon from "@icons/arrowLeft";
|
||||
import ArrowRightIcon from "@icons/arrowRight";
|
||||
import RefreshIcon from "@icons/refresh";
|
||||
|
||||
export default function AppHeader() {
|
||||
const goBack = () => {
|
||||
window.history.back();
|
||||
};
|
||||
const goBack = () => {
|
||||
window.history.back();
|
||||
};
|
||||
|
||||
const goForward = () => {
|
||||
window.history.forward();
|
||||
};
|
||||
const goForward = () => {
|
||||
window.history.forward();
|
||||
};
|
||||
|
||||
const reload = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
const reload = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-tauri-drag-region className="flex h-full w-full flex-1 items-center px-2">
|
||||
<div data-tauri-drag-region className="flex w-full items-center justify-center gap-2">
|
||||
<div className="flex h-full items-center gap-2">
|
||||
<button
|
||||
onClick={() => goBack()}
|
||||
className="group inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
|
||||
>
|
||||
<ArrowLeftIcon width={14} height={14} className="text-zinc-500 group-hover:text-zinc-300" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => goForward()}
|
||||
className="group inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
|
||||
>
|
||||
<ArrowRightIcon width={14} height={14} className="text-zinc-500 group-hover:text-zinc-300" />
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
autoFocus={false}
|
||||
placeholder="Search..."
|
||||
className="h-6 w-[453px] rounded border border-zinc-800 bg-zinc-900 px-2.5 text-center text-[11px] text-sm leading-5 text-zinc-500 placeholder:leading-5 placeholder:text-zinc-600 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-full items-center gap-2">
|
||||
<button
|
||||
onClick={() => reload()}
|
||||
className="group inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
|
||||
>
|
||||
<RefreshIcon width={14} height={14} className="text-zinc-500 group-hover:text-zinc-300" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex h-full w-full flex-1 items-center px-2"
|
||||
>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex w-full items-center justify-center gap-2"
|
||||
>
|
||||
<div className="flex h-full items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goBack()}
|
||||
className="group inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
|
||||
>
|
||||
<ArrowLeftIcon
|
||||
width={14}
|
||||
height={14}
|
||||
className="text-zinc-500 group-hover:text-zinc-300"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goForward()}
|
||||
className="group inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
|
||||
>
|
||||
<ArrowRightIcon
|
||||
width={14}
|
||||
height={14}
|
||||
className="text-zinc-500 group-hover:text-zinc-300"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
placeholder="Search..."
|
||||
className="h-6 w-[453px] rounded border border-zinc-800 bg-zinc-900 px-2.5 text-center text-[11px] text-sm leading-5 text-zinc-500 placeholder:leading-5 placeholder:text-zinc-600 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-full items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reload()}
|
||||
className="group inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
|
||||
>
|
||||
<RefreshIcon
|
||||
width={14}
|
||||
height={14}
|
||||
className="text-zinc-500 group-hover:text-zinc-300"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,75 +1,86 @@
|
||||
import { createBlobFromFile } from '@utils/createBlobFromFile';
|
||||
import { createBlobFromFile } from "@utils/createBlobFromFile";
|
||||
|
||||
import { open } from '@tauri-apps/api/dialog';
|
||||
import { Body, fetch } from '@tauri-apps/api/http';
|
||||
import { useState } from 'react';
|
||||
import { open } from "@tauri-apps/api/dialog";
|
||||
import { Body, fetch } from "@tauri-apps/api/http";
|
||||
import { useState } from "react";
|
||||
|
||||
export function AvatarUploader({ valueState }: { valueState: any }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const openFileDialog = async () => {
|
||||
const selected: any = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: 'Image',
|
||||
extensions: ['png', 'jpeg', 'jpg', 'gif'],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (Array.isArray(selected)) {
|
||||
// user selected multiple files
|
||||
} else if (selected === null) {
|
||||
// user cancelled the selection
|
||||
} else {
|
||||
setLoading(true);
|
||||
const openFileDialog = async () => {
|
||||
const selected: any = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: "Image",
|
||||
extensions: ["png", "jpeg", "jpg", "gif"],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (Array.isArray(selected)) {
|
||||
// user selected multiple files
|
||||
} else if (selected === null) {
|
||||
// user cancelled the selection
|
||||
} else {
|
||||
setLoading(true);
|
||||
|
||||
const filename = selected.split('/').pop();
|
||||
const file = await createBlobFromFile(selected);
|
||||
const buf = await file.arrayBuffer();
|
||||
const filename = selected.split("/").pop();
|
||||
const file = await createBlobFromFile(selected);
|
||||
const buf = await file.arrayBuffer();
|
||||
|
||||
const res: { data: { file: { id: string } } } = await fetch('https://void.cat/upload?cli=false', {
|
||||
method: 'POST',
|
||||
timeout: 5,
|
||||
headers: {
|
||||
accept: '*/*',
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'V-Filename': filename,
|
||||
'V-Description': 'Upload from https://lume.nu',
|
||||
'V-Strip-Metadata': 'true',
|
||||
},
|
||||
body: Body.bytes(buf),
|
||||
});
|
||||
const webpImage = 'https://void.cat/d/' + res.data.file.id + '.webp';
|
||||
const res: { data: { file: { id: string } } } = await fetch(
|
||||
"https://void.cat/upload?cli=false",
|
||||
{
|
||||
method: "POST",
|
||||
timeout: 5,
|
||||
headers: {
|
||||
accept: "*/*",
|
||||
"Content-Type": "application/octet-stream",
|
||||
"V-Filename": filename,
|
||||
"V-Description": "Upload from https://lume.nu",
|
||||
"V-Strip-Metadata": "true",
|
||||
},
|
||||
body: Body.bytes(buf),
|
||||
},
|
||||
);
|
||||
const webpImage = `https://void.cat/d/${res.data.file.id}.webp`;
|
||||
|
||||
valueState(webpImage);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
valueState(webpImage);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => openFileDialog()}
|
||||
type="button"
|
||||
className="inline-flex h-6 items-center justify-center rounded bg-zinc-900 px-3 text-xs font-medium text-zinc-200 ring-1 ring-zinc-800 hover:bg-zinc-700"
|
||||
>
|
||||
{loading ? (
|
||||
<svg
|
||||
className="h-4 w-4 animate-spin text-black dark:text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
) : (
|
||||
<span className="leading-none">Upload</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<button
|
||||
onClick={() => openFileDialog()}
|
||||
type="button"
|
||||
className="inline-flex h-6 items-center justify-center rounded bg-zinc-900 px-3 text-xs font-medium text-zinc-200 ring-1 ring-zinc-800 hover:bg-zinc-700"
|
||||
>
|
||||
{loading ? (
|
||||
<svg
|
||||
className="h-4 w-4 animate-spin text-black dark:text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<title id="loading">Loading</title>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<span className="leading-none">Upload</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,124 +1,134 @@
|
||||
import PlusCircleIcon from '@shared/icons/plusCircle';
|
||||
import PlusCircleIcon from "@shared/icons/plusCircle";
|
||||
|
||||
import { createBlobFromFile } from '@utils/createBlobFromFile';
|
||||
import { createBlobFromFile } from "@utils/createBlobFromFile";
|
||||
|
||||
import { open } from '@tauri-apps/api/dialog';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { Body, fetch } from '@tauri-apps/api/http';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Transforms } from 'slate';
|
||||
import { useSlateStatic } from 'slate-react';
|
||||
import { open } from "@tauri-apps/api/dialog";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { Body, fetch } from "@tauri-apps/api/http";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Transforms } from "slate";
|
||||
import { useSlateStatic } from "slate-react";
|
||||
|
||||
export function ImageUploader() {
|
||||
const editor = useSlateStatic();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const editor = useSlateStatic();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const insertImage = (editor, url) => {
|
||||
const image = { type: 'image', url, children: [{ text: url }] };
|
||||
Transforms.insertNodes(editor, image);
|
||||
};
|
||||
const insertImage = (editor, url) => {
|
||||
const image = { type: "image", url, children: [{ text: url }] };
|
||||
Transforms.insertNodes(editor, image);
|
||||
};
|
||||
|
||||
const uploadToVoidCat = useCallback(
|
||||
async (filepath) => {
|
||||
const filename = filepath.split('/').pop();
|
||||
const file = await createBlobFromFile(filepath);
|
||||
const buf = await file.arrayBuffer();
|
||||
const uploadToVoidCat = useCallback(
|
||||
async (filepath) => {
|
||||
const filename = filepath.split("/").pop();
|
||||
const file = await createBlobFromFile(filepath);
|
||||
const buf = await file.arrayBuffer();
|
||||
|
||||
try {
|
||||
const res: { data: { file: { id: string } } } = await fetch('https://void.cat/upload?cli=false', {
|
||||
method: 'POST',
|
||||
timeout: 5,
|
||||
headers: {
|
||||
accept: '*/*',
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'V-Filename': filename,
|
||||
'V-Description': 'Uploaded from https://lume.nu',
|
||||
'V-Strip-Metadata': 'true',
|
||||
},
|
||||
body: Body.bytes(buf),
|
||||
});
|
||||
const image = 'https://void.cat/d/' + res.data.file.id + '.webp';
|
||||
// update parent state
|
||||
insertImage(editor, image);
|
||||
// reset loading state
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
// reset loading state
|
||||
setLoading(false);
|
||||
// handle error
|
||||
if (error instanceof SyntaxError) {
|
||||
// Unexpected token < in JSON
|
||||
console.log('There was a SyntaxError', error);
|
||||
} else {
|
||||
console.log('There was an error', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
try {
|
||||
const res: { data: { file: { id: string } } } = await fetch(
|
||||
"https://void.cat/upload?cli=false",
|
||||
{
|
||||
method: "POST",
|
||||
timeout: 5,
|
||||
headers: {
|
||||
accept: "*/*",
|
||||
"Content-Type": "application/octet-stream",
|
||||
"V-Filename": filename,
|
||||
"V-Description": "Uploaded from https://lume.nu",
|
||||
"V-Strip-Metadata": "true",
|
||||
},
|
||||
body: Body.bytes(buf),
|
||||
},
|
||||
);
|
||||
const image = `https://void.cat/d/${res.data.file.id}.webp`;
|
||||
// update parent state
|
||||
insertImage(editor, image);
|
||||
// reset loading state
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
// reset loading state
|
||||
setLoading(false);
|
||||
// handle error
|
||||
if (error instanceof SyntaxError) {
|
||||
// Unexpected token < in JSON
|
||||
console.log("There was a SyntaxError", error);
|
||||
} else {
|
||||
console.log("There was an error", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const openFileDialog = async () => {
|
||||
const selected: any = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: 'Image',
|
||||
extensions: ['png', 'jpeg', 'jpg', 'gif'],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (Array.isArray(selected)) {
|
||||
// user selected multiple files
|
||||
} else if (selected === null) {
|
||||
// user cancelled the selection
|
||||
} else {
|
||||
setLoading(true);
|
||||
// upload file
|
||||
uploadToVoidCat(selected);
|
||||
}
|
||||
};
|
||||
const openFileDialog = async () => {
|
||||
const selected: any = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: "Image",
|
||||
extensions: ["png", "jpeg", "jpg", "gif"],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (Array.isArray(selected)) {
|
||||
// user selected multiple files
|
||||
} else if (selected === null) {
|
||||
// user cancelled the selection
|
||||
} else {
|
||||
setLoading(true);
|
||||
// upload file
|
||||
uploadToVoidCat(selected);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function initFileDrop() {
|
||||
const unlisten = await listen('tauri://file-drop', (event) => {
|
||||
// set loading state
|
||||
setLoading(true);
|
||||
// upload file
|
||||
uploadToVoidCat(event.payload[0]);
|
||||
});
|
||||
useEffect(() => {
|
||||
async function initFileDrop() {
|
||||
const unlisten = await listen("tauri://file-drop", (event) => {
|
||||
// set loading state
|
||||
setLoading(true);
|
||||
// upload file
|
||||
uploadToVoidCat(event.payload[0]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten();
|
||||
};
|
||||
}
|
||||
return () => {
|
||||
unlisten();
|
||||
};
|
||||
}
|
||||
|
||||
initFileDrop();
|
||||
}, [uploadToVoidCat]);
|
||||
initFileDrop();
|
||||
}, [uploadToVoidCat]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
autoFocus={false}
|
||||
onClick={() => openFileDialog()}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded hover:bg-zinc-800"
|
||||
>
|
||||
{loading ? (
|
||||
<svg
|
||||
className="h-4 w-4 animate-spin text-black dark:text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
) : (
|
||||
<PlusCircleIcon width={20} height={20} className="text-zinc-500" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openFileDialog()}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded hover:bg-zinc-800"
|
||||
>
|
||||
{loading ? (
|
||||
<svg
|
||||
className="h-4 w-4 animate-spin text-black dark:text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<title id="loading">Loading</title>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<PlusCircleIcon width={20} height={20} className="text-zinc-500" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,92 +1,105 @@
|
||||
import { Post } from '@shared/composer/types/post';
|
||||
import { User } from '@shared/composer/user';
|
||||
import { Post } from "@shared/composer/types/post";
|
||||
import { User } from "@shared/composer/user";
|
||||
|
||||
import CancelIcon from '@icons/cancel';
|
||||
import ChevronDownIcon from '@icons/chevronDown';
|
||||
import ChevronRightIcon from '@icons/chevronRight';
|
||||
import ComposeIcon from '@icons/compose';
|
||||
import CancelIcon from "@icons/cancel";
|
||||
import ChevronDownIcon from "@icons/chevronDown";
|
||||
import ChevronRightIcon from "@icons/chevronRight";
|
||||
import ComposeIcon from "@icons/compose";
|
||||
|
||||
import { composerAtom } from '@stores/composer';
|
||||
import { composerAtom } from "@stores/composer";
|
||||
|
||||
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
|
||||
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
|
||||
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { Fragment, useState } from 'react';
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { useAtom } from "jotai";
|
||||
import { Fragment, useState } from "react";
|
||||
|
||||
export function ComposerModal() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [composer] = useAtom(composerAtom);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [composer] = useAtom(composerAtom);
|
||||
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const openModal = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
const openModal = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
autoFocus={false}
|
||||
onClick={() => openModal()}
|
||||
className="inline-flex h-7 w-max items-center justify-center gap-1 rounded-md bg-fuchsia-500 px-2.5 text-xs font-medium text-zinc-200 shadow-button hover:bg-fuchsia-600 focus:outline-none"
|
||||
>
|
||||
<ComposeIcon width={14} height={14} />
|
||||
Compose
|
||||
</button>
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={closeModal}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
|
||||
</Transition.Child>
|
||||
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative h-min w-full max-w-xl rounded-lg border border-zinc-800 bg-zinc-900">
|
||||
<div className="flex items-center justify-between px-4 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div>{!isLoading && !isError && account && <User data={account} />}</div>
|
||||
<span>
|
||||
<ChevronRightIcon width={14} height={14} className="text-zinc-500" />
|
||||
</span>
|
||||
<div className="inline-flex h-6 w-max items-center justify-center gap-0.5 rounded bg-zinc-800 pl-3 pr-1.5 text-xs font-medium text-zinc-400 shadow-mini-button">
|
||||
New Post
|
||||
<ChevronDownIcon width={14} height={14} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
onClick={closeModal}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-800"
|
||||
>
|
||||
<CancelIcon width={16} height={16} className="text-zinc-500" />
|
||||
</div>
|
||||
</div>
|
||||
{composer.type === 'post' && account && <Post pubkey={account.pubkey} privkey={account.privkey} />}
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openModal()}
|
||||
className="inline-flex h-7 w-max items-center justify-center gap-1 rounded-md bg-fuchsia-500 px-2.5 text-xs font-medium text-zinc-200 shadow-button hover:bg-fuchsia-600 focus:outline-none"
|
||||
>
|
||||
<ComposeIcon width={14} height={14} />
|
||||
Compose
|
||||
</button>
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={closeModal}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
|
||||
</Transition.Child>
|
||||
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative h-min w-full max-w-xl rounded-lg border border-zinc-800 bg-zinc-900">
|
||||
<div className="flex items-center justify-between px-4 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
{!isLoading && !isError && account && (
|
||||
<User data={account} />
|
||||
)}
|
||||
</div>
|
||||
<span>
|
||||
<ChevronRightIcon
|
||||
width={14}
|
||||
height={14}
|
||||
className="text-zinc-500"
|
||||
/>
|
||||
</span>
|
||||
<div className="inline-flex h-6 w-max items-center justify-center gap-0.5 rounded bg-zinc-800 pl-3 pr-1.5 text-xs font-medium text-zinc-400 shadow-mini-button">
|
||||
New Post
|
||||
<ChevronDownIcon width={14} height={14} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
onKeyDown={closeModal}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-800"
|
||||
>
|
||||
<CancelIcon
|
||||
width={16}
|
||||
height={16}
|
||||
className="text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{composer.type === "post" && account && (
|
||||
<Post pubkey={account.pubkey} privkey={account.privkey} />
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,124 +1,141 @@
|
||||
import { ImageUploader } from '@shared/composer/imageUploader';
|
||||
import TrashIcon from '@shared/icons/trash';
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { ImageUploader } from "@shared/composer/imageUploader";
|
||||
import TrashIcon from "@shared/icons/trash";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import { WRITEONLY_RELAYS } from '@stores/constants';
|
||||
import { WRITEONLY_RELAYS } from "@stores/constants";
|
||||
|
||||
import { dateToUnix } from '@utils/date';
|
||||
import { dateToUnix } from "@utils/date";
|
||||
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { Node, Transforms, createEditor } from 'slate';
|
||||
import { withHistory } from 'slate-history';
|
||||
import { Editable, ReactEditor, Slate, useSlateStatic, withReact } from 'slate-react';
|
||||
import { getEventHash, signEvent } from "nostr-tools";
|
||||
import { useCallback, useContext, useMemo, useState } from "react";
|
||||
import { Node, Transforms, createEditor } from "slate";
|
||||
import { withHistory } from "slate-history";
|
||||
import {
|
||||
Editable,
|
||||
ReactEditor,
|
||||
Slate,
|
||||
useSlateStatic,
|
||||
withReact,
|
||||
} from "slate-react";
|
||||
|
||||
const withImages = (editor) => {
|
||||
const { isVoid } = editor;
|
||||
const { isVoid } = editor;
|
||||
|
||||
editor.isVoid = (element) => {
|
||||
return element.type === 'image' ? true : isVoid(element);
|
||||
};
|
||||
editor.isVoid = (element) => {
|
||||
return element.type === "image" ? true : isVoid(element);
|
||||
};
|
||||
|
||||
return editor;
|
||||
return editor;
|
||||
};
|
||||
|
||||
const ImagePreview = ({ attributes, children, element }: { attributes: any; children: any; element: any }) => {
|
||||
const editor: any = useSlateStatic();
|
||||
const path = ReactEditor.findPath(editor, element);
|
||||
const ImagePreview = ({
|
||||
attributes,
|
||||
children,
|
||||
element,
|
||||
}: { attributes: any; children: any; element: any }) => {
|
||||
const editor: any = useSlateStatic();
|
||||
const path = ReactEditor.findPath(editor, element);
|
||||
|
||||
return (
|
||||
<figure {...attributes} className="m-0 mt-3">
|
||||
{children}
|
||||
<div contentEditable={false} className="relative">
|
||||
<img src={element.url} className="m-0 h-auto w-full rounded-md" />
|
||||
<button
|
||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||
className="absolute right-2 top-2 inline-flex h-7 w-7 items-center justify-center gap-0.5 rounded bg-zinc-800 text-xs font-medium text-zinc-400 shadow-mini-button hover:bg-zinc-700"
|
||||
>
|
||||
<TrashIcon width={14} height={14} className="text-zinc-100" />
|
||||
</button>
|
||||
</div>
|
||||
</figure>
|
||||
);
|
||||
return (
|
||||
<figure {...attributes} className="m-0 mt-3">
|
||||
{children}
|
||||
<div contentEditable={false} className="relative">
|
||||
<img
|
||||
alt={element.url}
|
||||
src={element.url}
|
||||
className="m-0 h-auto w-full rounded-md"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||
className="absolute right-2 top-2 inline-flex h-7 w-7 items-center justify-center gap-0.5 rounded bg-zinc-800 text-xs font-medium text-zinc-400 shadow-mini-button hover:bg-zinc-700"
|
||||
>
|
||||
<TrashIcon width={14} height={14} className="text-zinc-100" />
|
||||
</button>
|
||||
</div>
|
||||
</figure>
|
||||
);
|
||||
};
|
||||
|
||||
export function Post({ pubkey, privkey }: { pubkey: string; privkey: string }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const editor = useMemo(() => withReact(withImages(withHistory(createEditor()))), []);
|
||||
const [content, setContent] = useState<Node[]>([
|
||||
{
|
||||
children: [
|
||||
{
|
||||
text: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const editor = useMemo(
|
||||
() => withReact(withImages(withHistory(createEditor()))),
|
||||
[],
|
||||
);
|
||||
const [content, setContent] = useState<Node[]>([
|
||||
{
|
||||
children: [
|
||||
{
|
||||
text: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const serialize = useCallback((nodes: Node[]) => {
|
||||
return nodes.map((n) => Node.string(n)).join('\n');
|
||||
}, []);
|
||||
const serialize = useCallback((nodes: Node[]) => {
|
||||
return nodes.map((n) => Node.string(n)).join("\n");
|
||||
}, []);
|
||||
|
||||
const submit = () => {
|
||||
// serialize content
|
||||
const serializedContent = serialize(content);
|
||||
console.log(serializedContent);
|
||||
const submit = () => {
|
||||
// serialize content
|
||||
const serializedContent = serialize(content);
|
||||
console.log(serializedContent);
|
||||
|
||||
const event: any = {
|
||||
content: serializedContent,
|
||||
created_at: dateToUnix(),
|
||||
kind: 1,
|
||||
pubkey: pubkey,
|
||||
tags: [],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, privkey);
|
||||
const event: any = {
|
||||
content: serializedContent,
|
||||
created_at: dateToUnix(),
|
||||
kind: 1,
|
||||
pubkey: pubkey,
|
||||
tags: [],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, privkey);
|
||||
|
||||
// publish note
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
};
|
||||
// publish note
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
};
|
||||
|
||||
const renderElement = useCallback((props: any) => {
|
||||
switch (props.element.type) {
|
||||
case 'image':
|
||||
if (props.element.url) {
|
||||
return <ImagePreview {...props} />;
|
||||
}
|
||||
default:
|
||||
return <p {...props.attributes}>{props.children}</p>;
|
||||
}
|
||||
}, []);
|
||||
const renderElement = useCallback((props: any) => {
|
||||
switch (props.element.type) {
|
||||
case "image":
|
||||
if (props.element.url) {
|
||||
return <ImagePreview {...props} />;
|
||||
}
|
||||
default:
|
||||
return <p {...props.attributes}>{props.children}</p>;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Slate editor={editor} value={content} onChange={setContent}>
|
||||
<div className="flex h-full flex-col px-4 pb-4">
|
||||
<div className="flex h-full w-full gap-2">
|
||||
<div className="flex w-8 shrink-0 items-center justify-center">
|
||||
<div className="h-full w-[2px] bg-zinc-800"></div>
|
||||
</div>
|
||||
<div className="prose prose-zinc relative h-max w-full max-w-none select-text break-words pb-3 dark:prose-invert prose-p:mb-0.5 prose-p:mt-0 prose-p:text-[15px] prose-p:leading-tight prose-a:text-[15px] prose-a:font-normal prose-a:leading-tight prose-a:text-fuchsia-500 prose-a:no-underline hover:prose-a:text-fuchsia-600 hover:prose-a:underline prose-ol:mb-1 prose-ul:mb-1 prose-li:text-[15px] prose-li:leading-tight">
|
||||
<Editable
|
||||
autoFocus
|
||||
placeholder="What's on your mind?"
|
||||
spellCheck="false"
|
||||
className="!min-h-[86px]"
|
||||
renderElement={renderElement}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<ImageUploader />
|
||||
<button
|
||||
type="button"
|
||||
autoFocus={false}
|
||||
onClick={submit}
|
||||
className="inline-flex h-7 w-max items-center justify-center gap-1 rounded-md bg-fuchsia-500 px-3.5 text-xs font-medium text-zinc-200 shadow-button hover:bg-fuchsia-600"
|
||||
>
|
||||
Post
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Slate>
|
||||
);
|
||||
return (
|
||||
<Slate editor={editor} value={content} onChange={setContent}>
|
||||
<div className="flex h-full flex-col px-4 pb-4">
|
||||
<div className="flex h-full w-full gap-2">
|
||||
<div className="flex w-8 shrink-0 items-center justify-center">
|
||||
<div className="h-full w-[2px] bg-zinc-800" />
|
||||
</div>
|
||||
<div className="prose prose-zinc relative h-max w-full max-w-none select-text break-words pb-3 dark:prose-invert prose-p:mb-0.5 prose-p:mt-0 prose-p:text-[15px] prose-p:leading-tight prose-a:text-[15px] prose-a:font-normal prose-a:leading-tight prose-a:text-fuchsia-500 prose-a:no-underline hover:prose-a:text-fuchsia-600 hover:prose-a:underline prose-ol:mb-1 prose-ul:mb-1 prose-li:text-[15px] prose-li:leading-tight">
|
||||
<Editable
|
||||
autoFocus
|
||||
placeholder="What's on your mind?"
|
||||
spellCheck="false"
|
||||
className="!min-h-[86px]"
|
||||
renderElement={renderElement}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<ImageUploader />
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
className="inline-flex h-7 w-max items-center justify-center gap-1 rounded-md bg-fuchsia-500 px-3.5 text-xs font-medium text-zinc-200 shadow-button hover:bg-fuchsia-600"
|
||||
>
|
||||
Post
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Slate>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
import { Image } from '@shared/image';
|
||||
import { Image } from "@shared/image";
|
||||
|
||||
import { DEFAULT_AVATAR, IMGPROXY_URL } from '@stores/constants';
|
||||
import { DEFAULT_AVATAR, IMGPROXY_URL } from "@stores/constants";
|
||||
|
||||
export function User({ data }: { data: any }) {
|
||||
const metadata = JSON.parse(data.metadata);
|
||||
const metadata = JSON.parse(data.metadata);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 shrink-0 overflow-hidden rounded bg-zinc-900">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${metadata?.picture ? metadata.picture : DEFAULT_AVATAR}`}
|
||||
alt={data.pubkey}
|
||||
className="h-8 w-8 object-cover"
|
||||
loading="auto"
|
||||
/>
|
||||
</div>
|
||||
<h5 className="text-sm font-semibold leading-none text-zinc-100">
|
||||
{metadata?.display_name || metadata?.name || (
|
||||
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700"></div>
|
||||
)}
|
||||
</h5>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 shrink-0 overflow-hidden rounded bg-zinc-900">
|
||||
<Image
|
||||
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${
|
||||
metadata?.picture ? metadata.picture : DEFAULT_AVATAR
|
||||
}`}
|
||||
alt={data.pubkey}
|
||||
className="h-8 w-8 object-cover"
|
||||
loading="auto"
|
||||
/>
|
||||
</div>
|
||||
<h5 className="text-sm font-semibold leading-none text-zinc-100">
|
||||
{metadata?.display_name || metadata?.name || (
|
||||
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700" />
|
||||
)}
|
||||
</h5>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,117 +1,130 @@
|
||||
import { RelayContext } from '@shared/relayProvider';
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
|
||||
import HeartBeatIcon from '@icons/heartbeat';
|
||||
import HeartBeatIcon from "@icons/heartbeat";
|
||||
|
||||
import { READONLY_RELAYS } from '@stores/constants';
|
||||
import { hasNewerNoteAtom } from '@stores/note';
|
||||
import { READONLY_RELAYS } from "@stores/constants";
|
||||
import { hasNewerNoteAtom } from "@stores/note";
|
||||
|
||||
import { dateToUnix } from '@utils/date';
|
||||
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
|
||||
import { createChat, createNote, updateAccount } from '@utils/storage';
|
||||
import { getParentID, nip02ToArray } from '@utils/transform';
|
||||
import { dateToUnix } from "@utils/date";
|
||||
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
|
||||
import { createChat, createNote, updateAccount } from "@utils/storage";
|
||||
import { getParentID, nip02ToArray } from "@utils/transform";
|
||||
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useContext, useRef } from 'react';
|
||||
import useSWRSubscription from 'swr/subscription';
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useContext, useRef } from "react";
|
||||
import useSWRSubscription from "swr/subscription";
|
||||
|
||||
export default function EventCollector() {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const setHasNewerNote = useSetAtom(hasNewerNoteAtom);
|
||||
const now = useRef(new Date());
|
||||
const setHasNewerNote = useSetAtom(hasNewerNoteAtom);
|
||||
const now = useRef(new Date());
|
||||
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
|
||||
useSWRSubscription(!isLoading && !isError && account ? ['eventCollector', account] : null, ([, key], {}) => {
|
||||
const follows = JSON.parse(key.follows);
|
||||
const followsAsArray = nip02ToArray(follows);
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [1, 6],
|
||||
authors: followsAsArray,
|
||||
since: dateToUnix(now.current),
|
||||
},
|
||||
{
|
||||
kinds: [0, 3],
|
||||
authors: [key.pubkey],
|
||||
},
|
||||
{
|
||||
kinds: [4],
|
||||
'#p': [key.pubkey],
|
||||
since: dateToUnix(now.current),
|
||||
},
|
||||
{
|
||||
kinds: [30023],
|
||||
since: dateToUnix(now.current),
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
switch (event.kind) {
|
||||
// metadata
|
||||
case 0:
|
||||
updateAccount('metadata', event.content, event.pubkey);
|
||||
break;
|
||||
// short text note
|
||||
case 1:
|
||||
const parentID = getParentID(event.tags, event.id);
|
||||
createNote(
|
||||
event.id,
|
||||
account.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at,
|
||||
parentID
|
||||
);
|
||||
// notify user reload to get newer note
|
||||
setHasNewerNote(true);
|
||||
break;
|
||||
// contacts
|
||||
case 3:
|
||||
// update account's folllows with NIP-02 tag list
|
||||
updateAccount('follows', event.tags, event.pubkey);
|
||||
break;
|
||||
// chat
|
||||
case 4:
|
||||
if (event.pubkey !== key.pubkey) {
|
||||
createChat(key.id, event.pubkey, event.created_at);
|
||||
}
|
||||
break;
|
||||
// repost
|
||||
case 6:
|
||||
createNote(
|
||||
event.id,
|
||||
key.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at,
|
||||
event.id
|
||||
);
|
||||
break;
|
||||
// long post
|
||||
case 30023:
|
||||
// insert event to local database
|
||||
createNote(event.id, account.id, event.pubkey, event.kind, event.tags, event.content, event.created_at, '');
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
);
|
||||
useSWRSubscription(
|
||||
!isLoading && !isError && account ? ["eventCollector", account] : null,
|
||||
([, key]) => {
|
||||
const follows = JSON.parse(key.follows);
|
||||
const followsAsArray = nip02ToArray(follows);
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [1, 6],
|
||||
authors: followsAsArray,
|
||||
since: dateToUnix(now.current),
|
||||
},
|
||||
{
|
||||
kinds: [0, 3],
|
||||
authors: [key.pubkey],
|
||||
},
|
||||
{
|
||||
kinds: [4],
|
||||
"#p": [key.pubkey],
|
||||
since: dateToUnix(now.current),
|
||||
},
|
||||
{
|
||||
kinds: [30023],
|
||||
since: dateToUnix(now.current),
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
switch (event.kind) {
|
||||
// metadata
|
||||
case 0:
|
||||
updateAccount("metadata", event.content, event.pubkey);
|
||||
break;
|
||||
// short text note
|
||||
case 1: {
|
||||
const parentID = getParentID(event.tags, event.id);
|
||||
createNote(
|
||||
event.id,
|
||||
account.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at,
|
||||
parentID,
|
||||
);
|
||||
// notify user reload to get newer note
|
||||
setHasNewerNote(true);
|
||||
break;
|
||||
}
|
||||
// contacts
|
||||
case 3:
|
||||
// update account's folllows with NIP-02 tag list
|
||||
updateAccount("follows", event.tags, event.pubkey);
|
||||
break;
|
||||
// chat
|
||||
case 4:
|
||||
if (event.pubkey !== key.pubkey) {
|
||||
createChat(key.id, event.pubkey, event.created_at);
|
||||
}
|
||||
break;
|
||||
// repost
|
||||
case 6:
|
||||
createNote(
|
||||
event.id,
|
||||
key.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at,
|
||||
event.id,
|
||||
);
|
||||
break;
|
||||
// long post
|
||||
case 30023:
|
||||
// insert event to local database
|
||||
createNote(
|
||||
event.id,
|
||||
account.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at,
|
||||
"",
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="inline-flex h-6 w-6 items-center justify-center rounded text-zinc-400 hover:bg-zinc-900 hover:text-zinc-200">
|
||||
<HeartBeatIcon width={14} height={14} />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="inline-flex h-6 w-6 items-center justify-center rounded text-zinc-400 hover:bg-zinc-900 hover:text-zinc-200">
|
||||
<HeartBeatIcon width={14} height={14} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,98 +1,110 @@
|
||||
import PlusIcon from '@icons/plus';
|
||||
import PlusIcon from "@icons/plus";
|
||||
|
||||
import { channelContentAtom } from '@stores/channel';
|
||||
import { chatContentAtom } from '@stores/chat';
|
||||
import { noteContentAtom } from '@stores/note';
|
||||
import { channelContentAtom } from "@stores/channel";
|
||||
import { chatContentAtom } from "@stores/chat";
|
||||
import { noteContentAtom } from "@stores/note";
|
||||
|
||||
import { createBlobFromFile } from '@utils/createBlobFromFile';
|
||||
import { createBlobFromFile } from "@utils/createBlobFromFile";
|
||||
|
||||
import { open } from '@tauri-apps/api/dialog';
|
||||
import { Body, fetch } from '@tauri-apps/api/http';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useState } from 'react';
|
||||
import { open } from "@tauri-apps/api/dialog";
|
||||
import { Body, fetch } from "@tauri-apps/api/http";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useState } from "react";
|
||||
|
||||
export function ImagePicker({ type }: { type: string }) {
|
||||
let atom;
|
||||
let atom;
|
||||
|
||||
switch (type) {
|
||||
case 'note':
|
||||
atom = noteContentAtom;
|
||||
break;
|
||||
case 'chat':
|
||||
atom = chatContentAtom;
|
||||
break;
|
||||
case 'channel':
|
||||
atom = channelContentAtom;
|
||||
break;
|
||||
default:
|
||||
throw new Error('Invalid type');
|
||||
}
|
||||
switch (type) {
|
||||
case "note":
|
||||
atom = noteContentAtom;
|
||||
break;
|
||||
case "chat":
|
||||
atom = chatContentAtom;
|
||||
break;
|
||||
case "channel":
|
||||
atom = channelContentAtom;
|
||||
break;
|
||||
default:
|
||||
throw new Error("Invalid type");
|
||||
}
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const setValue = useSetAtom(atom);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const setValue = useSetAtom(atom);
|
||||
|
||||
const openFileDialog = async () => {
|
||||
const selected: any = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: 'Image',
|
||||
extensions: ['png', 'jpeg', 'jpg', 'gif'],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (Array.isArray(selected)) {
|
||||
// user selected multiple files
|
||||
} else if (selected === null) {
|
||||
// user cancelled the selection
|
||||
} else {
|
||||
setLoading(true);
|
||||
const openFileDialog = async () => {
|
||||
const selected: any = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: "Image",
|
||||
extensions: ["png", "jpeg", "jpg", "gif"],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (Array.isArray(selected)) {
|
||||
// user selected multiple files
|
||||
} else if (selected === null) {
|
||||
// user cancelled the selection
|
||||
} else {
|
||||
setLoading(true);
|
||||
|
||||
const filename = selected.split('/').pop();
|
||||
const file = await createBlobFromFile(selected);
|
||||
const buf = await file.arrayBuffer();
|
||||
const filename = selected.split("/").pop();
|
||||
const file = await createBlobFromFile(selected);
|
||||
const buf = await file.arrayBuffer();
|
||||
|
||||
const res: { data: { file: { id: string } } } = await fetch('https://void.cat/upload?cli=false', {
|
||||
method: 'POST',
|
||||
timeout: 5,
|
||||
headers: {
|
||||
accept: '*/*',
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'V-Filename': filename,
|
||||
'V-Description': 'Upload from https://lume.nu',
|
||||
'V-Strip-Metadata': 'true',
|
||||
},
|
||||
body: Body.bytes(buf),
|
||||
});
|
||||
const webpImage = 'https://void.cat/d/' + res.data.file.id + '.webp';
|
||||
const res: { data: { file: { id: string } } } = await fetch(
|
||||
"https://void.cat/upload?cli=false",
|
||||
{
|
||||
method: "POST",
|
||||
timeout: 5,
|
||||
headers: {
|
||||
accept: "*/*",
|
||||
"Content-Type": "application/octet-stream",
|
||||
"V-Filename": filename,
|
||||
"V-Description": "Upload from https://lume.nu",
|
||||
"V-Strip-Metadata": "true",
|
||||
},
|
||||
body: Body.bytes(buf),
|
||||
},
|
||||
);
|
||||
const webpImage = `https://void.cat/d/${res.data.file.id}.webp`;
|
||||
|
||||
setValue((content: string) => content + ' ' + webpImage);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
setValue((content: string) => `${content} ${webpImage}`);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => openFileDialog()}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-700"
|
||||
>
|
||||
{loading ? (
|
||||
<svg
|
||||
className="h-4 w-4 animate-spin text-black dark:text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
) : (
|
||||
<PlusIcon width={16} height={16} className="text-zinc-400" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openFileDialog()}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-700"
|
||||
>
|
||||
{loading ? (
|
||||
<svg
|
||||
className="h-4 w-4 animate-spin text-black dark:text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<title id="loading">Loading</title>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<PlusIcon width={16} height={16} className="text-zinc-400" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { SVGProps } from 'react';
|
||||
import { SVGProps } from "react";
|
||||
|
||||
export default function ArrowLeftIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M10 18.25L3.75 12M3.75 12L10 5.75M3.75 12H20.25"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default function ArrowLeftIcon(
|
||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M10 18.25L3.75 12M3.75 12L10 5.75M3.75 12H20.25"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { SVGProps } from 'react';
|
||||
import { SVGProps } from "react";
|
||||
|
||||
export default function ArrowRightIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M14 5.75L20.25 12M20.25 12L14 18.25M20.25 12H3.75"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default function ArrowRightIcon(
|
||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M14 5.75L20.25 12M20.25 12L14 18.25M20.25 12H3.75"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { SVGProps } from 'react';
|
||||
import { SVGProps } from "react";
|
||||
|
||||
export default function BellIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg width={24} height={24} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M16 18.25C15.3267 20.0159 13.7891 21.25 12 21.25C10.2109 21.25 8.67327 20.0159 8 18.25M20.5 18.25L18.9554 8.67345C18.4048 5.2596 15.458 2.75 12 2.75C8.54203 2.75 5.59523 5.2596 5.04461 8.67345L3.5 18.25H20.5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default function BellIcon(
|
||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) {
|
||||
return (
|
||||
<svg
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M16 18.25C15.3267 20.0159 13.7891 21.25 12 21.25C10.2109 21.25 8.67327 20.0159 8 18.25M20.5 18.25L18.9554 8.67345C18.4048 5.2596 15.458 2.75 12 2.75C8.54203 2.75 5.59523 5.2596 5.04461 8.67345L3.5 18.25H20.5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { SVGProps } from 'react';
|
||||
import { SVGProps } from "react";
|
||||
|
||||
export default function CancelIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M4.75 4.75L19.25 19.25M19.25 4.75L4.75 19.25"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default function CancelIcon(
|
||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M4.75 4.75L19.25 19.25M19.25 4.75L4.75 19.25"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { SVGProps } from 'react';
|
||||
import { SVGProps } from "react";
|
||||
|
||||
export default function CheckCircleIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg width={24} height={24} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2ZM15.5805 9.97493C15.8428 9.65434 15.7955 9.18183 15.4749 8.91953C15.1543 8.65724 14.6818 8.70449 14.4195 9.02507L10.4443 13.8837L9.03033 12.4697C8.73744 12.1768 8.26256 12.1768 7.96967 12.4697C7.67678 12.7626 7.67678 13.2374 7.96967 13.5303L9.96967 15.5303C10.1195 15.6802 10.3257 15.7596 10.5374 15.7491C10.749 15.7385 10.9463 15.6389 11.0805 15.4749L15.5805 9.97493Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default function CheckCircleIcon(
|
||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) {
|
||||
return (
|
||||
<svg
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2ZM15.5805 9.97493C15.8428 9.65434 15.7955 9.18183 15.4749 8.91953C15.1543 8.65724 14.6818 8.70449 14.4195 9.02507L10.4443 13.8837L9.03033 12.4697C8.73744 12.1768 8.26256 12.1768 7.96967 12.4697C7.67678 12.7626 7.67678 13.2374 7.96967 13.5303L9.96967 15.5303C10.1195 15.6802 10.3257 15.7596 10.5374 15.7491C10.749 15.7385 10.9463 15.6389 11.0805 15.4749L15.5805 9.97493Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { SVGProps } from 'react';
|
||||
import { SVGProps } from "react";
|
||||
|
||||
export default function ChevronDownIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg width={24} height={24} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M8 10L12 14L16 10"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default function ChevronDownIcon(
|
||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) {
|
||||
return (
|
||||
<svg
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M8 10L12 14L16 10"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { SVGProps } from 'react';
|
||||
import { SVGProps } from "react";
|
||||
|
||||
export default function ChevronRightIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg width={24} height={24} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M10 16L14 12L10 8"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default function ChevronRightIcon(
|
||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) {
|
||||
return (
|
||||
<svg
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M10 16L14 12L10 8"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user