replace eslint/prettier with rome

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

View File

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

View File

@@ -1,28 +1,32 @@
import { Image } from '@shared/image';
import { Image } from "@shared/image";
import { DEFAULT_AVATAR } from '@stores/constants';
import { DEFAULT_AVATAR } from "@stores/constants";
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey";
export default function User({ pubkey }: { pubkey: string }) {
const { user } = useProfile(pubkey);
const { user } = useProfile(pubkey);
return (
<div className="flex items-center gap-2">
<div className="relative h-11 w-11 shrink rounded-md">
<Image
src={user?.picture || DEFAULT_AVATAR}
alt={pubkey}
className="h-11 w-11 rounded-md object-cover"
loading="lazy"
decoding="async"
/>
</div>
<div className="flex w-full flex-1 flex-col items-start text-start">
<span className="truncate font-medium leading-tight text-zinc-200">{user?.display_name || user?.name}</span>
<span className="text-sm leading-tight text-zinc-400">{user?.nip05?.toLowerCase() || shortenKey(pubkey)}</span>
</div>
</div>
);
return (
<div className="flex items-center gap-2">
<div className="relative h-11 w-11 shrink rounded-md">
<Image
src={user?.picture || DEFAULT_AVATAR}
alt={pubkey}
className="h-11 w-11 rounded-md object-cover"
loading="lazy"
decoding="async"
/>
</div>
<div className="flex w-full flex-1 flex-col items-start text-start">
<span className="truncate font-medium leading-tight text-zinc-200">
{user?.display_name || user?.name}
</span>
<span className="text-sm leading-tight text-zinc-400">
{user?.nip05?.toLowerCase() || shortenKey(pubkey)}
</span>
</div>
</div>
);
}

View File

@@ -1,50 +1,67 @@
import ArrowLeftIcon from '@icons/arrowLeft';
import ArrowRightIcon from '@icons/arrowRight';
import ArrowLeftIcon from "@icons/arrowLeft";
import ArrowRightIcon from "@icons/arrowRight";
import useSWR from 'swr';
import useSWR from "swr";
const fetcher = async () => {
const { platform } = await import('@tauri-apps/api/os');
return await platform();
const { platform } = await import("@tauri-apps/api/os");
return await platform();
};
export function LayoutOnboarding({ children }: { children: React.ReactNode }) {
const { data: platform } = useSWR('platform', fetcher);
const { data: platform } = useSWR("platform", fetcher);
const goBack = () => {
window.history.back();
};
const goBack = () => {
window.history.back();
};
const goForward = () => {
window.history.forward();
};
const goForward = () => {
window.history.forward();
};
return (
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white">
<div className="flex h-screen w-full flex-col">
<div
data-tauri-drag-region
className="relative h-11 shrink-0 border border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
>
<div data-tauri-drag-region className="flex h-full w-full flex-1 items-center px-2">
<div className={`flex h-full items-center gap-2 ${platform === 'darwin' ? 'pl-[68px]' : ''}`}>
<button
onClick={() => goBack()}
className="group inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-900"
>
<ArrowLeftIcon width={16} height={16} className="text-zinc-500 group-hover:text-zinc-300" />
</button>
<button
onClick={() => goForward()}
className="group inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-900"
>
<ArrowRightIcon width={16} height={16} className="text-zinc-500 group-hover:text-zinc-300" />
</button>
</div>
</div>
</div>
<div className="relative flex min-h-0 w-full flex-1">{children}</div>
</div>
</div>
);
return (
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white">
<div className="flex h-screen w-full flex-col">
<div
data-tauri-drag-region
className="relative h-11 shrink-0 border border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
>
<div
data-tauri-drag-region
className="flex h-full w-full flex-1 items-center px-2"
>
<div
className={`flex h-full items-center gap-2 ${
platform === "darwin" ? "pl-[68px]" : ""
}`}
>
<button
type="button"
onClick={() => goBack()}
className="group inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-900"
>
<ArrowLeftIcon
width={16}
height={16}
className="text-zinc-500 group-hover:text-zinc-300"
/>
</button>
<button
type="button"
onClick={() => goForward()}
className="group inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-900"
>
<ArrowRightIcon
width={16}
height={16}
className="text-zinc-500 group-hover:text-zinc-300"
/>
</button>
</div>
</div>
</div>
<div className="relative flex min-h-0 w-full flex-1">{children}</div>
</div>
</div>
);
}

View File

@@ -1,83 +1,98 @@
import EyeOffIcon from '@icons/eyeOff';
import EyeOnIcon from '@icons/eyeOn';
import EyeOffIcon from "@icons/eyeOff";
import EyeOnIcon from "@icons/eyeOn";
import { onboardingAtom } from '@stores/onboarding';
import { onboardingAtom } from "@stores/onboarding";
import { useSetAtom } from 'jotai';
import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools';
import { useMemo, useState } from 'react';
import { navigate } from 'vite-plugin-ssr/client/router';
import { useSetAtom } from "jotai";
import { generatePrivateKey, getPublicKey, nip19 } from "nostr-tools";
import { useMemo, useState } from "react";
import { navigate } from "vite-plugin-ssr/client/router";
export function Page() {
const [type, setType] = useState('password');
const setOnboarding = useSetAtom(onboardingAtom);
const privkey = useMemo(() => generatePrivateKey(), []);
const [type, setType] = useState("password");
const setOnboarding = useSetAtom(onboardingAtom);
const privkey = useMemo(() => generatePrivateKey(), []);
const pubkey = getPublicKey(privkey);
const npub = nip19.npubEncode(pubkey);
const nsec = nip19.nsecEncode(privkey);
const pubkey = getPublicKey(privkey);
const npub = nip19.npubEncode(pubkey);
const nsec = nip19.nsecEncode(privkey);
// toggle private key
const showPrivateKey = () => {
if (type === 'password') {
setType('text');
} else {
setType('password');
}
};
// toggle private key
const showPrivateKey = () => {
if (type === "password") {
setType("text");
} else {
setType("password");
}
};
const submit = () => {
setOnboarding((prev) => ({ ...prev, pubkey: pubkey, privkey: privkey }));
navigate('/auth/create/step-2');
};
const submit = () => {
setOnboarding((prev) => ({ ...prev, pubkey: pubkey, privkey: privkey }));
navigate("/auth/create/step-2");
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-2xl font-semibold text-zinc-200">Lume is auto-generated key for you</h1>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label className="text-sm font-semibold text-zinc-400">Public Key</label>
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
<input
readOnly
value={npub}
className="relative w-full rounded-lg border border-black/5 px-3.5 py-2.5 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-600"
/>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-semibold text-zinc-400">Private Key</label>
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
<input
readOnly
type={type}
value={nsec}
className="relative w-full rounded-lg border border-black/5 py-2.5 pl-3.5 pr-11 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-600"
/>
<button
onClick={() => showPrivateKey()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
>
{type === 'password' ? (
<EyeOffIcon width={20} height={20} className="text-zinc-500 group-hover:text-zinc-200" />
) : (
<EyeOnIcon width={20} height={20} className="text-zinc-500 group-hover:text-zinc-200" />
)}
</button>
</div>
</div>
<button
type="button"
onClick={() => submit()}
className="w-full transform rounded-lg bg-fuchsia-500 px-3.5 py-2.5 font-medium text-white shadow-button hover:bg-fuchsia-600 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-70"
>
<span>Continue </span>
</button>
</div>
</div>
</div>
);
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-2xl font-semibold text-zinc-200">
Lume is auto-generated key for you
</h1>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label className="text-sm font-semibold text-zinc-400">
Public Key
</label>
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
<input
readOnly
value={npub}
className="relative w-full rounded-lg border border-black/5 px-3.5 py-2.5 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-600"
/>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-semibold text-zinc-400">
Private Key
</label>
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
<input
readOnly
type={type}
value={nsec}
className="relative w-full rounded-lg border border-black/5 py-2.5 pl-3.5 pr-11 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-600"
/>
<button
type="button"
onClick={() => showPrivateKey()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
>
{type === "password" ? (
<EyeOffIcon
width={20}
height={20}
className="text-zinc-500 group-hover:text-zinc-200"
/>
) : (
<EyeOnIcon
width={20}
height={20}
className="text-zinc-500 group-hover:text-zinc-200"
/>
)}
</button>
</div>
</div>
<button
type="button"
onClick={() => submit()}
className="w-full transform rounded-lg bg-fuchsia-500 px-3.5 py-2.5 font-medium text-white shadow-button hover:bg-fuchsia-600 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-70"
>
<span>Continue </span>
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,123 +1,142 @@
import { AvatarUploader } from '@shared/avatarUploader';
import { Image } from '@shared/image';
import { AvatarUploader } from "@shared/avatarUploader";
import { Image } from "@shared/image";
import { DEFAULT_AVATAR } from '@stores/constants';
import { onboardingAtom } from '@stores/onboarding';
import { DEFAULT_AVATAR } from "@stores/constants";
import { onboardingAtom } from "@stores/onboarding";
import { useAtom } from 'jotai';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { navigate } from 'vite-plugin-ssr/client/router';
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { navigate } from "vite-plugin-ssr/client/router";
export function Page() {
const [image, setImage] = useState(DEFAULT_AVATAR);
const [loading, setLoading] = useState(false);
const [onboarding, setOnboarding] = useAtom(onboardingAtom);
const [image, setImage] = useState(DEFAULT_AVATAR);
const [loading, setLoading] = useState(false);
const [onboarding, setOnboarding] = useAtom(onboardingAtom);
const {
register,
handleSubmit,
setValue,
formState: { isDirty, isValid },
} = useForm({
defaultValues: async () => {
if (onboarding.metadata) {
return onboarding.metadata;
} else {
return null;
}
},
});
const {
register,
handleSubmit,
setValue,
formState: { isDirty, isValid },
} = useForm({
defaultValues: async () => {
if (onboarding.metadata) {
return onboarding.metadata;
} else {
return null;
}
},
});
const onSubmit = (data: any) => {
setLoading(true);
setOnboarding((prev) => ({ ...prev, metadata: data }));
navigate('/auth/create/step-3');
};
const onSubmit = (data: any) => {
setLoading(true);
setOnboarding((prev) => ({ ...prev, metadata: data }));
navigate("/auth/create/step-3");
};
useEffect(() => {
setValue('picture', image);
}, [setValue, image]);
useEffect(() => {
setValue("picture", image);
}, [setValue, image]);
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-2xl font-semibold text-zinc-200">Create your profile</h1>
</div>
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900 p-4">
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<input
type={'hidden'}
{...register('picture')}
value={image}
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">Avatar</label>
<div className="relative inline-flex h-36 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950">
<Image src={image} alt="avatar" className="relative z-10 h-11 w-11 rounded-md" />
<div className="absolute bottom-3 right-3 z-10">
<AvatarUploader valueState={setImage} />
</div>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">Display Name *</label>
<div className="relative w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
<input
type={'text'}
{...register('display_name', { required: true, minLength: 4 })}
spellCheck={false}
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">About</label>
<div className="relative h-20 w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
<textarea
{...register('about')}
spellCheck={false}
className="relative h-20 w-full resize-none rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
</div>
</div>
<div>
<button
type="submit"
disabled={!isDirty || !isValid}
className="w-full transform rounded-lg bg-fuchsia-500 px-3.5 py-2.5 font-medium text-white shadow-button hover:bg-fuchsia-600 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-70"
>
{loading ? (
<svg
className="h-4 w-4 animate-spin text-black dark:text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
) : (
<span>Continue </span>
)}
</button>
</div>
</form>
</div>
</div>
</div>
);
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-2xl font-semibold text-zinc-200">
Create your profile
</h1>
</div>
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900 p-4">
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<input
type={"hidden"}
{...register("picture")}
value={image}
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
Avatar
</label>
<div className="relative inline-flex h-36 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950">
<Image
src={image}
alt="avatar"
className="relative z-10 h-11 w-11 rounded-md"
/>
<div className="absolute bottom-3 right-3 z-10">
<AvatarUploader valueState={setImage} />
</div>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
Display Name *
</label>
<div className="relative w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
<input
type={"text"}
{...register("display_name", {
required: true,
minLength: 4,
})}
spellCheck={false}
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
About
</label>
<div className="relative h-20 w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
<textarea
{...register("about")}
spellCheck={false}
className="relative h-20 w-full resize-none rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
</div>
</div>
<div>
<button
type="submit"
disabled={!isDirty || !isValid}
className="w-full transform rounded-lg bg-fuchsia-500 px-3.5 py-2.5 font-medium text-white shadow-button hover:bg-fuchsia-600 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-70"
>
{loading ? (
<svg
className="h-4 w-4 animate-spin text-black dark:text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<title id="loading">Loading</title>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
) : (
<span>Continue </span>
)}
</button>
</div>
</form>
</div>
</div>
</div>
);
}

View File

@@ -1,182 +1,272 @@
import User from '@app/auth/components/user';
import User from "@app/auth/components/user";
import { RelayContext } from '@shared/relayProvider';
import { RelayContext } from "@shared/relayProvider";
import CheckCircleIcon from '@icons/checkCircle';
import CheckCircleIcon from "@icons/checkCircle";
import { WRITEONLY_RELAYS } from '@stores/constants';
import { onboardingAtom } from '@stores/onboarding';
import { WRITEONLY_RELAYS } from "@stores/constants";
import { onboardingAtom } from "@stores/onboarding";
import { createAccount, createPleb } from '@utils/storage';
import { arrayToNIP02 } from '@utils/transform';
import { createAccount, createPleb } from "@utils/storage";
import { arrayToNIP02 } from "@utils/transform";
import { useAtom } from 'jotai';
import { getEventHash, signEvent } from 'nostr-tools';
import { useContext, useState } from 'react';
import { navigate } from 'vite-plugin-ssr/client/router';
import { useAtom } from "jotai";
import { getEventHash, signEvent } from "nostr-tools";
import { useContext, useState } from "react";
import { navigate } from "vite-plugin-ssr/client/router";
const initialList = [
{ pubkey: '82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2' },
{ pubkey: 'a341f45ff9758f570a21b000c17d4e53a3a497c8397f26c0e6d61e5acffc7a98' },
{ pubkey: '04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9' },
{ pubkey: 'c4eabae1be3cf657bc1855ee05e69de9f059cb7a059227168b80b89761cbc4e0' },
{ pubkey: '6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93' },
{ pubkey: 'e88a691e98d9987c964521dff60025f60700378a4879180dcbbb4a5027850411' },
{ pubkey: '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d' },
{ pubkey: 'c49d52a573366792b9a6e4851587c28042fb24fa5625c6d67b8c95c8751aca15' },
{ pubkey: 'e33fe65f1fde44c6dc17eeb38fdad0fceaf1cae8722084332ed1e32496291d42' },
{ pubkey: '84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240' },
{ pubkey: '703e26b4f8bc0fa57f99d815dbb75b086012acc24fc557befa310f5aa08d1898' },
{ pubkey: 'bf2376e17ba4ec269d10fcc996a4746b451152be9031fa48e74553dde5526bce' },
{ pubkey: '4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0' },
{ pubkey: 'c9b19ffcd43e6a5f23b3d27106ce19e4ad2df89ba1031dd4617f1b591e108965' },
{ pubkey: 'c7dccba4fe4426a7b1ea239a5637ba40fab9862c8c86b3330fe65e9f667435f6' },
{ pubkey: '6e1534f56fc9e937e06237c8ba4b5662bcacc4e1a3cfab9c16d89390bec4fca3' },
{ pubkey: '50d94fc2d8580c682b071a542f8b1e31a200b0508bab95a33bef0855df281d63' },
{ pubkey: '3d2e51508699f98f0f2bdbe7a45b673c687fe6420f466dc296d90b908d51d594' },
{ pubkey: '6e3f51664e19e082df5217fd4492bb96907405a0b27028671dd7f297b688608c' },
{ pubkey: '2edbcea694d164629854a52583458fd6d965b161e3c48b57d3aff01940558884' },
{ pubkey: '3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24' },
{ pubkey: 'eab0e756d32b80bcd464f3d844b8040303075a13eabc3599a762c9ac7ab91f4f' },
{ pubkey: 'be1d89794bf92de5dd64c1e60f6a2c70c140abac9932418fee30c5c637fe9479' },
{ pubkey: 'a5e93aef8e820cbc7ab7b6205f854b87aed4b48c5f6b30fbbeba5c99e40dcf3f' },
{ pubkey: '1989034e56b8f606c724f45a12ce84a11841621aaf7182a1f6564380b9c4276b' },
{ pubkey: 'c48b5cced5ada74db078df6b00fa53fc1139d73bf0ed16de325d52220211dbd5' },
{ pubkey: '460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c' },
{ pubkey: '7f3b464b9ff3623630485060cbda3a7790131c5339a7803bde8feb79a5e1b06a' },
{ pubkey: 'b99dbca0184a32ce55904cb267b22e434823c97f418f36daf5d2dff0dd7b5c27' },
{ pubkey: 'e9e4276490374a0daf7759fd5f475deff6ffb9b0fc5fa98c902b5f4b2fe3bba2' },
{ pubkey: 'ea2e3c814d08a378f8a5b8faecb2884d05855975c5ca4b5c25e2d6f936286f14' },
{ pubkey: 'ff04a0e6cd80c141b0b55825fed127d4532a6eecdb7e743a38a3c28bf9f44609' },
{
pubkey: "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2",
},
{
pubkey: "a341f45ff9758f570a21b000c17d4e53a3a497c8397f26c0e6d61e5acffc7a98",
},
{
pubkey: "04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9",
},
{
pubkey: "c4eabae1be3cf657bc1855ee05e69de9f059cb7a059227168b80b89761cbc4e0",
},
{
pubkey: "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93",
},
{
pubkey: "e88a691e98d9987c964521dff60025f60700378a4879180dcbbb4a5027850411",
},
{
pubkey: "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
},
{
pubkey: "c49d52a573366792b9a6e4851587c28042fb24fa5625c6d67b8c95c8751aca15",
},
{
pubkey: "e33fe65f1fde44c6dc17eeb38fdad0fceaf1cae8722084332ed1e32496291d42",
},
{
pubkey: "84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240",
},
{
pubkey: "703e26b4f8bc0fa57f99d815dbb75b086012acc24fc557befa310f5aa08d1898",
},
{
pubkey: "bf2376e17ba4ec269d10fcc996a4746b451152be9031fa48e74553dde5526bce",
},
{
pubkey: "4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0",
},
{
pubkey: "c9b19ffcd43e6a5f23b3d27106ce19e4ad2df89ba1031dd4617f1b591e108965",
},
{
pubkey: "c7dccba4fe4426a7b1ea239a5637ba40fab9862c8c86b3330fe65e9f667435f6",
},
{
pubkey: "6e1534f56fc9e937e06237c8ba4b5662bcacc4e1a3cfab9c16d89390bec4fca3",
},
{
pubkey: "50d94fc2d8580c682b071a542f8b1e31a200b0508bab95a33bef0855df281d63",
},
{
pubkey: "3d2e51508699f98f0f2bdbe7a45b673c687fe6420f466dc296d90b908d51d594",
},
{
pubkey: "6e3f51664e19e082df5217fd4492bb96907405a0b27028671dd7f297b688608c",
},
{
pubkey: "2edbcea694d164629854a52583458fd6d965b161e3c48b57d3aff01940558884",
},
{
pubkey: "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24",
},
{
pubkey: "eab0e756d32b80bcd464f3d844b8040303075a13eabc3599a762c9ac7ab91f4f",
},
{
pubkey: "be1d89794bf92de5dd64c1e60f6a2c70c140abac9932418fee30c5c637fe9479",
},
{
pubkey: "a5e93aef8e820cbc7ab7b6205f854b87aed4b48c5f6b30fbbeba5c99e40dcf3f",
},
{
pubkey: "1989034e56b8f606c724f45a12ce84a11841621aaf7182a1f6564380b9c4276b",
},
{
pubkey: "c48b5cced5ada74db078df6b00fa53fc1139d73bf0ed16de325d52220211dbd5",
},
{
pubkey: "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
},
{
pubkey: "7f3b464b9ff3623630485060cbda3a7790131c5339a7803bde8feb79a5e1b06a",
},
{
pubkey: "b99dbca0184a32ce55904cb267b22e434823c97f418f36daf5d2dff0dd7b5c27",
},
{
pubkey: "e9e4276490374a0daf7759fd5f475deff6ffb9b0fc5fa98c902b5f4b2fe3bba2",
},
{
pubkey: "ea2e3c814d08a378f8a5b8faecb2884d05855975c5ca4b5c25e2d6f936286f14",
},
{
pubkey: "ff04a0e6cd80c141b0b55825fed127d4532a6eecdb7e743a38a3c28bf9f44609",
},
];
export function Page() {
const pool: any = useContext(RelayContext);
const pool: any = useContext(RelayContext);
const [loading, setLoading] = useState(false);
const [follows, setFollows] = useState([]);
const [onboarding] = useAtom(onboardingAtom);
const [loading, setLoading] = useState(false);
const [follows, setFollows] = useState([]);
const [onboarding] = useAtom(onboardingAtom);
// toggle follow state
const toggleFollow = (pubkey: string) => {
const arr = follows.includes(pubkey) ? follows.filter((i) => i !== pubkey) : [...follows, pubkey];
setFollows(arr);
};
// toggle follow state
const toggleFollow = (pubkey: string) => {
const arr = follows.includes(pubkey)
? follows.filter((i) => i !== pubkey)
: [...follows, pubkey];
setFollows(arr);
};
const broadcastAccount = () => {
// build event
const event: any = {
content: JSON.stringify(onboarding.metadata),
created_at: Math.floor(Date.now() / 1000),
kind: 0,
pubkey: onboarding.pubkey,
tags: [],
};
event.id = getEventHash(event);
event.sig = signEvent(event, onboarding.privkey);
// broadcast
pool.publish(event, WRITEONLY_RELAYS);
};
const broadcastAccount = () => {
// build event
const event: any = {
content: JSON.stringify(onboarding.metadata),
created_at: Math.floor(Date.now() / 1000),
kind: 0,
pubkey: onboarding.pubkey,
tags: [],
};
event.id = getEventHash(event);
event.sig = signEvent(event, onboarding.privkey);
// broadcast
pool.publish(event, WRITEONLY_RELAYS);
};
const broadcastContacts = () => {
const nip02 = arrayToNIP02(follows);
// build event
const event: any = {
content: '',
created_at: Math.floor(Date.now() / 1000),
kind: 3,
pubkey: onboarding.pubkey,
tags: nip02,
};
event.id = getEventHash(event);
event.sig = signEvent(event, onboarding.privkey);
// broadcast
pool.publish(event, WRITEONLY_RELAYS);
};
const broadcastContacts = () => {
const nip02 = arrayToNIP02(follows);
// build event
const event: any = {
content: "",
created_at: Math.floor(Date.now() / 1000),
kind: 3,
pubkey: onboarding.pubkey,
tags: nip02,
};
event.id = getEventHash(event);
event.sig = signEvent(event, onboarding.privkey);
// broadcast
pool.publish(event, WRITEONLY_RELAYS);
};
// save follows to database then broadcast
const submit = async () => {
setLoading(true);
// save follows to database then broadcast
const submit = async () => {
setLoading(true);
const followsIncludeSelf = follows.concat([onboarding.pubkey]);
// insert to database
createAccount(onboarding.pubkey, onboarding.privkey, onboarding.metadata, arrayToNIP02(followsIncludeSelf), 1)
.then((res) => {
if (res) {
for (const tag of follows) {
fetch(`https://us.rbr.bio/${tag}/metadata.json`)
.then((data) => data.json())
.then((data) => createPleb(tag, data ?? ''));
}
broadcastAccount();
broadcastContacts();
setTimeout(() => navigate('/', { overwriteLastHistoryEntry: true }), 2000);
} else {
console.error();
}
})
.catch(console.error);
};
const followsIncludeSelf = follows.concat([onboarding.pubkey]);
// insert to database
createAccount(
onboarding.pubkey,
onboarding.privkey,
onboarding.metadata,
arrayToNIP02(followsIncludeSelf),
1,
)
.then((res) => {
if (res) {
for (const tag of follows) {
fetch(`https://us.rbr.bio/${tag}/metadata.json`)
.then((data) => data.json())
.then((data) => createPleb(tag, data ?? ""));
}
broadcastAccount();
broadcastContacts();
setTimeout(
() => navigate("/", { overwriteLastHistoryEntry: true }),
2000,
);
} else {
console.error();
}
})
.catch(console.error);
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-2xl font-semibold text-zinc-200">Personalized your newsfeed</h1>
</div>
<div className="flex flex-col gap-4">
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900">
<div className="inline-flex h-10 w-full items-center gap-1 border-b border-zinc-800 px-4 text-sm font-medium text-zinc-400">
Follow at least
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text font-bold text-transparent">
{follows.length}/10
</span>{' '}
plebs
</div>
<div className="scrollbar-hide flex h-96 flex-col overflow-y-auto py-2">
{initialList.map((item: { pubkey: string }, index: number) => (
<button
key={index}
type="button"
onClick={() => toggleFollow(item.pubkey)}
className="inline-flex transform items-center justify-between bg-zinc-900 px-4 py-2 hover:bg-zinc-800 active:translate-y-1"
>
<User pubkey={item.pubkey} />
{follows.includes(item.pubkey) && (
<div>
<CheckCircleIcon width={16} height={16} className="text-green-400" />
</div>
)}
</button>
))}
</div>
</div>
{follows.length >= 10 && (
<button
onClick={() => submit()}
className="inline-flex h-10 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 px-3.5 font-medium text-white shadow-button hover:bg-fuchsia-600 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-70"
>
{loading === true ? (
<svg
className="h-5 w-5 animate-spin text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
) : (
<span>Continue </span>
)}
</button>
)}
</div>
</div>
</div>
);
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-2xl font-semibold text-zinc-200">
Personalized your newsfeed
</h1>
</div>
<div className="flex flex-col gap-4">
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900">
<div className="inline-flex h-10 w-full items-center gap-1 border-b border-zinc-800 px-4 text-sm font-medium text-zinc-400">
Follow at least
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text font-bold text-transparent">
{follows.length}/10
</span>{" "}
plebs
</div>
<div className="scrollbar-hide flex h-96 flex-col overflow-y-auto py-2">
{initialList.map((item: { pubkey: string }, index: number) => (
<button
key={`item-${index}`}
type="button"
onClick={() => toggleFollow(item.pubkey)}
className="inline-flex transform items-center justify-between bg-zinc-900 px-4 py-2 hover:bg-zinc-800 active:translate-y-1"
>
<User pubkey={item.pubkey} />
{follows.includes(item.pubkey) && (
<div>
<CheckCircleIcon
width={16}
height={16}
className="text-green-400"
/>
</div>
)}
</button>
))}
</div>
</div>
{follows.length >= 10 && (
<button
type="button"
onClick={() => submit()}
className="inline-flex h-10 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 px-3.5 font-medium text-white shadow-button hover:bg-fuchsia-600 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-70"
>
{loading === true ? (
<svg
className="h-5 w-5 animate-spin text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<title id="loading">Loading</title>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
) : (
<span>Continue </span>
)}
</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,124 +1,142 @@
import { onboardingAtom } from '@stores/onboarding';
import { onboardingAtom } from "@stores/onboarding";
import { useSetAtom } from 'jotai';
import { getPublicKey, nip19 } from 'nostr-tools';
import { Resolver, useForm } from 'react-hook-form';
import { navigate } from 'vite-plugin-ssr/client/router';
import { useSetAtom } from "jotai";
import { getPublicKey, nip19 } from "nostr-tools";
import { Resolver, useForm } from "react-hook-form";
import { navigate } from "vite-plugin-ssr/client/router";
type FormValues = {
key: string;
key: string;
};
const resolver: Resolver<FormValues> = async (values) => {
return {
values: values.key ? values : {},
errors: !values.key
? {
key: {
type: 'required',
message: 'This is required.',
},
}
: {},
};
return {
values: values.key ? values : {},
errors: !values.key
? {
key: {
type: "required",
message: "This is required.",
},
}
: {},
};
};
export function Page() {
const setOnboardingPrivkey = useSetAtom(onboardingAtom);
const setOnboardingPrivkey = useSetAtom(onboardingAtom);
const {
register,
setError,
handleSubmit,
formState: { errors, isDirty, isValid, isSubmitting },
} = useForm<FormValues>({ resolver });
const {
register,
setError,
handleSubmit,
formState: { errors, isDirty, isValid, isSubmitting },
} = useForm<FormValues>({ resolver });
const onSubmit = async (data: any) => {
try {
let privkey = data['key'];
const onSubmit = async (data: any) => {
try {
let privkey = data["key"];
if (privkey.substring(0, 4) === 'nsec') {
privkey = nip19.decode(privkey).data;
}
if (privkey.substring(0, 4) === "nsec") {
privkey = nip19.decode(privkey).data;
}
if (typeof getPublicKey(privkey) === 'string') {
setOnboardingPrivkey((prev) => ({ ...prev, privkey: privkey }));
navigate(`/auth/import/step-2`);
}
} catch (error) {
setError('key', {
type: 'custom',
message: 'Private Key is invalid, please check again',
});
}
};
if (typeof getPublicKey(privkey) === "string") {
setOnboardingPrivkey((prev) => ({ ...prev, privkey: privkey }));
navigate("/auth/import/step-2");
}
} catch (error) {
setError("key", {
type: "custom",
message: "Private Key is invalid, please check again",
});
}
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-2xl font-semibold text-zinc-200">Import your key</h1>
</div>
<div className="flex flex-col gap-4">
<div>
{/* #TODO: add function */}
<button className="inline-flex w-full transform items-center justify-center gap-1.5 rounded-lg bg-zinc-900 px-3.5 py-2.5 font-medium text-zinc-400 active:translate-y-1">
<div className="inline-flex items-center rounded-md bg-zinc-400/10 px-2 py-0.5 text-xs font-medium ring-1 ring-inset ring-zinc-400/20">
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-transparent">
Coming soon
</span>
</div>
<span>Continue with Nostr Connect</span>
</button>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-zinc-800"></div>
</div>
<div className="relative flex justify-center">
<span className="bg-zinc-950 px-2 text-sm text-zinc-500">or</span>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3">
<div className="flex flex-col gap-0.5">
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
<input
{...register('key', { required: true, minLength: 32 })}
type={'password'}
placeholder="Paste private key here..."
className="relative w-full rounded-lg border border-black/5 px-3.5 py-2.5 text-center shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
</div>
<span className="text-xs text-red-400">{errors.key && <p>{errors.key.message}</p>}</span>
</div>
<div className="flex h-9 items-center justify-center">
{isSubmitting ? (
<svg
className="h-5 w-5 animate-spin text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
) : (
<button
type="submit"
disabled={!isDirty || !isValid}
className="w-full transform rounded-lg bg-fuchsia-500 px-3.5 py-2.5 font-medium text-white shadow-button hover:bg-fuchsia-600 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-70"
>
<span className="drop-shadow-lg">Continue </span>
</button>
)}
</div>
</form>
</div>
</div>
</div>
);
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-2xl font-semibold text-zinc-200">
Import your key
</h1>
</div>
<div className="flex flex-col gap-4">
<div>
{/* #TODO: add function */}
<button
type="button"
className="inline-flex w-full transform items-center justify-center gap-1.5 rounded-lg bg-zinc-900 px-3.5 py-2.5 font-medium text-zinc-400 active:translate-y-1"
>
<div className="inline-flex items-center rounded-md bg-zinc-400/10 px-2 py-0.5 text-xs font-medium ring-1 ring-inset ring-zinc-400/20">
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-transparent">
Coming soon
</span>
</div>
<span>Continue with Nostr Connect</span>
</button>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-zinc-800" />
</div>
<div className="relative flex justify-center">
<span className="bg-zinc-950 px-2 text-sm text-zinc-500">or</span>
</div>
</div>
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-3"
>
<div className="flex flex-col gap-0.5">
<div className="relative shrink-0 before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
<input
{...register("key", { required: true, minLength: 32 })}
type={"password"}
placeholder="Paste private key here..."
className="relative w-full rounded-lg border border-black/5 px-3.5 py-2.5 text-center shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
</div>
<span className="text-xs text-red-400">
{errors.key && <p>{errors.key.message}</p>}
</span>
</div>
<div className="flex h-9 items-center justify-center">
{isSubmitting ? (
<svg
className="h-5 w-5 animate-spin text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<title id="loading">Loading</title>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
) : (
<button
type="submit"
disabled={!isDirty || !isValid}
className="w-full transform rounded-lg bg-fuchsia-500 px-3.5 py-2.5 font-medium text-white shadow-button hover:bg-fuchsia-600 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-70"
>
<span className="drop-shadow-lg">Continue </span>
</button>
)}
</div>
</form>
</div>
</div>
</div>
);
}

View File

@@ -1,143 +1,158 @@
import { Image } from '@shared/image';
import { RelayContext } from '@shared/relayProvider';
import { Image } from "@shared/image";
import { RelayContext } from "@shared/relayProvider";
import { DEFAULT_AVATAR, READONLY_RELAYS } from '@stores/constants';
import { onboardingAtom } from '@stores/onboarding';
import { DEFAULT_AVATAR, READONLY_RELAYS } from "@stores/constants";
import { onboardingAtom } from "@stores/onboarding";
import { shortenKey } from '@utils/shortenKey';
import { createAccount, createPleb } from '@utils/storage';
import { shortenKey } from "@utils/shortenKey";
import { createAccount, createPleb } from "@utils/storage";
import { useAtom } from 'jotai';
import { getPublicKey } from 'nostr-tools';
import { useContext, useMemo, useState } from 'react';
import useSWRSubscription from 'swr/subscription';
import { navigate } from 'vite-plugin-ssr/client/router';
import { useAtom } from "jotai";
import { getPublicKey } from "nostr-tools";
import { useContext, useMemo, useState } from "react";
import useSWRSubscription from "swr/subscription";
import { navigate } from "vite-plugin-ssr/client/router";
export function Page() {
const pool: any = useContext(RelayContext);
const pool: any = useContext(RelayContext);
const [loading, setLoading] = useState(false);
const [onboarding, setOnboarding] = useAtom(onboardingAtom);
const pubkey = useMemo(() => (onboarding.privkey ? getPublicKey(onboarding.privkey) : ''), [onboarding.privkey]);
const [loading, setLoading] = useState(false);
const [onboarding, setOnboarding] = useAtom(onboardingAtom);
const pubkey = useMemo(
() => (onboarding.privkey ? getPublicKey(onboarding.privkey) : ""),
[onboarding.privkey],
);
const { data, error } = useSWRSubscription(pubkey ? pubkey : null, (key, { next }) => {
const unsubscribe = pool.subscribe(
[
{
kinds: [0, 3],
authors: [key],
},
],
READONLY_RELAYS,
(event: any) => {
switch (event.kind) {
case 0:
// update state
next(null, JSON.parse(event.content));
// create account
setOnboarding((prev) => ({ ...prev, metadata: event.content }));
break;
case 3:
setOnboarding((prev) => ({ ...prev, follows: event.tags }));
break;
default:
break;
}
}
);
const { data, error } = useSWRSubscription(
pubkey ? pubkey : null,
(key, { next }) => {
const unsubscribe = pool.subscribe(
[
{
kinds: [0, 3],
authors: [key],
},
],
READONLY_RELAYS,
(event: any) => {
switch (event.kind) {
case 0:
// update state
next(null, JSON.parse(event.content));
// create account
setOnboarding((prev) => ({ ...prev, metadata: event.content }));
break;
case 3:
setOnboarding((prev) => ({ ...prev, follows: event.tags }));
break;
default:
break;
}
},
);
return () => {
unsubscribe();
};
});
return () => {
unsubscribe();
};
},
);
const submit = () => {
// show loading indicator
setLoading(true);
const submit = () => {
// show loading indicator
setLoading(true);
const follows = onboarding.follows.concat([['p', pubkey]]);
// insert to database
createAccount(pubkey, onboarding.privkey, onboarding.metadata, follows, 1)
.then((res) => {
if (res) {
for (const tag of onboarding.follows) {
fetch(`https://us.rbr.bio/${tag[1]}/metadata.json`)
.then((data) => data.json())
.then((data) => createPleb(tag[1], data ?? ''));
}
setTimeout(() => navigate('/', { overwriteLastHistoryEntry: true }), 2000);
} else {
console.error();
}
})
.catch(console.error);
};
const follows = onboarding.follows.concat([["p", pubkey]]);
// insert to database
createAccount(pubkey, onboarding.privkey, onboarding.metadata, follows, 1)
.then((res) => {
if (res) {
for (const tag of onboarding.follows) {
fetch(`https://us.rbr.bio/${tag[1]}/metadata.json`)
.then((data) => data.json())
.then((data) => createPleb(tag[1], data ?? ""));
}
setTimeout(
() => navigate("/", { overwriteLastHistoryEntry: true }),
2000,
);
} else {
console.error();
}
})
.catch(console.error);
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-2xl font-semibold">{loading ? 'Creating...' : 'Continue with'}</h1>
</div>
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900 p-4">
{error && <div>Failed to load profile</div>}
{!data ? (
<div className="w-full">
<div className="flex items-center gap-2">
<div className="h-11 w-11 animate-pulse rounded-lg bg-zinc-800"></div>
<div>
<h3 className="mb-1 h-4 w-16 animate-pulse rounded bg-zinc-800"></h3>
<p className="h-3 w-36 animate-pulse rounded bg-zinc-800"></p>
</div>
</div>
</div>
) : (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Image
className="relative inline-flex h-11 w-11 rounded-lg ring-2 ring-zinc-900"
src={data.picture || DEFAULT_AVATAR}
alt={pubkey}
/>
<div>
<h3 className="font-medium leading-none text-zinc-200">{data.display_name || data.name}</h3>
<p className="text-sm text-zinc-400">{data.nip05 || shortenKey(pubkey)}</p>
</div>
</div>
<button
type="button"
onClick={() => submit()}
className="inline-flex h-10 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 px-3.5 font-medium text-white shadow-button hover:bg-fuchsia-600 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-70"
>
{loading ? (
<svg
className="h-5 w-5 animate-spin text-black dark:text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
) : (
<span>Continue </span>
)}
</button>
</div>
)}
</div>
</div>
</div>
);
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-2xl font-semibold">
{loading ? "Creating..." : "Continue with"}
</h1>
</div>
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900 p-4">
{error && <div>Failed to load profile</div>}
{!data ? (
<div className="w-full">
<div className="flex items-center gap-2">
<div className="h-11 w-11 animate-pulse rounded-lg bg-zinc-800" />
<div>
<h3 className="mb-1 h-4 w-16 animate-pulse rounded bg-zinc-800" />
<p className="h-3 w-36 animate-pulse rounded bg-zinc-800" />
</div>
</div>
</div>
) : (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Image
className="relative inline-flex h-11 w-11 rounded-lg ring-2 ring-zinc-900"
src={data.picture || DEFAULT_AVATAR}
alt={pubkey}
/>
<div>
<h3 className="font-medium leading-none text-zinc-200">
{data.display_name || data.name}
</h3>
<p className="text-sm text-zinc-400">
{data.nip05 || shortenKey(pubkey)}
</p>
</div>
</div>
<button
type="button"
onClick={() => submit()}
className="inline-flex h-10 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 px-3.5 font-medium text-white shadow-button hover:bg-fuchsia-600 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-70"
>
{loading ? (
<svg
className="h-5 w-5 animate-spin text-black dark:text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
) : (
<span>Continue </span>
)}
</button>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,48 +1,48 @@
import { Image } from '@shared/image';
import { Image } from "@shared/image";
import ArrowRightIcon from '@icons/arrowRight';
import ArrowRightIcon from "@icons/arrowRight";
const PLEBS = [
'https://133332.xyz/p.jpg',
'https://void.cat/d/3Bp6jSHURFNQ9u3pK8nwtq.webp',
'https://i.imgur.com/f8SyhRL.jpg',
'http://nostr.build/i/6369.jpg',
'https://pbs.twimg.com/profile_images/1622010345589190656/mAPqsmtz_400x400.jpg',
'https://media.tenor.com/l5arkXy9RfIAAAAd/thunder.gif',
'https://nostr.build/i/p/nostr.build_0e412058980ed2ac4adf3de639304c9e970e2745ba9ca19c75f984f4f6da4971.jpeg',
'https://nostr.build/i/nostr.build_864a019a6c1d3a90a17363553d32b71de618d250f02cf0a59ca19fb3029fd5bc.jpg',
'https://void.cat/d/8zE9T8a39YfUVjrLM4xcpE.webp',
'https://avatars.githubusercontent.com/u/89577423',
'https://pbs.twimg.com/profile_images/1363180486080663554/iN-r_BiM_400x400.jpg',
'https://void.cat/d/JUBBqXgCcGBEh7jUgJaayy',
'https://phase1.attract-eu.com/wp-content/uploads/2020/03/ATTRACT_HPLM.png',
'https://www.retro-synthwave.com/wp-content/uploads/2017/01/PowerGlove-23.jpg',
'https://void.cat/d/KvAEMvYNmy1rfCH6a7HZzh.webp',
'https://media.giphy.com/media/NqfMNCkyGwtXhKFlCR/giphy-downsized-large.gif',
'https://i.imgur.com/VGpUNFS.jpg',
'https://nostr.build/i/p/nostr.build_b39254db43d5557df99d1eb516f1c2f56a21a01b10c248f6eb66aa827c9a90f4.jpeg',
'https://davidcoen.it/wp-content/uploads/2020/11/7004972-taglio.jpg',
'https://pbs.twimg.com/profile_images/1570432066348515330/26PtCuwF_400x400.jpg',
'https://nostr.build/i/nostr.build_9d33ee801aa08955be174554832952ab95a65d5e015176834c8aa9a4e2f2e3a5.jpg',
'https://www.linkpicture.com/q/0FE78CFF-C931-4568-A7AA-DD8AEE889992.jpeg',
'https://nostr.build/i/nostr.build_97d6e2d25dd92422eb3d6d645b7cee9ed9c614f331be7e6f7db9ccfdbc5ee260.png',
'https://pbs.twimg.com/profile_images/1569570198348337152/-n1KD74u_400x400.jpg',
'https://pbs.twimg.com/profile_images/1600149653898596354/5PVe-r-J_400x400.jpg',
'https://pbs.twimg.com/profile_images/1639659216372658178/Dnn-Ysp-_400x400.jpg',
'https://pbs.twimg.com/profile_images/1554429112978120706/yr1hXl6R_400x400.jpg',
'https://pbs.twimg.com/profile_images/1615478486688272385/q2ECeZDX_400x400.jpg',
'https://pbs.twimg.com/profile_images/1638644441773748226/tNsA6RpG_400x400.jpg',
'https://pbs.twimg.com/profile_images/1607882836740120576/3Tg1mTYJ_400x400.jpg',
'https://pbs.twimg.com/profile_images/1401907430339002369/WKrP9Esn_400x400.jpg',
'https://pbs.twimg.com/profile_images/1523971278478131200/TMPzfvhE_400x400.jpg',
'https://pbs.twimg.com/profile_images/1626421539884204032/aj4tmzsk_400x400.png',
'https://pbs.twimg.com/profile_images/1582771691779985408/C9MHYIgt_400x400.jpg',
'https://pbs.twimg.com/profile_images/1409612480465276931/38Vyx4e8_400x400.jpg',
'https://pbs.twimg.com/profile_images/1549826566787588098/MlduJCZO_400x400.jpg',
'https://pbs.twimg.com/profile_images/539211568035004416/sBMjPR9q_400x400.jpeg',
'https://pbs.twimg.com/profile_images/1548660003522887682/1QMHmles_400x400.jpg',
'https://pbs.twimg.com/profile_images/1362497143999787013/KLUoN1Vn_400x400.png',
'https://pbs.twimg.com/profile_images/1600434913240563713/AssmMGwf_400x400.jpg',
"https://133332.xyz/p.jpg",
"https://void.cat/d/3Bp6jSHURFNQ9u3pK8nwtq.webp",
"https://i.imgur.com/f8SyhRL.jpg",
"http://nostr.build/i/6369.jpg",
"https://pbs.twimg.com/profile_images/1622010345589190656/mAPqsmtz_400x400.jpg",
"https://media.tenor.com/l5arkXy9RfIAAAAd/thunder.gif",
"https://nostr.build/i/p/nostr.build_0e412058980ed2ac4adf3de639304c9e970e2745ba9ca19c75f984f4f6da4971.jpeg",
"https://nostr.build/i/nostr.build_864a019a6c1d3a90a17363553d32b71de618d250f02cf0a59ca19fb3029fd5bc.jpg",
"https://void.cat/d/8zE9T8a39YfUVjrLM4xcpE.webp",
"https://avatars.githubusercontent.com/u/89577423",
"https://pbs.twimg.com/profile_images/1363180486080663554/iN-r_BiM_400x400.jpg",
"https://void.cat/d/JUBBqXgCcGBEh7jUgJaayy",
"https://phase1.attract-eu.com/wp-content/uploads/2020/03/ATTRACT_HPLM.png",
"https://www.retro-synthwave.com/wp-content/uploads/2017/01/PowerGlove-23.jpg",
"https://void.cat/d/KvAEMvYNmy1rfCH6a7HZzh.webp",
"https://media.giphy.com/media/NqfMNCkyGwtXhKFlCR/giphy-downsized-large.gif",
"https://i.imgur.com/VGpUNFS.jpg",
"https://nostr.build/i/p/nostr.build_b39254db43d5557df99d1eb516f1c2f56a21a01b10c248f6eb66aa827c9a90f4.jpeg",
"https://davidcoen.it/wp-content/uploads/2020/11/7004972-taglio.jpg",
"https://pbs.twimg.com/profile_images/1570432066348515330/26PtCuwF_400x400.jpg",
"https://nostr.build/i/nostr.build_9d33ee801aa08955be174554832952ab95a65d5e015176834c8aa9a4e2f2e3a5.jpg",
"https://www.linkpicture.com/q/0FE78CFF-C931-4568-A7AA-DD8AEE889992.jpeg",
"https://nostr.build/i/nostr.build_97d6e2d25dd92422eb3d6d645b7cee9ed9c614f331be7e6f7db9ccfdbc5ee260.png",
"https://pbs.twimg.com/profile_images/1569570198348337152/-n1KD74u_400x400.jpg",
"https://pbs.twimg.com/profile_images/1600149653898596354/5PVe-r-J_400x400.jpg",
"https://pbs.twimg.com/profile_images/1639659216372658178/Dnn-Ysp-_400x400.jpg",
"https://pbs.twimg.com/profile_images/1554429112978120706/yr1hXl6R_400x400.jpg",
"https://pbs.twimg.com/profile_images/1615478486688272385/q2ECeZDX_400x400.jpg",
"https://pbs.twimg.com/profile_images/1638644441773748226/tNsA6RpG_400x400.jpg",
"https://pbs.twimg.com/profile_images/1607882836740120576/3Tg1mTYJ_400x400.jpg",
"https://pbs.twimg.com/profile_images/1401907430339002369/WKrP9Esn_400x400.jpg",
"https://pbs.twimg.com/profile_images/1523971278478131200/TMPzfvhE_400x400.jpg",
"https://pbs.twimg.com/profile_images/1626421539884204032/aj4tmzsk_400x400.png",
"https://pbs.twimg.com/profile_images/1582771691779985408/C9MHYIgt_400x400.jpg",
"https://pbs.twimg.com/profile_images/1409612480465276931/38Vyx4e8_400x400.jpg",
"https://pbs.twimg.com/profile_images/1549826566787588098/MlduJCZO_400x400.jpg",
"https://pbs.twimg.com/profile_images/539211568035004416/sBMjPR9q_400x400.jpeg",
"https://pbs.twimg.com/profile_images/1548660003522887682/1QMHmles_400x400.jpg",
"https://pbs.twimg.com/profile_images/1362497143999787013/KLUoN1Vn_400x400.png",
"https://pbs.twimg.com/profile_images/1600434913240563713/AssmMGwf_400x400.jpg",
];
const DURATION = 50000;
@@ -52,65 +52,80 @@ const PLEBS_PER_ROW = 20;
const random = (min, max) => Math.floor(Math.random() * (max - min)) + min;
const shuffle = (arr) => [...arr].sort(() => 0.5 - Math.random());
const InfiniteLoopSlider = ({ children, duration, reverse }: { children: any; duration: any; reverse: any }) => {
return (
<div>
<div
className="flex w-fit"
style={{
animationName: 'loop',
animationIterationCount: 'infinite',
animationDirection: reverse ? 'reverse' : 'normal',
animationDuration: duration + 'ms',
animationTimingFunction: 'linear',
}}
>
{children}
{children}
</div>
</div>
);
const InfiniteLoopSlider = ({
children,
duration,
reverse,
}: { children: any; duration: any; reverse: any }) => {
return (
<div>
<div
className="flex w-fit"
style={{
animationName: "loop",
animationIterationCount: "infinite",
animationDirection: reverse ? "reverse" : "normal",
animationDuration: `${duration}ms`,
animationTimingFunction: "linear",
}}
>
{children}
{children}
</div>
</div>
);
};
export function Page() {
return (
<div className="grid h-full w-full grid-rows-5">
<div className="row-span-3 overflow-hidden">
<div className="relaive flex w-full max-w-full shrink-0 flex-col gap-4 overflow-hidden p-4">
{[...new Array(ROWS)].map((_, i) => (
<InfiniteLoopSlider key={i} duration={random(DURATION - 5000, DURATION + 20000)} reverse={i % 2}>
{shuffle(PLEBS)
.slice(0, PLEBS_PER_ROW)
.map((tag) => (
<div key={tag} className="relative mr-4 h-11 w-11 gap-2 rounded-md bg-zinc-900 shadow-xl">
<Image src={tag} alt={tag} className="h-11 w-11 rounded-md border border-zinc-900" />
</div>
))}
</InfiniteLoopSlider>
))}
<div className="pointer-events-none absolute inset-0 bg-fade" />
</div>
</div>
<div className="row-span-2 flex w-full flex-col items-center gap-4 overflow-hidden pt-6 min-[1050px]:gap-8 min-[1050px]:pt-10">
<h1 className="animate-moveBg bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-5xl font-bold leading-none text-transparent">
Let&apos;s start!
</h1>
<div className="mt-4 flex flex-col items-center gap-1.5">
<a
href="/auth/create"
className="relative inline-flex h-14 w-64 items-center justify-center gap-2 rounded-full bg-zinc-900 px-6 text-lg font-medium ring-1 ring-zinc-800 hover:bg-zinc-800"
>
Create new key
<ArrowRightIcon width={20} height={20} />
</a>
<a
href="/auth/import"
className="inline-flex h-14 w-64 items-center justify-center gap-2 rounded-full px-6 text-base font-medium text-zinc-300 hover:bg-zinc-800"
>
Login with private key
</a>
</div>
</div>
</div>
);
return (
<div className="grid h-full w-full grid-rows-5">
<div className="row-span-3 overflow-hidden">
<div className="relaive flex w-full max-w-full shrink-0 flex-col gap-4 overflow-hidden p-4">
{[...new Array(ROWS)].map((_, i) => (
<InfiniteLoopSlider
key={`item-${i}`}
duration={random(DURATION - 5000, DURATION + 20000)}
reverse={i % 2}
>
{shuffle(PLEBS)
.slice(0, PLEBS_PER_ROW)
.map((tag) => (
<div
key={tag}
className="relative mr-4 h-11 w-11 gap-2 rounded-md bg-zinc-900 shadow-xl"
>
<Image
src={tag}
alt={tag}
className="h-11 w-11 rounded-md border border-zinc-900"
/>
</div>
))}
</InfiniteLoopSlider>
))}
<div className="pointer-events-none absolute inset-0 bg-fade" />
</div>
</div>
<div className="row-span-2 flex w-full flex-col items-center gap-4 overflow-hidden pt-6 min-[1050px]:gap-8 min-[1050px]:pt-10">
<h1 className="animate-moveBg bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-5xl font-bold leading-none text-transparent">
Let&apos;s start!
</h1>
<div className="mt-4 flex flex-col items-center gap-1.5">
<a
href="/auth/create"
className="relative inline-flex h-14 w-64 items-center justify-center gap-2 rounded-full bg-zinc-900 px-6 text-lg font-medium ring-1 ring-zinc-800 hover:bg-zinc-800"
>
Create new key
<ArrowRightIcon width={20} height={20} />
</a>
<a
href="/auth/import"
className="inline-flex h-14 w-64 items-center justify-center gap-2 rounded-full px-6 text-base font-medium text-zinc-300 hover:bg-zinc-800"
>
Login with private key
</a>
</div>
</div>
</div>
);
}

View File

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

View File

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

View File

@@ -1,254 +1,275 @@
import { AvatarUploader } from '@shared/avatarUploader';
import { Image } from '@shared/image';
import { RelayContext } from '@shared/relayProvider';
import { AvatarUploader } from "@shared/avatarUploader";
import { Image } from "@shared/image";
import { RelayContext } from "@shared/relayProvider";
import CancelIcon from '@icons/cancel';
import PlusIcon from '@icons/plus';
import CancelIcon from "@icons/cancel";
import PlusIcon from "@icons/plus";
import { DEFAULT_AVATAR, WRITEONLY_RELAYS } from '@stores/constants';
import { DEFAULT_AVATAR, WRITEONLY_RELAYS } from "@stores/constants";
import { dateToUnix } from '@utils/date';
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
import { createChannel } from '@utils/storage';
import { dateToUnix } from "@utils/date";
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
import { createChannel } from "@utils/storage";
import { Dialog, Transition } from '@headlessui/react';
import { getEventHash, signEvent } from 'nostr-tools';
import { Fragment, useContext, useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useSWRConfig } from 'swr';
import { navigate } from 'vite-plugin-ssr/client/router';
import { Dialog, Transition } from "@headlessui/react";
import { getEventHash, signEvent } from "nostr-tools";
import { Fragment, useContext, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useSWRConfig } from "swr";
import { navigate } from "vite-plugin-ssr/client/router";
export default function ChannelCreateModal() {
const pool: any = useContext(RelayContext);
const pool: any = useContext(RelayContext);
const { account, isError, isLoading } = useActiveAccount();
const { mutate } = useSWRConfig();
const { account, isError, isLoading } = useActiveAccount();
const { mutate } = useSWRConfig();
const [isOpen, setIsOpen] = useState(false);
const [image, setImage] = useState(DEFAULT_AVATAR);
const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [image, setImage] = useState(DEFAULT_AVATAR);
const [loading, setLoading] = useState(false);
const closeModal = () => {
setIsOpen(false);
};
const closeModal = () => {
setIsOpen(false);
};
const openModal = () => {
setIsOpen(true);
};
const openModal = () => {
setIsOpen(true);
};
const {
register,
handleSubmit,
reset,
setValue,
formState: { isDirty, isValid },
} = useForm();
const {
register,
handleSubmit,
reset,
setValue,
formState: { isDirty, isValid },
} = useForm();
const onSubmit = (data: any) => {
setLoading(true);
const onSubmit = (data: any) => {
setLoading(true);
if (!isError && !isLoading && account) {
const event: any = {
content: JSON.stringify(data),
created_at: dateToUnix(),
kind: 40,
pubkey: account.pubkey,
tags: [],
};
event.id = getEventHash(event);
event.sig = signEvent(event, account.privkey);
if (!isError && !isLoading && account) {
const event: any = {
content: JSON.stringify(data),
created_at: dateToUnix(),
kind: 40,
pubkey: account.pubkey,
tags: [],
};
event.id = getEventHash(event);
event.sig = signEvent(event, account.privkey);
// publish channel
pool.publish(event, WRITEONLY_RELAYS);
// insert to database
createChannel(event.id, event.pubkey, event.content, event.created_at);
// update channe llist
mutate('channels');
// reset form
reset();
setTimeout(() => {
// close modal
setIsOpen(false);
// redirect to channel page
navigate(`/app/channel?id=${event.id}`);
}, 2000);
} else {
console.log('error');
}
};
// publish channel
pool.publish(event, WRITEONLY_RELAYS);
// insert to database
createChannel(event.id, event.pubkey, event.content, event.created_at);
// update channe llist
mutate("channels");
// reset form
reset();
setTimeout(() => {
// close modal
setIsOpen(false);
// redirect to channel page
navigate(`/app/channel?id=${event.id}`);
}, 2000);
} else {
console.log("error");
}
};
useEffect(() => {
setValue('picture', image);
}, [setValue, image]);
useEffect(() => {
setValue("picture", image);
}, [setValue, image]);
return (
<>
<button
type="button"
onClick={() => openModal()}
className="group inline-flex h-8 items-center gap-2.5 rounded-md px-2.5 hover:bg-zinc-900"
>
<div className="inline-flex h-5 w-5 shrink items-center justify-center rounded bg-zinc-900 group-hover:bg-zinc-800">
<PlusIcon width={12} height={12} className="text-zinc-500" />
</div>
<div>
<h5 className="text-[13px] font-semibold text-zinc-500 group-hover:text-zinc-400">Add a new channel</h5>
</div>
</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Dialog.Title
as="h3"
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-2xl font-semibold leading-none text-transparent"
>
Create channel
</Dialog.Title>
<button
type="button"
onClick={closeModal}
autoFocus={false}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon width={20} height={20} className="text-zinc-300" />
</button>
</div>
<Dialog.Description className="leading-tight text-zinc-400">
Channels are freedom square, everyone can speech freely, no one can stop you or deceive what to
speech
</Dialog.Description>
</div>
</div>
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
<form onSubmit={handleSubmit(onSubmit)} className="flex h-full w-full flex-col gap-4">
<input
type={'hidden'}
{...register('picture')}
value={image}
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">Picture</label>
<div className="relative inline-flex h-36 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950">
<Image src={image} alt="channel picture" className="relative z-10 h-11 w-11 rounded-md" />
<div className="absolute bottom-3 right-3 z-10">
<AvatarUploader valueState={setImage} />
</div>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
Channel name *
</label>
<div className="relative w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
<input
type={'text'}
{...register('name', { required: true, minLength: 4 })}
spellCheck={false}
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
Description
</label>
<div className="relative h-20 w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
<textarea
{...register('about')}
spellCheck={false}
className="relative h-20 w-full resize-none rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
</div>
</div>
<div className="flex h-14 items-center justify-between gap-1 rounded-lg bg-zinc-800 px-4 py-2">
<div className="flex flex-col gap-0.5">
<div className="inline-flex items-center gap-1">
<span className="text-sm font-bold leading-none text-zinc-200">Make Private</span>
<div className="inline-flex items-center rounded-md bg-zinc-400/10 px-2 py-0.5 text-xs font-medium ring-1 ring-inset ring-zinc-400/20">
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-transparent">
Coming soon
</span>
</div>
</div>
<p className="text-sm leading-none text-zinc-400">
Private channels can only be viewed by member
</p>
</div>
<div>
<button
disabled
className="relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent bg-zinc-900 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-fuchsia-600 focus:ring-offset-2"
role="switch"
aria-checked="false"
>
<span className="pointer-events-none inline-block h-5 w-5 translate-x-0 transform rounded-full bg-zinc-600 shadow ring-0 transition duration-200 ease-in-out"></span>
</button>
</div>
</div>
<div>
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 font-medium text-white shadow-button active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30"
>
{loading ? (
<svg
className="h-4 w-4 animate-spin text-black dark:text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
) : (
'Create channel'
)}
</button>
</div>
</form>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
return (
<>
<button
type="button"
onClick={() => openModal()}
className="group inline-flex h-8 items-center gap-2.5 rounded-md px-2.5 hover:bg-zinc-900"
>
<div className="inline-flex h-5 w-5 shrink items-center justify-center rounded bg-zinc-900 group-hover:bg-zinc-800">
<PlusIcon width={12} height={12} className="text-zinc-500" />
</div>
<div>
<h5 className="text-[13px] font-semibold text-zinc-500 group-hover:text-zinc-400">
Add a new channel
</h5>
</div>
</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Dialog.Title
as="h3"
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-2xl font-semibold leading-none text-transparent"
>
Create channel
</Dialog.Title>
<button
type="button"
onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon
width={20}
height={20}
className="text-zinc-300"
/>
</button>
</div>
<Dialog.Description className="leading-tight text-zinc-400">
Channels are freedom square, everyone can speech freely,
no one can stop you or deceive what to speech
</Dialog.Description>
</div>
</div>
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
<form
onSubmit={handleSubmit(onSubmit)}
className="flex h-full w-full flex-col gap-4"
>
<input
type={"hidden"}
{...register("picture")}
value={image}
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
Picture
</label>
<div className="relative inline-flex h-36 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950">
<Image
src={image}
alt="channel picture"
className="relative z-10 h-11 w-11 rounded-md"
/>
<div className="absolute bottom-3 right-3 z-10">
<AvatarUploader valueState={setImage} />
</div>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
Channel name *
</label>
<div className="relative w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
<input
type={"text"}
{...register("name", {
required: true,
minLength: 4,
})}
spellCheck={false}
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
Description
</label>
<div className="relative h-20 w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
<textarea
{...register("about")}
spellCheck={false}
className="relative h-20 w-full resize-none rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
</div>
</div>
<div className="flex h-14 items-center justify-between gap-1 rounded-lg bg-zinc-800 px-4 py-2">
<div className="flex flex-col gap-0.5">
<div className="inline-flex items-center gap-1">
<span className="text-sm font-bold leading-none text-zinc-200">
Make Private
</span>
<div className="inline-flex items-center rounded-md bg-zinc-400/10 px-2 py-0.5 text-xs font-medium ring-1 ring-inset ring-zinc-400/20">
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-transparent">
Coming soon
</span>
</div>
</div>
<p className="text-sm leading-none text-zinc-400">
Private channels can only be viewed by member
</p>
</div>
<div>
<button
type="button"
disabled
className="relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent bg-zinc-900 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-fuchsia-600 focus:ring-offset-2"
role="switch"
aria-checked="false"
>
<span className="pointer-events-none inline-block h-5 w-5 translate-x-0 transform rounded-full bg-zinc-600 shadow ring-0 transition duration-200 ease-in-out" />
</button>
</div>
</div>
<div>
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 font-medium text-white shadow-button active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30"
>
{loading ? (
<svg
className="h-4 w-4 animate-spin text-black dark:text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<title id="loading">Loading</title>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
) : (
"Create channel"
)}
</button>
</div>
</form>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
}

View File

@@ -1,35 +1,41 @@
import { useChannelProfile } from '@app/channel/hooks/useChannelProfile';
import { useChannelProfile } from "@app/channel/hooks/useChannelProfile";
import { usePageContext } from '@utils/hooks/usePageContext';
import { usePageContext } from "@utils/hooks/usePageContext";
import { twMerge } from 'tailwind-merge';
import { twMerge } from "tailwind-merge";
export default function ChannelsListItem({ data }: { data: any }) {
const channel: any = useChannelProfile(data.event_id, data.pubkey);
const pageContext = usePageContext();
const channel: any = useChannelProfile(data.event_id, data.pubkey);
const pageContext = usePageContext();
const searchParams: any = pageContext.urlParsed.search;
const pageID = searchParams.id;
const searchParams: any = pageContext.urlParsed.search;
const pageID = searchParams.id;
return (
<a
href={`/app/channel?id=${data.event_id}&channelpub=${data.pubkey}`}
className={twMerge(
'group inline-flex h-8 items-center gap-2.5 rounded-md px-2.5 hover:bg-zinc-900',
pageID === data.event_id ? 'dark:bg-zinc-900 dark:text-zinc-100 hover:dark:bg-zinc-800' : ''
)}
>
<div
className={twMerge(
'inline-flex h-5 w-5 items-center justify-center rounded bg-zinc-900 group-hover:bg-zinc-800',
pageID === data.event_id ? 'dark:bg-zinc-800 group-hover:dark:bg-zinc-700' : ''
)}
>
<span className="text-xs text-zinc-200">#</span>
</div>
<div>
<h5 className="truncate text-[13px] font-semibold text-zinc-400">{channel?.name}</h5>
</div>
</a>
);
return (
<a
href={`/app/channel?id=${data.event_id}&channelpub=${data.pubkey}`}
className={twMerge(
"group inline-flex h-8 items-center gap-2.5 rounded-md px-2.5 hover:bg-zinc-900",
pageID === data.event_id
? "dark:bg-zinc-900 dark:text-zinc-100 hover:dark:bg-zinc-800"
: "",
)}
>
<div
className={twMerge(
"inline-flex h-5 w-5 items-center justify-center rounded bg-zinc-900 group-hover:bg-zinc-800",
pageID === data.event_id
? "dark:bg-zinc-800 group-hover:dark:bg-zinc-700"
: "",
)}
>
<span className="text-xs text-zinc-200">#</span>
</div>
<div>
<h5 className="truncate text-[13px] font-semibold text-zinc-400">
{channel?.name}
</h5>
</div>
</a>
);
}

View File

@@ -1,32 +1,34 @@
import ChannelCreateModal from '@app/channel/components/createModal';
import ChannelsListItem from '@app/channel/components/item';
import ChannelCreateModal from "@app/channel/components/createModal";
import ChannelsListItem from "@app/channel/components/item";
import { getChannels } from '@utils/storage';
import { getChannels } from "@utils/storage";
import useSWR from 'swr';
import useSWR from "swr";
const fetcher = () => getChannels(10, 0);
export default function ChannelsList() {
const { data, error }: any = useSWR('channels', fetcher);
const { data, error }: any = useSWR("channels", fetcher);
return (
<div className="flex flex-col gap-px">
{!data || error ? (
<>
<div className="inline-flex h-8 items-center gap-2 rounded-md px-2.5">
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800"></div>
<div className="h-3 w-full animate-pulse rounded-sm bg-zinc-800"></div>
</div>
<div className="inline-flex h-8 items-center gap-2 rounded-md px-2.5">
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800"></div>
<div className="h-3 w-full animate-pulse rounded-sm bg-zinc-800"></div>
</div>
</>
) : (
data.map((item: { event_id: string }) => <ChannelsListItem key={item.event_id} data={item} />)
)}
<ChannelCreateModal />
</div>
);
return (
<div className="flex flex-col gap-px">
{!data || error ? (
<>
<div className="inline-flex h-8 items-center gap-2 rounded-md px-2.5">
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3 w-full animate-pulse rounded-sm bg-zinc-800" />
</div>
<div className="inline-flex h-8 items-center gap-2 rounded-md px-2.5">
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3 w-full animate-pulse rounded-sm bg-zinc-800" />
</div>
</>
) : (
data.map((item: { event_id: string }) => (
<ChannelsListItem key={item.event_id} data={item} />
))
)}
<ChannelCreateModal />
</div>
);
}

View File

@@ -1,40 +1,44 @@
import MiniMember from '@app/channel/components/miniMember';
import MiniMember from "@app/channel/components/miniMember";
import { channelMembersAtom } from '@stores/channel';
import { channelMembersAtom } from "@stores/channel";
import { useAtomValue } from 'jotai';
import { useAtomValue } from "jotai";
export default function ChannelMembers() {
const membersAsSet = useAtomValue(channelMembersAtom);
const membersAsArray = [...membersAsSet];
const miniMembersList = membersAsArray.slice(0, 4);
const totalMembers =
membersAsArray.length > 0
? '+' +
Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
}).format(membersAsArray.length)
: 0;
const membersAsSet = useAtomValue(channelMembersAtom);
const membersAsArray = [...membersAsSet];
const miniMembersList = membersAsArray.slice(0, 4);
const totalMembers =
membersAsArray.length > 0
? `+${Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 1,
}).format(membersAsArray.length)}`
: 0;
return (
<div>
<div className="group flex -space-x-2 overflow-hidden hover:-space-x-1">
{miniMembersList.map((member, index) => (
<MiniMember key={index} pubkey={member} />
))}
{totalMembers ? (
<div className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-zinc-900 ring-2 ring-zinc-950 transition-all duration-150 ease-in-out group-hover:bg-zinc-800">
<span className="text-xs font-medium text-zinc-400 group-hover:text-zinc-200">{totalMembers}</span>
</div>
) : (
<div>
<button className="inline-flex h-8 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm text-white shadow-button">
Invite
</button>
</div>
)}
</div>
</div>
);
return (
<div>
<div className="group flex -space-x-2 overflow-hidden hover:-space-x-1">
{miniMembersList.map((member, index) => (
<MiniMember key={`item-${index}`} pubkey={member} />
))}
{totalMembers ? (
<div className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-zinc-900 ring-2 ring-zinc-950 transition-all duration-150 ease-in-out group-hover:bg-zinc-800">
<span className="text-xs font-medium text-zinc-400 group-hover:text-zinc-200">
{totalMembers}
</span>
</div>
) : (
<div>
<button
type="button"
className="inline-flex h-8 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm text-white shadow-button"
>
Invite
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,71 +1,78 @@
import ChannelMessageItem from '@app/channel/components/messages/item';
import ChannelMessageItem from "@app/channel/components/messages/item";
import { sortedChannelMessagesAtom } from '@stores/channel';
import { sortedChannelMessagesAtom } from "@stores/channel";
import { getHourAgo } from '@utils/date';
import { getHourAgo } from "@utils/date";
import { useAtomValue } from 'jotai';
import { useCallback, useRef } from 'react';
import { Virtuoso } from 'react-virtuoso';
import { useAtomValue } from "jotai";
import { useCallback, useRef } from "react";
import { Virtuoso } from "react-virtuoso";
export default function ChannelMessageList() {
const now = useRef(new Date());
const virtuosoRef = useRef(null);
const data = useAtomValue(sortedChannelMessagesAtom);
const now = useRef(new Date());
const virtuosoRef = useRef(null);
const data = useAtomValue(sortedChannelMessagesAtom);
const itemContent: any = useCallback(
(index: string | number) => {
return <ChannelMessageItem data={data[index]} />;
},
[data]
);
const itemContent: any = useCallback(
(index: string | number) => {
return <ChannelMessageItem data={data[index]} />;
},
[data],
);
const computeItemKey = useCallback(
(index: string | number) => {
return data[index].id;
},
[data]
);
const computeItemKey = useCallback(
(index: string | number) => {
return data[index].id;
},
[data],
);
return (
<div className="h-full w-full">
<Virtuoso
ref={virtuosoRef}
data={data}
itemContent={itemContent}
components={{
Header: () => (
<div className="relative py-4">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-zinc-800" />
</div>
<div className="relative flex justify-center">
<div className="inline-flex items-center gap-x-1.5 rounded-full bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-800">
{getHourAgo(24, now.current).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</div>
</div>
</div>
),
EmptyPlaceholder: () => (
<div className="flex flex-col gap-1 text-center">
<h3 className="text-sm font-semibold leading-none text-zinc-200">Nothing to see here yet</h3>
<p className="text-sm leading-none text-zinc-400">Be the first to share a message in this channel.</p>
</div>
),
}}
computeItemKey={computeItemKey}
initialTopMostItemIndex={data.length - 1}
alignToBottom={true}
followOutput={true}
overscan={50}
increaseViewportBy={{ top: 200, bottom: 200 }}
className="scrollbar-hide h-full w-full overflow-y-auto"
/>
</div>
);
return (
<div className="h-full w-full">
<Virtuoso
ref={virtuosoRef}
data={data}
itemContent={itemContent}
components={{
Header: () => (
<div className="relative py-4">
<div
className="absolute inset-0 flex items-center"
aria-hidden="true"
>
<div className="w-full border-t border-zinc-800" />
</div>
<div className="relative flex justify-center">
<div className="inline-flex items-center gap-x-1.5 rounded-full bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-800">
{getHourAgo(24, now.current).toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
})}
</div>
</div>
</div>
),
EmptyPlaceholder: () => (
<div className="flex flex-col gap-1 text-center">
<h3 className="text-sm font-semibold leading-none text-zinc-200">
Nothing to see here yet
</h3>
<p className="text-sm leading-none text-zinc-400">
Be the first to share a message in this channel.
</p>
</div>
),
}}
computeItemKey={computeItemKey}
initialTopMostItemIndex={data.length - 1}
alignToBottom={true}
followOutput={true}
overscan={50}
increaseViewportBy={{ top: 200, bottom: 200 }}
className="scrollbar-hide h-full w-full overflow-y-auto"
/>
</div>
);
}

View File

@@ -1,128 +1,134 @@
import UserReply from '@app/channel/components/messages/userReply';
import UserReply from "@app/channel/components/messages/userReply";
import { ImagePicker } from '@shared/form/imagePicker';
import { RelayContext } from '@shared/relayProvider';
import { ImagePicker } from "@shared/form/imagePicker";
import { RelayContext } from "@shared/relayProvider";
import CancelIcon from '@icons/cancel';
import CancelIcon from "@icons/cancel";
import { channelContentAtom, channelReplyAtom } from '@stores/channel';
import { WRITEONLY_RELAYS } from '@stores/constants';
import { channelContentAtom, channelReplyAtom } from "@stores/channel";
import { WRITEONLY_RELAYS } from "@stores/constants";
import { dateToUnix } from '@utils/date';
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
import { dateToUnix } from "@utils/date";
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
import { useAtom, useAtomValue } from 'jotai';
import { useResetAtom } from 'jotai/utils';
import { getEventHash, signEvent } from 'nostr-tools';
import { useContext } from 'react';
import { useAtom, useAtomValue } from "jotai";
import { useResetAtom } from "jotai/utils";
import { getEventHash, signEvent } from "nostr-tools";
import { useContext } from "react";
export default function ChannelMessageForm({ channelID }: { channelID: string | string[] }) {
const pool: any = useContext(RelayContext);
const { account, isLoading, isError } = useActiveAccount();
export default function ChannelMessageForm({
channelID,
}: { channelID: string | string[] }) {
const pool: any = useContext(RelayContext);
const { account, isLoading, isError } = useActiveAccount();
const [value, setValue] = useAtom(channelContentAtom);
const resetValue = useResetAtom(channelContentAtom);
const [value, setValue] = useAtom(channelContentAtom);
const resetValue = useResetAtom(channelContentAtom);
const channelReply = useAtomValue(channelReplyAtom);
const resetChannelReply = useResetAtom(channelReplyAtom);
const channelReply = useAtomValue(channelReplyAtom);
const resetChannelReply = useResetAtom(channelReplyAtom);
const submitEvent = () => {
let tags: any[][];
const submitEvent = () => {
let tags: any[][];
if (channelReply.id !== null) {
tags = [
['e', channelID, '', 'root'],
['e', channelReply.id, '', 'reply'],
['p', channelReply.pubkey, ''],
];
} else {
tags = [['e', channelID, '', 'root']];
}
if (channelReply.id !== null) {
tags = [
["e", channelID, "", "root"],
["e", channelReply.id, "", "reply"],
["p", channelReply.pubkey, ""],
];
} else {
tags = [["e", channelID, "", "root"]];
}
if (!isError && !isLoading && account) {
const event: any = {
content: value,
created_at: dateToUnix(),
kind: 42,
pubkey: account.pubkey,
tags: tags,
};
event.id = getEventHash(event);
event.sig = signEvent(event, account.privkey);
if (!isError && !isLoading && account) {
const event: any = {
content: value,
created_at: dateToUnix(),
kind: 42,
pubkey: account.pubkey,
tags: tags,
};
event.id = getEventHash(event);
event.sig = signEvent(event, account.privkey);
// publish note
pool.publish(event, WRITEONLY_RELAYS);
// reset state
resetValue();
// reset channel reply
resetChannelReply();
} else {
console.log('error');
}
};
// publish note
pool.publish(event, WRITEONLY_RELAYS);
// reset state
resetValue();
// reset channel reply
resetChannelReply();
} else {
console.log("error");
}
};
const handleEnterPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submitEvent();
}
};
const handleEnterPress = (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submitEvent();
}
};
const stopReply = () => {
resetChannelReply();
};
const stopReply = () => {
resetChannelReply();
};
return (
<div
className={`relative ${
channelReply.id ? 'h-36' : 'h-24'
} w-full overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20`}
>
{channelReply.id && (
<div className="absolute left-0 top-0 z-10 h-14 w-full p-[2px]">
<div className="flex h-full w-full items-center justify-between rounded-t-md border-b border-zinc-700/70 bg-zinc-900 px-3">
<div className="flex w-full flex-col">
<UserReply pubkey={channelReply.pubkey} />
<div className="-mt-3.5 pl-[32px]">
<div className="text-xs text-zinc-200">{channelReply.content}</div>
</div>
</div>
<button
onClick={() => stopReply()}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-800"
>
<CancelIcon width={12} height={12} className="text-zinc-100" />
</button>
</div>
</div>
)}
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleEnterPress}
spellCheck={false}
placeholder="Message"
className={`relative ${
channelReply.id ? 'h-36 pt-16' : 'h-24 pt-3'
} w-full resize-none rounded-lg border border-black/5 px-3.5 pb-3 text-sm shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500`}
/>
<div className="absolute bottom-2 w-full px-2">
<div className="flex w-full items-center justify-between bg-zinc-800">
<div className="flex items-center gap-2 divide-x divide-zinc-700">
<ImagePicker type="channel" />
<div className="flex items-center gap-2 pl-2"></div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => submitEvent()}
disabled={value.length === 0 ? true : false}
className="inline-flex h-8 w-16 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm font-medium shadow-button hover:bg-fuchsia-600 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
>
Send
</button>
</div>
</div>
</div>
</div>
);
return (
<div
className={`relative ${
channelReply.id ? "h-36" : "h-24"
} w-full overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20`}
>
{channelReply.id && (
<div className="absolute left-0 top-0 z-10 h-14 w-full p-[2px]">
<div className="flex h-full w-full items-center justify-between rounded-t-md border-b border-zinc-700/70 bg-zinc-900 px-3">
<div className="flex w-full flex-col">
<UserReply pubkey={channelReply.pubkey} />
<div className="-mt-3.5 pl-[32px]">
<div className="text-xs text-zinc-200">
{channelReply.content}
</div>
</div>
</div>
<button
type="button"
onClick={() => stopReply()}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-800"
>
<CancelIcon width={12} height={12} className="text-zinc-100" />
</button>
</div>
</div>
)}
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleEnterPress}
spellCheck={false}
placeholder="Message"
className={`relative ${
channelReply.id ? "h-36 pt-16" : "h-24 pt-3"
} w-full resize-none rounded-lg border border-black/5 px-3.5 pb-3 text-sm shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500`}
/>
<div className="absolute bottom-2 w-full px-2">
<div className="flex w-full items-center justify-between bg-zinc-800">
<div className="flex items-center gap-2 divide-x divide-zinc-700">
<ImagePicker type="channel" />
<div className="flex items-center gap-2 pl-2" />
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => submitEvent()}
disabled={value.length === 0 ? true : false}
className="inline-flex h-8 w-16 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm font-medium shadow-button hover:bg-fuchsia-600 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
>
Send
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,141 +1,147 @@
import { RelayContext } from '@shared/relayProvider';
import { Tooltip } from '@shared/tooltip';
import { RelayContext } from "@shared/relayProvider";
import { Tooltip } from "@shared/tooltip";
import CancelIcon from '@icons/cancel';
import HideIcon from '@icons/hide';
import CancelIcon from "@icons/cancel";
import HideIcon from "@icons/hide";
import { channelMessagesAtom } from '@stores/channel';
import { WRITEONLY_RELAYS } from '@stores/constants';
import { channelMessagesAtom } from "@stores/channel";
import { WRITEONLY_RELAYS } from "@stores/constants";
import { dateToUnix } from '@utils/date';
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
import { dateToUnix } from "@utils/date";
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
import { Dialog, Transition } from '@headlessui/react';
import { useAtom } from 'jotai';
import { getEventHash, signEvent } from 'nostr-tools';
import { Fragment, useContext, useState } from 'react';
import { Dialog, Transition } from "@headlessui/react";
import { useAtom } from "jotai";
import { getEventHash, signEvent } from "nostr-tools";
import { Fragment, useContext, useState } from "react";
export default function MessageHideButton({ id }: { id: string }) {
const pool: any = useContext(RelayContext);
const { account, isError, isLoading } = useActiveAccount();
const pool: any = useContext(RelayContext);
const { account, isError, isLoading } = useActiveAccount();
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useAtom(channelMessagesAtom);
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useAtom(channelMessagesAtom);
const closeModal = () => {
setIsOpen(false);
};
const closeModal = () => {
setIsOpen(false);
};
const openModal = () => {
setIsOpen(true);
};
const openModal = () => {
setIsOpen(true);
};
const hideMessage = () => {
if (!isError && !isLoading && account) {
const event: any = {
content: '',
created_at: dateToUnix(),
kind: 43,
pubkey: account.pubkey,
tags: [['e', id]],
};
event.id = getEventHash(event);
event.sig = signEvent(event, account.privkey);
const hideMessage = () => {
if (!isError && !isLoading && account) {
const event: any = {
content: "",
created_at: dateToUnix(),
kind: 43,
pubkey: account.pubkey,
tags: [["e", id]],
};
event.id = getEventHash(event);
event.sig = signEvent(event, account.privkey);
// publish note
pool.publish(event, WRITEONLY_RELAYS);
// update local state
const cloneMessages = [...messages];
const targetMessage = cloneMessages.findIndex((message) => message.id === id);
cloneMessages[targetMessage]['hide'] = true;
setMessages(cloneMessages);
// close modal
closeModal();
} else {
console.log('error');
}
};
// publish note
pool.publish(event, WRITEONLY_RELAYS);
// update local state
const cloneMessages = [...messages];
const targetMessage = cloneMessages.findIndex(
(message) => message.id === id,
);
cloneMessages[targetMessage]["hide"] = true;
setMessages(cloneMessages);
// close modal
closeModal();
} else {
console.log("error");
}
};
return (
<>
<Tooltip message="Hide this message">
<button
onClick={openModal}
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800"
>
<HideIcon width={16} height={16} className="text-zinc-200" />
</button>
</Tooltip>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col rounded-lg border border-zinc-800 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Dialog.Title
as="h3"
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-2xl font-semibold leading-none text-transparent"
>
Are you sure!
</Dialog.Title>
<button
type="button"
onClick={closeModal}
autoFocus={false}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon width={20} height={20} className="text-zinc-300" />
</button>
</div>
<Dialog.Description className="leading-tight text-zinc-400">
This message will be hidden from your feed.
</Dialog.Description>
</div>
</div>
<div className="flex h-full w-full flex-col items-end justify-center overflow-y-auto px-5 py-2.5">
<div className="flex items-center gap-2">
<button
type="button"
onClick={closeModal}
className="inline-flex h-9 items-center justify-center rounded-md px-2 text-sm font-medium text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"
>
Cancel
</button>
<button
type="button"
onClick={() => hideMessage()}
className="inline-flex h-9 items-center justify-center rounded-md bg-red-500 px-2 text-sm font-medium text-zinc-100 hover:bg-red-600"
>
Confirm
</button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
return (
<>
<Tooltip message="Hide this message">
<button
type="button"
onClick={openModal}
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800"
>
<HideIcon width={16} height={16} className="text-zinc-200" />
</button>
</Tooltip>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col rounded-lg border border-zinc-800 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Dialog.Title
as="h3"
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-2xl font-semibold leading-none text-transparent"
>
Are you sure!
</Dialog.Title>
<button
type="button"
onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon
width={20}
height={20}
className="text-zinc-300"
/>
</button>
</div>
<Dialog.Description className="leading-tight text-zinc-400">
This message will be hidden from your feed.
</Dialog.Description>
</div>
</div>
<div className="flex h-full w-full flex-col items-end justify-center overflow-y-auto px-5 py-2.5">
<div className="flex items-center gap-2">
<button
type="button"
onClick={closeModal}
className="inline-flex h-9 items-center justify-center rounded-md px-2 text-sm font-medium text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"
>
Cancel
</button>
<button
type="button"
onClick={() => hideMessage()}
className="inline-flex h-9 items-center justify-center rounded-md bg-red-500 px-2 text-sm font-medium text-zinc-100 hover:bg-red-600"
>
Confirm
</button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
}

View File

@@ -1,34 +1,42 @@
import MessageHideButton from '@app/channel/components/messages/hideButton';
import MessageMuteButton from '@app/channel/components/messages/muteButton';
import MessageReplyButton from '@app/channel/components/messages/replyButton';
import ChannelMessageUser from '@app/channel/components/messages/user';
import MessageHideButton from "@app/channel/components/messages/hideButton";
import MessageMuteButton from "@app/channel/components/messages/muteButton";
import MessageReplyButton from "@app/channel/components/messages/replyButton";
import ChannelMessageUser from "@app/channel/components/messages/user";
import { noteParser } from '@utils/parser';
import { noteParser } from "@utils/parser";
import { useMemo } from 'react';
import { useMemo } from "react";
export default function ChannelMessageItem({ data }: { data: any }) {
const content = useMemo(() => noteParser(data), [data]);
const content = useMemo(() => noteParser(data), [data]);
return (
<div className="group relative flex h-min min-h-min w-full select-text flex-col px-5 py-2 hover:bg-black/20">
<div className="flex flex-col">
<ChannelMessageUser pubkey={data.pubkey} time={data.created_at} />
<div className="-mt-[17px] pl-[48px]">
<div className="flex flex-col gap-2">
<div className="whitespace-pre-line break-words text-sm leading-tight">
{data.hide ? <span className="italic text-zinc-400">[hided message]</span> : content.parsed}
</div>
</div>
</div>
</div>
<div className="absolute -top-4 right-4 z-10 hidden group-hover:inline-flex">
<div className="inline-flex h-8 items-center justify-center gap-1.5 rounded bg-zinc-900 px-0.5 shadow-md shadow-black/20 ring-1 ring-zinc-800">
<MessageReplyButton id={data.id} pubkey={data.pubkey} content={data.content} />
<MessageHideButton id={data.id} />
<MessageMuteButton pubkey={data.pubkey} />
</div>
</div>
</div>
);
return (
<div className="group relative flex h-min min-h-min w-full select-text flex-col px-5 py-2 hover:bg-black/20">
<div className="flex flex-col">
<ChannelMessageUser pubkey={data.pubkey} time={data.created_at} />
<div className="-mt-[17px] pl-[48px]">
<div className="flex flex-col gap-2">
<div className="whitespace-pre-line break-words text-sm leading-tight">
{data.hide ? (
<span className="italic text-zinc-400">[hided message]</span>
) : (
content.parsed
)}
</div>
</div>
</div>
</div>
<div className="absolute -top-4 right-4 z-10 hidden group-hover:inline-flex">
<div className="inline-flex h-8 items-center justify-center gap-1.5 rounded bg-zinc-900 px-0.5 shadow-md shadow-black/20 ring-1 ring-zinc-800">
<MessageReplyButton
id={data.id}
pubkey={data.pubkey}
content={data.content}
/>
<MessageHideButton id={data.id} />
<MessageMuteButton pubkey={data.pubkey} />
</div>
</div>
</div>
);
}

View File

@@ -1,140 +1,146 @@
import { RelayContext } from '@shared/relayProvider';
import { Tooltip } from '@shared/tooltip';
import { RelayContext } from "@shared/relayProvider";
import { Tooltip } from "@shared/tooltip";
import CancelIcon from '@icons/cancel';
import MuteIcon from '@icons/mute';
import CancelIcon from "@icons/cancel";
import MuteIcon from "@icons/mute";
import { channelMessagesAtom } from '@stores/channel';
import { WRITEONLY_RELAYS } from '@stores/constants';
import { channelMessagesAtom } from "@stores/channel";
import { WRITEONLY_RELAYS } from "@stores/constants";
import { dateToUnix } from '@utils/date';
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
import { dateToUnix } from "@utils/date";
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
import { Dialog, Transition } from '@headlessui/react';
import { useAtom } from 'jotai';
import { getEventHash, signEvent } from 'nostr-tools';
import { Fragment, useContext, useState } from 'react';
import { Dialog, Transition } from "@headlessui/react";
import { useAtom } from "jotai";
import { getEventHash, signEvent } from "nostr-tools";
import { Fragment, useContext, useState } from "react";
export default function MessageMuteButton({ pubkey }: { pubkey: string }) {
const pool: any = useContext(RelayContext);
const { account, isError, isLoading } = useActiveAccount();
const pool: any = useContext(RelayContext);
const { account, isError, isLoading } = useActiveAccount();
const [messages, setMessages] = useAtom(channelMessagesAtom);
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useAtom(channelMessagesAtom);
const [isOpen, setIsOpen] = useState(false);
const closeModal = () => {
setIsOpen(false);
};
const closeModal = () => {
setIsOpen(false);
};
const openModal = () => {
setIsOpen(true);
};
const openModal = () => {
setIsOpen(true);
};
const muteUser = () => {
if (!isError && !isLoading && account) {
const event: any = {
content: '',
created_at: dateToUnix(),
kind: 44,
pubkey: account.pubkey,
tags: [['p', pubkey]],
};
event.id = getEventHash(event);
event.sig = signEvent(event, account.privkey);
const muteUser = () => {
if (!isError && !isLoading && account) {
const event: any = {
content: "",
created_at: dateToUnix(),
kind: 44,
pubkey: account.pubkey,
tags: [["p", pubkey]],
};
event.id = getEventHash(event);
event.sig = signEvent(event, account.privkey);
// publish note
pool.publish(event, WRITEONLY_RELAYS);
// update local state
const cloneMessages = [...messages];
const finalMessages = cloneMessages.filter((message) => message.pubkey !== pubkey);
setMessages(finalMessages);
// close modal
closeModal();
} else {
console.log('error');
}
};
// publish note
pool.publish(event, WRITEONLY_RELAYS);
// update local state
const cloneMessages = [...messages];
const finalMessages = cloneMessages.filter(
(message) => message.pubkey !== pubkey,
);
setMessages(finalMessages);
// close modal
closeModal();
} else {
console.log("error");
}
};
return (
<>
<Tooltip message="Mute this user">
<button
onClick={() => openModal()}
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800"
>
<MuteIcon width={16} height={16} className="text-zinc-200" />
</button>
</Tooltip>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col rounded-lg border border-zinc-800 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Dialog.Title
as="h3"
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-2xl font-semibold leading-none text-transparent"
>
Are you sure!
</Dialog.Title>
<button
type="button"
onClick={closeModal}
autoFocus={false}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon width={20} height={20} className="text-zinc-300" />
</button>
</div>
<Dialog.Description className="leading-tight text-zinc-400">
You will no longer see messages from this user.
</Dialog.Description>
</div>
</div>
<div className="flex h-full w-full flex-col items-end justify-center overflow-y-auto px-5 py-2.5">
<div className="flex items-center gap-2">
<button
type="button"
onClick={closeModal}
className="inline-flex h-9 items-center justify-center rounded-md px-2 text-sm font-medium text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"
>
Cancel
</button>
<button
type="button"
onClick={() => muteUser()}
className="inline-flex h-9 items-center justify-center rounded-md bg-red-500 px-2 text-sm font-medium text-zinc-100 hover:bg-red-600"
>
Confirm
</button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
return (
<>
<Tooltip message="Mute this user">
<button
type="button"
onClick={() => openModal()}
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800"
>
<MuteIcon width={16} height={16} className="text-zinc-200" />
</button>
</Tooltip>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col rounded-lg border border-zinc-800 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Dialog.Title
as="h3"
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-2xl font-semibold leading-none text-transparent"
>
Are you sure!
</Dialog.Title>
<button
type="button"
onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon
width={20}
height={20}
className="text-zinc-300"
/>
</button>
</div>
<Dialog.Description className="leading-tight text-zinc-400">
You will no longer see messages from this user.
</Dialog.Description>
</div>
</div>
<div className="flex h-full w-full flex-col items-end justify-center overflow-y-auto px-5 py-2.5">
<div className="flex items-center gap-2">
<button
type="button"
onClick={closeModal}
className="inline-flex h-9 items-center justify-center rounded-md px-2 text-sm font-medium text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"
>
Cancel
</button>
<button
type="button"
onClick={() => muteUser()}
className="inline-flex h-9 items-center justify-center rounded-md bg-red-500 px-2 text-sm font-medium text-zinc-100 hover:bg-red-600"
>
Confirm
</button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
}

View File

@@ -1,26 +1,31 @@
import { Tooltip } from '@shared/tooltip';
import { Tooltip } from "@shared/tooltip";
import ReplyMessageIcon from '@icons/replyMessage';
import ReplyMessageIcon from "@icons/replyMessage";
import { channelReplyAtom } from '@stores/channel';
import { channelReplyAtom } from "@stores/channel";
import { useSetAtom } from 'jotai';
import { useSetAtom } from "jotai";
export default function MessageReplyButton({ id, pubkey, content }: { id: string; pubkey: string; content: string }) {
const setChannelReplyAtom = useSetAtom(channelReplyAtom);
export default function MessageReplyButton({
id,
pubkey,
content,
}: { id: string; pubkey: string; content: string }) {
const setChannelReplyAtom = useSetAtom(channelReplyAtom);
const createReply = () => {
setChannelReplyAtom({ id: id, pubkey: pubkey, content: content });
};
const createReply = () => {
setChannelReplyAtom({ id: id, pubkey: pubkey, content: content });
};
return (
<Tooltip message="Reply to message">
<button
onClick={() => createReply()}
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800"
>
<ReplyMessageIcon width={16} height={16} className="text-zinc-200" />
</button>
</Tooltip>
);
return (
<Tooltip message="Reply to message">
<button
type="button"
onClick={() => createReply()}
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800"
>
<ReplyMessageIcon width={16} height={16} className="text-zinc-200" />
</button>
</Tooltip>
);
}

View File

@@ -1,49 +1,56 @@
import { Image } from '@shared/image';
import { Image } from "@shared/image";
import { DEFAULT_AVATAR, IMGPROXY_URL } from '@stores/constants';
import { DEFAULT_AVATAR, IMGPROXY_URL } from "@stores/constants";
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey";
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(relativeTime);
export default function ChannelMessageUser({ pubkey, time }: { pubkey: string; time: number }) {
const { user, isError, isLoading } = useProfile(pubkey);
export default function ChannelMessageUser({
pubkey,
time,
}: { pubkey: string; time: number }) {
const { user, isError, isLoading } = useProfile(pubkey);
return (
<div className="group flex items-start gap-3">
{isError || isLoading ? (
<>
<div className="relative h-9 w-9 shrink animate-pulse rounded-md bg-zinc-800"></div>
<div className="flex w-full flex-1 items-start justify-between">
<div className="flex items-baseline gap-2 text-sm">
<div className="h-4 w-20 animate-pulse rounded bg-zinc-800"></div>
</div>
</div>
</>
) : (
<>
<div className="relative h-9 w-9 shrink rounded-md">
<Image
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${user?.picture ? user.picture : DEFAULT_AVATAR}`}
alt={pubkey}
className="h-9 w-9 rounded-md object-cover"
/>
</div>
<div className="flex w-full flex-1 items-start justify-between">
<div className="flex items-baseline gap-2 text-sm">
<span className="font-semibold leading-none text-zinc-200 group-hover:underline">
{user?.display_name || user?.name || shortenKey(pubkey)}
</span>
<span className="leading-none text-zinc-500">·</span>
<span className="leading-none text-zinc-500">{dayjs().to(dayjs.unix(time))}</span>
</div>
</div>
</>
)}
</div>
);
return (
<div className="group flex items-start gap-3">
{isError || isLoading ? (
<>
<div className="relative h-9 w-9 shrink animate-pulse rounded-md bg-zinc-800" />
<div className="flex w-full flex-1 items-start justify-between">
<div className="flex items-baseline gap-2 text-sm">
<div className="h-4 w-20 animate-pulse rounded bg-zinc-800" />
</div>
</div>
</>
) : (
<>
<div className="relative h-9 w-9 shrink rounded-md">
<Image
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${
user?.picture ? user.picture : DEFAULT_AVATAR
}`}
alt={pubkey}
className="h-9 w-9 rounded-md object-cover"
/>
</div>
<div className="flex w-full flex-1 items-start justify-between">
<div className="flex items-baseline gap-2 text-sm">
<span className="font-semibold leading-none text-zinc-200 group-hover:underline">
{user?.display_name || user?.name || shortenKey(pubkey)}
</span>
<span className="leading-none text-zinc-500">·</span>
<span className="leading-none text-zinc-500">
{dayjs().to(dayjs.unix(time))}
</span>
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -1,34 +1,36 @@
import { Image } from '@shared/image';
import { Image } from "@shared/image";
import { DEFAULT_AVATAR, IMGPROXY_URL } from '@stores/constants';
import { DEFAULT_AVATAR, IMGPROXY_URL } from "@stores/constants";
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey";
export default function UserReply({ pubkey }: { pubkey: string }) {
const { user, isError, isLoading } = useProfile(pubkey);
const { user, isError, isLoading } = useProfile(pubkey);
return (
<div className="group flex items-start gap-1">
{isError || isLoading ? (
<>
<div className="relative h-7 w-7 shrink animate-pulse overflow-hidden rounded bg-zinc-800"></div>
<span className="h-2 w-10 animate-pulse rounded bg-zinc-800 text-xs font-medium leading-none text-zinc-500"></span>
</>
) : (
<>
<div className="relative h-7 w-7 shrink overflow-hidden rounded">
<Image
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${user?.picture ? user.picture : DEFAULT_AVATAR}`}
alt={pubkey}
className="h-7 w-7 rounded object-cover"
/>
</div>
<span className="text-xs font-medium leading-none text-zinc-500">
Replying to {user?.name || shortenKey(pubkey)}
</span>
</>
)}
</div>
);
return (
<div className="group flex items-start gap-1">
{isError || isLoading ? (
<>
<div className="relative h-7 w-7 shrink animate-pulse overflow-hidden rounded bg-zinc-800" />
<span className="h-2 w-10 animate-pulse rounded bg-zinc-800 text-xs font-medium leading-none text-zinc-500" />
</>
) : (
<>
<div className="relative h-7 w-7 shrink overflow-hidden rounded">
<Image
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${
user?.picture ? user.picture : DEFAULT_AVATAR
}`}
alt={pubkey}
className="h-7 w-7 rounded object-cover"
/>
</div>
<span className="text-xs font-medium leading-none text-zinc-500">
Replying to {user?.name || shortenKey(pubkey)}
</span>
</>
)}
</div>
);
}

View File

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

View File

@@ -1,23 +1,23 @@
import { Image } from '@shared/image';
import { Image } from "@shared/image";
import { DEFAULT_AVATAR } from '@stores/constants';
import { DEFAULT_AVATAR } from "@stores/constants";
import { useProfile } from '@utils/hooks/useProfile';
import { useProfile } from "@utils/hooks/useProfile";
export default function MiniMember({ pubkey }: { pubkey: string }) {
const { user, isError, isLoading } = useProfile(pubkey);
const { user, isError, isLoading } = useProfile(pubkey);
return (
<>
{isError || isLoading ? (
<div className="h-8 w-8 animate-pulse rounded-md bg-zinc-800"></div>
) : (
<Image
className="inline-block h-8 w-8 rounded-md bg-white ring-2 ring-zinc-950 transition-all duration-150 ease-in-out"
src={user?.picture || DEFAULT_AVATAR}
alt={user?.pubkey || 'user avatar'}
/>
)}
</>
);
return (
<>
{isError || isLoading ? (
<div className="h-8 w-8 animate-pulse rounded-md bg-zinc-800" />
) : (
<Image
className="inline-block h-8 w-8 rounded-md bg-white ring-2 ring-zinc-950 transition-all duration-150 ease-in-out"
src={user?.picture || DEFAULT_AVATAR}
alt={user?.pubkey || "user avatar"}
/>
)}
</>
);
}

View File

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

View File

@@ -1,247 +1,270 @@
import { AvatarUploader } from '@shared/avatarUploader';
import { Image } from '@shared/image';
import { RelayContext } from '@shared/relayProvider';
import { AvatarUploader } from "@shared/avatarUploader";
import { Image } from "@shared/image";
import { RelayContext } from "@shared/relayProvider";
import CancelIcon from '@icons/cancel';
import EditIcon from '@icons/edit';
import CancelIcon from "@icons/cancel";
import EditIcon from "@icons/edit";
import { DEFAULT_AVATAR, WRITEONLY_RELAYS } from '@stores/constants';
import { DEFAULT_AVATAR, WRITEONLY_RELAYS } from "@stores/constants";
import { dateToUnix } from '@utils/date';
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
import { getChannel } from '@utils/storage';
import { dateToUnix } from "@utils/date";
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
import { getChannel } from "@utils/storage";
import { Dialog, Transition } from '@headlessui/react';
import { getEventHash, signEvent } from 'nostr-tools';
import { Fragment, useContext, useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Dialog, Transition } from "@headlessui/react";
import { getEventHash, signEvent } from "nostr-tools";
import { Fragment, useContext, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
export default function ChannelUpdateModal({ id }: { id: string }) {
const pool: any = useContext(RelayContext);
const { account, isError, isLoading } = useActiveAccount();
const pool: any = useContext(RelayContext);
const { account, isError, isLoading } = useActiveAccount();
const [isOpen, setIsOpen] = useState(false);
const [image, setImage] = useState(DEFAULT_AVATAR);
const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [image, setImage] = useState(DEFAULT_AVATAR);
const [loading, setLoading] = useState(false);
const closeModal = () => {
setIsOpen(false);
};
const closeModal = () => {
setIsOpen(false);
};
const openModal = () => {
setIsOpen(true);
};
const openModal = () => {
setIsOpen(true);
};
const {
register,
handleSubmit,
reset,
setValue,
formState: { isDirty, isValid },
} = useForm({
defaultValues: async () => {
const channel = await getChannel(id);
const metadata = JSON.parse(channel.metadata);
// update image state
setImage(metadata.picture);
// set default values
return metadata;
},
});
const {
register,
handleSubmit,
reset,
setValue,
formState: { isDirty, isValid },
} = useForm({
defaultValues: async () => {
const channel = await getChannel(id);
const metadata = JSON.parse(channel.metadata);
// update image state
setImage(metadata.picture);
// set default values
return metadata;
},
});
const onSubmit = (data: any) => {
setLoading(true);
const onSubmit = (data: any) => {
setLoading(true);
if (!isError && !isLoading && account) {
const event: any = {
content: JSON.stringify(data),
created_at: dateToUnix(),
kind: 41,
pubkey: account.pubkey,
tags: [['e', id]],
};
event.id = getEventHash(event);
event.sig = signEvent(event, account.privkey);
if (!isError && !isLoading && account) {
const event: any = {
content: JSON.stringify(data),
created_at: dateToUnix(),
kind: 41,
pubkey: account.pubkey,
tags: [["e", id]],
};
event.id = getEventHash(event);
event.sig = signEvent(event, account.privkey);
// publish channel
pool.publish(event, WRITEONLY_RELAYS);
// reset form
reset();
// close modal
setIsOpen(false);
setLoading(false);
} else {
console.log('error');
}
};
// publish channel
pool.publish(event, WRITEONLY_RELAYS);
// reset form
reset();
// close modal
setIsOpen(false);
setLoading(false);
} else {
console.log("error");
}
};
useEffect(() => {
setValue('picture', image);
}, [setValue, image]);
useEffect(() => {
setValue("picture", image);
}, [setValue, image]);
return (
<>
<button
type="button"
onClick={() => openModal()}
className="group inline-flex h-8 w-8 items-center justify-center rounded-md bg-zinc-900 hover:bg-zinc-800 focus:outline-none"
>
<EditIcon width={16} height={16} className="text-zinc-400 group-hover:text-zinc-200" />
</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Dialog.Title
as="h3"
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-2xl font-semibold leading-none text-transparent"
>
Update channel
</Dialog.Title>
<button
type="button"
onClick={closeModal}
autoFocus={false}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon width={20} height={20} className="text-zinc-300" />
</button>
</div>
<Dialog.Description className="leading-tight text-zinc-400">
New metadata will be published on all relays, and will be immediately available to all users, so
please carefully.
</Dialog.Description>
</div>
</div>
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
<form onSubmit={handleSubmit(onSubmit)} className="flex h-full w-full flex-col gap-4">
<input
type={'hidden'}
{...register('picture')}
value={image}
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">Picture</label>
<div className="relative inline-flex h-36 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950">
<Image src={image} alt="channel picture" className="relative z-10 h-11 w-11 rounded-md" />
<div className="absolute bottom-3 right-3 z-10">
<AvatarUploader valueState={setImage} />
</div>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
Channel name *
</label>
<div className="relative w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
<input
type={'text'}
{...register('name', { required: true, minLength: 4 })}
spellCheck={false}
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
Description
</label>
<div className="relative h-20 w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
<textarea
{...register('about')}
spellCheck={false}
className="relative h-20 w-full resize-none rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
</div>
</div>
<div className="flex h-14 items-center justify-between gap-1 rounded-lg bg-zinc-800 px-4 py-2">
<div className="flex flex-col gap-0.5">
<div className="inline-flex items-center gap-1">
<span className="text-sm font-bold leading-none text-zinc-200">Make Private</span>
<div className="inline-flex items-center rounded-md bg-zinc-400/10 px-2 py-0.5 text-xs font-medium ring-1 ring-inset ring-zinc-400/20">
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-transparent">
Coming soon
</span>
</div>
</div>
<p className="text-sm leading-none text-zinc-400">
Private channels can only be viewed by member
</p>
</div>
<div>
<button
disabled
className="relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent bg-zinc-900 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-fuchsia-600 focus:ring-offset-2"
role="switch"
aria-checked="false"
>
<span className="pointer-events-none inline-block h-5 w-5 translate-x-0 transform rounded-full bg-zinc-600 shadow ring-0 transition duration-200 ease-in-out"></span>
</button>
</div>
</div>
<div>
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 font-medium text-white shadow-button active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30"
>
{loading ? (
<svg
className="h-4 w-4 animate-spin text-black dark:text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
) : (
'Update channel'
)}
</button>
</div>
</form>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
return (
<>
<button
type="button"
onClick={() => openModal()}
className="group inline-flex h-8 w-8 items-center justify-center rounded-md bg-zinc-900 hover:bg-zinc-800 focus:outline-none"
>
<EditIcon
width={16}
height={16}
className="text-zinc-400 group-hover:text-zinc-200"
/>
</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Dialog.Title
as="h3"
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-2xl font-semibold leading-none text-transparent"
>
Update channel
</Dialog.Title>
<button
type="button"
onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon
width={20}
height={20}
className="text-zinc-300"
/>
</button>
</div>
<Dialog.Description className="leading-tight text-zinc-400">
New metadata will be published on all relays, and will be
immediately available to all users, so please carefully.
</Dialog.Description>
</div>
</div>
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
<form
onSubmit={handleSubmit(onSubmit)}
className="flex h-full w-full flex-col gap-4"
>
<input
type={"hidden"}
{...register("picture")}
value={image}
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
Picture
</label>
<div className="relative inline-flex h-36 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950">
<Image
src={image}
alt="channel picture"
className="relative z-10 h-11 w-11 rounded-md"
/>
<div className="absolute bottom-3 right-3 z-10">
<AvatarUploader valueState={setImage} />
</div>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
Channel name *
</label>
<div className="relative w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
<input
type={"text"}
{...register("name", {
required: true,
minLength: 4,
})}
spellCheck={false}
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
Description
</label>
<div className="relative h-20 w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
<textarea
{...register("about")}
spellCheck={false}
className="relative h-20 w-full resize-none rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
</div>
</div>
<div className="flex h-14 items-center justify-between gap-1 rounded-lg bg-zinc-800 px-4 py-2">
<div className="flex flex-col gap-0.5">
<div className="inline-flex items-center gap-1">
<span className="text-sm font-bold leading-none text-zinc-200">
Make Private
</span>
<div className="inline-flex items-center rounded-md bg-zinc-400/10 px-2 py-0.5 text-xs font-medium ring-1 ring-inset ring-zinc-400/20">
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-transparent">
Coming soon
</span>
</div>
</div>
<p className="text-sm leading-none text-zinc-400">
Private channels can only be viewed by member
</p>
</div>
<div>
<button
type="button"
disabled
className="relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent bg-zinc-900 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-fuchsia-600 focus:ring-offset-2"
role="switch"
aria-checked="false"
>
<span className="pointer-events-none inline-block h-5 w-5 translate-x-0 transform rounded-full bg-zinc-600 shadow ring-0 transition duration-200 ease-in-out" />
</button>
</div>
</div>
<div>
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 font-medium text-white shadow-button active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30"
>
{loading ? (
<svg
className="h-4 w-4 animate-spin text-black dark:text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<title id="loading">Loading</title>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
) : (
"Update channel"
)}
</button>
</div>
</form>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
}

View File

@@ -1,56 +1,59 @@
import { RelayContext } from '@shared/relayProvider';
import { RelayContext } from "@shared/relayProvider";
import { READONLY_RELAYS } from '@stores/constants';
import { READONLY_RELAYS } from "@stores/constants";
import { getChannel, updateChannelMetadata } from '@utils/storage';
import { getChannel, updateChannelMetadata } from "@utils/storage";
import { useContext } from 'react';
import useSWR, { useSWRConfig } from 'swr';
import useSWRSubscription from 'swr/subscription';
import { useContext } from "react";
import useSWR, { useSWRConfig } from "swr";
import useSWRSubscription from "swr/subscription";
const fetcher = async ([, id]) => {
const result = await getChannel(id);
if (result) {
return JSON.parse(result.metadata);
} else {
return null;
}
const result = await getChannel(id);
if (result) {
return JSON.parse(result.metadata);
} else {
return null;
}
};
export function useChannelProfile(id: string, channelPubkey: string) {
const pool: any = useContext(RelayContext);
const pool: any = useContext(RelayContext);
const { mutate } = useSWRConfig();
const { data, isLoading } = useSWR(['channel-metadata', id], fetcher);
const { mutate } = useSWRConfig();
const { data, isLoading } = useSWR(["channel-metadata", id], fetcher);
useSWRSubscription(!isLoading && data ? ['channel-metadata', id] : null, ([, key], {}) => {
// subscribe to channel
const unsubscribe = pool.subscribe(
[
{
'#e': [key],
authors: [channelPubkey],
kinds: [41],
},
],
READONLY_RELAYS,
(event: { content: string }) => {
// update in local database
updateChannelMetadata(key, event.content);
// revaildate
mutate(['channel-metadata', key]);
},
undefined,
undefined,
{
unsubscribeOnEose: true,
}
);
useSWRSubscription(
!isLoading && data ? ["channel-metadata", id] : null,
([, key]) => {
// subscribe to channel
const unsubscribe = pool.subscribe(
[
{
"#e": [key],
authors: [channelPubkey],
kinds: [41],
},
],
READONLY_RELAYS,
(event: { content: string }) => {
// update in local database
updateChannelMetadata(key, event.content);
// revaildate
mutate(["channel-metadata", key]);
},
undefined,
undefined,
{
unsubscribeOnEose: true,
},
);
return () => {
unsubscribe();
};
});
return () => {
unsubscribe();
};
},
);
return data;
return data;
}

View File

@@ -1,29 +1,31 @@
import AppHeader from '@shared/appHeader';
import MultiAccounts from '@shared/multiAccounts';
import Navigation from '@shared/navigation';
import AppHeader from "@shared/appHeader";
import MultiAccounts from "@shared/multiAccounts";
import Navigation from "@shared/navigation";
export function LayoutChannel({ children }: { children: React.ReactNode }) {
return (
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white">
<div className="flex h-screen w-full flex-col">
<div
data-tauri-drag-region
className="relative h-9 shrink-0 border-b border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
>
<AppHeader />
</div>
<div className="relative flex min-h-0 w-full flex-1">
<div className="relative w-[68px] shrink-0 border-r border-zinc-900">
<MultiAccounts />
</div>
<div className="grid w-full grid-cols-4 xl:grid-cols-5">
<div className="scrollbar-hide col-span-1 overflow-y-auto overflow-x-hidden border-r border-zinc-900">
<Navigation />
</div>
<div className="col-span-3 m-3 overflow-hidden xl:col-span-4">{children}</div>
</div>
</div>
</div>
</div>
);
return (
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white">
<div className="flex h-screen w-full flex-col">
<div
data-tauri-drag-region
className="relative h-9 shrink-0 border-b border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
>
<AppHeader />
</div>
<div className="relative flex min-h-0 w-full flex-1">
<div className="relative w-[68px] shrink-0 border-r border-zinc-900">
<MultiAccounts />
</div>
<div className="grid w-full grid-cols-4 xl:grid-cols-5">
<div className="scrollbar-hide col-span-1 overflow-y-auto overflow-x-hidden border-r border-zinc-900">
<Navigation />
</div>
<div className="col-span-3 m-3 overflow-hidden xl:col-span-4">
{children}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,127 +1,140 @@
import ChannelBlackList from '@app/channel/components/blacklist';
import ChannelMembers from '@app/channel/components/members';
import ChannelMessageForm from '@app/channel/components/messages/form';
import ChannelMetadata from '@app/channel/components/metadata';
import ChannelUpdateModal from '@app/channel/components/updateModal';
import ChannelBlackList from "@app/channel/components/blacklist";
import ChannelMembers from "@app/channel/components/members";
import ChannelMessageForm from "@app/channel/components/messages/form";
import ChannelMetadata from "@app/channel/components/metadata";
import ChannelUpdateModal from "@app/channel/components/updateModal";
import { RelayContext } from '@shared/relayProvider';
import { RelayContext } from "@shared/relayProvider";
import { channelMessagesAtom, channelReplyAtom } from '@stores/channel';
import { READONLY_RELAYS } from '@stores/constants';
import { channelMessagesAtom, channelReplyAtom } from "@stores/channel";
import { READONLY_RELAYS } from "@stores/constants";
import { dateToUnix, getHourAgo } from '@utils/date';
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
import { usePageContext } from '@utils/hooks/usePageContext';
import { getActiveBlacklist, getBlacklist } from '@utils/storage';
import { arrayObjToPureArr } from '@utils/transform';
import { dateToUnix, getHourAgo } from "@utils/date";
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
import { usePageContext } from "@utils/hooks/usePageContext";
import { getActiveBlacklist, getBlacklist } from "@utils/storage";
import { arrayObjToPureArr } from "@utils/transform";
import { useSetAtom } from 'jotai';
import { useResetAtom } from 'jotai/utils';
import { Suspense, lazy, useContext, useEffect, useRef } from 'react';
import useSWR from 'swr';
import useSWRSubscription from 'swr/subscription';
import { useSetAtom } from "jotai";
import { useResetAtom } from "jotai/utils";
import { Suspense, lazy, useContext, useEffect, useRef } from "react";
import useSWR from "swr";
import useSWRSubscription from "swr/subscription";
const fetchMuted = async ([, id]) => {
const res = await getBlacklist(id, 44);
const array = arrayObjToPureArr(res);
return { original: res, array: array };
const res = await getBlacklist(id, 44);
const array = arrayObjToPureArr(res);
return { original: res, array: array };
};
const fetchHided = async ([, id]) => {
const res = await getActiveBlacklist(id, 43);
const array = arrayObjToPureArr(res);
return array;
const res = await getActiveBlacklist(id, 43);
const array = arrayObjToPureArr(res);
return array;
};
const ChannelMessageList = lazy(() => import('@app/channel/components/messageList'));
const ChannelMessageList = lazy(
() => import("@app/channel/components/messageList"),
);
export function Page() {
const pool: any = useContext(RelayContext);
const pageContext = usePageContext();
const searchParams: any = pageContext.urlParsed.search;
const pool: any = useContext(RelayContext);
const pageContext = usePageContext();
const searchParams: any = pageContext.urlParsed.search;
const channelID = searchParams.id;
const channelPubkey = searchParams.channelpub;
const channelID = searchParams.id;
const channelPubkey = searchParams.channelpub;
const { account, isLoading, isError } = useActiveAccount();
const { data: muted } = useSWR(!isLoading && !isError && account ? ['muted', account.id] : null, fetchMuted);
const { data: hided } = useSWR(!isLoading && !isError && account ? ['hided', account.id] : null, fetchHided);
const { account, isLoading, isError } = useActiveAccount();
const { data: muted } = useSWR(
!isLoading && !isError && account ? ["muted", account.id] : null,
fetchMuted,
);
const { data: hided } = useSWR(
!isLoading && !isError && account ? ["hided", account.id] : null,
fetchHided,
);
const setChannelMessages = useSetAtom(channelMessagesAtom);
const resetChannelMessages = useResetAtom(channelMessagesAtom);
const resetChannelReply = useResetAtom(channelReplyAtom);
const setChannelMessages = useSetAtom(channelMessagesAtom);
const resetChannelMessages = useResetAtom(channelMessagesAtom);
const resetChannelReply = useResetAtom(channelReplyAtom);
const now = useRef(new Date());
const now = useRef(new Date());
useSWRSubscription(account && channelID && muted && hided ? ['channel', channelID] : null, ([, key], {}: any) => {
// subscribe to channel
const unsubscribe = pool.subscribe(
[
{
'#e': [key],
kinds: [42],
since: dateToUnix(getHourAgo(24, now.current)),
limit: 20,
},
],
READONLY_RELAYS,
(event: { id: string; pubkey: string }) => {
const message: any = event;
if (hided.includes(event.id)) {
message['hide'] = true;
} else {
message['hide'] = false;
}
if (!muted.array.includes(event.pubkey)) {
setChannelMessages((prev) => [...prev, message]);
}
}
);
useSWRSubscription(
account && channelID && muted && hided ? ["channel", channelID] : null,
([, key]) => {
// subscribe to channel
const unsubscribe = pool.subscribe(
[
{
"#e": [key],
kinds: [42],
since: dateToUnix(getHourAgo(24, now.current)),
limit: 20,
},
],
READONLY_RELAYS,
(event: { id: string; pubkey: string }) => {
const message: any = event;
if (hided.includes(event.id)) {
message["hide"] = true;
} else {
message["hide"] = false;
}
if (!muted.array.includes(event.pubkey)) {
setChannelMessages((prev) => [...prev, message]);
}
},
);
return () => {
unsubscribe();
};
});
return () => {
unsubscribe();
};
},
);
useEffect(() => {
let ignore = false;
useEffect(() => {
let ignore = false;
if (!ignore) {
// reset channel reply
resetChannelReply();
// reset channel messages
resetChannelMessages();
}
if (!ignore) {
// reset channel reply
resetChannelReply();
// reset channel messages
resetChannelMessages();
}
return () => {
ignore = true;
};
});
return () => {
ignore = true;
};
});
return (
<div className="flex h-full flex-col justify-between gap-2">
<div className="flex h-11 w-full shrink-0 items-center justify-between">
<div>
<ChannelMetadata id={channelID} pubkey={channelPubkey} />
</div>
<div className="flex items-center gap-2">
<ChannelMembers />
{!muted ? <></> : <ChannelBlackList blacklist={muted.original} />}
{!isLoading && !isError && account ? (
account.pubkey === channelPubkey && <ChannelUpdateModal id={channelID} />
) : (
<></>
)}
</div>
</div>
<div className="relative flex w-full flex-1 flex-col justify-between rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
<Suspense fallback={<p>Loading...</p>}>
<ChannelMessageList />
</Suspense>
<div className="inline-flex shrink-0 p-3">
<ChannelMessageForm channelID={channelID} />
</div>
</div>
</div>
);
return (
<div className="flex h-full flex-col justify-between gap-2">
<div className="flex h-11 w-full shrink-0 items-center justify-between">
<div>
<ChannelMetadata id={channelID} pubkey={channelPubkey} />
</div>
<div className="flex items-center gap-2">
<ChannelMembers />
{!muted ? <></> : <ChannelBlackList blacklist={muted.original} />}
{!isLoading && !isError && account ? (
account.pubkey === channelPubkey && (
<ChannelUpdateModal id={channelID} />
)
) : (
<></>
)}
</div>
</div>
<div className="relative flex w-full flex-1 flex-col justify-between rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
<Suspense fallback={<p>Loading...</p>}>
<ChannelMessageList />
</Suspense>
<div className="inline-flex shrink-0 p-3">
<ChannelMessageForm channelID={channelID} />
</div>
</div>
</div>
);
}

View File

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

View File

@@ -1,53 +1,55 @@
import { Image } from '@shared/image';
import { Image } from "@shared/image";
import { DEFAULT_AVATAR } from '@stores/constants';
import { DEFAULT_AVATAR } from "@stores/constants";
import { usePageContext } from '@utils/hooks/usePageContext';
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
import { usePageContext } from "@utils/hooks/usePageContext";
import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey";
import { twMerge } from 'tailwind-merge';
import { twMerge } from "tailwind-merge";
export default function ChatsListItem({ pubkey }: { pubkey: string }) {
const pageContext = usePageContext();
const pageContext = usePageContext();
const searchParams: any = pageContext.urlParsed.search;
const pagePubkey = searchParams.pubkey;
const searchParams: any = pageContext.urlParsed.search;
const pagePubkey = searchParams.pubkey;
const { user, isError, isLoading } = useProfile(pubkey);
const { user, isError, isLoading } = useProfile(pubkey);
return (
<>
{isError && <div>error</div>}
{isLoading && !user ? (
<div className="inline-flex h-8 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800"></div>
<div>
<div className="h-2.5 w-full animate-pulse truncate rounded bg-zinc-800 text-sm font-medium"></div>
</div>
</div>
) : (
<a
href={`/app/chat?pubkey=${pubkey}`}
className={twMerge(
'group inline-flex h-8 items-center gap-2.5 rounded-md px-2.5 hover:bg-zinc-900',
pagePubkey === pubkey ? 'dark:bg-zinc-900 dark:text-zinc-100 hover:dark:bg-zinc-800' : ''
)}
>
<div className="relative h-5 w-5 shrink-0 rounded">
<Image
src={user.picture || DEFAULT_AVATAR}
alt={pubkey}
className="h-5 w-5 rounded bg-white object-cover"
/>
</div>
<div>
<h5 className="truncate text-[13px] font-semibold text-zinc-400 group-hover:text-zinc-200">
{user.display_name || user.name || shortenKey(pubkey)}
</h5>
</div>
</a>
)}
</>
);
return (
<>
{isError && <div>error</div>}
{isLoading && !user ? (
<div className="inline-flex h-8 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800" />
<div>
<div className="h-2.5 w-full animate-pulse truncate rounded bg-zinc-800 text-sm font-medium" />
</div>
</div>
) : (
<a
href={`/app/chat?pubkey=${pubkey}`}
className={twMerge(
"group inline-flex h-8 items-center gap-2.5 rounded-md px-2.5 hover:bg-zinc-900",
pagePubkey === pubkey
? "dark:bg-zinc-900 dark:text-zinc-100 hover:dark:bg-zinc-800"
: "",
)}
>
<div className="relative h-5 w-5 shrink-0 rounded">
<Image
src={user.picture || DEFAULT_AVATAR}
alt={pubkey}
className="h-5 w-5 rounded bg-white object-cover"
/>
</div>
<div>
<h5 className="truncate text-[13px] font-semibold text-zinc-400 group-hover:text-zinc-200">
{user.display_name || user.name || shortenKey(pubkey)}
</h5>
</div>
</a>
)}
</>
);
}

View File

@@ -1,34 +1,39 @@
import ChatsListItem from '@app/chat/components/item';
import ChatsListSelfItem from '@app/chat/components/self';
import ChatsListItem from "@app/chat/components/item";
import ChatsListSelfItem from "@app/chat/components/self";
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
import { getChats } from '@utils/storage';
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
import { getChats } from "@utils/storage";
import useSWR from 'swr';
import useSWR from "swr";
const fetcher = ([, account]) => getChats(account);
export default function ChatsList() {
const { account, isLoading, isError } = useActiveAccount();
const { data: chats, error }: any = useSWR(!isLoading && !isError && account ? ['chats', account] : null, fetcher);
const { account, isLoading, isError } = useActiveAccount();
const { data: chats, error }: any = useSWR(
!isLoading && !isError && account ? ["chats", account] : null,
fetcher,
);
return (
<div className="flex flex-col gap-px">
<ChatsListSelfItem />
{!chats || error ? (
<>
<div className="inline-flex h-8 items-center gap-2 rounded-md px-2.5">
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800"></div>
<div className="h-3 w-full animate-pulse bg-zinc-800"></div>
</div>
<div className="inline-flex h-8 items-center gap-2 rounded-md px-2.5">
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800"></div>
<div className="h-3 w-full animate-pulse bg-zinc-800"></div>
</div>
</>
) : (
chats.map((item: { pubkey: string }) => <ChatsListItem key={item.pubkey} pubkey={item.pubkey} />)
)}
</div>
);
return (
<div className="flex flex-col gap-px">
<ChatsListSelfItem />
{!chats || error ? (
<>
<div className="inline-flex h-8 items-center gap-2 rounded-md px-2.5">
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3 w-full animate-pulse bg-zinc-800" />
</div>
<div className="inline-flex h-8 items-center gap-2 rounded-md px-2.5">
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3 w-full animate-pulse bg-zinc-800" />
</div>
</>
) : (
chats.map((item: { pubkey: string }) => (
<ChatsListItem key={item.pubkey} pubkey={item.pubkey} />
))
)}
</div>
);
}

View File

@@ -1,47 +1,53 @@
import { ChatMessageItem } from '@app/chat/components/messages/item';
import { ChatMessageItem } from "@app/chat/components/messages/item";
import { sortedChatMessagesAtom } from '@stores/chat';
import { sortedChatMessagesAtom } from "@stores/chat";
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
import { useAtomValue } from 'jotai';
import { useCallback, useRef } from 'react';
import { Virtuoso } from 'react-virtuoso';
import { useAtomValue } from "jotai";
import { useCallback, useRef } from "react";
import { Virtuoso } from "react-virtuoso";
export default function ChatMessageList() {
const { account } = useActiveAccount();
const { account } = useActiveAccount();
const virtuosoRef = useRef(null);
const data = useAtomValue(sortedChatMessagesAtom);
const virtuosoRef = useRef(null);
const data = useAtomValue(sortedChatMessagesAtom);
const itemContent: any = useCallback(
(index: string | number) => {
return <ChatMessageItem data={data[index]} userPubkey={account.pubkey} userPrivkey={account.privkey} />;
},
[account.privkey, account.pubkey, data]
);
const itemContent: any = useCallback(
(index: string | number) => {
return (
<ChatMessageItem
data={data[index]}
userPubkey={account.pubkey}
userPrivkey={account.privkey}
/>
);
},
[account.privkey, account.pubkey, data],
);
const computeItemKey = useCallback(
(index: string | number) => {
return data[index].id;
},
[data]
);
const computeItemKey = useCallback(
(index: string | number) => {
return data[index].id;
},
[data],
);
return (
<div className="h-full w-full">
<Virtuoso
ref={virtuosoRef}
data={data}
itemContent={itemContent}
computeItemKey={computeItemKey}
initialTopMostItemIndex={data.length - 1}
alignToBottom={true}
followOutput={true}
overscan={50}
increaseViewportBy={{ top: 200, bottom: 200 }}
className="scrollbar-hide h-full w-full overflow-y-auto"
/>
</div>
);
return (
<div className="h-full w-full">
<Virtuoso
ref={virtuosoRef}
data={data}
itemContent={itemContent}
computeItemKey={computeItemKey}
initialTopMostItemIndex={data.length - 1}
alignToBottom={true}
followOutput={true}
overscan={50}
increaseViewportBy={{ top: 200, bottom: 200 }}
className="scrollbar-hide h-full w-full overflow-y-auto"
/>
</div>
);
}

View File

@@ -1,89 +1,92 @@
import { ImagePicker } from '@shared/form/imagePicker';
import { RelayContext } from '@shared/relayProvider';
import { ImagePicker } from "@shared/form/imagePicker";
import { RelayContext } from "@shared/relayProvider";
import { chatContentAtom } from '@stores/chat';
import { WRITEONLY_RELAYS } from '@stores/constants';
import { chatContentAtom } from "@stores/chat";
import { WRITEONLY_RELAYS } from "@stores/constants";
import { dateToUnix } from '@utils/date';
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
import { dateToUnix } from "@utils/date";
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
import { useAtom } from 'jotai';
import { useResetAtom } from 'jotai/utils';
import { getEventHash, nip04, signEvent } from 'nostr-tools';
import { useCallback, useContext } from 'react';
import { useAtom } from "jotai";
import { useResetAtom } from "jotai/utils";
import { getEventHash, nip04, signEvent } from "nostr-tools";
import { useCallback, useContext } from "react";
export default function ChatMessageForm({ receiverPubkey }: { receiverPubkey: string }) {
const pool: any = useContext(RelayContext);
const { account, isLoading, isError } = useActiveAccount();
export default function ChatMessageForm({
receiverPubkey,
}: { receiverPubkey: string }) {
const pool: any = useContext(RelayContext);
const { account, isLoading, isError } = useActiveAccount();
const [value, setValue] = useAtom(chatContentAtom);
const resetValue = useResetAtom(chatContentAtom);
const [value, setValue] = useAtom(chatContentAtom);
const resetValue = useResetAtom(chatContentAtom);
const encryptMessage = useCallback(
async (privkey: string) => {
return await nip04.encrypt(privkey, receiverPubkey, value);
},
[receiverPubkey, value]
);
const encryptMessage = useCallback(
async (privkey: string) => {
return await nip04.encrypt(privkey, receiverPubkey, value);
},
[receiverPubkey, value],
);
const submitEvent = () => {
if (!isError && !isLoading && account) {
encryptMessage(account.privkey)
.then((encryptedContent) => {
const event: any = {
content: encryptedContent,
created_at: dateToUnix(),
kind: 4,
pubkey: account.pubkey,
tags: [['p', receiverPubkey]],
};
event.id = getEventHash(event);
event.sig = signEvent(event, account.privkey);
// publish note
pool.publish(event, WRITEONLY_RELAYS);
// reset state
resetValue();
})
.catch(console.error);
}
};
const submitEvent = () => {
if (!isError && !isLoading && account) {
encryptMessage(account.privkey)
.then((encryptedContent) => {
const event: any = {
content: encryptedContent,
created_at: dateToUnix(),
kind: 4,
pubkey: account.pubkey,
tags: [["p", receiverPubkey]],
};
event.id = getEventHash(event);
event.sig = signEvent(event, account.privkey);
// publish note
pool.publish(event, WRITEONLY_RELAYS);
// reset state
resetValue();
})
.catch(console.error);
}
};
const handleEnterPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submitEvent();
}
};
const handleEnterPress = (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submitEvent();
}
};
return (
<div className="relative h-24 w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
<div>
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleEnterPress}
spellCheck={false}
placeholder="Message"
className="relative h-24 w-full resize-none rounded-lg border border-black/5 px-3.5 py-3 text-sm shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
</div>
<div className="absolute bottom-2 w-full px-2">
<div className="flex w-full items-center justify-between bg-zinc-800">
<div className="flex items-center gap-2 divide-x divide-zinc-700">
<ImagePicker type="chat" />
<div className="flex items-center gap-2 pl-2"></div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => submitEvent()}
disabled={value.length === 0 ? true : false}
className="inline-flex h-8 w-16 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm font-medium shadow-button hover:bg-fuchsia-600 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
>
Send
</button>
</div>
</div>
</div>
</div>
);
return (
<div className="relative h-24 w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
<div>
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleEnterPress}
spellCheck={false}
placeholder="Message"
className="relative h-24 w-full resize-none rounded-lg border border-black/5 px-3.5 py-3 text-sm shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
</div>
<div className="absolute bottom-2 w-full px-2">
<div className="flex w-full items-center justify-between bg-zinc-800">
<div className="flex items-center gap-2 divide-x divide-zinc-700">
<ImagePicker type="chat" />
<div className="flex items-center gap-2 pl-2" />
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => submitEvent()}
disabled={value.length === 0 ? true : false}
className="inline-flex h-8 w-16 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm font-medium shadow-button hover:bg-fuchsia-600 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
>
Send
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,39 +1,49 @@
import ChatMessageUser from '@app/chat/components/messages/user';
import { useDecryptMessage } from '@app/chat/hooks/useDecryptMessage';
import ImagePreview from '@app/note/components/preview/image';
import VideoPreview from '@app/note/components/preview/video';
import ChatMessageUser from "@app/chat/components/messages/user";
import { useDecryptMessage } from "@app/chat/hooks/useDecryptMessage";
import ImagePreview from "@app/note/components/preview/image";
import VideoPreview from "@app/note/components/preview/video";
import { noteParser } from '@utils/parser';
import { noteParser } from "@utils/parser";
import { memo } from 'react';
import { memo } from "react";
export const ChatMessageItem = memo(function MessageListItem({
data,
userPubkey,
userPrivkey,
data,
userPubkey,
userPrivkey,
}: {
data: any;
userPubkey: string;
userPrivkey: string;
data: any;
userPubkey: string;
userPrivkey: string;
}) {
const decryptedContent = useDecryptMessage(userPubkey, userPrivkey, data);
// if we have decrypted content, use it instead of the encrypted content
if (decryptedContent) {
data['content'] = decryptedContent;
}
// parse the note content
const content = noteParser(data);
const decryptedContent = useDecryptMessage(userPubkey, userPrivkey, data);
// if we have decrypted content, use it instead of the encrypted content
if (decryptedContent) {
data["content"] = decryptedContent;
}
// parse the note content
const content = noteParser(data);
return (
<div className="flex h-min min-h-min w-full select-text flex-col px-5 py-2 hover:bg-black/20">
<div className="flex flex-col">
<ChatMessageUser pubkey={data.pubkey} time={data.created_at} />
<div className="-mt-[17px] pl-[48px]">
<div className="whitespace-pre-line break-words text-sm leading-tight">{content.parsed}</div>
{Array.isArray(content.images) && content.images.length ? <ImagePreview urls={content.images} /> : <></>}
{Array.isArray(content.videos) && content.videos.length ? <VideoPreview urls={content.videos} /> : <></>}
</div>
</div>
</div>
);
return (
<div className="flex h-min min-h-min w-full select-text flex-col px-5 py-2 hover:bg-black/20">
<div className="flex flex-col">
<ChatMessageUser pubkey={data.pubkey} time={data.created_at} />
<div className="-mt-[17px] pl-[48px]">
<div className="whitespace-pre-line break-words text-sm leading-tight">
{content.parsed}
</div>
{Array.isArray(content.images) && content.images.length ? (
<ImagePreview urls={content.images} />
) : (
<></>
)}
{Array.isArray(content.videos) && content.videos.length ? (
<VideoPreview urls={content.videos} />
) : (
<></>
)}
</div>
</div>
</div>
);
});

View File

@@ -1,49 +1,56 @@
import { Image } from '@shared/image';
import { Image } from "@shared/image";
import { DEFAULT_AVATAR, IMGPROXY_URL } from '@stores/constants';
import { DEFAULT_AVATAR, IMGPROXY_URL } from "@stores/constants";
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey";
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(relativeTime);
export default function ChatMessageUser({ pubkey, time }: { pubkey: string; time: number }) {
const { user, isError, isLoading } = useProfile(pubkey);
export default function ChatMessageUser({
pubkey,
time,
}: { pubkey: string; time: number }) {
const { user, isError, isLoading } = useProfile(pubkey);
return (
<div className="group flex items-start gap-3">
{isError || isLoading ? (
<>
<div className="relative h-9 w-9 shrink animate-pulse rounded-md bg-zinc-800"></div>
<div className="flex w-full flex-1 items-start justify-between">
<div className="flex items-baseline gap-2 text-sm">
<div className="h-4 w-20 animate-pulse rounded bg-zinc-800"></div>
</div>
</div>
</>
) : (
<>
<div className="relative h-9 w-9 shrink rounded-md">
<Image
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${user?.picture ? user.picture : DEFAULT_AVATAR}`}
alt={pubkey}
className="h-9 w-9 rounded-md object-cover"
/>
</div>
<div className="flex w-full flex-1 items-start justify-between">
<div className="flex items-baseline gap-2 text-sm">
<span className="font-semibold leading-none text-zinc-200 group-hover:underline">
{user?.display_name || user?.name || shortenKey(pubkey)}
</span>
<span className="leading-none text-zinc-500">·</span>
<span className="leading-none text-zinc-500">{dayjs().to(dayjs.unix(time))}</span>
</div>
</div>
</>
)}
</div>
);
return (
<div className="group flex items-start gap-3">
{isError || isLoading ? (
<>
<div className="relative h-9 w-9 shrink animate-pulse rounded-md bg-zinc-800" />
<div className="flex w-full flex-1 items-start justify-between">
<div className="flex items-baseline gap-2 text-sm">
<div className="h-4 w-20 animate-pulse rounded bg-zinc-800" />
</div>
</div>
</>
) : (
<>
<div className="relative h-9 w-9 shrink rounded-md">
<Image
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${
user?.picture ? user.picture : DEFAULT_AVATAR
}`}
alt={pubkey}
className="h-9 w-9 rounded-md object-cover"
/>
</div>
<div className="flex w-full flex-1 items-start justify-between">
<div className="flex items-baseline gap-2 text-sm">
<span className="font-semibold leading-none text-zinc-200 group-hover:underline">
{user?.display_name || user?.name || shortenKey(pubkey)}
</span>
<span className="leading-none text-zinc-500">·</span>
<span className="leading-none text-zinc-500">
{dayjs().to(dayjs.unix(time))}
</span>
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -1,55 +1,59 @@
import { Image } from '@shared/image';
import { Image } from "@shared/image";
import { DEFAULT_AVATAR } from '@stores/constants';
import { DEFAULT_AVATAR } from "@stores/constants";
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
import { usePageContext } from '@utils/hooks/usePageContext';
import { shortenKey } from '@utils/shortenKey';
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
import { usePageContext } from "@utils/hooks/usePageContext";
import { shortenKey } from "@utils/shortenKey";
import { twMerge } from 'tailwind-merge';
import { twMerge } from "tailwind-merge";
export default function ChatsListSelfItem() {
const pageContext = usePageContext();
const pageContext = usePageContext();
const searchParams: any = pageContext.urlParsed.search;
const pagePubkey = searchParams.pubkey;
const searchParams: any = pageContext.urlParsed.search;
const pagePubkey = searchParams.pubkey;
const { account, isLoading, isError } = useActiveAccount();
const profile = account ? JSON.parse(account.metadata) : null;
const { account, isLoading, isError } = useActiveAccount();
const profile = account ? JSON.parse(account.metadata) : null;
return (
<>
{isError && <div>error</div>}
{isLoading && !account ? (
<div className="inline-flex h-8 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800"></div>
<div>
<div className="h-2.5 w-full animate-pulse truncate rounded bg-zinc-800 text-sm font-medium"></div>
</div>
</div>
) : (
<a
href={`/app/chat?pubkey=${account.pubkey}`}
className={twMerge(
'inline-flex h-8 items-center gap-2.5 rounded-md px-2.5 hover:bg-zinc-900',
pagePubkey === account.pubkey ? 'dark:bg-zinc-900 dark:text-zinc-100 hover:dark:bg-zinc-800' : ''
)}
>
<div className="relative h-5 w-5 shrink-0 rounded">
<Image
src={profile?.picture || DEFAULT_AVATAR}
alt={account.pubkey}
className="h-5 w-5 rounded bg-white object-cover"
/>
</div>
<div>
<h5 className="truncate text-[13px] font-semibold text-zinc-400">
{profile?.display_name || profile?.name || shortenKey(account.pubkey)}{' '}
<span className="text-zinc-500">(you)</span>
</h5>
</div>
</a>
)}
</>
);
return (
<>
{isError && <div>error</div>}
{isLoading && !account ? (
<div className="inline-flex h-8 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800" />
<div>
<div className="h-2.5 w-full animate-pulse truncate rounded bg-zinc-800 text-sm font-medium" />
</div>
</div>
) : (
<a
href={`/app/chat?pubkey=${account.pubkey}`}
className={twMerge(
"inline-flex h-8 items-center gap-2.5 rounded-md px-2.5 hover:bg-zinc-900",
pagePubkey === account.pubkey
? "dark:bg-zinc-900 dark:text-zinc-100 hover:dark:bg-zinc-800"
: "",
)}
>
<div className="relative h-5 w-5 shrink-0 rounded">
<Image
src={profile?.picture || DEFAULT_AVATAR}
alt={account.pubkey}
className="h-5 w-5 rounded bg-white object-cover"
/>
</div>
<div>
<h5 className="truncate text-[13px] font-semibold text-zinc-400">
{profile?.display_name ||
profile?.name ||
shortenKey(account.pubkey)}{" "}
<span className="text-zinc-500">(you)</span>
</h5>
</div>
</a>
)}
</>
);
}

View File

@@ -1,28 +1,32 @@
import { nip04 } from 'nostr-tools';
import { useCallback, useEffect, useState } from 'react';
import { nip04 } from "nostr-tools";
import { useCallback, useEffect, useState } from "react";
export function useDecryptMessage(userKey: string, userPriv: string, data: any) {
const [content, setContent] = useState(null);
export function useDecryptMessage(
userKey: string,
userPriv: string,
data: any,
) {
const [content, setContent] = useState(null);
const extractSenderKey = useCallback(() => {
const keyInTags = data.tags.find(([k, v]) => k === 'p' && v && v !== '')[1];
if (keyInTags === userKey) {
return data.pubkey;
} else {
return keyInTags;
}
}, [data.pubkey, data.tags, userKey]);
const extractSenderKey = useCallback(() => {
const keyInTags = data.tags.find(([k, v]) => k === "p" && v && v !== "")[1];
if (keyInTags === userKey) {
return data.pubkey;
} else {
return keyInTags;
}
}, [data.pubkey, data.tags, userKey]);
const decrypt = useCallback(async () => {
const senderKey = extractSenderKey();
const result = await nip04.decrypt(userPriv, senderKey, data.content);
// update state with decrypt content
setContent(result);
}, [extractSenderKey, userPriv, data.content]);
const decrypt = useCallback(async () => {
const senderKey = extractSenderKey();
const result = await nip04.decrypt(userPriv, senderKey, data.content);
// update state with decrypt content
setContent(result);
}, [extractSenderKey, userPriv, data.content]);
useEffect(() => {
decrypt().catch(console.error);
}, [decrypt]);
useEffect(() => {
decrypt().catch(console.error);
}, [decrypt]);
return content ? content : null;
return content ? content : null;
}

View File

@@ -1,29 +1,31 @@
import AppHeader from '@shared/appHeader';
import MultiAccounts from '@shared/multiAccounts';
import Navigation from '@shared/navigation';
import AppHeader from "@shared/appHeader";
import MultiAccounts from "@shared/multiAccounts";
import Navigation from "@shared/navigation";
export function LayoutChat({ children }: { children: React.ReactNode }) {
return (
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white">
<div className="flex h-screen w-full flex-col">
<div
data-tauri-drag-region
className="relative h-9 shrink-0 border-b border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
>
<AppHeader />
</div>
<div className="relative flex min-h-0 w-full flex-1">
<div className="relative w-[68px] shrink-0 border-r border-zinc-900">
<MultiAccounts />
</div>
<div className="grid w-full grid-cols-4 xl:grid-cols-5">
<div className="scrollbar-hide col-span-1 overflow-y-auto overflow-x-hidden border-r border-zinc-900">
<Navigation />
</div>
<div className="col-span-3 m-3 overflow-hidden xl:col-span-4">{children}</div>
</div>
</div>
</div>
</div>
);
return (
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white">
<div className="flex h-screen w-full flex-col">
<div
data-tauri-drag-region
className="relative h-9 shrink-0 border-b border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
>
<AppHeader />
</div>
<div className="relative flex min-h-0 w-full flex-1">
<div className="relative w-[68px] shrink-0 border-r border-zinc-900">
<MultiAccounts />
</div>
<div className="grid w-full grid-cols-4 xl:grid-cols-5">
<div className="scrollbar-hide col-span-1 overflow-y-auto overflow-x-hidden border-r border-zinc-900">
<Navigation />
</div>
<div className="col-span-3 m-3 overflow-hidden xl:col-span-4">
{children}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,80 +1,80 @@
import ChatMessageForm from '@app/chat/components/messages/form';
import ChatMessageForm from "@app/chat/components/messages/form";
import { RelayContext } from '@shared/relayProvider';
import { RelayContext } from "@shared/relayProvider";
import { chatMessagesAtom } from '@stores/chat';
import { READONLY_RELAYS } from '@stores/constants';
import { chatMessagesAtom } from "@stores/chat";
import { READONLY_RELAYS } from "@stores/constants";
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
import { usePageContext } from '@utils/hooks/usePageContext';
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
import { usePageContext } from "@utils/hooks/usePageContext";
import { useSetAtom } from 'jotai';
import { useResetAtom } from 'jotai/utils';
import { Suspense, lazy, useContext, useEffect } from 'react';
import useSWRSubscription from 'swr/subscription';
import { useSetAtom } from "jotai";
import { useResetAtom } from "jotai/utils";
import { Suspense, lazy, useContext, useEffect } from "react";
import useSWRSubscription from "swr/subscription";
const ChatMessageList = lazy(() => import('@app/chat/components/messageList'));
const ChatMessageList = lazy(() => import("@app/chat/components/messageList"));
export function Page() {
const pool: any = useContext(RelayContext);
const pageContext = usePageContext();
const searchParams: any = pageContext.urlParsed.search;
const pool: any = useContext(RelayContext);
const pageContext = usePageContext();
const searchParams: any = pageContext.urlParsed.search;
const pubkey = searchParams.pubkey;
const pubkey = searchParams.pubkey;
const { account } = useActiveAccount();
const { account } = useActiveAccount();
const setChatMessages = useSetAtom(chatMessagesAtom);
const resetChatMessages = useResetAtom(chatMessagesAtom);
const setChatMessages = useSetAtom(chatMessagesAtom);
const resetChatMessages = useResetAtom(chatMessagesAtom);
useSWRSubscription(account ? ['chat', pubkey] : null, ([, key], {}: any) => {
const unsubscribe = pool.subscribe(
[
{
kinds: [4],
authors: [key],
'#p': [account.pubkey],
limit: 20,
},
{
kinds: [4],
authors: [account.pubkey],
'#p': [key],
limit: 20,
},
],
READONLY_RELAYS,
(event: any) => {
setChatMessages((prev) => [...prev, event]);
}
);
useSWRSubscription(account ? ["chat", pubkey] : null, ([, key]) => {
const unsubscribe = pool.subscribe(
[
{
kinds: [4],
authors: [key],
"#p": [account.pubkey],
limit: 20,
},
{
kinds: [4],
authors: [account.pubkey],
"#p": [key],
limit: 20,
},
],
READONLY_RELAYS,
(event: any) => {
setChatMessages((prev) => [...prev, event]);
},
);
return () => {
unsubscribe();
};
});
return () => {
unsubscribe();
};
});
useEffect(() => {
let ignore = false;
useEffect(() => {
let ignore = false;
if (!ignore) {
// reset chat messages
resetChatMessages();
}
if (!ignore) {
// reset chat messages
resetChatMessages();
}
return () => {
ignore = true;
};
});
return () => {
ignore = true;
};
});
return (
<div className="relative flex h-full w-full flex-col justify-between rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
<Suspense fallback={<p>Loading...</p>}>
<ChatMessageList />
</Suspense>
<div className="shrink-0 p-3">
<ChatMessageForm receiverPubkey={pubkey} />
</div>
</div>
);
return (
<div className="relative flex h-full w-full flex-col justify-between rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
<Suspense fallback={<p>Loading...</p>}>
<ChatMessageList />
</Suspense>
<div className="shrink-0 p-3">
<ChatMessageForm receiverPubkey={pubkey} />
</div>
</div>
);
}

View File

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

View File

@@ -1,24 +1,26 @@
import { getActiveAccount } from '@utils/storage';
import { getActiveAccount } from "@utils/storage";
import useSWR from 'swr';
import { navigate } from 'vite-plugin-ssr/client/router';
import useSWR from "swr";
import { navigate } from "vite-plugin-ssr/client/router";
const fetcher = () => getActiveAccount();
export function Page() {
const { data, isLoading } = useSWR('account', fetcher, {
revalidateIfStale: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
});
const { data, isLoading } = useSWR("account", fetcher, {
revalidateIfStale: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
});
if (!isLoading && !data) {
navigate('/auth', { overwriteLastHistoryEntry: true });
}
if (!isLoading && !data) {
navigate("/auth", { overwriteLastHistoryEntry: true });
}
if (!isLoading && data) {
navigate('/app/inital-data', { overwriteLastHistoryEntry: true });
}
if (!isLoading && data) {
navigate("/app/inital-data", { overwriteLastHistoryEntry: true });
}
return <div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-white"></div>;
return (
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-white" />
);
}

View File

@@ -1,233 +1,246 @@
import { RelayContext } from '@shared/relayProvider';
import { RelayContext } from "@shared/relayProvider";
import LumeIcon from '@icons/lume';
import LumeIcon from "@icons/lume";
import { READONLY_RELAYS } from '@stores/constants';
import { READONLY_RELAYS } from "@stores/constants";
import { dateToUnix, getHourAgo } from '@utils/date';
import { dateToUnix, getHourAgo } from "@utils/date";
import {
addToBlacklist,
countTotalLongNotes,
countTotalNotes,
createChat,
createNote,
getActiveAccount,
getLastLogin,
updateLastLogin,
} from '@utils/storage';
import { getParentID, nip02ToArray } from '@utils/transform';
addToBlacklist,
countTotalLongNotes,
countTotalNotes,
createChat,
createNote,
getActiveAccount,
getLastLogin,
updateLastLogin,
} from "@utils/storage";
import { getParentID, nip02ToArray } from "@utils/transform";
import { useContext, useEffect, useRef } from 'react';
import { navigate } from 'vite-plugin-ssr/client/router';
import { useContext, useEffect, useRef } from "react";
import { navigate } from "vite-plugin-ssr/client/router";
export function Page() {
const pool: any = useContext(RelayContext);
const now = useRef(new Date());
const pool: any = useContext(RelayContext);
const now = useRef(new Date());
useEffect(() => {
let unsubscribe: () => void;
let timeout: any;
useEffect(() => {
let unsubscribe: () => void;
let timeout: any;
const fetchInitalData = async () => {
const account = await getActiveAccount();
const lastLogin = await getLastLogin();
const notes = await countTotalNotes();
const longNotes = await countTotalLongNotes();
const fetchInitalData = async () => {
const account = await getActiveAccount();
const lastLogin = await getLastLogin();
const notes = await countTotalNotes();
const longNotes = await countTotalLongNotes();
const follows = nip02ToArray(JSON.parse(account.follows));
const query = [];
const follows = nip02ToArray(JSON.parse(account.follows));
const query = [];
let sinceNotes: number;
let sinceLongNotes: number;
let sinceNotes: number;
let sinceLongNotes: number;
if (notes === 0) {
sinceNotes = dateToUnix(getHourAgo(48, now.current));
} else {
if (parseInt(lastLogin) > 0) {
sinceNotes = parseInt(lastLogin);
} else {
sinceNotes = dateToUnix(getHourAgo(48, now.current));
}
}
if (notes === 0) {
sinceNotes = dateToUnix(getHourAgo(48, now.current));
} else {
if (parseInt(lastLogin) > 0) {
sinceNotes = parseInt(lastLogin);
} else {
sinceNotes = dateToUnix(getHourAgo(48, now.current));
}
}
if (longNotes === 0) {
sinceLongNotes = 0;
} else {
if (parseInt(lastLogin) > 0) {
sinceLongNotes = parseInt(lastLogin);
} else {
sinceLongNotes = 0;
}
}
if (longNotes === 0) {
sinceLongNotes = 0;
} else {
if (parseInt(lastLogin) > 0) {
sinceLongNotes = parseInt(lastLogin);
} else {
sinceLongNotes = 0;
}
}
// kind 1 (notes) query
query.push({
kinds: [1, 6, 1063],
authors: follows,
since: sinceNotes,
until: dateToUnix(now.current),
});
// kind 1 (notes) query
query.push({
kinds: [1, 6, 1063],
authors: follows,
since: sinceNotes,
until: dateToUnix(now.current),
});
// kind 4 (chats) query
query.push({
kinds: [4],
'#p': [account.pubkey],
since: 0,
until: dateToUnix(now.current),
});
// kind 4 (chats) query
query.push({
kinds: [4],
"#p": [account.pubkey],
since: 0,
until: dateToUnix(now.current),
});
// kind 43, 43 (mute user, hide message) query
query.push({
authors: [account.pubkey],
kinds: [43, 44],
since: 0,
until: dateToUnix(now.current),
});
// kind 43, 43 (mute user, hide message) query
query.push({
authors: [account.pubkey],
kinds: [43, 44],
since: 0,
until: dateToUnix(now.current),
});
// kind 30023 (long post) query
query.push({
kinds: [30023],
since: sinceLongNotes,
until: dateToUnix(now.current),
});
// kind 30023 (long post) query
query.push({
kinds: [30023],
since: sinceLongNotes,
until: dateToUnix(now.current),
});
// subscribe relays
unsubscribe = pool.subscribe(
query,
READONLY_RELAYS,
(event: any) => {
switch (event.kind) {
// short text note
case 1:
const parentID = getParentID(event.tags, event.id);
// insert event to local database
createNote(
event.id,
account.id,
event.pubkey,
event.kind,
event.tags,
event.content,
event.created_at,
parentID
);
break;
// chat
case 4:
if (event.pubkey !== account.pubkey) {
createChat(account.id, event.pubkey, event.created_at);
}
break;
// repost
case 6:
createNote(
event.id,
account.id,
event.pubkey,
event.kind,
event.tags,
event.content,
event.created_at,
event.id
);
break;
// hide message (channel only)
case 43:
if (event.tags[0][0] === 'e') {
addToBlacklist(account.id, event.tags[0][1], 43, 1);
}
break;
// mute user (channel only)
case 44:
if (event.tags[0][0] === 'p') {
addToBlacklist(account.id, event.tags[0][1], 44, 1);
}
break;
case 1063:
createNote(
event.id,
account.id,
event.pubkey,
event.kind,
event.tags,
event.content,
event.created_at,
''
);
break;
// long post
case 30023:
// insert event to local database
createNote(
event.id,
account.id,
event.pubkey,
event.kind,
event.tags,
event.content,
event.created_at,
''
);
break;
default:
break;
}
},
undefined,
() => {
updateLastLogin(dateToUnix(now.current));
timeout = setTimeout(() => {
navigate('/app/today', { overwriteLastHistoryEntry: true });
}, 5000);
}
);
};
// subscribe relays
unsubscribe = pool.subscribe(
query,
READONLY_RELAYS,
(event: any) => {
switch (event.kind) {
// short text note
case 1: {
const parentID = getParentID(event.tags, event.id);
// insert event to local database
createNote(
event.id,
account.id,
event.pubkey,
event.kind,
event.tags,
event.content,
event.created_at,
parentID,
);
break;
}
// chat
case 4:
if (event.pubkey !== account.pubkey) {
createChat(account.id, event.pubkey, event.created_at);
}
break;
// repost
case 6:
createNote(
event.id,
account.id,
event.pubkey,
event.kind,
event.tags,
event.content,
event.created_at,
event.id,
);
break;
// hide message (channel only)
case 43:
if (event.tags[0][0] === "e") {
addToBlacklist(account.id, event.tags[0][1], 43, 1);
}
break;
// mute user (channel only)
case 44:
if (event.tags[0][0] === "p") {
addToBlacklist(account.id, event.tags[0][1], 44, 1);
}
break;
case 1063:
createNote(
event.id,
account.id,
event.pubkey,
event.kind,
event.tags,
event.content,
event.created_at,
"",
);
break;
// long post
case 30023:
// insert event to local database
createNote(
event.id,
account.id,
event.pubkey,
event.kind,
event.tags,
event.content,
event.created_at,
"",
);
break;
default:
break;
}
},
undefined,
() => {
updateLastLogin(dateToUnix(now.current));
timeout = setTimeout(() => {
navigate("/app/today", { overwriteLastHistoryEntry: true });
}, 5000);
},
);
};
fetchInitalData().catch(console.error);
fetchInitalData().catch(console.error);
return () => {
if (unsubscribe) {
unsubscribe();
}
clearTimeout(timeout);
};
}, [pool]);
return () => {
if (unsubscribe) {
unsubscribe();
}
clearTimeout(timeout);
};
}, [pool]);
return (
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-white">
<div className="relative h-full overflow-hidden">
{/* dragging area */}
<div data-tauri-drag-region className="absolute left-0 top-0 z-20 h-16 w-full bg-transparent" />
{/* end dragging area */}
<div className="relative flex h-full flex-col items-center justify-center">
<div className="flex flex-col items-center gap-2">
<LumeIcon className="h-16 w-16 text-black dark:text-white" />
<div className="text-center">
<h3 className="text-lg font-semibold leading-tight text-zinc-900 dark:text-zinc-100">
Here&apos;s an interesting fact:
</h3>
<p className="font-medium text-zinc-300 dark:text-zinc-600">
Bitcoin and Nostr can be used by anyone, and no one can stop you!
</p>
</div>
</div>
<div className="absolute bottom-16 left-1/2 -translate-x-1/2 transform">
<svg
className="h-5 w-5 animate-spin text-black dark:text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</div>
</div>
</div>
</div>
);
return (
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-white">
<div className="relative h-full overflow-hidden">
{/* dragging area */}
<div
data-tauri-drag-region
className="absolute left-0 top-0 z-20 h-16 w-full bg-transparent"
/>
{/* end dragging area */}
<div className="relative flex h-full flex-col items-center justify-center">
<div className="flex flex-col items-center gap-2">
<LumeIcon className="h-16 w-16 text-black dark:text-white" />
<div className="text-center">
<h3 className="text-lg font-semibold leading-tight text-zinc-900 dark:text-zinc-100">
Here&apos;s an interesting fact:
</h3>
<p className="font-medium text-zinc-300 dark:text-zinc-600">
Bitcoin and Nostr can be used by anyone, and no one can stop
you!
</p>
</div>
</div>
<div className="absolute bottom-16 left-1/2 -translate-x-1/2 transform">
<svg
className="h-5 w-5 animate-spin text-black dark:text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<title id="loading">Loading</title>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
</div>
</div>
</div>
);
}

View File

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

View File

@@ -1,38 +1,41 @@
import { Kind1 } from '@app/note/components/kind1';
import { Kind1063 } from '@app/note/components/kind1063';
import NoteMetadata from '@app/note/components/metadata';
import { NoteParent } from '@app/note/components/parent';
import { NoteDefaultUser } from '@app/note/components/user/default';
import { NoteWrapper } from '@app/note/components/wrapper';
import { Kind1 } from "@app/note/components/kind1";
import { Kind1063 } from "@app/note/components/kind1063";
import NoteMetadata from "@app/note/components/metadata";
import { NoteParent } from "@app/note/components/parent";
import { NoteDefaultUser } from "@app/note/components/user/default";
import { NoteWrapper } from "@app/note/components/wrapper";
import { noteParser } from '@utils/parser';
import { isTagsIncludeID } from '@utils/transform';
import { noteParser } from "@utils/parser";
import { isTagsIncludeID } from "@utils/transform";
import { useMemo } from 'react';
import { useMemo } from "react";
export function NoteBase({ event }: { event: any }) {
const content = useMemo(() => noteParser(event), [event]);
const checkParentID = isTagsIncludeID(event.parent_id, event.tags);
const content = useMemo(() => noteParser(event), [event]);
const checkParentID = isTagsIncludeID(event.parent_id, event.tags);
const href = event.parent_id ? `/app/note?id=${event.parent_id}` : `/app/note?id=${event.event_id}`;
const href = event.parent_id
? `/app/note?id=${event.parent_id}`
: `/app/note?id=${event.event_id}`;
return (
<NoteWrapper href={href} className="h-min w-full px-3 py-1.5">
<div className="rounded-md border border-zinc-800 bg-zinc-900 px-3 pt-3 shadow-input shadow-black/20">
{event.parent_id && (event.parent_id !== event.event_id || checkParentID) ? (
<NoteParent id={event.parent_id} />
) : (
<></>
)}
<div className="flex flex-col">
<NoteDefaultUser pubkey={event.pubkey} time={event.created_at} />
<div className="mt-3 pl-[46px]">
{event.kind === 1 && <Kind1 content={content} />}
{event.kind === 1063 && <Kind1063 metadata={event.tags} />}
<NoteMetadata id={event.event_id} eventPubkey={event.pubkey} />
</div>
</div>
</div>
</NoteWrapper>
);
return (
<NoteWrapper href={href} className="h-min w-full px-3 py-1.5">
<div className="rounded-md border border-zinc-800 bg-zinc-900 px-3 pt-3 shadow-input shadow-black/20">
{event.parent_id &&
(event.parent_id !== event.event_id || checkParentID) ? (
<NoteParent id={event.parent_id} />
) : (
<></>
)}
<div className="flex flex-col">
<NoteDefaultUser pubkey={event.pubkey} time={event.created_at} />
<div className="mt-3 pl-[46px]">
{event.kind === 1 && <Kind1 content={content} />}
{event.kind === 1063 && <Kind1063 metadata={event.tags} />}
<NoteMetadata id={event.event_id} eventPubkey={event.pubkey} />
</div>
</div>
</div>
</NoteWrapper>
);
}

View File

@@ -1,31 +1,41 @@
import { MentionNote } from '@app/note/components/mentions/note';
import { MentionUser } from '@app/note/components/mentions/user';
import ImagePreview from '@app/note/components/preview/image';
import VideoPreview from '@app/note/components/preview/video';
import { MentionNote } from "@app/note/components/mentions/note";
import { MentionUser } from "@app/note/components/mentions/user";
import ImagePreview from "@app/note/components/preview/image";
import VideoPreview from "@app/note/components/preview/video";
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
export function Kind1({ content }: { content: any }) {
return (
<>
<ReactMarkdown
remarkPlugins={[[remarkGfm]]}
linkTarget="_blank"
className="prose prose-zinc max-w-none select-text break-words dark:prose-invert prose-p:text-[15px] prose-p:leading-tight prose-a:text-[15px] prose-a:font-normal prose-a:leading-tight prose-a:text-fuchsia-500 prose-a:no-underline hover:prose-a:text-fuchsia-600 hover:prose-a:underline prose-ol:mb-1 prose-ul:mb-1 prose-li:text-[15px] prose-li:leading-tight"
components={{
em: ({ ...props }) => <MentionUser {...props} />,
}}
>
{content.parsed}
</ReactMarkdown>
{Array.isArray(content.images) && content.images.length ? <ImagePreview urls={content.images} /> : <></>}
{Array.isArray(content.videos) && content.videos.length ? <VideoPreview urls={content.videos} /> : <></>}
{Array.isArray(content.notes) && content.notes.length ? (
content.notes.map((note: string) => <MentionNote key={note} id={note} />)
) : (
<></>
)}
</>
);
return (
<>
<ReactMarkdown
remarkPlugins={[[remarkGfm]]}
linkTarget="_blank"
className="prose prose-zinc max-w-none select-text break-words dark:prose-invert prose-p:text-[15px] prose-p:leading-tight prose-a:text-[15px] prose-a:font-normal prose-a:leading-tight prose-a:text-fuchsia-500 prose-a:no-underline hover:prose-a:text-fuchsia-600 hover:prose-a:underline prose-ol:mb-1 prose-ul:mb-1 prose-li:text-[15px] prose-li:leading-tight"
components={{
em: ({ ...props }) => <MentionUser {...props} />,
}}
>
{content.parsed}
</ReactMarkdown>
{Array.isArray(content.images) && content.images.length ? (
<ImagePreview urls={content.images} />
) : (
<></>
)}
{Array.isArray(content.videos) && content.videos.length ? (
<VideoPreview urls={content.videos} />
) : (
<></>
)}
{Array.isArray(content.notes) && content.notes.length ? (
content.notes.map((note: string) => (
<MentionNote key={note} id={note} />
))
) : (
<></>
)}
</>
);
}

View File

@@ -1,15 +1,21 @@
import { Image } from '@shared/image';
import { Image } from "@shared/image";
function isImage(url: string) {
return /\.(jpg|jpeg|gif|png|webp|avif)$/.test(url);
return /\.(jpg|jpeg|gif|png|webp|avif)$/.test(url);
}
export function Kind1063({ metadata }: { metadata: string[] }) {
const url = metadata[0][1];
const url = metadata[0][1];
return (
<div className="mt-3">
{isImage(url) && <Image src={url} alt="image" className="h-auto w-full rounded-lg object-cover" />}
</div>
);
return (
<div className="mt-3">
{isImage(url) && (
<Image
src={url}
alt="image"
className="h-auto w-full rounded-lg object-cover"
/>
)}
</div>
);
}

View File

@@ -1,60 +1,66 @@
import { Kind1 } from '@app/note/components/kind1';
import { Kind1063 } from '@app/note/components/kind1063';
import { NoteSkeleton } from '@app/note/components/skeleton';
import { NoteDefaultUser } from '@app/note/components/user/default';
import { NoteWrapper } from '@app/note/components/wrapper';
import { Kind1 } from "@app/note/components/kind1";
import { Kind1063 } from "@app/note/components/kind1063";
import { NoteSkeleton } from "@app/note/components/skeleton";
import { NoteDefaultUser } from "@app/note/components/user/default";
import { NoteWrapper } from "@app/note/components/wrapper";
import { RelayContext } from '@shared/relayProvider';
import { RelayContext } from "@shared/relayProvider";
import { READONLY_RELAYS } from '@stores/constants';
import { READONLY_RELAYS } from "@stores/constants";
import { noteParser } from '@utils/parser';
import { noteParser } from "@utils/parser";
import { memo, useContext } from 'react';
import useSWRSubscription from 'swr/subscription';
import { memo, useContext } from "react";
import useSWRSubscription from "swr/subscription";
export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
const pool: any = useContext(RelayContext);
const pool: any = useContext(RelayContext);
const { data, error } = useSWRSubscription(id ? id : null, (key, { next }) => {
const unsubscribe = pool.subscribe(
[
{
ids: [key],
},
],
READONLY_RELAYS,
(event: any) => {
next(null, event);
},
undefined,
undefined,
{
unsubscribeOnEose: true,
}
);
const { data, error } = useSWRSubscription(
id ? id : null,
(key, { next }) => {
const unsubscribe = pool.subscribe(
[
{
ids: [key],
},
],
READONLY_RELAYS,
(event: any) => {
next(null, event);
},
undefined,
undefined,
{
unsubscribeOnEose: true,
},
);
return () => {
unsubscribe();
};
});
return () => {
unsubscribe();
};
},
);
const kind1 = !error && data?.kind === 1 ? noteParser(data) : null;
const kind1063 = !error && data?.kind === 1063 ? data.tags : null;
const kind1 = !error && data?.kind === 1 ? noteParser(data) : null;
const kind1063 = !error && data?.kind === 1063 ? data.tags : null;
return (
<NoteWrapper href={`/app/note?id=${id}`} className="mt-3 rounded-lg border border-zinc-800 px-3 py-3">
{data ? (
<>
<NoteDefaultUser pubkey={data.pubkey} time={data.created_at} />
<div className="mt-1 pl-[46px]">
{kind1 && <Kind1 content={kind1} />}
{kind1063 && <Kind1063 metadata={kind1063} />}
</div>
</>
) : (
<NoteSkeleton />
)}
</NoteWrapper>
);
return (
<NoteWrapper
href={`/app/note?id=${id}`}
className="mt-3 rounded-lg border border-zinc-800 px-3 py-3"
>
{data ? (
<>
<NoteDefaultUser pubkey={data.pubkey} time={data.created_at} />
<div className="mt-1 pl-[46px]">
{kind1 && <Kind1 content={kind1} />}
{kind1063 && <Kind1063 metadata={kind1063} />}
</div>
</>
) : (
<NoteSkeleton />
)}
</NoteWrapper>
);
});

View File

@@ -1,9 +1,13 @@
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey";
export function MentionUser(props: { children: any[] }) {
const pubkey = props.children[0];
const { user } = useProfile(pubkey);
const pubkey = props.children[0];
const { user } = useProfile(pubkey);
return <span className="text-fuchsia-500">@{user?.name || user?.display_name || shortenKey(pubkey)}</span>;
return (
<span className="text-fuchsia-500">
@{user?.name || user?.display_name || shortenKey(pubkey)}
</span>
);
}

View File

@@ -1,67 +1,79 @@
import NoteLike from '@app/note/components/metadata/like';
import NoteReply from '@app/note/components/metadata/reply';
import NoteRepost from '@app/note/components/metadata/repost';
import NoteLike from "@app/note/components/metadata/like";
import NoteReply from "@app/note/components/metadata/reply";
import NoteRepost from "@app/note/components/metadata/repost";
import { RelayContext } from '@shared/relayProvider';
import { RelayContext } from "@shared/relayProvider";
import ZapIcon from '@icons/zap';
import ZapIcon from "@icons/zap";
import { READONLY_RELAYS } from '@stores/constants';
import { READONLY_RELAYS } from "@stores/constants";
import { useContext, useState } from 'react';
import useSWRSubscription from 'swr/subscription';
import { useContext, useState } from "react";
import useSWRSubscription from "swr/subscription";
export default function NoteMetadata({ id, eventPubkey }: { id: string; eventPubkey: string }) {
const pool: any = useContext(RelayContext);
export default function NoteMetadata({
id,
eventPubkey,
}: { id: string; eventPubkey: string }) {
const pool: any = useContext(RelayContext);
const [replies, setReplies] = useState(0);
const [reposts, setReposts] = useState(0);
const [likes, setLikes] = useState(0);
const [replies, setReplies] = useState(0);
const [reposts, setReposts] = useState(0);
const [likes, setLikes] = useState(0);
useSWRSubscription(id ? ['note-metadata', id] : null, ([, key], {}) => {
const unsubscribe = pool.subscribe(
[
{
'#e': [key],
since: 0,
kinds: [1, 6, 7],
limit: 20,
},
],
READONLY_RELAYS,
(event: any) => {
switch (event.kind) {
case 1:
setReplies((replies) => replies + 1);
break;
case 6:
setReposts((reposts) => reposts + 1);
break;
case 7:
if (event.content === '🤙' || event.content === '+') {
setLikes((likes) => likes + 1);
}
break;
default:
break;
}
}
);
useSWRSubscription(id ? ["note-metadata", id] : null, ([, key]) => {
const unsubscribe = pool.subscribe(
[
{
"#e": [key],
since: 0,
kinds: [1, 6, 7],
limit: 20,
},
],
READONLY_RELAYS,
(event: any) => {
switch (event.kind) {
case 1:
setReplies((replies) => replies + 1);
break;
case 6:
setReposts((reposts) => reposts + 1);
break;
case 7:
if (event.content === "🤙" || event.content === "+") {
setLikes((likes) => likes + 1);
}
break;
default:
break;
}
},
);
return () => {
unsubscribe();
};
});
return () => {
unsubscribe();
};
});
return (
<div className="mt-4 flex h-12 items-center gap-16 border-t border-zinc-800/50">
<NoteReply id={id} replies={replies} />
<NoteLike id={id} pubkey={eventPubkey} likes={likes} />
<NoteRepost id={id} pubkey={eventPubkey} reposts={reposts} />
<button className="group inline-flex w-min items-center gap-1.5">
<ZapIcon width={20} height={20} className="text-zinc-400 group-hover:text-orange-400" />
<span className="text-sm leading-none text-zinc-400 group-hover:text-zinc-200">{0}</span>
</button>
</div>
);
return (
<div className="mt-4 flex h-12 items-center gap-16 border-t border-zinc-800/50">
<NoteReply id={id} replies={replies} />
<NoteLike id={id} pubkey={eventPubkey} likes={likes} />
<NoteRepost id={id} pubkey={eventPubkey} reposts={reposts} />
<button
type="button"
className="group inline-flex w-min items-center gap-1.5"
>
<ZapIcon
width={20}
height={20}
className="text-zinc-400 group-hover:text-orange-400"
/>
<span className="text-sm leading-none text-zinc-400 group-hover:text-zinc-200">
{0}
</span>
</button>
</div>
);
}

View File

@@ -1,54 +1,68 @@
import { RelayContext } from '@shared/relayProvider';
import { RelayContext } from "@shared/relayProvider";
import LikeIcon from '@icons/like';
import LikeIcon from "@icons/like";
import { WRITEONLY_RELAYS } from '@stores/constants';
import { WRITEONLY_RELAYS } from "@stores/constants";
import { dateToUnix } from '@utils/date';
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
import { dateToUnix } from "@utils/date";
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
import { getEventHash, signEvent } from 'nostr-tools';
import { useContext, useEffect, useState } from 'react';
import { getEventHash, signEvent } from "nostr-tools";
import { useContext, useEffect, useState } from "react";
export default function NoteLike({ id, pubkey, likes }: { id: string; pubkey: string; likes: number }) {
const pool: any = useContext(RelayContext);
const { account, isLoading, isError } = useActiveAccount();
export default function NoteLike({
id,
pubkey,
likes,
}: { id: string; pubkey: string; likes: number }) {
const pool: any = useContext(RelayContext);
const { account, isLoading, isError } = useActiveAccount();
const [count, setCount] = useState(0);
const [count, setCount] = useState(0);
const submitEvent = (e: any) => {
e.stopPropagation();
const submitEvent = (e: any) => {
e.stopPropagation();
if (!isLoading && !isError && account) {
const event: any = {
content: '+',
kind: 7,
tags: [
['e', id],
['p', pubkey],
],
created_at: dateToUnix(),
pubkey: account.pubkey,
};
event.id = getEventHash(event);
event.sig = signEvent(event, account.privkey);
// publish event to all relays
pool.publish(event, WRITEONLY_RELAYS);
// update state
setCount(count + 1);
} else {
console.log('error');
}
};
if (!isLoading && !isError && account) {
const event: any = {
content: "+",
kind: 7,
tags: [
["e", id],
["p", pubkey],
],
created_at: dateToUnix(),
pubkey: account.pubkey,
};
event.id = getEventHash(event);
event.sig = signEvent(event, account.privkey);
// publish event to all relays
pool.publish(event, WRITEONLY_RELAYS);
// update state
setCount(count + 1);
} else {
console.log("error");
}
};
useEffect(() => {
setCount(likes);
}, [likes]);
useEffect(() => {
setCount(likes);
}, [likes]);
return (
<button type="button" onClick={(e) => submitEvent(e)} className="group inline-flex w-min items-center gap-1.5">
<LikeIcon width={16} height={16} className="text-zinc-400 group-hover:text-rose-400" />
<span className="text-sm leading-none text-zinc-400 group-hover:text-zinc-200">{count}</span>
</button>
);
return (
<button
type="button"
onClick={(e) => submitEvent(e)}
className="group inline-flex w-min items-center gap-1.5"
>
<LikeIcon
width={16}
height={16}
className="text-zinc-400 group-hover:text-rose-400"
/>
<span className="text-sm leading-none text-zinc-400 group-hover:text-zinc-200">
{count}
</span>
</button>
);
}

View File

@@ -1,132 +1,150 @@
import { Image } from '@shared/image';
import { RelayContext } from '@shared/relayProvider';
import { Image } from "@shared/image";
import { RelayContext } from "@shared/relayProvider";
import ReplyIcon from '@icons/reply';
import ReplyIcon from "@icons/reply";
import { WRITEONLY_RELAYS } from '@stores/constants';
import { WRITEONLY_RELAYS } from "@stores/constants";
import { dateToUnix } from '@utils/date';
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
import { dateToUnix } from "@utils/date";
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
import { Dialog, Transition } from '@headlessui/react';
import { getEventHash, signEvent } from 'nostr-tools';
import { Fragment, useContext, useEffect, useState } from 'react';
import { Dialog, Transition } from "@headlessui/react";
import { getEventHash, signEvent } from "nostr-tools";
import { Fragment, useContext, useEffect, useState } from "react";
export default function NoteReply({ id, replies }: { id: string; replies: number }) {
const pool: any = useContext(RelayContext);
export default function NoteReply({
id,
replies,
}: { id: string; replies: number }) {
const pool: any = useContext(RelayContext);
const [count, setCount] = useState(0);
const [isOpen, setIsOpen] = useState(false);
const [value, setValue] = useState('');
const [count, setCount] = useState(0);
const [isOpen, setIsOpen] = useState(false);
const [value, setValue] = useState("");
const { account, isLoading, isError } = useActiveAccount();
const profile = account ? JSON.parse(account.metadata) : null;
const { account, isLoading, isError } = useActiveAccount();
const profile = account ? JSON.parse(account.metadata) : null;
const closeModal = () => {
setIsOpen(false);
};
const closeModal = () => {
setIsOpen(false);
};
const openModal = () => {
setIsOpen(true);
};
const openModal = () => {
setIsOpen(true);
};
const submitEvent = () => {
if (!isLoading && !isError && account) {
const event: any = {
content: value,
created_at: dateToUnix(),
kind: 1,
pubkey: account.pubkey,
tags: [['e', id]],
};
event.id = getEventHash(event);
event.sig = signEvent(event, account.privkey);
const submitEvent = () => {
if (!isLoading && !isError && account) {
const event: any = {
content: value,
created_at: dateToUnix(),
kind: 1,
pubkey: account.pubkey,
tags: [["e", id]],
};
event.id = getEventHash(event);
event.sig = signEvent(event, account.privkey);
// publish event
pool.publish(event, WRITEONLY_RELAYS);
// close modal
setIsOpen(false);
setCount(count + 1);
} else {
console.log('error');
}
};
// publish event
pool.publish(event, WRITEONLY_RELAYS);
// close modal
setIsOpen(false);
setCount(count + 1);
} else {
console.log("error");
}
};
useEffect(() => {
setCount(replies);
}, [replies]);
useEffect(() => {
setCount(replies);
}, [replies]);
return (
<>
<button type="button" onClick={() => openModal()} className="group inline-flex w-min items-center gap-1.5">
<ReplyIcon width={16} height={16} className="text-zinc-400 group-hover:text-green-400" />
<span className="text-sm leading-none text-zinc-400 group-hover:text-zinc-200">{count}</span>
</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900 p-3">
{/* root note */}
{/* comment form */}
<div className="flex gap-2">
<div>
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-md border border-white/10">
<Image src={profile?.picture} alt="user's avatar" className="h-11 w-11 rounded-md object-cover" />
</div>
</div>
<div className="relative h-24 w-full flex-1 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
<div>
<textarea
name="content"
onChange={(e) => setValue(e.target.value)}
placeholder="Send your comment"
className="relative h-24 w-full resize-none rounded-md border border-black/5 px-3.5 py-3 text-sm shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
spellCheck={false}
/>
</div>
<div className="absolute bottom-2 w-full px-2">
<div className="flex w-full items-center justify-between bg-zinc-800">
<div className="flex items-center gap-2 divide-x divide-zinc-700">
<div className="flex items-center gap-2 pl-2"></div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => submitEvent()}
disabled={value.length === 0 ? true : false}
className="inline-flex h-8 w-16 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm font-medium shadow-md shadow-fuchsia-900/50 hover:bg-fuchsia-600"
>
<span className="text-white drop-shadow">Send</span>
</button>
</div>
</div>
</div>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
return (
<>
<button
type="button"
onClick={() => openModal()}
className="group inline-flex w-min items-center gap-1.5"
>
<ReplyIcon
width={16}
height={16}
className="text-zinc-400 group-hover:text-green-400"
/>
<span className="text-sm leading-none text-zinc-400 group-hover:text-zinc-200">
{count}
</span>
</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900 p-3">
{/* root note */}
{/* comment form */}
<div className="flex gap-2">
<div>
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-md border border-white/10">
<Image
src={profile?.picture}
alt="user's avatar"
className="h-11 w-11 rounded-md object-cover"
/>
</div>
</div>
<div className="relative h-24 w-full flex-1 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
<div>
<textarea
name="content"
onChange={(e) => setValue(e.target.value)}
placeholder="Send your comment"
className="relative h-24 w-full resize-none rounded-md border border-black/5 px-3.5 py-3 text-sm shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
spellCheck={false}
/>
</div>
<div className="absolute bottom-2 w-full px-2">
<div className="flex w-full items-center justify-between bg-zinc-800">
<div className="flex items-center gap-2 divide-x divide-zinc-700">
<div className="flex items-center gap-2 pl-2" />
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => submitEvent()}
disabled={value.length === 0 ? true : false}
className="inline-flex h-8 w-16 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm font-medium shadow-md shadow-fuchsia-900/50 hover:bg-fuchsia-600"
>
<span className="text-white drop-shadow">Send</span>
</button>
</div>
</div>
</div>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
}

View File

@@ -1,54 +1,68 @@
import { RelayContext } from '@shared/relayProvider';
import { RelayContext } from "@shared/relayProvider";
import RepostIcon from '@icons/repost';
import RepostIcon from "@icons/repost";
import { WRITEONLY_RELAYS } from '@stores/constants';
import { WRITEONLY_RELAYS } from "@stores/constants";
import { dateToUnix } from '@utils/date';
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
import { dateToUnix } from "@utils/date";
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
import { getEventHash, signEvent } from 'nostr-tools';
import { useContext, useEffect, useState } from 'react';
import { getEventHash, signEvent } from "nostr-tools";
import { useContext, useEffect, useState } from "react";
export default function NoteRepost({ id, pubkey, reposts }: { id: string; pubkey: string; reposts: number }) {
const pool: any = useContext(RelayContext);
const { account, isLoading, isError } = useActiveAccount();
export default function NoteRepost({
id,
pubkey,
reposts,
}: { id: string; pubkey: string; reposts: number }) {
const pool: any = useContext(RelayContext);
const { account, isLoading, isError } = useActiveAccount();
const [count, setCount] = useState(0);
const [count, setCount] = useState(0);
const submitEvent = (e: any) => {
e.stopPropagation();
const submitEvent = (e: any) => {
e.stopPropagation();
if (!isLoading && !isError && account) {
const event: any = {
content: '',
kind: 6,
tags: [
['e', id],
['p', pubkey],
],
created_at: dateToUnix(),
pubkey: account.pubkey,
};
event.id = getEventHash(event);
event.sig = signEvent(event, account.privkey);
// publish event to all relays
pool.publish(event, WRITEONLY_RELAYS);
// update state
setCount(count + 1);
} else {
console.log('error');
}
};
if (!isLoading && !isError && account) {
const event: any = {
content: "",
kind: 6,
tags: [
["e", id],
["p", pubkey],
],
created_at: dateToUnix(),
pubkey: account.pubkey,
};
event.id = getEventHash(event);
event.sig = signEvent(event, account.privkey);
// publish event to all relays
pool.publish(event, WRITEONLY_RELAYS);
// update state
setCount(count + 1);
} else {
console.log("error");
}
};
useEffect(() => {
setCount(reposts);
}, [reposts]);
useEffect(() => {
setCount(reposts);
}, [reposts]);
return (
<button type="button" onClick={(e) => submitEvent(e)} className="group inline-flex w-min items-center gap-1.5">
<RepostIcon width={16} height={16} className="text-zinc-400 group-hover:text-blue-400" />
<span className="text-sm leading-none text-zinc-400 group-hover:text-zinc-200">{count}</span>
</button>
);
return (
<button
type="button"
onClick={(e) => submitEvent(e)}
className="group inline-flex w-min items-center gap-1.5"
>
<RepostIcon
width={16}
height={16}
className="text-zinc-400 group-hover:text-blue-400"
/>
<span className="text-sm leading-none text-zinc-400 group-hover:text-zinc-200">
{count}
</span>
</button>
);
}

View File

@@ -1,62 +1,65 @@
import { Kind1 } from '@app/note/components/kind1';
import { Kind1063 } from '@app/note/components/kind1063';
import NoteMetadata from '@app/note/components/metadata';
import { NoteSkeleton } from '@app/note/components/skeleton';
import { NoteDefaultUser } from '@app/note/components/user/default';
import { Kind1 } from "@app/note/components/kind1";
import { Kind1063 } from "@app/note/components/kind1063";
import NoteMetadata from "@app/note/components/metadata";
import { NoteSkeleton } from "@app/note/components/skeleton";
import { NoteDefaultUser } from "@app/note/components/user/default";
import { RelayContext } from '@shared/relayProvider';
import { RelayContext } from "@shared/relayProvider";
import { READONLY_RELAYS } from '@stores/constants';
import { READONLY_RELAYS } from "@stores/constants";
import { noteParser } from '@utils/parser';
import { noteParser } from "@utils/parser";
import { memo, useContext } from 'react';
import useSWRSubscription from 'swr/subscription';
import { memo, useContext } from "react";
import useSWRSubscription from "swr/subscription";
export const NoteParent = memo(function NoteParent({ id }: { id: string }) {
const pool: any = useContext(RelayContext);
const pool: any = useContext(RelayContext);
const { data, error } = useSWRSubscription(id ? id : null, (key, { next }) => {
const unsubscribe = pool.subscribe(
[
{
ids: [key],
},
],
READONLY_RELAYS,
(event: any) => {
next(null, event);
},
undefined,
undefined,
{
unsubscribeOnEose: true,
}
);
const { data, error } = useSWRSubscription(
id ? id : null,
(key, { next }) => {
const unsubscribe = pool.subscribe(
[
{
ids: [key],
},
],
READONLY_RELAYS,
(event: any) => {
next(null, event);
},
undefined,
undefined,
{
unsubscribeOnEose: true,
},
);
return () => {
unsubscribe();
};
});
return () => {
unsubscribe();
};
},
);
const kind1 = !error && data?.kind === 1 ? noteParser(data) : null;
const kind1063 = !error && data?.kind === 1063 ? data.tags : null;
const kind1 = !error && data?.kind === 1 ? noteParser(data) : null;
const kind1063 = !error && data?.kind === 1063 ? data.tags : null;
return (
<div className="relative flex flex-col pb-6">
<div className="absolute left-[16px] top-0 h-full w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600"></div>
{data ? (
<>
<NoteDefaultUser pubkey={data.pubkey} time={data.created_at} />
<div className="mt-3 pl-[46px]">
{kind1 && <Kind1 content={kind1} />}
{kind1063 && <Kind1063 metadata={kind1063} />}
<NoteMetadata id={data.id} eventPubkey={data.pubkey} />
</div>
</>
) : (
<NoteSkeleton />
)}
</div>
);
return (
<div className="relative flex flex-col pb-6">
<div className="absolute left-[16px] top-0 h-full w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600" />
{data ? (
<>
<NoteDefaultUser pubkey={data.pubkey} time={data.created_at} />
<div className="mt-3 pl-[46px]">
{kind1 && <Kind1 content={kind1} />}
{kind1063 && <Kind1063 metadata={kind1063} />}
<NoteMetadata id={data.id} eventPubkey={data.pubkey} />
</div>
</>
) : (
<NoteSkeleton />
)}
</div>
);
});

View File

@@ -1,11 +1,15 @@
import { Image } from '@shared/image';
import { Image } from "@shared/image";
export default function ImagePreview({ urls }: { urls: string[] }) {
return (
<div className="mt-3 grid h-full w-full grid-cols-3">
<div className="col-span-3">
<Image src={urls[0]} alt="image" className="h-auto w-full rounded-lg object-cover" />
</div>
</div>
);
return (
<div className="mt-3 grid h-full w-full grid-cols-3">
<div className="col-span-3">
<Image
src={urls[0]}
alt="image"
className="h-auto w-full rounded-lg object-cover"
/>
</div>
</div>
);
}

View File

@@ -1,14 +1,14 @@
import { MediaOutlet, MediaPlayer } from '@vidstack/react';
import { MediaOutlet, MediaPlayer } from "@vidstack/react";
export default function VideoPreview({ urls }: { urls: string[] }) {
return (
<div
onClick={(e) => e.stopPropagation()}
className="relative mt-2 flex w-full flex-col overflow-hidden rounded-lg bg-zinc-950"
>
<MediaPlayer src={urls[0]} poster="" controls>
<MediaOutlet />
</MediaPlayer>
</div>
);
return (
<div
onKeyDown={(e) => e.stopPropagation()}
className="relative mt-2 flex w-full flex-col overflow-hidden rounded-lg bg-zinc-950"
>
<MediaPlayer src={urls[0]} poster="" controls>
<MediaOutlet />
</MediaPlayer>
</div>
);
}

View File

@@ -1,21 +1,24 @@
import { RootNote } from '@app/note/components/rootNote';
import { NoteRepostUser } from '@app/note/components/user/repost';
import { NoteWrapper } from '@app/note/components/wrapper';
import { RootNote } from "@app/note/components/rootNote";
import { NoteRepostUser } from "@app/note/components/user/repost";
import { NoteWrapper } from "@app/note/components/wrapper";
import { getQuoteID } from '@utils/transform';
import { getQuoteID } from "@utils/transform";
export function NoteQuoteRepost({ event }: { event: any }) {
const rootID = getQuoteID(event.tags);
const rootID = getQuoteID(event.tags);
return (
<NoteWrapper href={`/app/note?id=${rootID}`} className="h-min w-full px-3 py-1.5">
<div className="rounded-md border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
<div className="relative px-3 pb-5 pt-3">
<div className="absolute left-[29px] top-[20px] h-[70px] w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600"></div>
<NoteRepostUser pubkey={event.pubkey} time={event.created_at} />
</div>
<RootNote id={rootID} fallback={event.content} />
</div>
</NoteWrapper>
);
return (
<NoteWrapper
href={`/app/note?id=${rootID}`}
className="h-min w-full px-3 py-1.5"
>
<div className="rounded-md border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
<div className="relative px-3 pb-5 pt-3">
<div className="absolute left-[29px] top-[20px] h-[70px] w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600" />
<NoteRepostUser pubkey={event.pubkey} time={event.created_at} />
</div>
<RootNote id={rootID} fallback={event.content} />
</div>
</NoteWrapper>
);
}

View File

@@ -1,74 +1,79 @@
import { Image } from '@shared/image';
import { RelayContext } from '@shared/relayProvider';
import { Image } from "@shared/image";
import { RelayContext } from "@shared/relayProvider";
import { WRITEONLY_RELAYS } from '@stores/constants';
import { WRITEONLY_RELAYS } from "@stores/constants";
import { dateToUnix } from '@utils/date';
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
import { dateToUnix } from "@utils/date";
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
import { getEventHash, signEvent } from 'nostr-tools';
import { useContext, useState } from 'react';
import { getEventHash, signEvent } from "nostr-tools";
import { useContext, useState } from "react";
export default function NoteReplyForm({ id }: { id: string }) {
const pool: any = useContext(RelayContext);
const { account, isLoading, isError } = useActiveAccount();
const pool: any = useContext(RelayContext);
const { account, isLoading, isError } = useActiveAccount();
const [value, setValue] = useState('');
const profile = account ? JSON.parse(account.metadata) : null;
const [value, setValue] = useState("");
const profile = account ? JSON.parse(account.metadata) : null;
const submitEvent = () => {
if (!isLoading && !isError && account) {
const event: any = {
content: value,
created_at: dateToUnix(),
kind: 1,
pubkey: account.pubkey,
tags: [['e', id]],
};
event.id = getEventHash(event);
event.sig = signEvent(event, account.privkey);
const submitEvent = () => {
if (!isLoading && !isError && account) {
const event: any = {
content: value,
created_at: dateToUnix(),
kind: 1,
pubkey: account.pubkey,
tags: [["e", id]],
};
event.id = getEventHash(event);
event.sig = signEvent(event, account.privkey);
// publish note
pool.publish(event, WRITEONLY_RELAYS);
// reset form
setValue('');
} else {
console.log('error');
}
};
// publish note
pool.publish(event, WRITEONLY_RELAYS);
// reset form
setValue("");
} else {
console.log("error");
}
};
return (
<div className="flex gap-2.5 px-3 py-4">
<div>
<div className="relative h-9 w-9 shrink-0 overflow-hidden rounded-md">
<Image src={profile?.picture} alt={account?.pubkey} className="h-9 w-9 rounded-md object-cover" />
</div>
</div>
<div className="relative h-24 w-full flex-1 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
<div>
<textarea
name="content"
onChange={(e) => setValue(e.target.value)}
placeholder="Reply to this thread..."
className="relative h-24 w-full resize-none rounded-md border border-black/5 px-3.5 py-3 text-sm shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
spellCheck={false}
/>
</div>
<div className="absolute bottom-2 w-full px-2">
<div className="flex w-full items-center justify-between bg-zinc-800">
<div className="flex items-center gap-2 divide-x divide-zinc-700"></div>
<div className="flex items-center gap-2">
<button
onClick={() => submitEvent()}
disabled={value.length === 0 ? true : false}
className="inline-flex h-8 w-16 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm font-medium shadow-button hover:bg-fuchsia-600 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
>
Reply
</button>
</div>
</div>
</div>
</div>
</div>
);
return (
<div className="flex gap-2.5 px-3 py-4">
<div>
<div className="relative h-9 w-9 shrink-0 overflow-hidden rounded-md">
<Image
src={profile?.picture}
alt={account?.pubkey}
className="h-9 w-9 rounded-md object-cover"
/>
</div>
</div>
<div className="relative h-24 w-full flex-1 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
<div>
<textarea
name="content"
onChange={(e) => setValue(e.target.value)}
placeholder="Reply to this thread..."
className="relative h-24 w-full resize-none rounded-md border border-black/5 px-3.5 py-3 text-sm shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
spellCheck={false}
/>
</div>
<div className="absolute bottom-2 w-full px-2">
<div className="flex w-full items-center justify-between bg-zinc-800">
<div className="flex items-center gap-2 divide-x divide-zinc-700" />
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => submitEvent()}
disabled={value.length === 0 ? true : false}
className="inline-flex h-8 w-16 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm font-medium shadow-button hover:bg-fuchsia-600 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
>
Reply
</button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,19 +1,19 @@
import { Kind1 } from '@app/note/components/kind1';
import NoteReplyUser from '@app/note/components/user/reply';
import { Kind1 } from "@app/note/components/kind1";
import NoteReplyUser from "@app/note/components/user/reply";
import { noteParser } from '@utils/parser';
import { noteParser } from "@utils/parser";
export default function Reply({ data }: { data: any }) {
const content = noteParser(data);
const content = noteParser(data);
return (
<div className="flex h-min min-h-min w-full select-text flex-col px-3 py-3">
<div className="flex flex-col">
<NoteReplyUser pubkey={data.pubkey} time={data.created_at} />
<div className="-mt-[18px] pl-[46px]">
<Kind1 content={content} />
</div>
</div>
</div>
);
return (
<div className="flex h-min min-h-min w-full select-text flex-col px-3 py-3">
<div className="flex flex-col">
<NoteReplyUser pubkey={data.pubkey} time={data.created_at} />
<div className="-mt-[18px] pl-[46px]">
<Kind1 content={content} />
</div>
</div>
</div>
);
}

View File

@@ -1,66 +1,69 @@
import NoteReplyForm from '@app/note/components/replies/form';
import Reply from '@app/note/components/replies/item';
import NoteReplyForm from "@app/note/components/replies/form";
import Reply from "@app/note/components/replies/item";
import { RelayContext } from '@shared/relayProvider';
import { RelayContext } from "@shared/relayProvider";
import { READONLY_RELAYS } from '@stores/constants';
import { READONLY_RELAYS } from "@stores/constants";
import { sortEvents } from '@utils/transform';
import { sortEvents } from "@utils/transform";
import { useContext } from 'react';
import useSWRSubscription from 'swr/subscription';
import { useContext } from "react";
import useSWRSubscription from "swr/subscription";
export default function RepliesList({ id }: { id: string }) {
const pool: any = useContext(RelayContext);
const pool: any = useContext(RelayContext);
const { data, error } = useSWRSubscription(id ? ['note-replies', id] : null, ([, key], { next }) => {
// subscribe to note
const unsubscribe = pool.subscribe(
[
{
'#e': [key],
since: 0,
kinds: [1, 1063],
limit: 20,
},
],
READONLY_RELAYS,
(event: any) => {
next(null, (prev: any) => (prev ? [...prev, event] : [event]));
}
);
const { data, error } = useSWRSubscription(
id ? ["note-replies", id] : null,
([, key], { next }) => {
// subscribe to note
const unsubscribe = pool.subscribe(
[
{
"#e": [key],
since: 0,
kinds: [1, 1063],
limit: 20,
},
],
READONLY_RELAYS,
(event: any) => {
next(null, (prev: any) => (prev ? [...prev, event] : [event]));
},
);
return () => {
unsubscribe();
};
});
return () => {
unsubscribe();
};
},
);
return (
<div className="mt-5">
<div className="mb-2">
<h5 className="text-lg font-semibold text-zinc-300">Replies</h5>
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
<div className="flex flex-col divide-y divide-zinc-800">
<NoteReplyForm id={id} />
{error && <div>failed to load</div>}
{!data ? (
<div className="flex gap-2 px-3 py-4">
<div className="relative h-9 w-9 shrink animate-pulse rounded-md bg-zinc-800"></div>
<div className="flex w-full flex-1 flex-col justify-center gap-1">
<div className="flex items-baseline gap-2 text-sm">
<div className="h-2.5 w-20 animate-pulse rounded-sm bg-zinc-800"></div>
</div>
<div className="h-4 w-44 animate-pulse rounded-sm bg-zinc-800"></div>
</div>
</div>
) : (
sortEvents(data).map((event: any) => {
return <Reply key={event.id} data={event} />;
})
)}
</div>
</div>
</div>
);
return (
<div className="mt-5">
<div className="mb-2">
<h5 className="text-lg font-semibold text-zinc-300">Replies</h5>
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
<div className="flex flex-col divide-y divide-zinc-800">
<NoteReplyForm id={id} />
{error && <div>failed to load</div>}
{!data ? (
<div className="flex gap-2 px-3 py-4">
<div className="relative h-9 w-9 shrink animate-pulse rounded-md bg-zinc-800" />
<div className="flex w-full flex-1 flex-col justify-center gap-1">
<div className="flex items-baseline gap-2 text-sm">
<div className="h-2.5 w-20 animate-pulse rounded-sm bg-zinc-800" />
</div>
<div className="h-4 w-44 animate-pulse rounded-sm bg-zinc-800" />
</div>
</div>
) : (
sortEvents(data).map((event: any) => {
return <Reply key={event.id} data={event} />;
})
)}
</div>
</div>
</div>
);
}

View File

@@ -1,95 +1,107 @@
import { Kind1 } from '@app/note/components/kind1';
import { Kind1063 } from '@app/note/components/kind1063';
import NoteMetadata from '@app/note/components/metadata';
import { NoteSkeleton } from '@app/note/components/skeleton';
import { NoteDefaultUser } from '@app/note/components/user/default';
import { Kind1 } from "@app/note/components/kind1";
import { Kind1063 } from "@app/note/components/kind1063";
import NoteMetadata from "@app/note/components/metadata";
import { NoteSkeleton } from "@app/note/components/skeleton";
import { NoteDefaultUser } from "@app/note/components/user/default";
import { RelayContext } from '@shared/relayProvider';
import { RelayContext } from "@shared/relayProvider";
import { READONLY_RELAYS } from '@stores/constants';
import { READONLY_RELAYS } from "@stores/constants";
import { noteParser } from '@utils/parser';
import { noteParser } from "@utils/parser";
import { memo, useContext } from 'react';
import useSWRSubscription from 'swr/subscription';
import { navigate } from 'vite-plugin-ssr/client/router';
import { memo, useContext } from "react";
import useSWRSubscription from "swr/subscription";
import { navigate } from "vite-plugin-ssr/client/router";
function isJSON(str: string) {
try {
JSON.parse(str);
} catch (e) {
return false;
}
return true;
try {
JSON.parse(str);
} catch (e) {
return false;
}
return true;
}
export const RootNote = memo(function RootNote({ id, fallback }: { id: string; fallback?: any }) {
const pool: any = useContext(RelayContext);
const parseFallback = isJSON(fallback) ? JSON.parse(fallback) : null;
export const RootNote = memo(function RootNote({
id,
fallback,
}: { id: string; fallback?: any }) {
const pool: any = useContext(RelayContext);
const parseFallback = isJSON(fallback) ? JSON.parse(fallback) : null;
const { data, error } = useSWRSubscription(parseFallback ? null : id, (key, { next }) => {
const unsubscribe = pool.subscribe(
[
{
ids: [key],
},
],
READONLY_RELAYS,
(event: any) => {
next(null, event);
},
undefined,
undefined,
{
unsubscribeOnEose: true,
}
);
const { data, error } = useSWRSubscription(
parseFallback ? null : id,
(key, { next }) => {
const unsubscribe = pool.subscribe(
[
{
ids: [key],
},
],
READONLY_RELAYS,
(event: any) => {
next(null, event);
},
undefined,
undefined,
{
unsubscribeOnEose: true,
},
);
return () => {
unsubscribe();
};
});
return () => {
unsubscribe();
};
},
);
const openNote = (e) => {
const selection = window.getSelection();
if (selection.toString().length === 0) {
navigate(`/app/note?id=${id}`);
} else {
e.stopPropagation();
}
};
const openNote = (e) => {
const selection = window.getSelection();
if (selection.toString().length === 0) {
navigate(`/app/note?id=${id}`);
} else {
e.stopPropagation();
}
};
const kind1 = !error && data?.kind === 1 ? noteParser(data) : null;
const kind1063 = !error && data?.kind === 1063 ? data.tags : null;
const kind1 = !error && data?.kind === 1 ? noteParser(data) : null;
const kind1063 = !error && data?.kind === 1063 ? data.tags : null;
if (parseFallback) {
const contentFallback = noteParser(parseFallback);
if (parseFallback) {
const contentFallback = noteParser(parseFallback);
return (
<div onClick={(e) => openNote(e)} className="flex flex-col px-3">
<NoteDefaultUser pubkey={parseFallback.pubkey} time={parseFallback.created_at} />
<div className="mt-3 pl-[46px]">
<Kind1 content={contentFallback} />
<NoteMetadata id={parseFallback.id} eventPubkey={parseFallback.pubkey} />
</div>
</div>
);
}
return (
<div onKeyDown={(e) => openNote(e)} className="flex flex-col px-3">
<NoteDefaultUser
pubkey={parseFallback.pubkey}
time={parseFallback.created_at}
/>
<div className="mt-3 pl-[46px]">
<Kind1 content={contentFallback} />
<NoteMetadata
id={parseFallback.id}
eventPubkey={parseFallback.pubkey}
/>
</div>
</div>
);
}
return (
<div onClick={(e) => openNote(e)} className="flex flex-col px-3">
{data ? (
<>
<NoteDefaultUser pubkey={data.pubkey} time={data.created_at} />
<div className="mt-3 pl-[46px]">
{kind1 && <Kind1 content={kind1} />}
{kind1063 && <Kind1063 metadata={kind1063} />}
<NoteMetadata id={data.id} eventPubkey={data.pubkey} />
</div>
</>
) : (
<NoteSkeleton />
)}
</div>
);
return (
<div onKeyDown={(e) => openNote(e)} className="flex flex-col px-3">
{data ? (
<>
<NoteDefaultUser pubkey={data.pubkey} time={data.created_at} />
<div className="mt-3 pl-[46px]">
{kind1 && <Kind1 content={kind1} />}
{kind1063 && <Kind1063 metadata={kind1063} />}
<NoteMetadata id={data.id} eventPubkey={data.pubkey} />
</div>
</>
) : (
<NoteSkeleton />
)}
</div>
);
});

View File

@@ -1,20 +1,20 @@
export function NoteSkeleton() {
return (
<div className="flex h-min flex-col pb-3">
<div className="flex items-center gap-2.5">
<div className="relative h-9 w-9 shrink overflow-hidden rounded-md bg-zinc-700" />
<div className="flex flex-col gap-0.5">
<div className="h-3 w-20 rounded-sm bg-zinc-700" />
<div className="h-2 w-12 rounded-sm bg-zinc-700" />
</div>
</div>
<div className="mt-3 animate-pulse pl-[46px]">
<div className="flex flex-col gap-1">
<div className="h-4 w-full rounded-sm bg-zinc-700" />
<div className="h-4 w-2/3 rounded-sm bg-zinc-700" />
<div className="h-4 w-1/2 rounded-sm bg-zinc-700" />
</div>
</div>
</div>
);
return (
<div className="flex h-min flex-col pb-3">
<div className="flex items-center gap-2.5">
<div className="relative h-9 w-9 shrink overflow-hidden rounded-md bg-zinc-700" />
<div className="flex flex-col gap-0.5">
<div className="h-3 w-20 rounded-sm bg-zinc-700" />
<div className="h-2 w-12 rounded-sm bg-zinc-700" />
</div>
</div>
<div className="mt-3 animate-pulse pl-[46px]">
<div className="flex flex-col gap-1">
<div className="h-4 w-full rounded-sm bg-zinc-700" />
<div className="h-4 w-2/3 rounded-sm bg-zinc-700" />
<div className="h-4 w-1/2 rounded-sm bg-zinc-700" />
</div>
</div>
</div>
);
}

View File

@@ -1,94 +1,105 @@
import { Image } from '@shared/image';
import { Image } from "@shared/image";
import { DEFAULT_AVATAR, IMGPROXY_URL } from '@stores/constants';
import { DEFAULT_AVATAR, IMGPROXY_URL } from "@stores/constants";
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey";
import { Popover, Transition } from '@headlessui/react';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { Fragment } from 'react';
import { Popover, Transition } from "@headlessui/react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { Fragment } from "react";
dayjs.extend(relativeTime);
export function NoteDefaultUser({ pubkey, time }: { pubkey: string; time: number }) {
const { user } = useProfile(pubkey);
export function NoteDefaultUser({
pubkey,
time,
}: { pubkey: string; time: number }) {
const { user } = useProfile(pubkey);
return (
<Popover className="relative flex items-center gap-2.5">
<Popover.Button className="h-9 w-9 shrink-0 overflow-hidden rounded-md bg-zinc-900">
<Image
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${user?.picture ? user.picture : DEFAULT_AVATAR}`}
alt={pubkey}
className="h-9 w-9 object-cover"
/>
</Popover.Button>
<div className="flex w-full flex-1 items-start justify-between">
<div className="flex flex-col gap-0.5">
<h5 className="text-sm font-semibold leading-none">
{user?.display_name || user?.name || <div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700"></div>}
</h5>
<div className="flex items-baseline gap-1.5 text-sm leading-none text-zinc-500">
<span>{user?.nip05 || shortenKey(pubkey)}</span>
<span></span>
<span>{dayjs().to(dayjs.unix(time), true)}</span>
</div>
</div>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute left-0 top-8 z-10 mt-3 w-screen max-w-sm px-4 sm:px-0 lg:max-w-3xl">
<div
onClick={(e) => e.stopPropagation()}
className="w-full max-w-xs overflow-hidden rounded-lg border border-zinc-700 bg-zinc-900 shadow-input ring-1 ring-black ring-opacity-5"
>
<div className="flex items-start gap-2.5 border-b border-zinc-800 px-3 py-3">
<Image
src={`${IMGPROXY_URL}/rs:fit:200:200/plain/${user?.picture ? user.picture : DEFAULT_AVATAR}`}
alt={pubkey}
className="h-14 w-14 shrink-0 rounded-lg object-cover"
/>
<div className="flex w-full flex-1 flex-col gap-2">
<div className="inline-flex w-2/3 flex-col gap-0.5">
<h5 className="text-sm font-semibold leading-none">
{user?.display_name || user?.name || (
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700"></div>
)}
</h5>
<span className="truncate text-sm leading-none text-zinc-500">
{user?.nip05 || shortenKey(pubkey)}
</span>
</div>
<div>
<p className="line-clamp-3 text-sm leading-tight text-zinc-100">{user?.about}</p>
</div>
</div>
</div>
<div className="flex items-center gap-2 px-3 py-3">
<a
href={`/app/user?pubkey=${pubkey}`}
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-800 text-sm font-medium"
>
View full profile
</a>
<a
href={`/app/chat?pubkey=${pubkey}`}
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-800 text-sm font-medium"
>
Message
</a>
</div>
</div>
</Popover.Panel>
</Transition>
</Popover>
);
return (
<Popover className="relative flex items-center gap-2.5">
<Popover.Button className="h-9 w-9 shrink-0 overflow-hidden rounded-md bg-zinc-900">
<Image
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${
user?.picture ? user.picture : DEFAULT_AVATAR
}`}
alt={pubkey}
className="h-9 w-9 object-cover"
/>
</Popover.Button>
<div className="flex w-full flex-1 items-start justify-between">
<div className="flex flex-col gap-0.5">
<h5 className="text-sm font-semibold leading-none">
{user?.display_name || user?.name || (
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700" />
)}
</h5>
<div className="flex items-baseline gap-1.5 text-sm leading-none text-zinc-500">
<span>{user?.nip05 || shortenKey(pubkey)}</span>
<span></span>
<span>{dayjs().to(dayjs.unix(time), true)}</span>
</div>
</div>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute left-0 top-8 z-10 mt-3 w-screen max-w-sm px-4 sm:px-0 lg:max-w-3xl">
<div
onKeyDown={(e) => e.stopPropagation()}
className="w-full max-w-xs overflow-hidden rounded-lg border border-zinc-700 bg-zinc-900 shadow-input ring-1 ring-black ring-opacity-5"
>
<div className="flex items-start gap-2.5 border-b border-zinc-800 px-3 py-3">
<Image
src={`${IMGPROXY_URL}/rs:fit:200:200/plain/${
user?.picture ? user.picture : DEFAULT_AVATAR
}`}
alt={pubkey}
className="h-14 w-14 shrink-0 rounded-lg object-cover"
/>
<div className="flex w-full flex-1 flex-col gap-2">
<div className="inline-flex w-2/3 flex-col gap-0.5">
<h5 className="text-sm font-semibold leading-none">
{user?.display_name || user?.name || (
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700" />
)}
</h5>
<span className="truncate text-sm leading-none text-zinc-500">
{user?.nip05 || shortenKey(pubkey)}
</span>
</div>
<div>
<p className="line-clamp-3 text-sm leading-tight text-zinc-100">
{user?.about}
</p>
</div>
</div>
</div>
<div className="flex items-center gap-2 px-3 py-3">
<a
href={`/app/user?pubkey=${pubkey}`}
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-800 text-sm font-medium"
>
View full profile
</a>
<a
href={`/app/chat?pubkey=${pubkey}`}
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-800 text-sm font-medium"
>
Message
</a>
</div>
</div>
</Popover.Panel>
</Transition>
</Popover>
);
}

View File

@@ -1,36 +1,43 @@
import { Image } from '@shared/image';
import { Image } from "@shared/image";
import { DEFAULT_AVATAR, IMGPROXY_URL } from '@stores/constants';
import { DEFAULT_AVATAR, IMGPROXY_URL } from "@stores/constants";
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey";
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(relativeTime);
export default function NoteReplyUser({ pubkey, time }: { pubkey: string; time: number }) {
const { user } = useProfile(pubkey);
export default function NoteReplyUser({
pubkey,
time,
}: { pubkey: string; time: number }) {
const { user } = useProfile(pubkey);
return (
<div className="group flex items-start gap-2.5">
<div className="relative h-9 w-9 shrink-0 rounded-md">
<Image
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${user?.picture ? user.picture : DEFAULT_AVATAR}`}
alt={pubkey}
className="h-9 w-9 rounded-md object-cover"
/>
</div>
<div className="flex w-full flex-1 items-start justify-between">
<div className="flex items-baseline gap-2 text-sm">
<span className="font-semibold leading-none text-zinc-200 group-hover:underline">
{user?.display_name || user?.name || shortenKey(pubkey)}
</span>
<span className="leading-none text-zinc-500">·</span>
<span className="leading-none text-zinc-500">{dayjs().to(dayjs.unix(time), true)}</span>
</div>
</div>
</div>
);
return (
<div className="group flex items-start gap-2.5">
<div className="relative h-9 w-9 shrink-0 rounded-md">
<Image
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${
user?.picture ? user.picture : DEFAULT_AVATAR
}`}
alt={pubkey}
className="h-9 w-9 rounded-md object-cover"
/>
</div>
<div className="flex w-full flex-1 items-start justify-between">
<div className="flex items-baseline gap-2 text-sm">
<span className="font-semibold leading-none text-zinc-200 group-hover:underline">
{user?.display_name || user?.name || shortenKey(pubkey)}
</span>
<span className="leading-none text-zinc-500">·</span>
<span className="leading-none text-zinc-500">
{dayjs().to(dayjs.unix(time), true)}
</span>
</div>
</div>
</div>
);
}

View File

@@ -1,93 +1,106 @@
import { Image } from '@shared/image';
import { Image } from "@shared/image";
import { DEFAULT_AVATAR, IMGPROXY_URL } from '@stores/constants';
import { DEFAULT_AVATAR, IMGPROXY_URL } from "@stores/constants";
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey";
import { Popover, Transition } from '@headlessui/react';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { Fragment } from 'react';
import { Popover, Transition } from "@headlessui/react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { Fragment } from "react";
dayjs.extend(relativeTime);
export function NoteRepostUser({ pubkey, time }: { pubkey: string; time: number }) {
const { user } = useProfile(pubkey);
export function NoteRepostUser({
pubkey,
time,
}: { pubkey: string; time: number }) {
const { user } = useProfile(pubkey);
return (
<Popover className="relative flex items-center gap-2.5">
<Popover.Button className="h-9 w-9 shrink-0 overflow-hidden rounded-md bg-zinc-900">
<Image
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${user?.picture ? user.picture : DEFAULT_AVATAR}`}
alt={pubkey}
className="h-9 w-9 rounded-md object-cover"
/>
</Popover.Button>
<div className="flex items-baseline gap-1.5 text-sm">
<h5 className="font-semibold leading-tight group-hover:underline">
{user?.display_name || user?.name || <div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700"></div>}
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-transparent">
{' '}
reposted
</span>
</h5>
<span className="leading-tight text-zinc-500">·</span>
<span className="text-zinc-500">{dayjs().to(dayjs.unix(time), true)}</span>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute left-0 top-8 z-10 mt-3 w-screen max-w-sm px-4 sm:px-0 lg:max-w-3xl">
<div
onClick={(e) => e.stopPropagation()}
className="w-full max-w-xs overflow-hidden rounded-lg border border-zinc-700 bg-zinc-900 shadow-input ring-1 ring-black ring-opacity-5"
>
<div className="flex items-start gap-2.5 border-b border-zinc-800 px-3 py-3">
<Image
src={`${IMGPROXY_URL}/rs:fit:200:200/plain/${user?.picture ? user.picture : DEFAULT_AVATAR}`}
alt={pubkey}
className="h-14 w-14 shrink-0 rounded-lg object-cover"
/>
<div className="flex w-full flex-1 flex-col gap-2">
<div className="inline-flex w-2/3 flex-col gap-0.5">
<h5 className="text-sm font-semibold leading-none">
{user?.display_name || user?.name || (
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700"></div>
)}
</h5>
<span className="truncate text-sm leading-none text-zinc-500">
{user?.nip05 || shortenKey(pubkey)}
</span>
</div>
<div>
<p className="line-clamp-3 text-sm leading-tight text-zinc-100">{user?.about}</p>
</div>
</div>
</div>
<div className="flex items-center gap-2 px-3 py-3">
<a
href={`/app/user?pubkey=${pubkey}`}
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-800 text-sm font-medium"
>
View full profile
</a>
<a
href={`/app/chat?pubkey=${pubkey}`}
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-800 text-sm font-medium"
>
Message
</a>
</div>
</div>
</Popover.Panel>
</Transition>
</Popover>
);
return (
<Popover className="relative flex items-center gap-2.5">
<Popover.Button className="h-9 w-9 shrink-0 overflow-hidden rounded-md bg-zinc-900">
<Image
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${
user?.picture ? user.picture : DEFAULT_AVATAR
}`}
alt={pubkey}
className="h-9 w-9 rounded-md object-cover"
/>
</Popover.Button>
<div className="flex items-baseline gap-1.5 text-sm">
<h5 className="font-semibold leading-tight group-hover:underline">
{user?.display_name || user?.name || (
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700" />
)}
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-transparent">
{" "}
reposted
</span>
</h5>
<span className="leading-tight text-zinc-500">·</span>
<span className="text-zinc-500">
{dayjs().to(dayjs.unix(time), true)}
</span>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute left-0 top-8 z-10 mt-3 w-screen max-w-sm px-4 sm:px-0 lg:max-w-3xl">
<div
onKeyDown={(e) => e.stopPropagation()}
className="w-full max-w-xs overflow-hidden rounded-lg border border-zinc-700 bg-zinc-900 shadow-input ring-1 ring-black ring-opacity-5"
>
<div className="flex items-start gap-2.5 border-b border-zinc-800 px-3 py-3">
<Image
src={`${IMGPROXY_URL}/rs:fit:200:200/plain/${
user?.picture ? user.picture : DEFAULT_AVATAR
}`}
alt={pubkey}
className="h-14 w-14 shrink-0 rounded-lg object-cover"
/>
<div className="flex w-full flex-1 flex-col gap-2">
<div className="inline-flex w-2/3 flex-col gap-0.5">
<h5 className="text-sm font-semibold leading-none">
{user?.display_name || user?.name || (
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700" />
)}
</h5>
<span className="truncate text-sm leading-none text-zinc-500">
{user?.nip05 || shortenKey(pubkey)}
</span>
</div>
<div>
<p className="line-clamp-3 text-sm leading-tight text-zinc-100">
{user?.about}
</p>
</div>
</div>
</div>
<div className="flex items-center gap-2 px-3 py-3">
<a
href={`/app/user?pubkey=${pubkey}`}
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-800 text-sm font-medium"
>
View full profile
</a>
<a
href={`/app/chat?pubkey=${pubkey}`}
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-800 text-sm font-medium"
>
Message
</a>
</div>
</div>
</Popover.Panel>
</Transition>
</Popover>
);
}

View File

@@ -1,26 +1,26 @@
import { navigate } from 'vite-plugin-ssr/client/router';
import { navigate } from "vite-plugin-ssr/client/router";
export function NoteWrapper({
children,
href,
className,
children,
href,
className,
}: {
children: React.ReactNode;
href: string;
className: string;
children: React.ReactNode;
href: string;
className: string;
}) {
const openThread = (event: any, href: string) => {
const selection = window.getSelection();
if (selection.toString().length === 0) {
navigate(href, { keepScrollPosition: true });
} else {
event.stopPropagation();
}
};
const openThread = (event: any, href: string) => {
const selection = window.getSelection();
if (selection.toString().length === 0) {
navigate(href, { keepScrollPosition: true });
} else {
event.stopPropagation();
}
};
return (
<div onClick={(event) => openThread(event, href)} className={className}>
{children}
</div>
);
return (
<div onKeyDown={(event) => openThread(event, href)} className={className}>
{children}
</div>
);
}

View File

@@ -1,29 +1,31 @@
import AppHeader from '@shared/appHeader';
import MultiAccounts from '@shared/multiAccounts';
import Navigation from '@shared/navigation';
import AppHeader from "@shared/appHeader";
import MultiAccounts from "@shared/multiAccounts";
import Navigation from "@shared/navigation";
export function LayoutNewsfeed({ children }: { children: React.ReactNode }) {
return (
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white">
<div className="flex h-screen w-full flex-col">
<div
data-tauri-drag-region
className="relative h-9 shrink-0 border-b border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
>
<AppHeader />
</div>
<div className="relative flex min-h-0 w-full flex-1">
<div className="relative w-[68px] shrink-0 border-r border-zinc-900">
<MultiAccounts />
</div>
<div className="grid w-full grid-cols-4 xl:grid-cols-5">
<div className="scrollbar-hide col-span-1 overflow-y-auto overflow-x-hidden border-r border-zinc-900">
<Navigation />
</div>
<div className="col-span-3 overflow-hidden xl:col-span-4">{children}</div>
</div>
</div>
</div>
</div>
);
return (
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white">
<div className="flex h-screen w-full flex-col">
<div
data-tauri-drag-region
className="relative h-9 shrink-0 border-b border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
>
<AppHeader />
</div>
<div className="relative flex min-h-0 w-full flex-1">
<div className="relative w-[68px] shrink-0 border-r border-zinc-900">
<MultiAccounts />
</div>
<div className="grid w-full grid-cols-4 xl:grid-cols-5">
<div className="scrollbar-hide col-span-1 overflow-y-auto overflow-x-hidden border-r border-zinc-900">
<Navigation />
</div>
<div className="col-span-3 overflow-hidden xl:col-span-4">
{children}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,86 +1,89 @@
import { Kind1 } from '@app/note/components/kind1';
import NoteMetadata from '@app/note/components/metadata';
import RepliesList from '@app/note/components/replies/list';
import { NoteDefaultUser } from '@app/note/components/user/default';
import { Kind1 } from "@app/note/components/kind1";
import NoteMetadata from "@app/note/components/metadata";
import RepliesList from "@app/note/components/replies/list";
import { NoteDefaultUser } from "@app/note/components/user/default";
import { RelayContext } from '@shared/relayProvider';
import { RelayContext } from "@shared/relayProvider";
import { READONLY_RELAYS } from '@stores/constants';
import { READONLY_RELAYS } from "@stores/constants";
import { usePageContext } from '@utils/hooks/usePageContext';
import { noteParser } from '@utils/parser';
import { usePageContext } from "@utils/hooks/usePageContext";
import { noteParser } from "@utils/parser";
import { useContext } from 'react';
import useSWRSubscription from 'swr/subscription';
import { useContext } from "react";
import useSWRSubscription from "swr/subscription";
export function Page() {
const pool: any = useContext(RelayContext);
const pageContext = usePageContext();
const searchParams: any = pageContext.urlParsed.search;
const pool: any = useContext(RelayContext);
const pageContext = usePageContext();
const searchParams: any = pageContext.urlParsed.search;
const noteID = searchParams.id;
const noteID = searchParams.id;
const { data, error } = useSWRSubscription(noteID ? ['note', noteID] : null, ([, key], { next }) => {
// subscribe to note
const unsubscribe = pool.subscribe(
[
{
ids: [key],
},
],
READONLY_RELAYS,
(event: any) => {
next(null, event);
}
);
const { data, error } = useSWRSubscription(
noteID ? ["note", noteID] : null,
([, key], { next }) => {
// subscribe to note
const unsubscribe = pool.subscribe(
[
{
ids: [key],
},
],
READONLY_RELAYS,
(event: any) => {
next(null, event);
},
);
return () => {
unsubscribe();
};
});
return () => {
unsubscribe();
};
},
);
const content = !error && data ? noteParser(data) : null;
const content = !error && data ? noteParser(data) : null;
return (
<div className="scrollbar-hide h-full w-full overflow-y-auto">
<div className="p-3">
<div className="relative w-full rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
{!data || error ? (
<div className="animated-pulse p-3">
<div className="flex items-start gap-2">
<div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-zinc-700" />
<div className="flex w-full flex-1 items-start justify-between">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2 text-sm">
<div className="h-4 w-16 rounded bg-zinc-700" />
</div>
</div>
</div>
</div>
<div className="mt-3">
<div className="flex flex-col gap-6">
<div className="h-16 w-full rounded bg-zinc-700" />
<div className="flex items-center gap-8">
<div className="h-4 w-12 rounded bg-zinc-700" />
<div className="h-4 w-12 rounded bg-zinc-700" />
</div>
</div>
</div>
</div>
) : (
<div className="relative z-10 flex flex-col">
<div className="px-3 pt-3">
<NoteDefaultUser pubkey={data.pubkey} time={data.created_at} />
<div className="mt-3">
<Kind1 content={content} />
<NoteMetadata id={noteID} eventPubkey={data.pubkey} />
</div>
</div>
</div>
)}
</div>
<RepliesList id={noteID} />
</div>
</div>
);
return (
<div className="scrollbar-hide h-full w-full overflow-y-auto">
<div className="p-3">
<div className="relative w-full rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
{!data || error ? (
<div className="animated-pulse p-3">
<div className="flex items-start gap-2">
<div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-zinc-700" />
<div className="flex w-full flex-1 items-start justify-between">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2 text-sm">
<div className="h-4 w-16 rounded bg-zinc-700" />
</div>
</div>
</div>
</div>
<div className="mt-3">
<div className="flex flex-col gap-6">
<div className="h-16 w-full rounded bg-zinc-700" />
<div className="flex items-center gap-8">
<div className="h-4 w-12 rounded bg-zinc-700" />
<div className="h-4 w-12 rounded bg-zinc-700" />
</div>
</div>
</div>
</div>
) : (
<div className="relative z-10 flex flex-col">
<div className="px-3 pt-3">
<NoteDefaultUser pubkey={data.pubkey} time={data.created_at} />
<div className="mt-3">
<Kind1 content={content} />
<NoteMetadata id={noteID} eventPubkey={data.pubkey} />
</div>
</div>
</div>
)}
</div>
<RepliesList id={noteID} />
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

@@ -1,45 +1,58 @@
import { CreateViewModal } from '@app/today/components/views/createModal';
import { CreateViewModal } from "@app/today/components/views/createModal";
export function Header() {
return (
<div className="flex w-full gap-4">
<button className="from-zinc-90 inline-flex h-11 items-center overflow-hidden border-b border-fuchsia-500 hover:bg-zinc-900">
<span className="px-2 text-sm font-semibold text-zinc-300">Following</span>
</button>
<div className="flex h-11 items-center -space-x-1 overflow-hidden">
<img className="inline-block h-6 w-6 rounded ring-2 ring-zinc-950" src="https://133332.xyz/p.jpg" alt="" />
<img
className="inline-block h-6 w-6 rounded ring-2 ring-zinc-950"
src="https://void.cat/d/3Bp6jSHURFNQ9u3pK8nwtq.webp"
alt=""
/>
<img
className="inline-block h-6 w-6 rounded ring-2 ring-zinc-950"
src="https://void.cat/d/8zE9T8a39YfUVjrLM4xcpE.webp"
alt=""
/>
<img
className="ring-zinc-95 ring-20 inline-block h-6 w-6 rounded"
src="https://nostr.build/i/p/nostr.build_0e412058980ed2ac4adf3de639304c9e970e2745ba9ca19c75f984f4f6da4971.jpeg"
alt=""
/>
<img
className="ring-zinc-95 ring-20 inline-block h-6 w-6 rounded"
src="https://davidcoen.it/wp-content/uploads/2020/11/7004972-taglio.jpg"
alt=""
/>
</div>
<div className="flex h-11 items-center overflow-hidden">
<img
className="ring-zinc-95 ring-20 inline-block h-6 w-6 rounded"
src="https://void.cat/d/KvAEMvYNmy1rfCH6a7HZzh.webp"
alt=""
/>
</div>
<div className="flex h-11 items-center overflow-hidden">
<img className="ring-zinc-95 ring-20 inline-block h-6 w-6 rounded" src="http://nostr.build/i/6369.jpg" alt="" />
</div>
<CreateViewModal />
</div>
);
return (
<div className="flex w-full gap-4">
<button
type="button"
className="from-zinc-90 inline-flex h-11 items-center overflow-hidden border-b border-fuchsia-500 hover:bg-zinc-900"
>
<span className="px-2 text-sm font-semibold text-zinc-300">
Following
</span>
</button>
<div className="flex h-11 items-center -space-x-1 overflow-hidden">
<img
className="inline-block h-6 w-6 rounded ring-2 ring-zinc-950"
src="https://133332.xyz/p.jpg"
alt=""
/>
<img
className="inline-block h-6 w-6 rounded ring-2 ring-zinc-950"
src="https://void.cat/d/3Bp6jSHURFNQ9u3pK8nwtq.webp"
alt=""
/>
<img
className="inline-block h-6 w-6 rounded ring-2 ring-zinc-950"
src="https://void.cat/d/8zE9T8a39YfUVjrLM4xcpE.webp"
alt=""
/>
<img
className="ring-zinc-95 ring-20 inline-block h-6 w-6 rounded"
src="https://nostr.build/i/p/nostr.build_0e412058980ed2ac4adf3de639304c9e970e2745ba9ca19c75f984f4f6da4971.jpeg"
alt=""
/>
<img
className="ring-zinc-95 ring-20 inline-block h-6 w-6 rounded"
src="https://davidcoen.it/wp-content/uploads/2020/11/7004972-taglio.jpg"
alt=""
/>
</div>
<div className="flex h-11 items-center overflow-hidden">
<img
className="ring-zinc-95 ring-20 inline-block h-6 w-6 rounded"
src="https://void.cat/d/KvAEMvYNmy1rfCH6a7HZzh.webp"
alt=""
/>
</div>
<div className="flex h-11 items-center overflow-hidden">
<img
className="ring-zinc-95 ring-20 inline-block h-6 w-6 rounded"
src="http://nostr.build/i/6369.jpg"
alt=""
/>
</div>
<CreateViewModal />
</div>
);
}

View File

@@ -1,86 +1,89 @@
import CancelIcon from '@icons/cancel';
import PlusIcon from '@icons/plus';
import CancelIcon from "@icons/cancel";
import PlusIcon from "@icons/plus";
import { Dialog, Transition } from '@headlessui/react';
import { Fragment, useState } from 'react';
import { Dialog, Transition } from "@headlessui/react";
import { Fragment, useState } from "react";
export function CreateViewModal() {
const [isOpen, setIsOpen] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const closeModal = () => {
setIsOpen(false);
};
const closeModal = () => {
setIsOpen(false);
};
const openModal = () => {
setIsOpen(true);
};
const openModal = () => {
setIsOpen(true);
};
return (
<>
<button
type="button"
onClick={openModal}
className="inline-flex h-11 items-center overflow-hidden border-b border-transparent hover:bg-zinc-900"
>
<span className="inline-flex items-center gap-1 px-2 text-sm font-medium text-zinc-500">
<PlusIcon width={14} height={14} />
View
</span>
</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<Dialog.Title
as="h3"
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-xl font-semibold leading-none text-transparent"
>
Create a view
</Dialog.Title>
<button
type="button"
onClick={closeModal}
autoFocus={false}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon width={16} height={16} className="text-zinc-400" />
</button>
</div>
<Dialog.Description className="text-sm leading-tight text-zinc-400">
View is specific feature help you pin who you want to see in your feed. You can add maximum 5
people in a view.
</Dialog.Description>
</div>
</div>
<div className="flex h-full w-full flex-col overflow-y-auto pb-5 pt-3"></div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
return (
<>
<button
type="button"
onClick={openModal}
className="inline-flex h-11 items-center overflow-hidden border-b border-transparent hover:bg-zinc-900"
>
<span className="inline-flex items-center gap-1 px-2 text-sm font-medium text-zinc-500">
<PlusIcon width={14} height={14} />
View
</span>
</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<Dialog.Title
as="h3"
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-xl font-semibold leading-none text-transparent"
>
Create a view
</Dialog.Title>
<button
type="button"
onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon
width={16}
height={16}
className="text-zinc-400"
/>
</button>
</div>
<Dialog.Description className="text-sm leading-tight text-zinc-400">
View is specific feature help you pin who you want to see
in your feed. You can add maximum 5 people in a view.
</Dialog.Description>
</div>
</div>
<div className="flex h-full w-full flex-col overflow-y-auto pb-5 pt-3" />
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
}

View File

@@ -1,29 +1,31 @@
import AppHeader from '@shared/appHeader';
import MultiAccounts from '@shared/multiAccounts';
import Navigation from '@shared/navigation';
import AppHeader from "@shared/appHeader";
import MultiAccounts from "@shared/multiAccounts";
import Navigation from "@shared/navigation";
export function LayoutNewsfeed({ children }: { children: React.ReactNode }) {
return (
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white">
<div className="flex h-screen w-full flex-col">
<div
data-tauri-drag-region
className="relative h-9 shrink-0 border-b border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
>
<AppHeader />
</div>
<div className="relative flex min-h-0 w-full flex-1">
<div className="relative w-[68px] shrink-0 border-r border-zinc-900">
<MultiAccounts />
</div>
<div className="grid w-full grid-cols-4 xl:grid-cols-5">
<div className="scrollbar-hide col-span-1 overflow-y-auto overflow-x-hidden border-r border-zinc-900">
<Navigation />
</div>
<div className="col-span-3 overflow-hidden xl:col-span-4">{children}</div>
</div>
</div>
</div>
</div>
);
return (
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white">
<div className="flex h-screen w-full flex-col">
<div
data-tauri-drag-region
className="relative h-9 shrink-0 border-b border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
>
<AppHeader />
</div>
<div className="relative flex min-h-0 w-full flex-1">
<div className="relative w-[68px] shrink-0 border-r border-zinc-900">
<MultiAccounts />
</div>
<div className="grid w-full grid-cols-4 xl:grid-cols-5">
<div className="scrollbar-hide col-span-1 overflow-y-auto overflow-x-hidden border-r border-zinc-900">
<Navigation />
</div>
<div className="col-span-3 overflow-hidden xl:col-span-4">
{children}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,113 +1,136 @@
import { NoteBase } from '@app/note/components/base';
import { NoteQuoteRepost } from '@app/note/components/quoteRepost';
import { NoteSkeleton } from '@app/note/components/skeleton';
import { Header } from '@app/today/components/header';
import { NoteBase } from "@app/note/components/base";
import { NoteQuoteRepost } from "@app/note/components/quoteRepost";
import { NoteSkeleton } from "@app/note/components/skeleton";
import { Header } from "@app/today/components/header";
import { getNotes } from '@utils/storage';
import { getNotes } from "@utils/storage";
import { useInfiniteQuery } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useEffect, useRef } from 'react';
import { useInfiniteQuery } from "@tanstack/react-query";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useEffect, useRef } from "react";
const ITEM_PER_PAGE = 10;
const TIME = Math.floor(Date.now() / 1000);
export function Page() {
const { status, error, data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage }: any = useInfiniteQuery({
queryKey: ['following'],
queryFn: async ({ pageParam = 0 }) => {
return await getNotes(TIME, ITEM_PER_PAGE, pageParam);
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const {
status,
error,
data,
fetchNextPage,
hasNextPage,
isFetching,
isFetchingNextPage,
}: any = useInfiniteQuery({
queryKey: ["following"],
queryFn: async ({ pageParam = 0 }) => {
return await getNotes(TIME, ITEM_PER_PAGE, pageParam);
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const allRows = data ? data.pages.flatMap((d: { data: any }) => d.data) : [];
const parentRef = useRef();
const allRows = data ? data.pages.flatMap((d: { data: any }) => d.data) : [];
const parentRef = useRef();
const rowVirtualizer = useVirtualizer({
count: hasNextPage ? allRows.length + 1 : allRows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 400,
overscan: 2,
});
const rowVirtualizer = useVirtualizer({
count: hasNextPage ? allRows.length + 1 : allRows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 400,
overscan: 2,
});
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
useEffect(() => {
const [lastItem] = [...rowVirtualizer.getVirtualItems()].reverse();
useEffect(() => {
const [lastItem] = [...rowVirtualizer.getVirtualItems()].reverse();
if (!lastItem) {
return;
}
if (!lastItem) {
return;
}
if (lastItem.index >= allRows.length - 1 && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetchNextPage, allRows.length, rowVirtualizer.getVirtualItems()]);
if (
lastItem.index >= allRows.length - 1 &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetchNextPage, allRows.length, rowVirtualizer.getVirtualItems()]);
return (
<div
ref={parentRef}
className="scrollbar-hide flex h-full flex-col justify-between gap-1.5 overflow-y-auto"
style={{ contain: 'strict' }}
>
<div className="flex h-11 w-full shrink-0 items-center justify-between border-b border-zinc-900 px-3">
<Header />
</div>
<div className="flex-1">
{status === 'loading' ? (
<div className="px-3 py-1.5">
<div className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-3 shadow-input shadow-black/20">
<NoteSkeleton />
</div>
</div>
) : status === 'error' ? (
<div>{error.message}</div>
) : (
<div
className="relative w-full"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
}}
>
<div
className="absolute left-0 top-0 w-full"
style={{
transform: `translateY(${itemsVirtualizer[0].start - rowVirtualizer.options.scrollMargin}px)`,
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const note = allRows[virtualRow.index];
if (note) {
if (note.kind === 1) {
return (
<div key={virtualRow.index} data-index={virtualRow.index} ref={rowVirtualizer.measureElement}>
<NoteBase key={note.event_id} event={note} />
</div>
);
} else {
return (
<div key={virtualRow.index} data-index={virtualRow.index} ref={rowVirtualizer.measureElement}>
<NoteQuoteRepost key={note.event_id} event={note} />
</div>
);
}
}
})}
</div>
</div>
)}
<div>
{isFetching && !isFetchingNextPage ? (
<div className="px-3 py-1.5">
<div className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-3 shadow-input shadow-black/20">
<NoteSkeleton />
</div>
</div>
) : null}
</div>
</div>
</div>
);
return (
<div
ref={parentRef}
className="scrollbar-hide flex h-full flex-col justify-between gap-1.5 overflow-y-auto"
style={{ contain: "strict" }}
>
<div className="flex h-11 w-full shrink-0 items-center justify-between border-b border-zinc-900 px-3">
<Header />
</div>
<div className="flex-1">
{status === "loading" ? (
<div className="px-3 py-1.5">
<div className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-3 shadow-input shadow-black/20">
<NoteSkeleton />
</div>
</div>
) : status === "error" ? (
<div>{error.message}</div>
) : (
<div
className="relative w-full"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
}}
>
<div
className="absolute left-0 top-0 w-full"
style={{
transform: `translateY(${
itemsVirtualizer[0].start -
rowVirtualizer.options.scrollMargin
}px)`,
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const note = allRows[virtualRow.index];
if (note) {
if (note.kind === 1) {
return (
<div
key={virtualRow.index}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
>
<NoteBase key={note.event_id} event={note} />
</div>
);
} else {
return (
<div
key={virtualRow.index}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
>
<NoteQuoteRepost key={note.event_id} event={note} />
</div>
);
}
}
})}
</div>
</div>
)}
<div>
{isFetching && !isFetchingNextPage ? (
<div className="px-3 py-1.5">
<div className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-3 shadow-input shadow-black/20">
<NoteSkeleton />
</div>
</div>
) : null}
</div>
</div>
</div>
);
}

View File

@@ -1,10 +1,10 @@
import { StrictMode } from 'react';
import { Root, createRoot, hydrateRoot } from 'react-dom/client';
import 'vidstack/styles/defaults.css';
import { StrictMode } from "react";
import { Root, createRoot, hydrateRoot } from "react-dom/client";
import "vidstack/styles/defaults.css";
import './index.css';
import { Shell } from './shell';
import { PageContextClient } from './types';
import "./index.css";
import { Shell } from "./shell";
import { PageContextClient } from "./types";
export const clientRouting = true;
export const hydrationCanBeAborted = true;
@@ -12,27 +12,30 @@ export const hydrationCanBeAborted = true;
let root: Root;
export async function render(pageContext: PageContextClient) {
const { Page, pageProps } = pageContext;
const { Page, pageProps } = pageContext;
if (!Page) throw new Error('Client-side render() hook expects pageContext.Page to be defined');
if (!Page)
throw new Error(
"Client-side render() hook expects pageContext.Page to be defined",
);
const page = (
<StrictMode>
<Shell pageContext={pageContext}>
<Page {...pageProps} />
</Shell>
</StrictMode>
);
const page = (
<StrictMode>
<Shell pageContext={pageContext}>
<Page {...pageProps} />
</Shell>
</StrictMode>
);
const container = document.getElementById('app');
// SPA
if (container.innerHTML === '' || !pageContext.isHydration) {
if (!root) {
root = createRoot(container);
}
root.render(page);
// SSR
} else {
root = hydrateRoot(container, page);
}
const container = document.getElementById("app");
// SPA
if (container.innerHTML === "" || !pageContext.isHydration) {
if (!root) {
root = createRoot(container);
}
root.render(page);
// SSR
} else {
root = hydrateRoot(container, page);
}
}

View File

@@ -1,33 +1,36 @@
import { StrictMode } from 'react';
import ReactDOMServer from 'react-dom/server';
import { dangerouslySkipEscape, escapeInject } from 'vite-plugin-ssr/server';
import { StrictMode } from "react";
import ReactDOMServer from "react-dom/server";
import { dangerouslySkipEscape, escapeInject } from "vite-plugin-ssr/server";
import { Shell } from './shell';
import { PageContextServer } from './types';
import { Shell } from "./shell";
import { PageContextServer } from "./types";
export const passToClient = ['pageProps'];
export const passToClient = ["pageProps"];
export function render(pageContext: PageContextServer) {
let pageHtml: string;
let pageHtml: string;
if (!pageContext.Page) {
// SPA
pageHtml = '';
} else {
// SSR / HTML-only
const { Page, pageProps } = pageContext;
if (!Page) throw new Error('My render() hook expects pageContext.Page to be defined');
if (!pageContext.Page) {
// SPA
pageHtml = "";
} else {
// SSR / HTML-only
const { Page, pageProps } = pageContext;
if (!Page)
throw new Error(
"My render() hook expects pageContext.Page to be defined",
);
pageHtml = ReactDOMServer.renderToString(
<StrictMode>
<Shell pageContext={pageContext}>
<Page {...pageProps} />
</Shell>
</StrictMode>
);
}
pageHtml = ReactDOMServer.renderToString(
<StrictMode>
<Shell pageContext={pageContext}>
<Page {...pageProps} />
</Shell>
</StrictMode>,
);
}
return escapeInject`<!DOCTYPE html>
return escapeInject`<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />

View File

@@ -1,17 +1,17 @@
export function Page({ is404 }: { is404: boolean }) {
if (is404) {
return (
<>
<h1>404 Page Not Found</h1>
<p>This page could not be found.</p>
</>
);
} else {
return (
<>
<h1>500 Internal Server Error</h1>
<p>Something went wrong.</p>
</>
);
}
if (is404) {
return (
<>
<h1>404 Page Not Found</h1>
<p>This page could not be found.</p>
</>
);
} else {
return (
<>
<h1>500 Internal Server Error</h1>
<p>Something went wrong.</p>
</>
);
}
}

View File

@@ -1,3 +1,7 @@
export function LayoutDefault({ children }: { children: React.ReactNode }) {
return <div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-white">{children}</div>;
return (
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-white">
{children}
</div>
);
}

View File

@@ -1,24 +1,31 @@
import { RelayProvider } from '@shared/relayProvider';
import { RelayProvider } from "@shared/relayProvider";
import { PageContextProvider } from '@utils/hooks/usePageContext';
import { PageContextProvider } from "@utils/hooks/usePageContext";
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { LayoutDefault } from './layoutDefault';
import { PageContext } from './types';
import { LayoutDefault } from "./layoutDefault";
import { PageContext } from "./types";
const queryClient = new QueryClient();
export function Shell({ children, pageContext }: { children: React.ReactNode; pageContext: PageContext }) {
const Layout = (pageContext.exports.Layout as React.ElementType) || (LayoutDefault as React.ElementType);
export function Shell({
children,
pageContext,
}: { children: React.ReactNode; pageContext: PageContext }) {
const Layout =
(pageContext.exports.Layout as React.ElementType) ||
(LayoutDefault as React.ElementType);
return (
<PageContextProvider pageContext={pageContext}>
<RelayProvider>
<Layout>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</Layout>
</RelayProvider>
</PageContextProvider>
);
return (
<PageContextProvider pageContext={pageContext}>
<RelayProvider>
<Layout>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</Layout>
</RelayProvider>
</PageContextProvider>
);
}

View File

@@ -1,12 +1,12 @@
import type {
PageContextBuiltIn,
/*
PageContextBuiltIn,
/*
// When using Client Routing https://vite-plugin-ssr.com/clientRouting
PageContextBuiltInClientWithClientRouting as PageContextBuiltInClient
/*/
// When using Server Routing
PageContextBuiltInClientWithServerRouting as PageContextBuiltInClient, //*/
} from 'vite-plugin-ssr/types';
// When using Server Routing
PageContextBuiltInClientWithServerRouting as PageContextBuiltInClient, //*/
} from "vite-plugin-ssr/types";
export type { PageContextServer };
export type { PageContextClient };
@@ -17,16 +17,16 @@ type Page = (pageProps: PageProps) => React.ReactElement;
type PageProps = Record<string, never>;
export type PageContextCustom = {
Page: Page;
pageProps?: PageProps;
redirectTo?: string;
urlPathname: string;
exports: {
documentProps?: {
title?: string;
description?: string;
};
};
Page: Page;
pageProps?: PageProps;
redirectTo?: string;
urlPathname: string;
exports: {
documentProps?: {
title?: string;
description?: string;
};
};
};
type PageContextServer = PageContextBuiltIn<Page> & PageContextCustom;

View File

@@ -1,18 +1,21 @@
import { Image } from '@shared/image';
import { Image } from "@shared/image";
import { DEFAULT_AVATAR } from '@stores/constants';
import { DEFAULT_AVATAR } from "@stores/constants";
export default function ActiveAccount({ user }: { user: any }) {
const userData = JSON.parse(user.metadata);
const userData = JSON.parse(user.metadata);
return (
<button className="relative h-10 w-10 overflow-hidden rounded-lg">
<Image
src={userData.picture || DEFAULT_AVATAR}
alt="user's avatar"
loading="auto"
className="h-10 w-10 object-cover"
/>
</button>
);
return (
<button
type="button"
className="relative h-10 w-10 overflow-hidden rounded-lg"
>
<Image
src={userData.picture || DEFAULT_AVATAR}
alt="user's avatar"
loading="auto"
className="h-10 w-10 object-cover"
/>
</button>
);
}

View File

@@ -1,17 +1,17 @@
import { Image } from '@shared/image';
import { Image } from "@shared/image";
import { DEFAULT_AVATAR } from '@stores/constants';
import { DEFAULT_AVATAR } from "@stores/constants";
export default function InactiveAccount({ user }: { user: any }) {
const userData = JSON.parse(user.metadata);
const userData = JSON.parse(user.metadata);
return (
<div className="relative h-10 w-10 shrink rounded-lg">
<Image
src={userData.picture || DEFAULT_AVATAR}
alt="user's avatar"
className="h-10 w-10 rounded-lg object-cover"
/>
</div>
);
return (
<div className="relative h-10 w-10 shrink rounded-lg">
<Image
src={userData.picture || DEFAULT_AVATAR}
alt="user's avatar"
className="h-10 w-10 rounded-lg object-cover"
/>
</div>
);
}

View File

@@ -1,24 +1,27 @@
import { usePageContext } from '@utils/hooks/usePageContext';
import { usePageContext } from "@utils/hooks/usePageContext";
import { twMerge } from 'tailwind-merge';
import { twMerge } from "tailwind-merge";
export default function ActiveLink({
href,
className,
activeClassName,
children,
href,
className,
activeClassName,
children,
}: {
href: string;
className: string;
activeClassName: string;
children: React.ReactNode;
href: string;
className: string;
activeClassName: string;
children: React.ReactNode;
}) {
const pageContext = usePageContext();
const pathName = pageContext.urlPathname;
const pageContext = usePageContext();
const pathName = pageContext.urlPathname;
return (
<a href={href} className={twMerge(className, href === pathName ? activeClassName : '')}>
{children}
</a>
);
return (
<a
href={href}
className={twMerge(className, href === pathName ? activeClassName : "")}
>
{children}
</a>
);
}

View File

@@ -1,55 +1,75 @@
import ArrowLeftIcon from '@icons/arrowLeft';
import ArrowRightIcon from '@icons/arrowRight';
import RefreshIcon from '@icons/refresh';
import ArrowLeftIcon from "@icons/arrowLeft";
import ArrowRightIcon from "@icons/arrowRight";
import RefreshIcon from "@icons/refresh";
export default function AppHeader() {
const goBack = () => {
window.history.back();
};
const goBack = () => {
window.history.back();
};
const goForward = () => {
window.history.forward();
};
const goForward = () => {
window.history.forward();
};
const reload = () => {
window.location.reload();
};
const reload = () => {
window.location.reload();
};
return (
<div data-tauri-drag-region className="flex h-full w-full flex-1 items-center px-2">
<div data-tauri-drag-region className="flex w-full items-center justify-center gap-2">
<div className="flex h-full items-center gap-2">
<button
onClick={() => goBack()}
className="group inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<ArrowLeftIcon width={14} height={14} className="text-zinc-500 group-hover:text-zinc-300" />
</button>
<button
onClick={() => goForward()}
className="group inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<ArrowRightIcon width={14} height={14} className="text-zinc-500 group-hover:text-zinc-300" />
</button>
</div>
<div>
<input
autoCapitalize="none"
autoCorrect="off"
autoFocus={false}
placeholder="Search..."
className="h-6 w-[453px] rounded border border-zinc-800 bg-zinc-900 px-2.5 text-center text-[11px] text-sm leading-5 text-zinc-500 placeholder:leading-5 placeholder:text-zinc-600 focus:outline-none"
/>
</div>
<div className="flex h-full items-center gap-2">
<button
onClick={() => reload()}
className="group inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<RefreshIcon width={14} height={14} className="text-zinc-500 group-hover:text-zinc-300" />
</button>
</div>
</div>
</div>
);
return (
<div
data-tauri-drag-region
className="flex h-full w-full flex-1 items-center px-2"
>
<div
data-tauri-drag-region
className="flex w-full items-center justify-center gap-2"
>
<div className="flex h-full items-center gap-2">
<button
type="button"
onClick={() => goBack()}
className="group inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<ArrowLeftIcon
width={14}
height={14}
className="text-zinc-500 group-hover:text-zinc-300"
/>
</button>
<button
type="button"
onClick={() => goForward()}
className="group inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<ArrowRightIcon
width={14}
height={14}
className="text-zinc-500 group-hover:text-zinc-300"
/>
</button>
</div>
<div>
<input
autoCapitalize="none"
autoCorrect="off"
placeholder="Search..."
className="h-6 w-[453px] rounded border border-zinc-800 bg-zinc-900 px-2.5 text-center text-[11px] text-sm leading-5 text-zinc-500 placeholder:leading-5 placeholder:text-zinc-600 focus:outline-none"
/>
</div>
<div className="flex h-full items-center gap-2">
<button
type="button"
onClick={() => reload()}
className="group inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<RefreshIcon
width={14}
height={14}
className="text-zinc-500 group-hover:text-zinc-300"
/>
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,75 +1,86 @@
import { createBlobFromFile } from '@utils/createBlobFromFile';
import { createBlobFromFile } from "@utils/createBlobFromFile";
import { open } from '@tauri-apps/api/dialog';
import { Body, fetch } from '@tauri-apps/api/http';
import { useState } from 'react';
import { open } from "@tauri-apps/api/dialog";
import { Body, fetch } from "@tauri-apps/api/http";
import { useState } from "react";
export function AvatarUploader({ valueState }: { valueState: any }) {
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(false);
const openFileDialog = async () => {
const selected: any = await open({
multiple: false,
filters: [
{
name: 'Image',
extensions: ['png', 'jpeg', 'jpg', 'gif'],
},
],
});
if (Array.isArray(selected)) {
// user selected multiple files
} else if (selected === null) {
// user cancelled the selection
} else {
setLoading(true);
const openFileDialog = async () => {
const selected: any = await open({
multiple: false,
filters: [
{
name: "Image",
extensions: ["png", "jpeg", "jpg", "gif"],
},
],
});
if (Array.isArray(selected)) {
// user selected multiple files
} else if (selected === null) {
// user cancelled the selection
} else {
setLoading(true);
const filename = selected.split('/').pop();
const file = await createBlobFromFile(selected);
const buf = await file.arrayBuffer();
const filename = selected.split("/").pop();
const file = await createBlobFromFile(selected);
const buf = await file.arrayBuffer();
const res: { data: { file: { id: string } } } = await fetch('https://void.cat/upload?cli=false', {
method: 'POST',
timeout: 5,
headers: {
accept: '*/*',
'Content-Type': 'application/octet-stream',
'V-Filename': filename,
'V-Description': 'Upload from https://lume.nu',
'V-Strip-Metadata': 'true',
},
body: Body.bytes(buf),
});
const webpImage = 'https://void.cat/d/' + res.data.file.id + '.webp';
const res: { data: { file: { id: string } } } = await fetch(
"https://void.cat/upload?cli=false",
{
method: "POST",
timeout: 5,
headers: {
accept: "*/*",
"Content-Type": "application/octet-stream",
"V-Filename": filename,
"V-Description": "Upload from https://lume.nu",
"V-Strip-Metadata": "true",
},
body: Body.bytes(buf),
},
);
const webpImage = `https://void.cat/d/${res.data.file.id}.webp`;
valueState(webpImage);
setLoading(false);
}
};
valueState(webpImage);
setLoading(false);
}
};
return (
<button
onClick={() => openFileDialog()}
type="button"
className="inline-flex h-6 items-center justify-center rounded bg-zinc-900 px-3 text-xs font-medium text-zinc-200 ring-1 ring-zinc-800 hover:bg-zinc-700"
>
{loading ? (
<svg
className="h-4 w-4 animate-spin text-black dark:text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
) : (
<span className="leading-none">Upload</span>
)}
</button>
);
return (
<button
onClick={() => openFileDialog()}
type="button"
className="inline-flex h-6 items-center justify-center rounded bg-zinc-900 px-3 text-xs font-medium text-zinc-200 ring-1 ring-zinc-800 hover:bg-zinc-700"
>
{loading ? (
<svg
className="h-4 w-4 animate-spin text-black dark:text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<title id="loading">Loading</title>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
) : (
<span className="leading-none">Upload</span>
)}
</button>
);
}

View File

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

View File

@@ -1,92 +1,105 @@
import { Post } from '@shared/composer/types/post';
import { User } from '@shared/composer/user';
import { Post } from "@shared/composer/types/post";
import { User } from "@shared/composer/user";
import CancelIcon from '@icons/cancel';
import ChevronDownIcon from '@icons/chevronDown';
import ChevronRightIcon from '@icons/chevronRight';
import ComposeIcon from '@icons/compose';
import CancelIcon from "@icons/cancel";
import ChevronDownIcon from "@icons/chevronDown";
import ChevronRightIcon from "@icons/chevronRight";
import ComposeIcon from "@icons/compose";
import { composerAtom } from '@stores/composer';
import { composerAtom } from "@stores/composer";
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
import { Dialog, Transition } from '@headlessui/react';
import { useAtom } from 'jotai';
import { Fragment, useState } from 'react';
import { Dialog, Transition } from "@headlessui/react";
import { useAtom } from "jotai";
import { Fragment, useState } from "react";
export function ComposerModal() {
const [isOpen, setIsOpen] = useState(false);
const [composer] = useAtom(composerAtom);
const [isOpen, setIsOpen] = useState(false);
const [composer] = useAtom(composerAtom);
const { account, isLoading, isError } = useActiveAccount();
const { account, isLoading, isError } = useActiveAccount();
const closeModal = () => {
setIsOpen(false);
};
const closeModal = () => {
setIsOpen(false);
};
const openModal = () => {
setIsOpen(true);
};
const openModal = () => {
setIsOpen(true);
};
return (
<>
<button
type="button"
autoFocus={false}
onClick={() => openModal()}
className="inline-flex h-7 w-max items-center justify-center gap-1 rounded-md bg-fuchsia-500 px-2.5 text-xs font-medium text-zinc-200 shadow-button hover:bg-fuchsia-600 focus:outline-none"
>
<ComposeIcon width={14} height={14} />
Compose
</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative h-min w-full max-w-xl rounded-lg border border-zinc-800 bg-zinc-900">
<div className="flex items-center justify-between px-4 py-4">
<div className="flex items-center gap-2">
<div>{!isLoading && !isError && account && <User data={account} />}</div>
<span>
<ChevronRightIcon width={14} height={14} className="text-zinc-500" />
</span>
<div className="inline-flex h-6 w-max items-center justify-center gap-0.5 rounded bg-zinc-800 pl-3 pr-1.5 text-xs font-medium text-zinc-400 shadow-mini-button">
New Post
<ChevronDownIcon width={14} height={14} />
</div>
</div>
<div
onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-800"
>
<CancelIcon width={16} height={16} className="text-zinc-500" />
</div>
</div>
{composer.type === 'post' && account && <Post pubkey={account.pubkey} privkey={account.privkey} />}
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
return (
<>
<button
type="button"
onClick={() => openModal()}
className="inline-flex h-7 w-max items-center justify-center gap-1 rounded-md bg-fuchsia-500 px-2.5 text-xs font-medium text-zinc-200 shadow-button hover:bg-fuchsia-600 focus:outline-none"
>
<ComposeIcon width={14} height={14} />
Compose
</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative h-min w-full max-w-xl rounded-lg border border-zinc-800 bg-zinc-900">
<div className="flex items-center justify-between px-4 py-4">
<div className="flex items-center gap-2">
<div>
{!isLoading && !isError && account && (
<User data={account} />
)}
</div>
<span>
<ChevronRightIcon
width={14}
height={14}
className="text-zinc-500"
/>
</span>
<div className="inline-flex h-6 w-max items-center justify-center gap-0.5 rounded bg-zinc-800 pl-3 pr-1.5 text-xs font-medium text-zinc-400 shadow-mini-button">
New Post
<ChevronDownIcon width={14} height={14} />
</div>
</div>
<div
onKeyDown={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-800"
>
<CancelIcon
width={16}
height={16}
className="text-zinc-500"
/>
</div>
</div>
{composer.type === "post" && account && (
<Post pubkey={account.pubkey} privkey={account.privkey} />
)}
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
}

View File

@@ -1,124 +1,141 @@
import { ImageUploader } from '@shared/composer/imageUploader';
import TrashIcon from '@shared/icons/trash';
import { RelayContext } from '@shared/relayProvider';
import { ImageUploader } from "@shared/composer/imageUploader";
import TrashIcon from "@shared/icons/trash";
import { RelayContext } from "@shared/relayProvider";
import { WRITEONLY_RELAYS } from '@stores/constants';
import { WRITEONLY_RELAYS } from "@stores/constants";
import { dateToUnix } from '@utils/date';
import { dateToUnix } from "@utils/date";
import { getEventHash, signEvent } from 'nostr-tools';
import { useCallback, useContext, useMemo, useState } from 'react';
import { Node, Transforms, createEditor } from 'slate';
import { withHistory } from 'slate-history';
import { Editable, ReactEditor, Slate, useSlateStatic, withReact } from 'slate-react';
import { getEventHash, signEvent } from "nostr-tools";
import { useCallback, useContext, useMemo, useState } from "react";
import { Node, Transforms, createEditor } from "slate";
import { withHistory } from "slate-history";
import {
Editable,
ReactEditor,
Slate,
useSlateStatic,
withReact,
} from "slate-react";
const withImages = (editor) => {
const { isVoid } = editor;
const { isVoid } = editor;
editor.isVoid = (element) => {
return element.type === 'image' ? true : isVoid(element);
};
editor.isVoid = (element) => {
return element.type === "image" ? true : isVoid(element);
};
return editor;
return editor;
};
const ImagePreview = ({ attributes, children, element }: { attributes: any; children: any; element: any }) => {
const editor: any = useSlateStatic();
const path = ReactEditor.findPath(editor, element);
const ImagePreview = ({
attributes,
children,
element,
}: { attributes: any; children: any; element: any }) => {
const editor: any = useSlateStatic();
const path = ReactEditor.findPath(editor, element);
return (
<figure {...attributes} className="m-0 mt-3">
{children}
<div contentEditable={false} className="relative">
<img src={element.url} className="m-0 h-auto w-full rounded-md" />
<button
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="absolute right-2 top-2 inline-flex h-7 w-7 items-center justify-center gap-0.5 rounded bg-zinc-800 text-xs font-medium text-zinc-400 shadow-mini-button hover:bg-zinc-700"
>
<TrashIcon width={14} height={14} className="text-zinc-100" />
</button>
</div>
</figure>
);
return (
<figure {...attributes} className="m-0 mt-3">
{children}
<div contentEditable={false} className="relative">
<img
alt={element.url}
src={element.url}
className="m-0 h-auto w-full rounded-md"
/>
<button
type="button"
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="absolute right-2 top-2 inline-flex h-7 w-7 items-center justify-center gap-0.5 rounded bg-zinc-800 text-xs font-medium text-zinc-400 shadow-mini-button hover:bg-zinc-700"
>
<TrashIcon width={14} height={14} className="text-zinc-100" />
</button>
</div>
</figure>
);
};
export function Post({ pubkey, privkey }: { pubkey: string; privkey: string }) {
const pool: any = useContext(RelayContext);
const pool: any = useContext(RelayContext);
const editor = useMemo(() => withReact(withImages(withHistory(createEditor()))), []);
const [content, setContent] = useState<Node[]>([
{
children: [
{
text: '',
},
],
},
]);
const editor = useMemo(
() => withReact(withImages(withHistory(createEditor()))),
[],
);
const [content, setContent] = useState<Node[]>([
{
children: [
{
text: "",
},
],
},
]);
const serialize = useCallback((nodes: Node[]) => {
return nodes.map((n) => Node.string(n)).join('\n');
}, []);
const serialize = useCallback((nodes: Node[]) => {
return nodes.map((n) => Node.string(n)).join("\n");
}, []);
const submit = () => {
// serialize content
const serializedContent = serialize(content);
console.log(serializedContent);
const submit = () => {
// serialize content
const serializedContent = serialize(content);
console.log(serializedContent);
const event: any = {
content: serializedContent,
created_at: dateToUnix(),
kind: 1,
pubkey: pubkey,
tags: [],
};
event.id = getEventHash(event);
event.sig = signEvent(event, privkey);
const event: any = {
content: serializedContent,
created_at: dateToUnix(),
kind: 1,
pubkey: pubkey,
tags: [],
};
event.id = getEventHash(event);
event.sig = signEvent(event, privkey);
// publish note
pool.publish(event, WRITEONLY_RELAYS);
};
// publish note
pool.publish(event, WRITEONLY_RELAYS);
};
const renderElement = useCallback((props: any) => {
switch (props.element.type) {
case 'image':
if (props.element.url) {
return <ImagePreview {...props} />;
}
default:
return <p {...props.attributes}>{props.children}</p>;
}
}, []);
const renderElement = useCallback((props: any) => {
switch (props.element.type) {
case "image":
if (props.element.url) {
return <ImagePreview {...props} />;
}
default:
return <p {...props.attributes}>{props.children}</p>;
}
}, []);
return (
<Slate editor={editor} value={content} onChange={setContent}>
<div className="flex h-full flex-col px-4 pb-4">
<div className="flex h-full w-full gap-2">
<div className="flex w-8 shrink-0 items-center justify-center">
<div className="h-full w-[2px] bg-zinc-800"></div>
</div>
<div className="prose prose-zinc relative h-max w-full max-w-none select-text break-words pb-3 dark:prose-invert prose-p:mb-0.5 prose-p:mt-0 prose-p:text-[15px] prose-p:leading-tight prose-a:text-[15px] prose-a:font-normal prose-a:leading-tight prose-a:text-fuchsia-500 prose-a:no-underline hover:prose-a:text-fuchsia-600 hover:prose-a:underline prose-ol:mb-1 prose-ul:mb-1 prose-li:text-[15px] prose-li:leading-tight">
<Editable
autoFocus
placeholder="What's on your mind?"
spellCheck="false"
className="!min-h-[86px]"
renderElement={renderElement}
/>
</div>
</div>
<div className="flex items-center justify-between">
<ImageUploader />
<button
type="button"
autoFocus={false}
onClick={submit}
className="inline-flex h-7 w-max items-center justify-center gap-1 rounded-md bg-fuchsia-500 px-3.5 text-xs font-medium text-zinc-200 shadow-button hover:bg-fuchsia-600"
>
Post
</button>
</div>
</div>
</Slate>
);
return (
<Slate editor={editor} value={content} onChange={setContent}>
<div className="flex h-full flex-col px-4 pb-4">
<div className="flex h-full w-full gap-2">
<div className="flex w-8 shrink-0 items-center justify-center">
<div className="h-full w-[2px] bg-zinc-800" />
</div>
<div className="prose prose-zinc relative h-max w-full max-w-none select-text break-words pb-3 dark:prose-invert prose-p:mb-0.5 prose-p:mt-0 prose-p:text-[15px] prose-p:leading-tight prose-a:text-[15px] prose-a:font-normal prose-a:leading-tight prose-a:text-fuchsia-500 prose-a:no-underline hover:prose-a:text-fuchsia-600 hover:prose-a:underline prose-ol:mb-1 prose-ul:mb-1 prose-li:text-[15px] prose-li:leading-tight">
<Editable
autoFocus
placeholder="What's on your mind?"
spellCheck="false"
className="!min-h-[86px]"
renderElement={renderElement}
/>
</div>
</div>
<div className="flex items-center justify-between">
<ImageUploader />
<button
type="button"
onClick={submit}
className="inline-flex h-7 w-max items-center justify-center gap-1 rounded-md bg-fuchsia-500 px-3.5 text-xs font-medium text-zinc-200 shadow-button hover:bg-fuchsia-600"
>
Post
</button>
</div>
</div>
</Slate>
);
}

View File

@@ -1,25 +1,27 @@
import { Image } from '@shared/image';
import { Image } from "@shared/image";
import { DEFAULT_AVATAR, IMGPROXY_URL } from '@stores/constants';
import { DEFAULT_AVATAR, IMGPROXY_URL } from "@stores/constants";
export function User({ data }: { data: any }) {
const metadata = JSON.parse(data.metadata);
const metadata = JSON.parse(data.metadata);
return (
<div className="flex items-center gap-2">
<div className="h-8 w-8 shrink-0 overflow-hidden rounded bg-zinc-900">
<Image
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${metadata?.picture ? metadata.picture : DEFAULT_AVATAR}`}
alt={data.pubkey}
className="h-8 w-8 object-cover"
loading="auto"
/>
</div>
<h5 className="text-sm font-semibold leading-none text-zinc-100">
{metadata?.display_name || metadata?.name || (
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700"></div>
)}
</h5>
</div>
);
return (
<div className="flex items-center gap-2">
<div className="h-8 w-8 shrink-0 overflow-hidden rounded bg-zinc-900">
<Image
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${
metadata?.picture ? metadata.picture : DEFAULT_AVATAR
}`}
alt={data.pubkey}
className="h-8 w-8 object-cover"
loading="auto"
/>
</div>
<h5 className="text-sm font-semibold leading-none text-zinc-100">
{metadata?.display_name || metadata?.name || (
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700" />
)}
</h5>
</div>
);
}

View File

@@ -1,117 +1,130 @@
import { RelayContext } from '@shared/relayProvider';
import { RelayContext } from "@shared/relayProvider";
import HeartBeatIcon from '@icons/heartbeat';
import HeartBeatIcon from "@icons/heartbeat";
import { READONLY_RELAYS } from '@stores/constants';
import { hasNewerNoteAtom } from '@stores/note';
import { READONLY_RELAYS } from "@stores/constants";
import { hasNewerNoteAtom } from "@stores/note";
import { dateToUnix } from '@utils/date';
import { useActiveAccount } from '@utils/hooks/useActiveAccount';
import { createChat, createNote, updateAccount } from '@utils/storage';
import { getParentID, nip02ToArray } from '@utils/transform';
import { dateToUnix } from "@utils/date";
import { useActiveAccount } from "@utils/hooks/useActiveAccount";
import { createChat, createNote, updateAccount } from "@utils/storage";
import { getParentID, nip02ToArray } from "@utils/transform";
import { useSetAtom } from 'jotai';
import { useContext, useRef } from 'react';
import useSWRSubscription from 'swr/subscription';
import { useSetAtom } from "jotai";
import { useContext, useRef } from "react";
import useSWRSubscription from "swr/subscription";
export default function EventCollector() {
const pool: any = useContext(RelayContext);
const pool: any = useContext(RelayContext);
const setHasNewerNote = useSetAtom(hasNewerNoteAtom);
const now = useRef(new Date());
const setHasNewerNote = useSetAtom(hasNewerNoteAtom);
const now = useRef(new Date());
const { account, isLoading, isError } = useActiveAccount();
const { account, isLoading, isError } = useActiveAccount();
useSWRSubscription(!isLoading && !isError && account ? ['eventCollector', account] : null, ([, key], {}) => {
const follows = JSON.parse(key.follows);
const followsAsArray = nip02ToArray(follows);
const unsubscribe = pool.subscribe(
[
{
kinds: [1, 6],
authors: followsAsArray,
since: dateToUnix(now.current),
},
{
kinds: [0, 3],
authors: [key.pubkey],
},
{
kinds: [4],
'#p': [key.pubkey],
since: dateToUnix(now.current),
},
{
kinds: [30023],
since: dateToUnix(now.current),
},
],
READONLY_RELAYS,
(event: any) => {
switch (event.kind) {
// metadata
case 0:
updateAccount('metadata', event.content, event.pubkey);
break;
// short text note
case 1:
const parentID = getParentID(event.tags, event.id);
createNote(
event.id,
account.id,
event.pubkey,
event.kind,
event.tags,
event.content,
event.created_at,
parentID
);
// notify user reload to get newer note
setHasNewerNote(true);
break;
// contacts
case 3:
// update account's folllows with NIP-02 tag list
updateAccount('follows', event.tags, event.pubkey);
break;
// chat
case 4:
if (event.pubkey !== key.pubkey) {
createChat(key.id, event.pubkey, event.created_at);
}
break;
// repost
case 6:
createNote(
event.id,
key.id,
event.pubkey,
event.kind,
event.tags,
event.content,
event.created_at,
event.id
);
break;
// long post
case 30023:
// insert event to local database
createNote(event.id, account.id, event.pubkey, event.kind, event.tags, event.content, event.created_at, '');
break;
default:
break;
}
}
);
useSWRSubscription(
!isLoading && !isError && account ? ["eventCollector", account] : null,
([, key]) => {
const follows = JSON.parse(key.follows);
const followsAsArray = nip02ToArray(follows);
const unsubscribe = pool.subscribe(
[
{
kinds: [1, 6],
authors: followsAsArray,
since: dateToUnix(now.current),
},
{
kinds: [0, 3],
authors: [key.pubkey],
},
{
kinds: [4],
"#p": [key.pubkey],
since: dateToUnix(now.current),
},
{
kinds: [30023],
since: dateToUnix(now.current),
},
],
READONLY_RELAYS,
(event: any) => {
switch (event.kind) {
// metadata
case 0:
updateAccount("metadata", event.content, event.pubkey);
break;
// short text note
case 1: {
const parentID = getParentID(event.tags, event.id);
createNote(
event.id,
account.id,
event.pubkey,
event.kind,
event.tags,
event.content,
event.created_at,
parentID,
);
// notify user reload to get newer note
setHasNewerNote(true);
break;
}
// contacts
case 3:
// update account's folllows with NIP-02 tag list
updateAccount("follows", event.tags, event.pubkey);
break;
// chat
case 4:
if (event.pubkey !== key.pubkey) {
createChat(key.id, event.pubkey, event.created_at);
}
break;
// repost
case 6:
createNote(
event.id,
key.id,
event.pubkey,
event.kind,
event.tags,
event.content,
event.created_at,
event.id,
);
break;
// long post
case 30023:
// insert event to local database
createNote(
event.id,
account.id,
event.pubkey,
event.kind,
event.tags,
event.content,
event.created_at,
"",
);
break;
default:
break;
}
},
);
return () => {
unsubscribe();
};
});
return () => {
unsubscribe();
};
},
);
return (
<div className="inline-flex h-6 w-6 items-center justify-center rounded text-zinc-400 hover:bg-zinc-900 hover:text-zinc-200">
<HeartBeatIcon width={14} height={14} />
</div>
);
return (
<div className="inline-flex h-6 w-6 items-center justify-center rounded text-zinc-400 hover:bg-zinc-900 hover:text-zinc-200">
<HeartBeatIcon width={14} height={14} />
</div>
);
}

View File

@@ -1,98 +1,110 @@
import PlusIcon from '@icons/plus';
import PlusIcon from "@icons/plus";
import { channelContentAtom } from '@stores/channel';
import { chatContentAtom } from '@stores/chat';
import { noteContentAtom } from '@stores/note';
import { channelContentAtom } from "@stores/channel";
import { chatContentAtom } from "@stores/chat";
import { noteContentAtom } from "@stores/note";
import { createBlobFromFile } from '@utils/createBlobFromFile';
import { createBlobFromFile } from "@utils/createBlobFromFile";
import { open } from '@tauri-apps/api/dialog';
import { Body, fetch } from '@tauri-apps/api/http';
import { useSetAtom } from 'jotai';
import { useState } from 'react';
import { open } from "@tauri-apps/api/dialog";
import { Body, fetch } from "@tauri-apps/api/http";
import { useSetAtom } from "jotai";
import { useState } from "react";
export function ImagePicker({ type }: { type: string }) {
let atom;
let atom;
switch (type) {
case 'note':
atom = noteContentAtom;
break;
case 'chat':
atom = chatContentAtom;
break;
case 'channel':
atom = channelContentAtom;
break;
default:
throw new Error('Invalid type');
}
switch (type) {
case "note":
atom = noteContentAtom;
break;
case "chat":
atom = chatContentAtom;
break;
case "channel":
atom = channelContentAtom;
break;
default:
throw new Error("Invalid type");
}
const [loading, setLoading] = useState(false);
const setValue = useSetAtom(atom);
const [loading, setLoading] = useState(false);
const setValue = useSetAtom(atom);
const openFileDialog = async () => {
const selected: any = await open({
multiple: false,
filters: [
{
name: 'Image',
extensions: ['png', 'jpeg', 'jpg', 'gif'],
},
],
});
if (Array.isArray(selected)) {
// user selected multiple files
} else if (selected === null) {
// user cancelled the selection
} else {
setLoading(true);
const openFileDialog = async () => {
const selected: any = await open({
multiple: false,
filters: [
{
name: "Image",
extensions: ["png", "jpeg", "jpg", "gif"],
},
],
});
if (Array.isArray(selected)) {
// user selected multiple files
} else if (selected === null) {
// user cancelled the selection
} else {
setLoading(true);
const filename = selected.split('/').pop();
const file = await createBlobFromFile(selected);
const buf = await file.arrayBuffer();
const filename = selected.split("/").pop();
const file = await createBlobFromFile(selected);
const buf = await file.arrayBuffer();
const res: { data: { file: { id: string } } } = await fetch('https://void.cat/upload?cli=false', {
method: 'POST',
timeout: 5,
headers: {
accept: '*/*',
'Content-Type': 'application/octet-stream',
'V-Filename': filename,
'V-Description': 'Upload from https://lume.nu',
'V-Strip-Metadata': 'true',
},
body: Body.bytes(buf),
});
const webpImage = 'https://void.cat/d/' + res.data.file.id + '.webp';
const res: { data: { file: { id: string } } } = await fetch(
"https://void.cat/upload?cli=false",
{
method: "POST",
timeout: 5,
headers: {
accept: "*/*",
"Content-Type": "application/octet-stream",
"V-Filename": filename,
"V-Description": "Upload from https://lume.nu",
"V-Strip-Metadata": "true",
},
body: Body.bytes(buf),
},
);
const webpImage = `https://void.cat/d/${res.data.file.id}.webp`;
setValue((content: string) => content + ' ' + webpImage);
setLoading(false);
}
};
setValue((content: string) => `${content} ${webpImage}`);
setLoading(false);
}
};
return (
<button
onClick={() => openFileDialog()}
className="inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-700"
>
{loading ? (
<svg
className="h-4 w-4 animate-spin text-black dark:text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
) : (
<PlusIcon width={16} height={16} className="text-zinc-400" />
)}
</button>
);
return (
<button
type="button"
onClick={() => openFileDialog()}
className="inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-700"
>
{loading ? (
<svg
className="h-4 w-4 animate-spin text-black dark:text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<title id="loading">Loading</title>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
) : (
<PlusIcon width={16} height={16} className="text-zinc-400" />
)}
</button>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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