replace eslint/prettier with rome

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

View File

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

View File

@@ -1,24 +0,0 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"tabWidth": 2,
"printWidth": 120,
"useTabs": false,
"endOfLine": "lf",
"bracketSpacing": true,
"bracketSameLine": false,
"importOrder": [
"^@app/(.*)$",
"^@shared/(.*)$",
"^@icons/(.*)$",
"^@stores/(.*)$",
"^@utils/(.*)$",
"<THIRD_PARTY_MODULES>",
"^[./]"
],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true,
"plugins": ["@trivago/prettier-plugin-sort-imports", "prettier-plugin-tailwindcss"],
"pluginSearchDirs": false
}

View File

@@ -10,8 +10,7 @@
"prepare": "husky install" "prepare": "husky install"
}, },
"lint-staged": { "lint-staged": {
"**/*": "prettier --write --ignore-unknown", "**/*.{js,ts,jsx,tsx}": "rome check --apply"
"**/*.{js,ts,jsx,tsx}": "eslint --fix"
}, },
"dependencies": { "dependencies": {
"@floating-ui/react": "^0.23.1", "@floating-ui/react": "^0.23.1",
@@ -42,29 +41,20 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/typography": "^0.5.9", "@tailwindcss/typography": "^0.5.9",
"@tauri-apps/cli": "^1.3.1", "@tauri-apps/cli": "^1.3.1",
"@trivago/prettier-plugin-sort-imports": "^4.1.1",
"@types/node": "^18.16.9", "@types/node": "^18.16.9",
"@types/react": "^18.2.6", "@types/react": "^18.2.6",
"@types/react-dom": "^18.2.4", "@types/react-dom": "^18.2.4",
"@types/youtube-player": "^5.5.7", "@types/youtube-player": "^5.5.7",
"@typescript-eslint/eslint-plugin": "^5.59.5",
"@typescript-eslint/parser": "^5.59.5",
"@vitejs/plugin-react-swc": "^3.3.1", "@vitejs/plugin-react-swc": "^3.3.1",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"csstype": "^3.1.2", "csstype": "^3.1.2",
"encoding": "^0.1.13", "encoding": "^0.1.13",
"eslint": "^8.40.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.3.5",
"husky": "^8.0.3", "husky": "^8.0.3",
"lint-staged": "^13.2.2", "lint-staged": "^13.2.2",
"postcss": "^8.4.23", "postcss": "^8.4.23",
"prettier": "^2.8.8",
"prettier-plugin-tailwindcss": "^0.2.8",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"rome": "12.1.0",
"tailwindcss": "^3.3.2", "tailwindcss": "^3.3.2",
"typescript": "^4.9.5", "typescript": "^4.9.5",
"vite": "^4.3.5", "vite": "^4.3.5",

3264
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

18
rome.json Normal file
View File

@@ -0,0 +1,18 @@
{
"$schema": "https://docs.rome.tools/schemas/12.1.0/schema.json",
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"a11y": {
"noSvgWithoutTitle": "off"
},
"suspicious": {
"noExplicitAny": "off"
}
}
}
}

View File

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

View File

@@ -1,28 +1,32 @@
import { Image } from '@shared/image'; import { Image } from "@shared/image";
import { DEFAULT_AVATAR } from '@stores/constants'; import { DEFAULT_AVATAR } from "@stores/constants";
import { useProfile } from '@utils/hooks/useProfile'; import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from '@utils/shortenKey'; import { shortenKey } from "@utils/shortenKey";
export default function User({ pubkey }: { pubkey: string }) { export default function User({ pubkey }: { pubkey: string }) {
const { user } = useProfile(pubkey); const { user } = useProfile(pubkey);
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative h-11 w-11 shrink rounded-md"> <div className="relative h-11 w-11 shrink rounded-md">
<Image <Image
src={user?.picture || DEFAULT_AVATAR} src={user?.picture || DEFAULT_AVATAR}
alt={pubkey} alt={pubkey}
className="h-11 w-11 rounded-md object-cover" className="h-11 w-11 rounded-md object-cover"
loading="lazy" loading="lazy"
decoding="async" decoding="async"
/> />
</div> </div>
<div className="flex w-full flex-1 flex-col items-start text-start"> <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="truncate font-medium leading-tight text-zinc-200">
<span className="text-sm leading-tight text-zinc-400">{user?.nip05?.toLowerCase() || shortenKey(pubkey)}</span> {user?.display_name || user?.name}
</div> </span>
</div> <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 ArrowLeftIcon from "@icons/arrowLeft";
import ArrowRightIcon from '@icons/arrowRight'; import ArrowRightIcon from "@icons/arrowRight";
import useSWR from 'swr'; import useSWR from "swr";
const fetcher = async () => { const fetcher = async () => {
const { platform } = await import('@tauri-apps/api/os'); const { platform } = await import("@tauri-apps/api/os");
return await platform(); return await platform();
}; };
export function LayoutOnboarding({ children }: { children: React.ReactNode }) { export function LayoutOnboarding({ children }: { children: React.ReactNode }) {
const { data: platform } = useSWR('platform', fetcher); const { data: platform } = useSWR("platform", fetcher);
const goBack = () => { const goBack = () => {
window.history.back(); window.history.back();
}; };
const goForward = () => { const goForward = () => {
window.history.forward(); window.history.forward();
}; };
return ( return (
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white"> <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 className="flex h-screen w-full flex-col">
<div <div
data-tauri-drag-region data-tauri-drag-region
className="relative h-11 shrink-0 border border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black" 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
<div className={`flex h-full items-center gap-2 ${platform === 'darwin' ? 'pl-[68px]' : ''}`}> data-tauri-drag-region
<button className="flex h-full w-full flex-1 items-center px-2"
onClick={() => goBack()} >
className="group inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-900" <div
> className={`flex h-full items-center gap-2 ${
<ArrowLeftIcon width={16} height={16} className="text-zinc-500 group-hover:text-zinc-300" /> platform === "darwin" ? "pl-[68px]" : ""
</button> }`}
<button >
onClick={() => goForward()} <button
className="group inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-900" type="button"
> onClick={() => goBack()}
<ArrowRightIcon width={16} height={16} className="text-zinc-500 group-hover:text-zinc-300" /> className="group inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-900"
</button> >
</div> <ArrowLeftIcon
</div> width={16}
</div> height={16}
<div className="relative flex min-h-0 w-full flex-1">{children}</div> className="text-zinc-500 group-hover:text-zinc-300"
</div> />
</div> </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 EyeOffIcon from "@icons/eyeOff";
import EyeOnIcon from '@icons/eyeOn'; import EyeOnIcon from "@icons/eyeOn";
import { onboardingAtom } from '@stores/onboarding'; import { onboardingAtom } from "@stores/onboarding";
import { useSetAtom } from 'jotai'; import { useSetAtom } from "jotai";
import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools'; import { generatePrivateKey, getPublicKey, nip19 } from "nostr-tools";
import { useMemo, useState } from 'react'; import { useMemo, useState } from "react";
import { navigate } from 'vite-plugin-ssr/client/router'; import { navigate } from "vite-plugin-ssr/client/router";
export function Page() { export function Page() {
const [type, setType] = useState('password'); const [type, setType] = useState("password");
const setOnboarding = useSetAtom(onboardingAtom); const setOnboarding = useSetAtom(onboardingAtom);
const privkey = useMemo(() => generatePrivateKey(), []); const privkey = useMemo(() => generatePrivateKey(), []);
const pubkey = getPublicKey(privkey); const pubkey = getPublicKey(privkey);
const npub = nip19.npubEncode(pubkey); const npub = nip19.npubEncode(pubkey);
const nsec = nip19.nsecEncode(privkey); const nsec = nip19.nsecEncode(privkey);
// toggle private key // toggle private key
const showPrivateKey = () => { const showPrivateKey = () => {
if (type === 'password') { if (type === "password") {
setType('text'); setType("text");
} else { } else {
setType('password'); setType("password");
} }
}; };
const submit = () => { const submit = () => {
setOnboarding((prev) => ({ ...prev, pubkey: pubkey, privkey: privkey })); setOnboarding((prev) => ({ ...prev, pubkey: pubkey, privkey: privkey }));
navigate('/auth/create/step-2'); navigate("/auth/create/step-2");
}; };
return ( return (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md"> <div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center"> <div className="mb-8 text-center">
<h1 className="text-2xl font-semibold text-zinc-200">Lume is auto-generated key for you</h1> <h1 className="text-2xl font-semibold text-zinc-200">
</div> Lume is auto-generated key for you
<div className="flex flex-col gap-4"> </h1>
<div className="flex flex-col gap-1"> </div>
<label className="text-sm font-semibold text-zinc-400">Public Key</label> <div className="flex flex-col gap-4">
<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"> <div className="flex flex-col gap-1">
<input <label className="text-sm font-semibold text-zinc-400">
readOnly Public Key
value={npub} </label>
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 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
</div> readOnly
</div> value={npub}
<div className="flex flex-col gap-1"> 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"
<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"> </div>
<input </div>
readOnly <div className="flex flex-col gap-1">
type={type} <label className="text-sm font-semibold text-zinc-400">
value={nsec} Private Key
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" </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">
<button <input
onClick={() => showPrivateKey()} readOnly
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700" type={type}
> value={nsec}
{type === 'password' ? ( 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"
<EyeOffIcon width={20} height={20} className="text-zinc-500 group-hover:text-zinc-200" /> />
) : ( <button
<EyeOnIcon width={20} height={20} className="text-zinc-500 group-hover:text-zinc-200" /> type="button"
)} onClick={() => showPrivateKey()}
</button> className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
</div> >
</div> {type === "password" ? (
<button <EyeOffIcon
type="button" width={20}
onClick={() => submit()} height={20}
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" className="text-zinc-500 group-hover:text-zinc-200"
> />
<span>Continue </span> ) : (
</button> <EyeOnIcon
</div> width={20}
</div> height={20}
</div> 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 { AvatarUploader } from "@shared/avatarUploader";
import { Image } from '@shared/image'; import { Image } from "@shared/image";
import { DEFAULT_AVATAR } from '@stores/constants'; import { DEFAULT_AVATAR } from "@stores/constants";
import { onboardingAtom } from '@stores/onboarding'; import { onboardingAtom } from "@stores/onboarding";
import { useAtom } from 'jotai'; import { useAtom } from "jotai";
import { useEffect, useState } from 'react'; import { useEffect, useState } from "react";
import { useForm } from 'react-hook-form'; import { useForm } from "react-hook-form";
import { navigate } from 'vite-plugin-ssr/client/router'; import { navigate } from "vite-plugin-ssr/client/router";
export function Page() { export function Page() {
const [image, setImage] = useState(DEFAULT_AVATAR); const [image, setImage] = useState(DEFAULT_AVATAR);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [onboarding, setOnboarding] = useAtom(onboardingAtom); const [onboarding, setOnboarding] = useAtom(onboardingAtom);
const { const {
register, register,
handleSubmit, handleSubmit,
setValue, setValue,
formState: { isDirty, isValid }, formState: { isDirty, isValid },
} = useForm({ } = useForm({
defaultValues: async () => { defaultValues: async () => {
if (onboarding.metadata) { if (onboarding.metadata) {
return onboarding.metadata; return onboarding.metadata;
} else { } else {
return null; return null;
} }
}, },
}); });
const onSubmit = (data: any) => { const onSubmit = (data: any) => {
setLoading(true); setLoading(true);
setOnboarding((prev) => ({ ...prev, metadata: data })); setOnboarding((prev) => ({ ...prev, metadata: data }));
navigate('/auth/create/step-3'); navigate("/auth/create/step-3");
}; };
useEffect(() => { useEffect(() => {
setValue('picture', image); setValue("picture", image);
}, [setValue, image]); }, [setValue, image]);
return ( return (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md"> <div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center"> <div className="mb-8 text-center">
<h1 className="text-2xl font-semibold text-zinc-200">Create your profile</h1> <h1 className="text-2xl font-semibold text-zinc-200">
</div> Create your profile
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900 p-4"> </h1>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4"> </div>
<input <div className="w-full rounded-lg border border-zinc-800 bg-zinc-900 p-4">
type={'hidden'} <form
{...register('picture')} onSubmit={handleSubmit(onSubmit)}
value={image} className="flex flex-col gap-4"
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" >
/> <input
<div className="flex flex-col gap-1"> type={"hidden"}
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">Avatar</label> {...register("picture")}
<div className="relative inline-flex h-36 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950"> value={image}
<Image src={image} alt="avatar" className="relative z-10 h-11 w-11 rounded-md" /> 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="absolute bottom-3 right-3 z-10"> />
<AvatarUploader valueState={setImage} /> <div className="flex flex-col gap-1">
</div> <label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
</div> Avatar
</div> </label>
<div className="flex flex-col gap-1"> <div className="relative inline-flex h-36 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950">
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">Display Name *</label> <Image
<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"> src={image}
<input alt="avatar"
type={'text'} className="relative z-10 h-11 w-11 rounded-md"
{...register('display_name', { required: true, minLength: 4 })} />
spellCheck={false} <div className="absolute bottom-3 right-3 z-10">
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" <AvatarUploader valueState={setImage} />
/> </div>
</div> </div>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">About</label> <label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
<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"> Display Name *
<textarea </label>
{...register('about')} <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">
spellCheck={false} <input
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" type={"text"}
/> {...register("display_name", {
</div> required: true,
</div> minLength: 4,
<div> })}
<button spellCheck={false}
type="submit" 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"
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" </div>
> </div>
{loading ? ( <div className="flex flex-col gap-1">
<svg <label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
className="h-4 w-4 animate-spin text-black dark:text-white" About
xmlns="http://www.w3.org/2000/svg" </label>
fill="none" <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">
viewBox="0 0 24 24" <textarea
> {...register("about")}
<circle spellCheck={false}
className="opacity-25" 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"
cx="12" />
cy="12" </div>
r="10" </div>
stroke="currentColor" <div>
strokeWidth="4" <button
></circle> type="submit"
<path disabled={!isDirty || !isValid}
className="opacity-75" 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"
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" {loading ? (
></path> <svg
</svg> className="h-4 w-4 animate-spin text-black dark:text-white"
) : ( xmlns="http://www.w3.org/2000/svg"
<span>Continue </span> fill="none"
)} viewBox="0 0 24 24"
</button> >
</div> <title id="loading">Loading</title>
</form> <circle
</div> className="opacity-25"
</div> cx="12"
</div> 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 { WRITEONLY_RELAYS } from "@stores/constants";
import { onboardingAtom } from '@stores/onboarding'; import { onboardingAtom } from "@stores/onboarding";
import { createAccount, createPleb } from '@utils/storage'; import { createAccount, createPleb } from "@utils/storage";
import { arrayToNIP02 } from '@utils/transform'; import { arrayToNIP02 } from "@utils/transform";
import { useAtom } from 'jotai'; import { useAtom } from "jotai";
import { getEventHash, signEvent } from 'nostr-tools'; import { getEventHash, signEvent } from "nostr-tools";
import { useContext, useState } from 'react'; import { useContext, useState } from "react";
import { navigate } from 'vite-plugin-ssr/client/router'; import { navigate } from "vite-plugin-ssr/client/router";
const initialList = [ const initialList = [
{ pubkey: '82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2' }, {
{ pubkey: 'a341f45ff9758f570a21b000c17d4e53a3a497c8397f26c0e6d61e5acffc7a98' }, pubkey: "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2",
{ pubkey: '04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9' }, },
{ pubkey: 'c4eabae1be3cf657bc1855ee05e69de9f059cb7a059227168b80b89761cbc4e0' }, {
{ pubkey: '6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93' }, pubkey: "a341f45ff9758f570a21b000c17d4e53a3a497c8397f26c0e6d61e5acffc7a98",
{ pubkey: 'e88a691e98d9987c964521dff60025f60700378a4879180dcbbb4a5027850411' }, },
{ pubkey: '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d' }, {
{ pubkey: 'c49d52a573366792b9a6e4851587c28042fb24fa5625c6d67b8c95c8751aca15' }, pubkey: "04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9",
{ pubkey: 'e33fe65f1fde44c6dc17eeb38fdad0fceaf1cae8722084332ed1e32496291d42' }, },
{ pubkey: '84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240' }, {
{ pubkey: '703e26b4f8bc0fa57f99d815dbb75b086012acc24fc557befa310f5aa08d1898' }, pubkey: "c4eabae1be3cf657bc1855ee05e69de9f059cb7a059227168b80b89761cbc4e0",
{ pubkey: 'bf2376e17ba4ec269d10fcc996a4746b451152be9031fa48e74553dde5526bce' }, },
{ pubkey: '4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0' }, {
{ pubkey: 'c9b19ffcd43e6a5f23b3d27106ce19e4ad2df89ba1031dd4617f1b591e108965' }, pubkey: "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93",
{ pubkey: 'c7dccba4fe4426a7b1ea239a5637ba40fab9862c8c86b3330fe65e9f667435f6' }, },
{ pubkey: '6e1534f56fc9e937e06237c8ba4b5662bcacc4e1a3cfab9c16d89390bec4fca3' }, {
{ pubkey: '50d94fc2d8580c682b071a542f8b1e31a200b0508bab95a33bef0855df281d63' }, pubkey: "e88a691e98d9987c964521dff60025f60700378a4879180dcbbb4a5027850411",
{ pubkey: '3d2e51508699f98f0f2bdbe7a45b673c687fe6420f466dc296d90b908d51d594' }, },
{ pubkey: '6e3f51664e19e082df5217fd4492bb96907405a0b27028671dd7f297b688608c' }, {
{ pubkey: '2edbcea694d164629854a52583458fd6d965b161e3c48b57d3aff01940558884' }, pubkey: "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
{ pubkey: '3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24' }, },
{ pubkey: 'eab0e756d32b80bcd464f3d844b8040303075a13eabc3599a762c9ac7ab91f4f' }, {
{ pubkey: 'be1d89794bf92de5dd64c1e60f6a2c70c140abac9932418fee30c5c637fe9479' }, pubkey: "c49d52a573366792b9a6e4851587c28042fb24fa5625c6d67b8c95c8751aca15",
{ pubkey: 'a5e93aef8e820cbc7ab7b6205f854b87aed4b48c5f6b30fbbeba5c99e40dcf3f' }, },
{ pubkey: '1989034e56b8f606c724f45a12ce84a11841621aaf7182a1f6564380b9c4276b' }, {
{ pubkey: 'c48b5cced5ada74db078df6b00fa53fc1139d73bf0ed16de325d52220211dbd5' }, pubkey: "e33fe65f1fde44c6dc17eeb38fdad0fceaf1cae8722084332ed1e32496291d42",
{ pubkey: '460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c' }, },
{ pubkey: '7f3b464b9ff3623630485060cbda3a7790131c5339a7803bde8feb79a5e1b06a' }, {
{ pubkey: 'b99dbca0184a32ce55904cb267b22e434823c97f418f36daf5d2dff0dd7b5c27' }, pubkey: "84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240",
{ pubkey: 'e9e4276490374a0daf7759fd5f475deff6ffb9b0fc5fa98c902b5f4b2fe3bba2' }, },
{ pubkey: 'ea2e3c814d08a378f8a5b8faecb2884d05855975c5ca4b5c25e2d6f936286f14' }, {
{ pubkey: 'ff04a0e6cd80c141b0b55825fed127d4532a6eecdb7e743a38a3c28bf9f44609' }, 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() { export function Page() {
const pool: any = useContext(RelayContext); const pool: any = useContext(RelayContext);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [follows, setFollows] = useState([]); const [follows, setFollows] = useState([]);
const [onboarding] = useAtom(onboardingAtom); const [onboarding] = useAtom(onboardingAtom);
// toggle follow state // toggle follow state
const toggleFollow = (pubkey: string) => { const toggleFollow = (pubkey: string) => {
const arr = follows.includes(pubkey) ? follows.filter((i) => i !== pubkey) : [...follows, pubkey]; const arr = follows.includes(pubkey)
setFollows(arr); ? follows.filter((i) => i !== pubkey)
}; : [...follows, pubkey];
setFollows(arr);
};
const broadcastAccount = () => { const broadcastAccount = () => {
// build event // build event
const event: any = { const event: any = {
content: JSON.stringify(onboarding.metadata), content: JSON.stringify(onboarding.metadata),
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
kind: 0, kind: 0,
pubkey: onboarding.pubkey, pubkey: onboarding.pubkey,
tags: [], tags: [],
}; };
event.id = getEventHash(event); event.id = getEventHash(event);
event.sig = signEvent(event, onboarding.privkey); event.sig = signEvent(event, onboarding.privkey);
// broadcast // broadcast
pool.publish(event, WRITEONLY_RELAYS); pool.publish(event, WRITEONLY_RELAYS);
}; };
const broadcastContacts = () => { const broadcastContacts = () => {
const nip02 = arrayToNIP02(follows); const nip02 = arrayToNIP02(follows);
// build event // build event
const event: any = { const event: any = {
content: '', content: "",
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
kind: 3, kind: 3,
pubkey: onboarding.pubkey, pubkey: onboarding.pubkey,
tags: nip02, tags: nip02,
}; };
event.id = getEventHash(event); event.id = getEventHash(event);
event.sig = signEvent(event, onboarding.privkey); event.sig = signEvent(event, onboarding.privkey);
// broadcast // broadcast
pool.publish(event, WRITEONLY_RELAYS); pool.publish(event, WRITEONLY_RELAYS);
}; };
// save follows to database then broadcast // save follows to database then broadcast
const submit = async () => { const submit = async () => {
setLoading(true); setLoading(true);
const followsIncludeSelf = follows.concat([onboarding.pubkey]); const followsIncludeSelf = follows.concat([onboarding.pubkey]);
// insert to database // insert to database
createAccount(onboarding.pubkey, onboarding.privkey, onboarding.metadata, arrayToNIP02(followsIncludeSelf), 1) createAccount(
.then((res) => { onboarding.pubkey,
if (res) { onboarding.privkey,
for (const tag of follows) { onboarding.metadata,
fetch(`https://us.rbr.bio/${tag}/metadata.json`) arrayToNIP02(followsIncludeSelf),
.then((data) => data.json()) 1,
.then((data) => createPleb(tag, data ?? '')); )
} .then((res) => {
broadcastAccount(); if (res) {
broadcastContacts(); for (const tag of follows) {
setTimeout(() => navigate('/', { overwriteLastHistoryEntry: true }), 2000); fetch(`https://us.rbr.bio/${tag}/metadata.json`)
} else { .then((data) => data.json())
console.error(); .then((data) => createPleb(tag, data ?? ""));
} }
}) broadcastAccount();
.catch(console.error); broadcastContacts();
}; setTimeout(
() => navigate("/", { overwriteLastHistoryEntry: true }),
2000,
);
} else {
console.error();
}
})
.catch(console.error);
};
return ( return (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md"> <div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center"> <div className="mb-8 text-center">
<h1 className="text-2xl font-semibold text-zinc-200">Personalized your newsfeed</h1> <h1 className="text-2xl font-semibold text-zinc-200">
</div> Personalized your newsfeed
<div className="flex flex-col gap-4"> </h1>
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900"> </div>
<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"> <div className="flex flex-col gap-4">
Follow at least <div className="w-full rounded-lg border border-zinc-800 bg-zinc-900">
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text font-bold text-transparent"> <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">
{follows.length}/10 Follow at least
</span>{' '} <span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text font-bold text-transparent">
plebs {follows.length}/10
</div> </span>{" "}
<div className="scrollbar-hide flex h-96 flex-col overflow-y-auto py-2"> plebs
{initialList.map((item: { pubkey: string }, index: number) => ( </div>
<button <div className="scrollbar-hide flex h-96 flex-col overflow-y-auto py-2">
key={index} {initialList.map((item: { pubkey: string }, index: number) => (
type="button" <button
onClick={() => toggleFollow(item.pubkey)} key={`item-${index}`}
className="inline-flex transform items-center justify-between bg-zinc-900 px-4 py-2 hover:bg-zinc-800 active:translate-y-1" type="button"
> onClick={() => toggleFollow(item.pubkey)}
<User pubkey={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"
{follows.includes(item.pubkey) && ( >
<div> <User pubkey={item.pubkey} />
<CheckCircleIcon width={16} height={16} className="text-green-400" /> {follows.includes(item.pubkey) && (
</div> <div>
)} <CheckCircleIcon
</button> width={16}
))} height={16}
</div> className="text-green-400"
</div> />
{follows.length >= 10 && ( </div>
<button )}
onClick={() => submit()} </button>
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" ))}
> </div>
{loading === true ? ( </div>
<svg {follows.length >= 10 && (
className="h-5 w-5 animate-spin text-white" <button
xmlns="http://www.w3.org/2000/svg" type="button"
fill="none" onClick={() => submit()}
viewBox="0 0 24 24" 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"
> >
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> {loading === true ? (
<path <svg
className="opacity-75" className="h-5 w-5 animate-spin text-white"
fill="currentColor" xmlns="http://www.w3.org/2000/svg"
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" fill="none"
></path> viewBox="0 0 24 24"
</svg> >
) : ( <title id="loading">Loading</title>
<span>Continue </span> <circle
)} className="opacity-25"
</button> cx="12"
)} cy="12"
</div> r="10"
</div> stroke="currentColor"
</div> 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 { useSetAtom } from "jotai";
import { getPublicKey, nip19 } from 'nostr-tools'; import { getPublicKey, nip19 } from "nostr-tools";
import { Resolver, useForm } from 'react-hook-form'; import { Resolver, useForm } from "react-hook-form";
import { navigate } from 'vite-plugin-ssr/client/router'; import { navigate } from "vite-plugin-ssr/client/router";
type FormValues = { type FormValues = {
key: string; key: string;
}; };
const resolver: Resolver<FormValues> = async (values) => { const resolver: Resolver<FormValues> = async (values) => {
return { return {
values: values.key ? values : {}, values: values.key ? values : {},
errors: !values.key errors: !values.key
? { ? {
key: { key: {
type: 'required', type: "required",
message: 'This is required.', message: "This is required.",
}, },
} }
: {}, : {},
}; };
}; };
export function Page() { export function Page() {
const setOnboardingPrivkey = useSetAtom(onboardingAtom); const setOnboardingPrivkey = useSetAtom(onboardingAtom);
const { const {
register, register,
setError, setError,
handleSubmit, handleSubmit,
formState: { errors, isDirty, isValid, isSubmitting }, formState: { errors, isDirty, isValid, isSubmitting },
} = useForm<FormValues>({ resolver }); } = useForm<FormValues>({ resolver });
const onSubmit = async (data: any) => { const onSubmit = async (data: any) => {
try { try {
let privkey = data['key']; let privkey = data["key"];
if (privkey.substring(0, 4) === 'nsec') { if (privkey.substring(0, 4) === "nsec") {
privkey = nip19.decode(privkey).data; privkey = nip19.decode(privkey).data;
} }
if (typeof getPublicKey(privkey) === 'string') { if (typeof getPublicKey(privkey) === "string") {
setOnboardingPrivkey((prev) => ({ ...prev, privkey: privkey })); setOnboardingPrivkey((prev) => ({ ...prev, privkey: privkey }));
navigate(`/auth/import/step-2`); navigate("/auth/import/step-2");
} }
} catch (error) { } catch (error) {
setError('key', { setError("key", {
type: 'custom', type: "custom",
message: 'Private Key is invalid, please check again', message: "Private Key is invalid, please check again",
}); });
} }
}; };
return ( return (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md"> <div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center"> <div className="mb-8 text-center">
<h1 className="text-2xl font-semibold text-zinc-200">Import your key</h1> <h1 className="text-2xl font-semibold text-zinc-200">
</div> Import your key
<div className="flex flex-col gap-4"> </h1>
<div> </div>
{/* #TODO: add function */} <div className="flex flex-col gap-4">
<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>
<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"> {/* #TODO: add function */}
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-transparent"> <button
Coming soon type="button"
</span> 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> >
<span>Continue with Nostr Connect</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">
</button> <span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-transparent">
</div> Coming soon
<div className="relative"> </span>
<div className="absolute inset-0 flex items-center"> </div>
<div className="w-full border-t border-zinc-800"></div> <span>Continue with Nostr Connect</span>
</div> </button>
<div className="relative flex justify-center"> </div>
<span className="bg-zinc-950 px-2 text-sm text-zinc-500">or</span> <div className="relative">
</div> <div className="absolute inset-0 flex items-center">
</div> <div className="w-full border-t border-zinc-800" />
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3"> </div>
<div className="flex flex-col gap-0.5"> <div className="relative flex justify-center">
<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"> <span className="bg-zinc-950 px-2 text-sm text-zinc-500">or</span>
<input </div>
{...register('key', { required: true, minLength: 32 })} </div>
type={'password'} <form
placeholder="Paste private key here..." onSubmit={handleSubmit(onSubmit)}
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" className="flex flex-col gap-3"
/> >
</div> <div className="flex flex-col gap-0.5">
<span className="text-xs text-red-400">{errors.key && <p>{errors.key.message}</p>}</span> <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">
</div> <input
<div className="flex h-9 items-center justify-center"> {...register("key", { required: true, minLength: 32 })}
{isSubmitting ? ( type={"password"}
<svg placeholder="Paste private key here..."
className="h-5 w-5 animate-spin text-white" 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"
xmlns="http://www.w3.org/2000/svg" />
fill="none" </div>
viewBox="0 0 24 24" <span className="text-xs text-red-400">
> {errors.key && <p>{errors.key.message}</p>}
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> </span>
<path </div>
className="opacity-75" <div className="flex h-9 items-center justify-center">
fill="currentColor" {isSubmitting ? (
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
></path> className="h-5 w-5 animate-spin text-white"
</svg> xmlns="http://www.w3.org/2000/svg"
) : ( fill="none"
<button viewBox="0 0 24 24"
type="submit" >
disabled={!isDirty || !isValid} <title id="loading">Loading</title>
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" <circle
> className="opacity-25"
<span className="drop-shadow-lg">Continue </span> cx="12"
</button> cy="12"
)} r="10"
</div> stroke="currentColor"
</form> strokeWidth="4"
</div> />
</div> <path
</div> 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 { Image } from "@shared/image";
import { RelayContext } from '@shared/relayProvider'; import { RelayContext } from "@shared/relayProvider";
import { DEFAULT_AVATAR, READONLY_RELAYS } from '@stores/constants'; import { DEFAULT_AVATAR, READONLY_RELAYS } from "@stores/constants";
import { onboardingAtom } from '@stores/onboarding'; import { onboardingAtom } from "@stores/onboarding";
import { shortenKey } from '@utils/shortenKey'; import { shortenKey } from "@utils/shortenKey";
import { createAccount, createPleb } from '@utils/storage'; import { createAccount, createPleb } from "@utils/storage";
import { useAtom } from 'jotai'; import { useAtom } from "jotai";
import { getPublicKey } from 'nostr-tools'; import { getPublicKey } from "nostr-tools";
import { useContext, useMemo, useState } from 'react'; import { useContext, useMemo, useState } from "react";
import useSWRSubscription from 'swr/subscription'; import useSWRSubscription from "swr/subscription";
import { navigate } from 'vite-plugin-ssr/client/router'; import { navigate } from "vite-plugin-ssr/client/router";
export function Page() { export function Page() {
const pool: any = useContext(RelayContext); const pool: any = useContext(RelayContext);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [onboarding, setOnboarding] = useAtom(onboardingAtom); const [onboarding, setOnboarding] = useAtom(onboardingAtom);
const pubkey = useMemo(() => (onboarding.privkey ? getPublicKey(onboarding.privkey) : ''), [onboarding.privkey]); const pubkey = useMemo(
() => (onboarding.privkey ? getPublicKey(onboarding.privkey) : ""),
[onboarding.privkey],
);
const { data, error } = useSWRSubscription(pubkey ? pubkey : null, (key, { next }) => { const { data, error } = useSWRSubscription(
const unsubscribe = pool.subscribe( pubkey ? pubkey : null,
[ (key, { next }) => {
{ const unsubscribe = pool.subscribe(
kinds: [0, 3], [
authors: [key], {
}, kinds: [0, 3],
], authors: [key],
READONLY_RELAYS, },
(event: any) => { ],
switch (event.kind) { READONLY_RELAYS,
case 0: (event: any) => {
// update state switch (event.kind) {
next(null, JSON.parse(event.content)); case 0:
// create account // update state
setOnboarding((prev) => ({ ...prev, metadata: event.content })); next(null, JSON.parse(event.content));
break; // create account
case 3: setOnboarding((prev) => ({ ...prev, metadata: event.content }));
setOnboarding((prev) => ({ ...prev, follows: event.tags })); break;
break; case 3:
default: setOnboarding((prev) => ({ ...prev, follows: event.tags }));
break; break;
} default:
} break;
); }
},
);
return () => { return () => {
unsubscribe(); unsubscribe();
}; };
}); },
);
const submit = () => { const submit = () => {
// show loading indicator // show loading indicator
setLoading(true); setLoading(true);
const follows = onboarding.follows.concat([['p', pubkey]]); const follows = onboarding.follows.concat([["p", pubkey]]);
// insert to database // insert to database
createAccount(pubkey, onboarding.privkey, onboarding.metadata, follows, 1) createAccount(pubkey, onboarding.privkey, onboarding.metadata, follows, 1)
.then((res) => { .then((res) => {
if (res) { if (res) {
for (const tag of onboarding.follows) { for (const tag of onboarding.follows) {
fetch(`https://us.rbr.bio/${tag[1]}/metadata.json`) fetch(`https://us.rbr.bio/${tag[1]}/metadata.json`)
.then((data) => data.json()) .then((data) => data.json())
.then((data) => createPleb(tag[1], data ?? '')); .then((data) => createPleb(tag[1], data ?? ""));
} }
setTimeout(() => navigate('/', { overwriteLastHistoryEntry: true }), 2000); setTimeout(
} else { () => navigate("/", { overwriteLastHistoryEntry: true }),
console.error(); 2000,
} );
}) } else {
.catch(console.error); console.error();
}; }
})
.catch(console.error);
};
return ( return (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md"> <div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center"> <div className="mb-8 text-center">
<h1 className="text-2xl font-semibold">{loading ? 'Creating...' : 'Continue with'}</h1> <h1 className="text-2xl font-semibold">
</div> {loading ? "Creating..." : "Continue with"}
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900 p-4"> </h1>
{error && <div>Failed to load profile</div>} </div>
{!data ? ( <div className="w-full rounded-lg border border-zinc-800 bg-zinc-900 p-4">
<div className="w-full"> {error && <div>Failed to load profile</div>}
<div className="flex items-center gap-2"> {!data ? (
<div className="h-11 w-11 animate-pulse rounded-lg bg-zinc-800"></div> <div className="w-full">
<div> <div className="flex items-center gap-2">
<h3 className="mb-1 h-4 w-16 animate-pulse rounded bg-zinc-800"></h3> <div className="h-11 w-11 animate-pulse rounded-lg bg-zinc-800" />
<p className="h-3 w-36 animate-pulse rounded bg-zinc-800"></p> <div>
</div> <h3 className="mb-1 h-4 w-16 animate-pulse rounded bg-zinc-800" />
</div> <p className="h-3 w-36 animate-pulse rounded bg-zinc-800" />
</div> </div>
) : ( </div>
<div className="flex flex-col gap-3"> </div>
<div className="flex items-center gap-2"> ) : (
<Image <div className="flex flex-col gap-3">
className="relative inline-flex h-11 w-11 rounded-lg ring-2 ring-zinc-900" <div className="flex items-center gap-2">
src={data.picture || DEFAULT_AVATAR} <Image
alt={pubkey} className="relative inline-flex h-11 w-11 rounded-lg ring-2 ring-zinc-900"
/> src={data.picture || DEFAULT_AVATAR}
<div> alt={pubkey}
<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> <h3 className="font-medium leading-none text-zinc-200">
</div> {data.display_name || data.name}
<button </h3>
type="button" <p className="text-sm text-zinc-400">
onClick={() => submit()} {data.nip05 || shortenKey(pubkey)}
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" </p>
> </div>
{loading ? ( </div>
<svg <button
className="h-5 w-5 animate-spin text-black dark:text-white" type="button"
xmlns="http://www.w3.org/2000/svg" onClick={() => submit()}
fill="none" 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"
viewBox="0 0 24 24" >
> {loading ? (
<circle <svg
className="opacity-25" className="h-5 w-5 animate-spin text-black dark:text-white"
cx="12" xmlns="http://www.w3.org/2000/svg"
cy="12" fill="none"
r="10" viewBox="0 0 24 24"
stroke="currentColor" >
strokeWidth="4" <circle
></circle> className="opacity-25"
<path cx="12"
className="opacity-75" cy="12"
fill="currentColor" r="10"
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" stroke="currentColor"
></path> strokeWidth="4"
</svg> />
) : ( <path
<span>Continue </span> className="opacity-75"
)} fill="currentColor"
</button> 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"
</div> />
)} </svg>
</div> ) : (
</div> <span>Continue </span>
</div> )}
); </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 = [ const PLEBS = [
'https://133332.xyz/p.jpg', "https://133332.xyz/p.jpg",
'https://void.cat/d/3Bp6jSHURFNQ9u3pK8nwtq.webp', "https://void.cat/d/3Bp6jSHURFNQ9u3pK8nwtq.webp",
'https://i.imgur.com/f8SyhRL.jpg', "https://i.imgur.com/f8SyhRL.jpg",
'http://nostr.build/i/6369.jpg', "http://nostr.build/i/6369.jpg",
'https://pbs.twimg.com/profile_images/1622010345589190656/mAPqsmtz_400x400.jpg', "https://pbs.twimg.com/profile_images/1622010345589190656/mAPqsmtz_400x400.jpg",
'https://media.tenor.com/l5arkXy9RfIAAAAd/thunder.gif', "https://media.tenor.com/l5arkXy9RfIAAAAd/thunder.gif",
'https://nostr.build/i/p/nostr.build_0e412058980ed2ac4adf3de639304c9e970e2745ba9ca19c75f984f4f6da4971.jpeg', "https://nostr.build/i/p/nostr.build_0e412058980ed2ac4adf3de639304c9e970e2745ba9ca19c75f984f4f6da4971.jpeg",
'https://nostr.build/i/nostr.build_864a019a6c1d3a90a17363553d32b71de618d250f02cf0a59ca19fb3029fd5bc.jpg', "https://nostr.build/i/nostr.build_864a019a6c1d3a90a17363553d32b71de618d250f02cf0a59ca19fb3029fd5bc.jpg",
'https://void.cat/d/8zE9T8a39YfUVjrLM4xcpE.webp', "https://void.cat/d/8zE9T8a39YfUVjrLM4xcpE.webp",
'https://avatars.githubusercontent.com/u/89577423', "https://avatars.githubusercontent.com/u/89577423",
'https://pbs.twimg.com/profile_images/1363180486080663554/iN-r_BiM_400x400.jpg', "https://pbs.twimg.com/profile_images/1363180486080663554/iN-r_BiM_400x400.jpg",
'https://void.cat/d/JUBBqXgCcGBEh7jUgJaayy', "https://void.cat/d/JUBBqXgCcGBEh7jUgJaayy",
'https://phase1.attract-eu.com/wp-content/uploads/2020/03/ATTRACT_HPLM.png', "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://www.retro-synthwave.com/wp-content/uploads/2017/01/PowerGlove-23.jpg",
'https://void.cat/d/KvAEMvYNmy1rfCH6a7HZzh.webp', "https://void.cat/d/KvAEMvYNmy1rfCH6a7HZzh.webp",
'https://media.giphy.com/media/NqfMNCkyGwtXhKFlCR/giphy-downsized-large.gif', "https://media.giphy.com/media/NqfMNCkyGwtXhKFlCR/giphy-downsized-large.gif",
'https://i.imgur.com/VGpUNFS.jpg', "https://i.imgur.com/VGpUNFS.jpg",
'https://nostr.build/i/p/nostr.build_b39254db43d5557df99d1eb516f1c2f56a21a01b10c248f6eb66aa827c9a90f4.jpeg', "https://nostr.build/i/p/nostr.build_b39254db43d5557df99d1eb516f1c2f56a21a01b10c248f6eb66aa827c9a90f4.jpeg",
'https://davidcoen.it/wp-content/uploads/2020/11/7004972-taglio.jpg', "https://davidcoen.it/wp-content/uploads/2020/11/7004972-taglio.jpg",
'https://pbs.twimg.com/profile_images/1570432066348515330/26PtCuwF_400x400.jpg', "https://pbs.twimg.com/profile_images/1570432066348515330/26PtCuwF_400x400.jpg",
'https://nostr.build/i/nostr.build_9d33ee801aa08955be174554832952ab95a65d5e015176834c8aa9a4e2f2e3a5.jpg', "https://nostr.build/i/nostr.build_9d33ee801aa08955be174554832952ab95a65d5e015176834c8aa9a4e2f2e3a5.jpg",
'https://www.linkpicture.com/q/0FE78CFF-C931-4568-A7AA-DD8AEE889992.jpeg', "https://www.linkpicture.com/q/0FE78CFF-C931-4568-A7AA-DD8AEE889992.jpeg",
'https://nostr.build/i/nostr.build_97d6e2d25dd92422eb3d6d645b7cee9ed9c614f331be7e6f7db9ccfdbc5ee260.png', "https://nostr.build/i/nostr.build_97d6e2d25dd92422eb3d6d645b7cee9ed9c614f331be7e6f7db9ccfdbc5ee260.png",
'https://pbs.twimg.com/profile_images/1569570198348337152/-n1KD74u_400x400.jpg', "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/1600149653898596354/5PVe-r-J_400x400.jpg",
'https://pbs.twimg.com/profile_images/1639659216372658178/Dnn-Ysp-_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/1554429112978120706/yr1hXl6R_400x400.jpg",
'https://pbs.twimg.com/profile_images/1615478486688272385/q2ECeZDX_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/1638644441773748226/tNsA6RpG_400x400.jpg",
'https://pbs.twimg.com/profile_images/1607882836740120576/3Tg1mTYJ_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/1401907430339002369/WKrP9Esn_400x400.jpg",
'https://pbs.twimg.com/profile_images/1523971278478131200/TMPzfvhE_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/1626421539884204032/aj4tmzsk_400x400.png",
'https://pbs.twimg.com/profile_images/1582771691779985408/C9MHYIgt_400x400.jpg', "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/1409612480465276931/38Vyx4e8_400x400.jpg",
'https://pbs.twimg.com/profile_images/1549826566787588098/MlduJCZO_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/539211568035004416/sBMjPR9q_400x400.jpeg",
'https://pbs.twimg.com/profile_images/1548660003522887682/1QMHmles_400x400.jpg', "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/1362497143999787013/KLUoN1Vn_400x400.png",
'https://pbs.twimg.com/profile_images/1600434913240563713/AssmMGwf_400x400.jpg', "https://pbs.twimg.com/profile_images/1600434913240563713/AssmMGwf_400x400.jpg",
]; ];
const DURATION = 50000; const DURATION = 50000;
@@ -52,65 +52,80 @@ const PLEBS_PER_ROW = 20;
const random = (min, max) => Math.floor(Math.random() * (max - min)) + min; const random = (min, max) => Math.floor(Math.random() * (max - min)) + min;
const shuffle = (arr) => [...arr].sort(() => 0.5 - Math.random()); const shuffle = (arr) => [...arr].sort(() => 0.5 - Math.random());
const InfiniteLoopSlider = ({ children, duration, reverse }: { children: any; duration: any; reverse: any }) => { const InfiniteLoopSlider = ({
return ( children,
<div> duration,
<div reverse,
className="flex w-fit" }: { children: any; duration: any; reverse: any }) => {
style={{ return (
animationName: 'loop', <div>
animationIterationCount: 'infinite', <div
animationDirection: reverse ? 'reverse' : 'normal', className="flex w-fit"
animationDuration: duration + 'ms', style={{
animationTimingFunction: 'linear', animationName: "loop",
}} animationIterationCount: "infinite",
> animationDirection: reverse ? "reverse" : "normal",
{children} animationDuration: `${duration}ms`,
{children} animationTimingFunction: "linear",
</div> }}
</div> >
); {children}
{children}
</div>
</div>
);
}; };
export function Page() { export function Page() {
return ( return (
<div className="grid h-full w-full grid-rows-5"> <div className="grid h-full w-full grid-rows-5">
<div className="row-span-3 overflow-hidden"> <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"> <div className="relaive flex w-full max-w-full shrink-0 flex-col gap-4 overflow-hidden p-4">
{[...new Array(ROWS)].map((_, i) => ( {[...new Array(ROWS)].map((_, i) => (
<InfiniteLoopSlider key={i} duration={random(DURATION - 5000, DURATION + 20000)} reverse={i % 2}> <InfiniteLoopSlider
{shuffle(PLEBS) key={`item-${i}`}
.slice(0, PLEBS_PER_ROW) duration={random(DURATION - 5000, DURATION + 20000)}
.map((tag) => ( reverse={i % 2}
<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" /> {shuffle(PLEBS)
</div> .slice(0, PLEBS_PER_ROW)
))} .map((tag) => (
</InfiniteLoopSlider> <div
))} key={tag}
<div className="pointer-events-none absolute inset-0 bg-fade" /> className="relative mr-4 h-11 w-11 gap-2 rounded-md bg-zinc-900 shadow-xl"
</div> >
</div> <Image
<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"> src={tag}
<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"> alt={tag}
Let&apos;s start! className="h-11 w-11 rounded-md border border-zinc-900"
</h1> />
<div className="mt-4 flex flex-col items-center gap-1.5"> </div>
<a ))}
href="/auth/create" </InfiniteLoopSlider>
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" ))}
> <div className="pointer-events-none absolute inset-0 bg-fade" />
Create new key </div>
<ArrowRightIcon width={20} height={20} /> </div>
</a> <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">
<a <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">
href="/auth/import" Let&apos;s start!
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" </h1>
> <div className="mt-4 flex flex-col items-center gap-1.5">
Login with private key <a
</a> href="/auth/create"
</div> 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"
</div> >
</div> 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 { Popover, Transition } from "@headlessui/react";
import { Fragment } from 'react'; import { Fragment } from "react";
export default function ChannelBlackList({ blacklist }: { blacklist: any }) { export default function ChannelBlackList({ blacklist }: { blacklist: any }) {
return ( return (
<Popover className="relative"> <Popover className="relative">
{({ open }) => ( {({ open }) => (
<> <>
<Popover.Button <Popover.Button
className={`group inline-flex h-8 w-8 items-center justify-center rounded-md ring-2 ring-zinc-950 focus:outline-none ${ 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' 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 <MuteIcon
as={Fragment} width={16}
enter="transition ease-out duration-200" height={16}
enterFrom="opacity-0 translate-y-1" className="text-zinc-400 group-hover:text-zinc-200"
enterTo="opacity-100 translate-y-0" />
leave="transition ease-in duration-150" </Popover.Button>
leaveFrom="opacity-100 translate-y-0" <Transition
leaveTo="opacity-0 translate-y-1" as={Fragment}
> enter="transition ease-out duration-200"
<Popover.Panel className="absolute right-0 z-10 mt-1 w-screen max-w-xs transform px-4 sm:px-0"> enterFrom="opacity-0 translate-y-1"
<div className="flex flex-col gap-2 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900 shadow-popover"> enterTo="opacity-100 translate-y-0"
<div className="h-min w-full shrink-0 border-b border-zinc-800 p-3"> leave="transition ease-in duration-150"
<div className="flex flex-col gap-0.5"> leaveFrom="opacity-100 translate-y-0"
<h3 className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text font-semibold leading-none text-transparent"> leaveTo="opacity-0 translate-y-1"
Your muted list >
</h3> <Popover.Panel className="absolute right-0 z-10 mt-1 w-screen max-w-xs transform px-4 sm:px-0">
<p className="text-xs leading-tight text-zinc-400"> <div className="flex flex-col gap-2 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900 shadow-popover">
Currently, unmute only affect locally, when you move to new client, muted list will loaded again <div className="h-min w-full shrink-0 border-b border-zinc-800 p-3">
</p> <div className="flex flex-col gap-0.5">
</div> <h3 className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text font-semibold leading-none text-transparent">
</div> Your muted list
<div className="flex flex-col gap-2 px-3 pb-3 pt-1"> </h3>
{blacklist.map((item: any) => ( <p className="text-xs leading-tight text-zinc-400">
<MutedItem key={item.id} data={item} /> Currently, unmute only affect locally, when you move to
))} new client, muted list will loaded again
</div> </p>
</div> </div>
</Popover.Panel> </div>
</Transition> <div className="flex flex-col gap-2 px-3 pb-3 pt-1">
</> {blacklist.map((item: any) => (
)} <MutedItem key={item.id} data={item} />
</Popover> ))}
); </div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
);
} }

View File

@@ -1,254 +1,275 @@
import { AvatarUploader } from '@shared/avatarUploader'; import { AvatarUploader } from "@shared/avatarUploader";
import { Image } from '@shared/image'; import { Image } from "@shared/image";
import { RelayContext } from '@shared/relayProvider'; import { RelayContext } from "@shared/relayProvider";
import CancelIcon from '@icons/cancel'; import CancelIcon from "@icons/cancel";
import PlusIcon from '@icons/plus'; 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 { dateToUnix } from "@utils/date";
import { useActiveAccount } from '@utils/hooks/useActiveAccount'; import { useActiveAccount } from "@utils/hooks/useActiveAccount";
import { createChannel } from '@utils/storage'; import { createChannel } from "@utils/storage";
import { Dialog, Transition } from '@headlessui/react'; import { Dialog, Transition } from "@headlessui/react";
import { getEventHash, signEvent } from 'nostr-tools'; import { getEventHash, signEvent } from "nostr-tools";
import { Fragment, useContext, useEffect, useState } from 'react'; import { Fragment, useContext, useEffect, useState } from "react";
import { useForm } from 'react-hook-form'; import { useForm } from "react-hook-form";
import { useSWRConfig } from 'swr'; import { useSWRConfig } from "swr";
import { navigate } from 'vite-plugin-ssr/client/router'; import { navigate } from "vite-plugin-ssr/client/router";
export default function ChannelCreateModal() { export default function ChannelCreateModal() {
const pool: any = useContext(RelayContext); const pool: any = useContext(RelayContext);
const { account, isError, isLoading } = useActiveAccount(); const { account, isError, isLoading } = useActiveAccount();
const { mutate } = useSWRConfig(); const { mutate } = useSWRConfig();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [image, setImage] = useState(DEFAULT_AVATAR); const [image, setImage] = useState(DEFAULT_AVATAR);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const closeModal = () => { const closeModal = () => {
setIsOpen(false); setIsOpen(false);
}; };
const openModal = () => { const openModal = () => {
setIsOpen(true); setIsOpen(true);
}; };
const { const {
register, register,
handleSubmit, handleSubmit,
reset, reset,
setValue, setValue,
formState: { isDirty, isValid }, formState: { isDirty, isValid },
} = useForm(); } = useForm();
const onSubmit = (data: any) => { const onSubmit = (data: any) => {
setLoading(true); setLoading(true);
if (!isError && !isLoading && account) { if (!isError && !isLoading && account) {
const event: any = { const event: any = {
content: JSON.stringify(data), content: JSON.stringify(data),
created_at: dateToUnix(), created_at: dateToUnix(),
kind: 40, kind: 40,
pubkey: account.pubkey, pubkey: account.pubkey,
tags: [], tags: [],
}; };
event.id = getEventHash(event); event.id = getEventHash(event);
event.sig = signEvent(event, account.privkey); event.sig = signEvent(event, account.privkey);
// publish channel // publish channel
pool.publish(event, WRITEONLY_RELAYS); pool.publish(event, WRITEONLY_RELAYS);
// insert to database // insert to database
createChannel(event.id, event.pubkey, event.content, event.created_at); createChannel(event.id, event.pubkey, event.content, event.created_at);
// update channe llist // update channe llist
mutate('channels'); mutate("channels");
// reset form // reset form
reset(); reset();
setTimeout(() => { setTimeout(() => {
// close modal // close modal
setIsOpen(false); setIsOpen(false);
// redirect to channel page // redirect to channel page
navigate(`/app/channel?id=${event.id}`); navigate(`/app/channel?id=${event.id}`);
}, 2000); }, 2000);
} else { } else {
console.log('error'); console.log("error");
} }
}; };
useEffect(() => { useEffect(() => {
setValue('picture', image); setValue("picture", image);
}, [setValue, image]); }, [setValue, image]);
return ( return (
<> <>
<button <button
type="button" type="button"
onClick={() => openModal()} onClick={() => openModal()}
className="group inline-flex h-8 items-center gap-2.5 rounded-md px-2.5 hover:bg-zinc-900" 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"> <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" /> <PlusIcon width={12} height={12} className="text-zinc-500" />
</div> </div>
<div> <div>
<h5 className="text-[13px] font-semibold text-zinc-500 group-hover:text-zinc-400">Add a new channel</h5> <h5 className="text-[13px] font-semibold text-zinc-500 group-hover:text-zinc-400">
</div> Add a new channel
</button> </h5>
<Transition appear show={isOpen} as={Fragment}> </div>
<Dialog as="div" className="relative z-10" onClose={closeModal}> </button>
<Transition.Child <Transition appear show={isOpen} as={Fragment}>
as={Fragment} <Dialog as="div" className="relative z-10" onClose={closeModal}>
enter="ease-out duration-300" <Transition.Child
enterFrom="opacity-0" as={Fragment}
enterTo="opacity-100" enter="ease-out duration-300"
leave="ease-in duration-200" enterFrom="opacity-0"
leaveFrom="opacity-100" enterTo="opacity-100"
leaveTo="opacity-0" leave="ease-in duration-200"
> leaveFrom="opacity-100"
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" /> leaveTo="opacity-0"
</Transition.Child> >
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center"> <div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
<Transition.Child </Transition.Child>
as={Fragment} <div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
enter="ease-out duration-300" <Transition.Child
enterFrom="opacity-0 scale-95" as={Fragment}
enterTo="opacity-100 scale-100" enter="ease-out duration-300"
leave="ease-in duration-200" enterFrom="opacity-0 scale-95"
leaveFrom="opacity-100 scale-100" enterTo="opacity-100 scale-100"
leaveTo="opacity-0 scale-95" leave="ease-in duration-200"
> leaveFrom="opacity-100 scale-100"
<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"> leaveTo="opacity-0 scale-95"
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6"> >
<div className="flex flex-col gap-2"> <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="flex items-center justify-between"> <div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
<Dialog.Title <div className="flex flex-col gap-2">
as="h3" <div className="flex items-center justify-between">
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-2xl font-semibold leading-none text-transparent" <Dialog.Title
> as="h3"
Create channel className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-2xl font-semibold leading-none text-transparent"
</Dialog.Title> >
<button Create channel
type="button" </Dialog.Title>
onClick={closeModal} <button
autoFocus={false} type="button"
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900" 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> <CancelIcon
</div> width={20}
<Dialog.Description className="leading-tight text-zinc-400"> height={20}
Channels are freedom square, everyone can speech freely, no one can stop you or deceive what to className="text-zinc-300"
speech />
</Dialog.Description> </button>
</div> </div>
</div> <Dialog.Description className="leading-tight text-zinc-400">
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3"> Channels are freedom square, everyone can speech freely,
<form onSubmit={handleSubmit(onSubmit)} className="flex h-full w-full flex-col gap-4"> no one can stop you or deceive what to speech
<input </Dialog.Description>
type={'hidden'} </div>
{...register('picture')} </div>
value={image} <div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
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" <form
/> onSubmit={handleSubmit(onSubmit)}
<div className="flex flex-col gap-1"> className="flex h-full w-full flex-col gap-4"
<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"> <input
<Image src={image} alt="channel picture" className="relative z-10 h-11 w-11 rounded-md" /> type={"hidden"}
<div className="absolute bottom-3 right-3 z-10"> {...register("picture")}
<AvatarUploader valueState={setImage} /> value={image}
</div> 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">
<div className="flex flex-col gap-1"> <label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400"> Picture
Channel name * </label>
</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">
<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"> <Image
<input src={image}
type={'text'} alt="channel picture"
{...register('name', { required: true, minLength: 4 })} className="relative z-10 h-11 w-11 rounded-md"
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 className="absolute bottom-3 right-3 z-10">
/> <AvatarUploader valueState={setImage} />
</div> </div>
</div> </div>
<div className="flex flex-col gap-1"> </div>
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-400"> <div className="flex flex-col gap-1">
Description <label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
</label> Channel name *
<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"> </label>
<textarea <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">
{...register('about')} <input
spellCheck={false} type={"text"}
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" {...register("name", {
/> required: true,
</div> minLength: 4,
</div> })}
<div className="flex h-14 items-center justify-between gap-1 rounded-lg bg-zinc-800 px-4 py-2"> spellCheck={false}
<div className="flex flex-col gap-0.5"> 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="inline-flex items-center gap-1"> />
<span className="text-sm font-bold leading-none text-zinc-200">Make Private</span> </div>
<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"> </div>
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-transparent"> <div className="flex flex-col gap-1">
Coming soon <label className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
</span> Description
</div> </label>
</div> <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">
<p className="text-sm leading-none text-zinc-400"> <textarea
Private channels can only be viewed by member {...register("about")}
</p> spellCheck={false}
</div> 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> />
<button </div>
disabled </div>
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" <div className="flex h-14 items-center justify-between gap-1 rounded-lg bg-zinc-800 px-4 py-2">
role="switch" <div className="flex flex-col gap-0.5">
aria-checked="false" <div className="inline-flex items-center gap-1">
> <span className="text-sm font-bold leading-none text-zinc-200">
<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> Make Private
</button> </span>
</div> <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">
</div> <span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-transparent">
<div> Coming soon
<button </span>
type="submit" </div>
disabled={!isDirty || !isValid} </div>
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" <p className="text-sm leading-none text-zinc-400">
> Private channels can only be viewed by member
{loading ? ( </p>
<svg </div>
className="h-4 w-4 animate-spin text-black dark:text-white" <div>
xmlns="http://www.w3.org/2000/svg" <button
fill="none" type="button"
viewBox="0 0 24 24" 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"
<circle role="switch"
className="opacity-25" aria-checked="false"
cx="12" >
cy="12" <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" />
r="10" </button>
stroke="currentColor" </div>
strokeWidth="4" </div>
></circle> <div>
<path <button
className="opacity-75" type="submit"
fill="currentColor" disabled={!isDirty || !isValid}
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" 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"
></path> >
</svg> {loading ? (
) : ( <svg
'Create channel' className="h-4 w-4 animate-spin text-black dark:text-white"
)} xmlns="http://www.w3.org/2000/svg"
</button> fill="none"
</div> viewBox="0 0 24 24"
</form> >
</div> <title id="loading">Loading</title>
</Dialog.Panel> <circle
</Transition.Child> className="opacity-25"
</div> cx="12"
</Dialog> cy="12"
</Transition> 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 }) { export default function ChannelsListItem({ data }: { data: any }) {
const channel: any = useChannelProfile(data.event_id, data.pubkey); const channel: any = useChannelProfile(data.event_id, data.pubkey);
const pageContext = usePageContext(); const pageContext = usePageContext();
const searchParams: any = pageContext.urlParsed.search; const searchParams: any = pageContext.urlParsed.search;
const pageID = searchParams.id; const pageID = searchParams.id;
return ( return (
<a <a
href={`/app/channel?id=${data.event_id}&channelpub=${data.pubkey}`} href={`/app/channel?id=${data.event_id}&channelpub=${data.pubkey}`}
className={twMerge( className={twMerge(
'group inline-flex h-8 items-center gap-2.5 rounded-md px-2.5 hover:bg-zinc-900', "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' : '' 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', <div
pageID === data.event_id ? 'dark:bg-zinc-800 group-hover:dark:bg-zinc-700' : '' 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
<span className="text-xs text-zinc-200">#</span> ? "dark:bg-zinc-800 group-hover:dark:bg-zinc-700"
</div> : "",
<div> )}
<h5 className="truncate text-[13px] font-semibold text-zinc-400">{channel?.name}</h5> >
</div> <span className="text-xs text-zinc-200">#</span>
</a> </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 ChannelCreateModal from "@app/channel/components/createModal";
import ChannelsListItem from '@app/channel/components/item'; 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); const fetcher = () => getChannels(10, 0);
export default function ChannelsList() { export default function ChannelsList() {
const { data, error }: any = useSWR('channels', fetcher); const { data, error }: any = useSWR("channels", fetcher);
return ( return (
<div className="flex flex-col gap-px"> <div className="flex flex-col gap-px">
{!data || error ? ( {!data || error ? (
<> <>
<div className="inline-flex h-8 items-center gap-2 rounded-md px-2.5"> <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="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="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="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="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="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} />) data.map((item: { event_id: string }) => (
)} <ChannelsListItem key={item.event_id} data={item} />
<ChannelCreateModal /> ))
</div> )}
); <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() { export default function ChannelMembers() {
const membersAsSet = useAtomValue(channelMembersAtom); const membersAsSet = useAtomValue(channelMembersAtom);
const membersAsArray = [...membersAsSet]; const membersAsArray = [...membersAsSet];
const miniMembersList = membersAsArray.slice(0, 4); const miniMembersList = membersAsArray.slice(0, 4);
const totalMembers = const totalMembers =
membersAsArray.length > 0 membersAsArray.length > 0
? '+' + ? `+${Intl.NumberFormat("en-US", {
Intl.NumberFormat('en-US', { notation: "compact",
notation: 'compact', maximumFractionDigits: 1,
maximumFractionDigits: 1, }).format(membersAsArray.length)}`
}).format(membersAsArray.length) : 0;
: 0;
return ( return (
<div> <div>
<div className="group flex -space-x-2 overflow-hidden hover:-space-x-1"> <div className="group flex -space-x-2 overflow-hidden hover:-space-x-1">
{miniMembersList.map((member, index) => ( {miniMembersList.map((member, index) => (
<MiniMember key={index} pubkey={member} /> <MiniMember key={`item-${index}`} pubkey={member} />
))} ))}
{totalMembers ? ( {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"> <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> <span className="text-xs font-medium text-zinc-400 group-hover:text-zinc-200">
</div> {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 <div>
</button> <button
</div> type="button"
)} className="inline-flex h-8 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm text-white shadow-button"
</div> >
</div> 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 { useAtomValue } from "jotai";
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from "react";
import { Virtuoso } from 'react-virtuoso'; import { Virtuoso } from "react-virtuoso";
export default function ChannelMessageList() { export default function ChannelMessageList() {
const now = useRef(new Date()); const now = useRef(new Date());
const virtuosoRef = useRef(null); const virtuosoRef = useRef(null);
const data = useAtomValue(sortedChannelMessagesAtom); const data = useAtomValue(sortedChannelMessagesAtom);
const itemContent: any = useCallback( const itemContent: any = useCallback(
(index: string | number) => { (index: string | number) => {
return <ChannelMessageItem data={data[index]} />; return <ChannelMessageItem data={data[index]} />;
}, },
[data] [data],
); );
const computeItemKey = useCallback( const computeItemKey = useCallback(
(index: string | number) => { (index: string | number) => {
return data[index].id; return data[index].id;
}, },
[data] [data],
); );
return ( return (
<div className="h-full w-full"> <div className="h-full w-full">
<Virtuoso <Virtuoso
ref={virtuosoRef} ref={virtuosoRef}
data={data} data={data}
itemContent={itemContent} itemContent={itemContent}
components={{ components={{
Header: () => ( Header: () => (
<div className="relative py-4"> <div className="relative py-4">
<div className="absolute inset-0 flex items-center" aria-hidden="true"> <div
<div className="w-full border-t border-zinc-800" /> className="absolute inset-0 flex items-center"
</div> aria-hidden="true"
<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"> <div className="w-full border-t border-zinc-800" />
{getHourAgo(24, now.current).toLocaleDateString('en-US', { </div>
weekday: 'long', <div className="relative flex justify-center">
year: 'numeric', <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">
month: 'long', {getHourAgo(24, now.current).toLocaleDateString("en-US", {
day: 'numeric', weekday: "long",
})} year: "numeric",
</div> month: "long",
</div> day: "numeric",
</div> })}
), </div>
EmptyPlaceholder: () => ( </div>
<div className="flex flex-col gap-1 text-center"> </div>
<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> EmptyPlaceholder: () => (
</div> <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
computeItemKey={computeItemKey} </h3>
initialTopMostItemIndex={data.length - 1} <p className="text-sm leading-none text-zinc-400">
alignToBottom={true} Be the first to share a message in this channel.
followOutput={true} </p>
overscan={50} </div>
increaseViewportBy={{ top: 200, bottom: 200 }} ),
className="scrollbar-hide h-full w-full overflow-y-auto" }}
/> computeItemKey={computeItemKey}
</div> 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 { ImagePicker } from "@shared/form/imagePicker";
import { RelayContext } from '@shared/relayProvider'; import { RelayContext } from "@shared/relayProvider";
import CancelIcon from '@icons/cancel'; import CancelIcon from "@icons/cancel";
import { channelContentAtom, channelReplyAtom } from '@stores/channel'; import { channelContentAtom, channelReplyAtom } from "@stores/channel";
import { WRITEONLY_RELAYS } from '@stores/constants'; import { WRITEONLY_RELAYS } from "@stores/constants";
import { dateToUnix } from '@utils/date'; import { dateToUnix } from "@utils/date";
import { useActiveAccount } from '@utils/hooks/useActiveAccount'; import { useActiveAccount } from "@utils/hooks/useActiveAccount";
import { useAtom, useAtomValue } from 'jotai'; import { useAtom, useAtomValue } from "jotai";
import { useResetAtom } from 'jotai/utils'; import { useResetAtom } from "jotai/utils";
import { getEventHash, signEvent } from 'nostr-tools'; import { getEventHash, signEvent } from "nostr-tools";
import { useContext } from 'react'; import { useContext } from "react";
export default function ChannelMessageForm({ channelID }: { channelID: string | string[] }) { export default function ChannelMessageForm({
const pool: any = useContext(RelayContext); channelID,
const { account, isLoading, isError } = useActiveAccount(); }: { channelID: string | string[] }) {
const pool: any = useContext(RelayContext);
const { account, isLoading, isError } = useActiveAccount();
const [value, setValue] = useAtom(channelContentAtom); const [value, setValue] = useAtom(channelContentAtom);
const resetValue = useResetAtom(channelContentAtom); const resetValue = useResetAtom(channelContentAtom);
const channelReply = useAtomValue(channelReplyAtom); const channelReply = useAtomValue(channelReplyAtom);
const resetChannelReply = useResetAtom(channelReplyAtom); const resetChannelReply = useResetAtom(channelReplyAtom);
const submitEvent = () => { const submitEvent = () => {
let tags: any[][]; let tags: any[][];
if (channelReply.id !== null) { if (channelReply.id !== null) {
tags = [ tags = [
['e', channelID, '', 'root'], ["e", channelID, "", "root"],
['e', channelReply.id, '', 'reply'], ["e", channelReply.id, "", "reply"],
['p', channelReply.pubkey, ''], ["p", channelReply.pubkey, ""],
]; ];
} else { } else {
tags = [['e', channelID, '', 'root']]; tags = [["e", channelID, "", "root"]];
} }
if (!isError && !isLoading && account) { if (!isError && !isLoading && account) {
const event: any = { const event: any = {
content: value, content: value,
created_at: dateToUnix(), created_at: dateToUnix(),
kind: 42, kind: 42,
pubkey: account.pubkey, pubkey: account.pubkey,
tags: tags, tags: tags,
}; };
event.id = getEventHash(event); event.id = getEventHash(event);
event.sig = signEvent(event, account.privkey); event.sig = signEvent(event, account.privkey);
// publish note // publish note
pool.publish(event, WRITEONLY_RELAYS); pool.publish(event, WRITEONLY_RELAYS);
// reset state // reset state
resetValue(); resetValue();
// reset channel reply // reset channel reply
resetChannelReply(); resetChannelReply();
} else { } else {
console.log('error'); console.log("error");
} }
}; };
const handleEnterPress = (e) => { const handleEnterPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
submitEvent(); submitEvent();
} }
}; };
const stopReply = () => { const stopReply = () => {
resetChannelReply(); resetChannelReply();
}; };
return ( return (
<div <div
className={`relative ${ className={`relative ${
channelReply.id ? 'h-36' : 'h-24' 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`} } 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 && ( {channelReply.id && (
<div className="absolute left-0 top-0 z-10 h-14 w-full p-[2px]"> <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 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"> <div className="flex w-full flex-col">
<UserReply pubkey={channelReply.pubkey} /> <UserReply pubkey={channelReply.pubkey} />
<div className="-mt-3.5 pl-[32px]"> <div className="-mt-3.5 pl-[32px]">
<div className="text-xs text-zinc-200">{channelReply.content}</div> <div className="text-xs text-zinc-200">
</div> {channelReply.content}
</div> </div>
<button </div>
onClick={() => stopReply()} </div>
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-800" <button
> type="button"
<CancelIcon width={12} height={12} className="text-zinc-100" /> onClick={() => stopReply()}
</button> className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-800"
</div> >
</div> <CancelIcon width={12} height={12} className="text-zinc-100" />
)} </button>
<textarea </div>
value={value} </div>
onChange={(e) => setValue(e.target.value)} )}
onKeyDown={handleEnterPress} <textarea
spellCheck={false} value={value}
placeholder="Message" onChange={(e) => setValue(e.target.value)}
className={`relative ${ onKeyDown={handleEnterPress}
channelReply.id ? 'h-36 pt-16' : 'h-24 pt-3' spellCheck={false}
} 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`} placeholder="Message"
/> className={`relative ${
<div className="absolute bottom-2 w-full px-2"> channelReply.id ? "h-36 pt-16" : "h-24 pt-3"
<div className="flex w-full items-center justify-between bg-zinc-800"> } 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="flex items-center gap-2 divide-x divide-zinc-700"> />
<ImagePicker type="channel" /> <div className="absolute bottom-2 w-full px-2">
<div className="flex items-center gap-2 pl-2"></div> <div className="flex w-full items-center justify-between bg-zinc-800">
</div> <div className="flex items-center gap-2 divide-x divide-zinc-700">
<div className="flex items-center gap-2"> <ImagePicker type="channel" />
<button <div className="flex items-center gap-2 pl-2" />
onClick={() => submitEvent()} </div>
disabled={value.length === 0 ? true : false} <div className="flex items-center gap-2">
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" <button
> type="button"
Send onClick={() => submitEvent()}
</button> disabled={value.length === 0 ? true : false}
</div> 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"
</div> >
</div> Send
</div> </button>
); </div>
</div>
</div>
</div>
);
} }

View File

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

View File

@@ -1,34 +1,42 @@
import MessageHideButton from '@app/channel/components/messages/hideButton'; import MessageHideButton from "@app/channel/components/messages/hideButton";
import MessageMuteButton from '@app/channel/components/messages/muteButton'; import MessageMuteButton from "@app/channel/components/messages/muteButton";
import MessageReplyButton from '@app/channel/components/messages/replyButton'; import MessageReplyButton from "@app/channel/components/messages/replyButton";
import ChannelMessageUser from '@app/channel/components/messages/user'; 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 }) { export default function ChannelMessageItem({ data }: { data: any }) {
const content = useMemo(() => noteParser(data), [data]); const content = useMemo(() => noteParser(data), [data]);
return ( 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="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"> <div className="flex flex-col">
<ChannelMessageUser pubkey={data.pubkey} time={data.created_at} /> <ChannelMessageUser pubkey={data.pubkey} time={data.created_at} />
<div className="-mt-[17px] pl-[48px]"> <div className="-mt-[17px] pl-[48px]">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="whitespace-pre-line break-words text-sm leading-tight"> <div className="whitespace-pre-line break-words text-sm leading-tight">
{data.hide ? <span className="italic text-zinc-400">[hided message]</span> : content.parsed} {data.hide ? (
</div> <span className="italic text-zinc-400">[hided message]</span>
</div> ) : (
</div> content.parsed
</div> )}
<div className="absolute -top-4 right-4 z-10 hidden group-hover:inline-flex"> </div>
<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"> </div>
<MessageReplyButton id={data.id} pubkey={data.pubkey} content={data.content} /> </div>
<MessageHideButton id={data.id} /> </div>
<MessageMuteButton pubkey={data.pubkey} /> <div className="absolute -top-4 right-4 z-10 hidden group-hover:inline-flex">
</div> <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">
</div> <MessageReplyButton
</div> 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 { RelayContext } from "@shared/relayProvider";
import { Tooltip } from '@shared/tooltip'; import { Tooltip } from "@shared/tooltip";
import CancelIcon from '@icons/cancel'; import CancelIcon from "@icons/cancel";
import MuteIcon from '@icons/mute'; import MuteIcon from "@icons/mute";
import { channelMessagesAtom } from '@stores/channel'; import { channelMessagesAtom } from "@stores/channel";
import { WRITEONLY_RELAYS } from '@stores/constants'; import { WRITEONLY_RELAYS } from "@stores/constants";
import { dateToUnix } from '@utils/date'; import { dateToUnix } from "@utils/date";
import { useActiveAccount } from '@utils/hooks/useActiveAccount'; import { useActiveAccount } from "@utils/hooks/useActiveAccount";
import { Dialog, Transition } from '@headlessui/react'; import { Dialog, Transition } from "@headlessui/react";
import { useAtom } from 'jotai'; import { useAtom } from "jotai";
import { getEventHash, signEvent } from 'nostr-tools'; import { getEventHash, signEvent } from "nostr-tools";
import { Fragment, useContext, useState } from 'react'; import { Fragment, useContext, useState } from "react";
export default function MessageMuteButton({ pubkey }: { pubkey: string }) { export default function MessageMuteButton({ pubkey }: { pubkey: string }) {
const pool: any = useContext(RelayContext); const pool: any = useContext(RelayContext);
const { account, isError, isLoading } = useActiveAccount(); const { account, isError, isLoading } = useActiveAccount();
const [messages, setMessages] = useAtom(channelMessagesAtom); const [messages, setMessages] = useAtom(channelMessagesAtom);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const closeModal = () => { const closeModal = () => {
setIsOpen(false); setIsOpen(false);
}; };
const openModal = () => { const openModal = () => {
setIsOpen(true); setIsOpen(true);
}; };
const muteUser = () => { const muteUser = () => {
if (!isError && !isLoading && account) { if (!isError && !isLoading && account) {
const event: any = { const event: any = {
content: '', content: "",
created_at: dateToUnix(), created_at: dateToUnix(),
kind: 44, kind: 44,
pubkey: account.pubkey, pubkey: account.pubkey,
tags: [['p', pubkey]], tags: [["p", pubkey]],
}; };
event.id = getEventHash(event); event.id = getEventHash(event);
event.sig = signEvent(event, account.privkey); event.sig = signEvent(event, account.privkey);
// publish note // publish note
pool.publish(event, WRITEONLY_RELAYS); pool.publish(event, WRITEONLY_RELAYS);
// update local state // update local state
const cloneMessages = [...messages]; const cloneMessages = [...messages];
const finalMessages = cloneMessages.filter((message) => message.pubkey !== pubkey); const finalMessages = cloneMessages.filter(
setMessages(finalMessages); (message) => message.pubkey !== pubkey,
// close modal );
closeModal(); setMessages(finalMessages);
} else { // close modal
console.log('error'); closeModal();
} } else {
}; console.log("error");
}
};
return ( return (
<> <>
<Tooltip message="Mute this user"> <Tooltip message="Mute this user">
<button <button
onClick={() => openModal()} type="button"
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800" 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> <MuteIcon width={16} height={16} className="text-zinc-200" />
</Tooltip> </button>
<Transition appear show={isOpen} as={Fragment}> </Tooltip>
<Dialog as="div" className="relative z-10" onClose={closeModal}> <Transition appear show={isOpen} as={Fragment}>
<Transition.Child <Dialog as="div" className="relative z-10" onClose={closeModal}>
as={Fragment} <Transition.Child
enter="ease-out duration-300" as={Fragment}
enterFrom="opacity-0" enter="ease-out duration-300"
enterTo="opacity-100" enterFrom="opacity-0"
leave="ease-in duration-200" enterTo="opacity-100"
leaveFrom="opacity-100" leave="ease-in duration-200"
leaveTo="opacity-0" 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 bg-black bg-opacity-30 backdrop-blur-md" />
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center"> </Transition.Child>
<Transition.Child <div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
as={Fragment} <Transition.Child
enter="ease-out duration-300" as={Fragment}
enterFrom="opacity-0 scale-95" enter="ease-out duration-300"
enterTo="opacity-100 scale-100" enterFrom="opacity-0 scale-95"
leave="ease-in duration-200" enterTo="opacity-100 scale-100"
leaveFrom="opacity-100 scale-100" leave="ease-in duration-200"
leaveTo="opacity-0 scale-95" 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"> <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="flex flex-col gap-2"> <div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
<div className="flex items-center justify-between"> <div className="flex flex-col gap-2">
<Dialog.Title <div className="flex items-center justify-between">
as="h3" <Dialog.Title
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-2xl font-semibold leading-none text-transparent" 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> Are you sure!
<button </Dialog.Title>
type="button" <button
onClick={closeModal} type="button"
autoFocus={false} onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900" 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" /> <CancelIcon
</button> width={20}
</div> height={20}
<Dialog.Description className="leading-tight text-zinc-400"> className="text-zinc-300"
You will no longer see messages from this user. />
</Dialog.Description> </button>
</div> </div>
</div> <Dialog.Description className="leading-tight text-zinc-400">
<div className="flex h-full w-full flex-col items-end justify-center overflow-y-auto px-5 py-2.5"> You will no longer see messages from this user.
<div className="flex items-center gap-2"> </Dialog.Description>
<button </div>
type="button" </div>
onClick={closeModal} <div className="flex h-full w-full flex-col items-end justify-center overflow-y-auto px-5 py-2.5">
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" <div className="flex items-center gap-2">
> <button
Cancel type="button"
</button> onClick={closeModal}
<button 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"
type="button" >
onClick={() => muteUser()} Cancel
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" </button>
> <button
Confirm type="button"
</button> onClick={() => muteUser()}
</div> 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"
</div> >
</Dialog.Panel> Confirm
</Transition.Child> </button>
</div> </div>
</Dialog> </div>
</Transition> </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 }) { export default function MessageReplyButton({
const setChannelReplyAtom = useSetAtom(channelReplyAtom); id,
pubkey,
content,
}: { id: string; pubkey: string; content: string }) {
const setChannelReplyAtom = useSetAtom(channelReplyAtom);
const createReply = () => { const createReply = () => {
setChannelReplyAtom({ id: id, pubkey: pubkey, content: content }); setChannelReplyAtom({ id: id, pubkey: pubkey, content: content });
}; };
return ( return (
<Tooltip message="Reply to message"> <Tooltip message="Reply to message">
<button <button
onClick={() => createReply()} type="button"
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800" 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> <ReplyMessageIcon width={16} height={16} className="text-zinc-200" />
</Tooltip> </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 { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from '@utils/shortenKey'; import { shortenKey } from "@utils/shortenKey";
import dayjs from 'dayjs'; import dayjs from "dayjs";
import relativeTime from 'dayjs/plugin/relativeTime'; import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
export default function ChannelMessageUser({ pubkey, time }: { pubkey: string; time: number }) { export default function ChannelMessageUser({
const { user, isError, isLoading } = useProfile(pubkey); pubkey,
time,
}: { pubkey: string; time: number }) {
const { user, isError, isLoading } = useProfile(pubkey);
return ( return (
<div className="group flex items-start gap-3"> <div className="group flex items-start gap-3">
{isError || isLoading ? ( {isError || isLoading ? (
<> <>
<div className="relative h-9 w-9 shrink animate-pulse rounded-md bg-zinc-800"></div> <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 w-full flex-1 items-start justify-between">
<div className="flex items-baseline gap-2 text-sm"> <div className="flex items-baseline gap-2 text-sm">
<div className="h-4 w-20 animate-pulse rounded bg-zinc-800"></div> <div className="h-4 w-20 animate-pulse rounded bg-zinc-800" />
</div> </div>
</div> </div>
</> </>
) : ( ) : (
<> <>
<div className="relative h-9 w-9 shrink rounded-md"> <div className="relative h-9 w-9 shrink rounded-md">
<Image <Image
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${user?.picture ? user.picture : DEFAULT_AVATAR}`} src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${
alt={pubkey} user?.picture ? user.picture : DEFAULT_AVATAR
className="h-9 w-9 rounded-md object-cover" }`}
/> alt={pubkey}
</div> className="h-9 w-9 rounded-md object-cover"
<div className="flex w-full flex-1 items-start justify-between"> />
<div className="flex items-baseline gap-2 text-sm"> </div>
<span className="font-semibold leading-none text-zinc-200 group-hover:underline"> <div className="flex w-full flex-1 items-start justify-between">
{user?.display_name || user?.name || shortenKey(pubkey)} <div className="flex items-baseline gap-2 text-sm">
</span> <span className="font-semibold leading-none text-zinc-200 group-hover:underline">
<span className="leading-none text-zinc-500">·</span> {user?.display_name || user?.name || shortenKey(pubkey)}
<span className="leading-none text-zinc-500">{dayjs().to(dayjs.unix(time))}</span> </span>
</div> <span className="leading-none text-zinc-500">·</span>
</div> <span className="leading-none text-zinc-500">
</> {dayjs().to(dayjs.unix(time))}
)} </span>
</div> </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 { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from '@utils/shortenKey'; import { shortenKey } from "@utils/shortenKey";
export default function UserReply({ pubkey }: { pubkey: string }) { export default function UserReply({ pubkey }: { pubkey: string }) {
const { user, isError, isLoading } = useProfile(pubkey); const { user, isError, isLoading } = useProfile(pubkey);
return ( return (
<div className="group flex items-start gap-1"> <div className="group flex items-start gap-1">
{isError || isLoading ? ( {isError || isLoading ? (
<> <>
<div className="relative h-7 w-7 shrink animate-pulse overflow-hidden rounded bg-zinc-800"></div> <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"></span> <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"> <div className="relative h-7 w-7 shrink overflow-hidden rounded">
<Image <Image
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${user?.picture ? user.picture : DEFAULT_AVATAR}`} src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${
alt={pubkey} user?.picture ? user.picture : DEFAULT_AVATAR
className="h-7 w-7 rounded object-cover" }`}
/> alt={pubkey}
</div> className="h-7 w-7 rounded object-cover"
<span className="text-xs font-medium leading-none text-zinc-500"> />
Replying to {user?.name || shortenKey(pubkey)} </div>
</span> <span className="text-xs font-medium leading-none text-zinc-500">
</> Replying to {user?.name || shortenKey(pubkey)}
)} </span>
</div> </>
); )}
</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 }) { export default function ChannelMetadata({
const metadata = useChannelProfile(id, pubkey); id,
const noteID = id ? nip19.noteEncode(id) : null; pubkey,
}: { id: string; pubkey: string }) {
const metadata = useChannelProfile(id, pubkey);
const noteID = id ? nip19.noteEncode(id) : null;
const copyNoteID = async () => { const copyNoteID = async () => {
const { writeText } = await import('@tauri-apps/api/clipboard'); const { writeText } = await import("@tauri-apps/api/clipboard");
if (noteID) { if (noteID) {
await writeText(noteID); await writeText(noteID);
} }
}; };
return ( return (
<div className="inline-flex items-center gap-2"> <div className="inline-flex items-center gap-2">
<div className="relative shrink-0 rounded-md"> <div className="relative shrink-0 rounded-md">
<Image <Image
src={metadata?.picture || DEFAULT_AVATAR} src={metadata?.picture || DEFAULT_AVATAR}
alt={id} alt={id}
className="h-8 w-8 rounded bg-zinc-900 object-contain ring-2 ring-zinc-950" className="h-8 w-8 rounded bg-zinc-900 object-contain ring-2 ring-zinc-950"
/> />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<h5 className="truncate text-sm font-medium leading-none text-zinc-100">{metadata?.name}</h5> <h5 className="truncate text-sm font-medium leading-none text-zinc-100">
<button onClick={() => copyNoteID()}> {metadata?.name}
<CopyIcon width={14} height={14} className="text-zinc-400" /> </h5>
</button> <button type="button" onClick={() => copyNoteID()}>
</div> <CopyIcon width={14} height={14} className="text-zinc-400" />
<p className="text-xs leading-none text-zinc-400"> </button>
{metadata?.about || (noteID && noteID.substring(0, 24) + '...')} </div>
</p> <p className="text-xs leading-none text-zinc-400">
</div> {metadata?.about || (noteID && `${noteID.substring(0, 24)}...`)}
</div> </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 }) { export default function MiniMember({ pubkey }: { pubkey: string }) {
const { user, isError, isLoading } = useProfile(pubkey); const { user, isError, isLoading } = useProfile(pubkey);
return ( return (
<> <>
{isError || isLoading ? ( {isError || isLoading ? (
<div className="h-8 w-8 animate-pulse rounded-md bg-zinc-800"></div> <div className="h-8 w-8 animate-pulse rounded-md bg-zinc-800" />
) : ( ) : (
<Image <Image
className="inline-block h-8 w-8 rounded-md bg-white ring-2 ring-zinc-950 transition-all duration-150 ease-in-out" 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} src={user?.picture || DEFAULT_AVATAR}
alt={user?.pubkey || 'user 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 { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from '@utils/shortenKey'; import { shortenKey } from "@utils/shortenKey";
import { useState } from 'react'; import { useState } from "react";
export default function MutedItem({ data }: { data: any }) { export default function MutedItem({ data }: { data: any }) {
const { user, isError, isLoading } = useProfile(data.content); const { user, isError, isLoading } = useProfile(data.content);
const [status, setStatus] = useState(data.status); const [status, setStatus] = useState(data.status);
const unmute = async () => { const unmute = async () => {
const { updateItemInBlacklist } = await import('@utils/storage'); const { updateItemInBlacklist } = await import("@utils/storage");
const res = await updateItemInBlacklist(data.content, 0); const res = await updateItemInBlacklist(data.content, 0);
if (res) { if (res) {
setStatus(0); setStatus(0);
} }
}; };
const mute = async () => { const mute = async () => {
const { updateItemInBlacklist } = await import('@utils/storage'); const { updateItemInBlacklist } = await import("@utils/storage");
const res = await updateItemInBlacklist(data.content, 1); const res = await updateItemInBlacklist(data.content, 1);
if (res) { if (res) {
setStatus(1); setStatus(1);
} }
}; };
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
{isError || isLoading ? ( {isError || isLoading ? (
<> <>
<div className="flex items-center gap-1.5"> <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="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="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-3 w-16 animate-pulse bg-zinc-800" />
<div className="h-2 w-10 animate-pulse bg-zinc-800"></div> <div className="h-2 w-10 animate-pulse bg-zinc-800" />
</div> </div>
</div> </div>
</> </>
) : ( ) : (
<> <>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="relative h-9 w-9 shrink rounded-md"> <div className="relative h-9 w-9 shrink rounded-md">
<Image <Image
src={user?.picture || DEFAULT_AVATAR} src={user?.picture || DEFAULT_AVATAR}
alt={data.content} alt={data.content}
className="h-9 w-9 rounded-md object-cover" className="h-9 w-9 rounded-md object-cover"
/> />
</div> </div>
<div className="flex w-full flex-1 flex-col items-start gap-0.5 text-start"> <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"> <span className="truncate text-sm font-medium leading-none text-zinc-200">
{user?.display_name || user?.name || 'Pleb'} {user?.display_name || user?.name || "Pleb"}
</span> </span>
<span className="text-xs leading-none text-zinc-400">{shortenKey(data.content)}</span> <span className="text-xs leading-none text-zinc-400">
</div> {shortenKey(data.content)}
</div> </span>
<div> </div>
{status === 1 ? ( </div>
<button <div>
onClick={() => unmute()} {status === 1 ? (
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" <button
> type="button"
Unmute onClick={() => unmute()}
</button> 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"
) : ( >
<button Unmute
onClick={() => mute()} </button>
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" ) : (
> <button
Mute type="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"
</div> >
</> Mute
)} </button>
</div> )}
); </div>
</>
)}
</div>
);
} }

View File

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

View File

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

View File

@@ -1,127 +1,140 @@
import ChannelBlackList from '@app/channel/components/blacklist'; import ChannelBlackList from "@app/channel/components/blacklist";
import ChannelMembers from '@app/channel/components/members'; import ChannelMembers from "@app/channel/components/members";
import ChannelMessageForm from '@app/channel/components/messages/form'; import ChannelMessageForm from "@app/channel/components/messages/form";
import ChannelMetadata from '@app/channel/components/metadata'; import ChannelMetadata from "@app/channel/components/metadata";
import ChannelUpdateModal from '@app/channel/components/updateModal'; import ChannelUpdateModal from "@app/channel/components/updateModal";
import { RelayContext } from '@shared/relayProvider'; import { RelayContext } from "@shared/relayProvider";
import { channelMessagesAtom, channelReplyAtom } from '@stores/channel'; import { channelMessagesAtom, channelReplyAtom } from "@stores/channel";
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 { useActiveAccount } from '@utils/hooks/useActiveAccount'; import { useActiveAccount } from "@utils/hooks/useActiveAccount";
import { usePageContext } from '@utils/hooks/usePageContext'; import { usePageContext } from "@utils/hooks/usePageContext";
import { getActiveBlacklist, getBlacklist } from '@utils/storage'; import { getActiveBlacklist, getBlacklist } from "@utils/storage";
import { arrayObjToPureArr } from '@utils/transform'; import { arrayObjToPureArr } from "@utils/transform";
import { useSetAtom } from 'jotai'; import { useSetAtom } from "jotai";
import { useResetAtom } from 'jotai/utils'; import { useResetAtom } from "jotai/utils";
import { Suspense, lazy, useContext, useEffect, useRef } from 'react'; import { Suspense, lazy, useContext, useEffect, useRef } from "react";
import useSWR from 'swr'; import useSWR from "swr";
import useSWRSubscription from 'swr/subscription'; import useSWRSubscription from "swr/subscription";
const fetchMuted = async ([, id]) => { const fetchMuted = async ([, id]) => {
const res = await getBlacklist(id, 44); const res = await getBlacklist(id, 44);
const array = arrayObjToPureArr(res); const array = arrayObjToPureArr(res);
return { original: res, array: array }; return { original: res, array: array };
}; };
const fetchHided = async ([, id]) => { const fetchHided = async ([, id]) => {
const res = await getActiveBlacklist(id, 43); const res = await getActiveBlacklist(id, 43);
const array = arrayObjToPureArr(res); const array = arrayObjToPureArr(res);
return array; return array;
}; };
const ChannelMessageList = lazy(() => import('@app/channel/components/messageList')); const ChannelMessageList = lazy(
() => import("@app/channel/components/messageList"),
);
export function Page() { export function Page() {
const pool: any = useContext(RelayContext); const pool: any = useContext(RelayContext);
const pageContext = usePageContext(); const pageContext = usePageContext();
const searchParams: any = pageContext.urlParsed.search; const searchParams: any = pageContext.urlParsed.search;
const channelID = searchParams.id; const channelID = searchParams.id;
const channelPubkey = searchParams.channelpub; const channelPubkey = searchParams.channelpub;
const { account, isLoading, isError } = useActiveAccount(); const { account, isLoading, isError } = useActiveAccount();
const { data: muted } = useSWR(!isLoading && !isError && account ? ['muted', account.id] : null, fetchMuted); const { data: muted } = useSWR(
const { data: hided } = useSWR(!isLoading && !isError && account ? ['hided', account.id] : null, fetchHided); !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 setChannelMessages = useSetAtom(channelMessagesAtom);
const resetChannelMessages = useResetAtom(channelMessagesAtom); const resetChannelMessages = useResetAtom(channelMessagesAtom);
const resetChannelReply = useResetAtom(channelReplyAtom); const resetChannelReply = useResetAtom(channelReplyAtom);
const now = useRef(new Date()); const now = useRef(new Date());
useSWRSubscription(account && channelID && muted && hided ? ['channel', channelID] : null, ([, key], {}: any) => { useSWRSubscription(
// subscribe to channel account && channelID && muted && hided ? ["channel", channelID] : null,
const unsubscribe = pool.subscribe( ([, key]) => {
[ // subscribe to channel
{ const unsubscribe = pool.subscribe(
'#e': [key], [
kinds: [42], {
since: dateToUnix(getHourAgo(24, now.current)), "#e": [key],
limit: 20, kinds: [42],
}, since: dateToUnix(getHourAgo(24, now.current)),
], limit: 20,
READONLY_RELAYS, },
(event: { id: string; pubkey: string }) => { ],
const message: any = event; READONLY_RELAYS,
if (hided.includes(event.id)) { (event: { id: string; pubkey: string }) => {
message['hide'] = true; const message: any = event;
} else { if (hided.includes(event.id)) {
message['hide'] = false; message["hide"] = true;
} } else {
if (!muted.array.includes(event.pubkey)) { message["hide"] = false;
setChannelMessages((prev) => [...prev, message]); }
} if (!muted.array.includes(event.pubkey)) {
} setChannelMessages((prev) => [...prev, message]);
); }
},
);
return () => { return () => {
unsubscribe(); unsubscribe();
}; };
}); },
);
useEffect(() => { useEffect(() => {
let ignore = false; let ignore = false;
if (!ignore) { if (!ignore) {
// reset channel reply // reset channel reply
resetChannelReply(); resetChannelReply();
// reset channel messages // reset channel messages
resetChannelMessages(); resetChannelMessages();
} }
return () => { return () => {
ignore = true; ignore = true;
}; };
}); });
return ( return (
<div className="flex h-full flex-col justify-between gap-2"> <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 className="flex h-11 w-full shrink-0 items-center justify-between">
<div> <div>
<ChannelMetadata id={channelID} pubkey={channelPubkey} /> <ChannelMetadata id={channelID} pubkey={channelPubkey} />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ChannelMembers /> <ChannelMembers />
{!muted ? <></> : <ChannelBlackList blacklist={muted.original} />} {!muted ? <></> : <ChannelBlackList blacklist={muted.original} />}
{!isLoading && !isError && account ? ( {!isLoading && !isError && account ? (
account.pubkey === channelPubkey && <ChannelUpdateModal id={channelID} /> 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"> </div>
<Suspense fallback={<p>Loading...</p>}> </div>
<ChannelMessageList /> <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> <Suspense fallback={<p>Loading...</p>}>
<div className="inline-flex shrink-0 p-3"> <ChannelMessageList />
<ChannelMessageForm channelID={channelID} /> </Suspense>
</div> <div className="inline-flex shrink-0 p-3">
</div> <ChannelMessageForm channelID={channelID} />
</div> </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 { usePageContext } from "@utils/hooks/usePageContext";
import { useProfile } from '@utils/hooks/useProfile'; import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from '@utils/shortenKey'; import { shortenKey } from "@utils/shortenKey";
import { twMerge } from 'tailwind-merge'; import { twMerge } from "tailwind-merge";
export default function ChatsListItem({ pubkey }: { pubkey: string }) { export default function ChatsListItem({ pubkey }: { pubkey: string }) {
const pageContext = usePageContext(); const pageContext = usePageContext();
const searchParams: any = pageContext.urlParsed.search; const searchParams: any = pageContext.urlParsed.search;
const pagePubkey = searchParams.pubkey; const pagePubkey = searchParams.pubkey;
const { user, isError, isLoading } = useProfile(pubkey); const { user, isError, isLoading } = useProfile(pubkey);
return ( return (
<> <>
{isError && <div>error</div>} {isError && <div>error</div>}
{isLoading && !user ? ( {isLoading && !user ? (
<div className="inline-flex h-8 items-center gap-2.5 rounded-md px-2.5"> <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="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 className="h-2.5 w-full animate-pulse truncate rounded bg-zinc-800 text-sm font-medium" />
</div> </div>
</div> </div>
) : ( ) : (
<a <a
href={`/app/chat?pubkey=${pubkey}`} href={`/app/chat?pubkey=${pubkey}`}
className={twMerge( className={twMerge(
'group inline-flex h-8 items-center gap-2.5 rounded-md px-2.5 hover:bg-zinc-900', "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' : '' 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} <div className="relative h-5 w-5 shrink-0 rounded">
alt={pubkey} <Image
className="h-5 w-5 rounded bg-white object-cover" src={user.picture || DEFAULT_AVATAR}
/> alt={pubkey}
</div> className="h-5 w-5 rounded bg-white object-cover"
<div> />
<h5 className="truncate text-[13px] font-semibold text-zinc-400 group-hover:text-zinc-200"> </div>
{user.display_name || user.name || shortenKey(pubkey)} <div>
</h5> <h5 className="truncate text-[13px] font-semibold text-zinc-400 group-hover:text-zinc-200">
</div> {user.display_name || user.name || shortenKey(pubkey)}
</a> </h5>
)} </div>
</> </a>
); )}
</>
);
} }

View File

@@ -1,34 +1,39 @@
import ChatsListItem from '@app/chat/components/item'; import ChatsListItem from "@app/chat/components/item";
import ChatsListSelfItem from '@app/chat/components/self'; import ChatsListSelfItem from "@app/chat/components/self";
import { useActiveAccount } from '@utils/hooks/useActiveAccount'; import { useActiveAccount } from "@utils/hooks/useActiveAccount";
import { getChats } from '@utils/storage'; import { getChats } from "@utils/storage";
import useSWR from 'swr'; import useSWR from "swr";
const fetcher = ([, account]) => getChats(account); const fetcher = ([, account]) => getChats(account);
export default function ChatsList() { export default function ChatsList() {
const { account, isLoading, isError } = useActiveAccount(); const { account, isLoading, isError } = useActiveAccount();
const { data: chats, error }: any = useSWR(!isLoading && !isError && account ? ['chats', account] : null, fetcher); const { data: chats, error }: any = useSWR(
!isLoading && !isError && account ? ["chats", account] : null,
fetcher,
);
return ( return (
<div className="flex flex-col gap-px"> <div className="flex flex-col gap-px">
<ChatsListSelfItem /> <ChatsListSelfItem />
{!chats || error ? ( {!chats || error ? (
<> <>
<div className="inline-flex h-8 items-center gap-2 rounded-md px-2.5"> <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="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="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="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="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="h-3 w-full animate-pulse bg-zinc-800" />
</div> </div>
</> </>
) : ( ) : (
chats.map((item: { pubkey: string }) => <ChatsListItem key={item.pubkey} pubkey={item.pubkey} />) chats.map((item: { pubkey: string }) => (
)} <ChatsListItem key={item.pubkey} pubkey={item.pubkey} />
</div> ))
); )}
</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 { useAtomValue } from "jotai";
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from "react";
import { Virtuoso } from 'react-virtuoso'; import { Virtuoso } from "react-virtuoso";
export default function ChatMessageList() { export default function ChatMessageList() {
const { account } = useActiveAccount(); const { account } = useActiveAccount();
const virtuosoRef = useRef(null); const virtuosoRef = useRef(null);
const data = useAtomValue(sortedChatMessagesAtom); const data = useAtomValue(sortedChatMessagesAtom);
const itemContent: any = useCallback( const itemContent: any = useCallback(
(index: string | number) => { (index: string | number) => {
return <ChatMessageItem data={data[index]} userPubkey={account.pubkey} userPrivkey={account.privkey} />; return (
}, <ChatMessageItem
[account.privkey, account.pubkey, data] data={data[index]}
); userPubkey={account.pubkey}
userPrivkey={account.privkey}
/>
);
},
[account.privkey, account.pubkey, data],
);
const computeItemKey = useCallback( const computeItemKey = useCallback(
(index: string | number) => { (index: string | number) => {
return data[index].id; return data[index].id;
}, },
[data] [data],
); );
return ( return (
<div className="h-full w-full"> <div className="h-full w-full">
<Virtuoso <Virtuoso
ref={virtuosoRef} ref={virtuosoRef}
data={data} data={data}
itemContent={itemContent} itemContent={itemContent}
computeItemKey={computeItemKey} computeItemKey={computeItemKey}
initialTopMostItemIndex={data.length - 1} initialTopMostItemIndex={data.length - 1}
alignToBottom={true} alignToBottom={true}
followOutput={true} followOutput={true}
overscan={50} overscan={50}
increaseViewportBy={{ top: 200, bottom: 200 }} increaseViewportBy={{ top: 200, bottom: 200 }}
className="scrollbar-hide h-full w-full overflow-y-auto" className="scrollbar-hide h-full w-full overflow-y-auto"
/> />
</div> </div>
); );
} }

View File

@@ -1,89 +1,92 @@
import { ImagePicker } from '@shared/form/imagePicker'; import { ImagePicker } from "@shared/form/imagePicker";
import { RelayContext } from '@shared/relayProvider'; import { RelayContext } from "@shared/relayProvider";
import { chatContentAtom } from '@stores/chat'; import { chatContentAtom } from "@stores/chat";
import { WRITEONLY_RELAYS } from '@stores/constants'; import { WRITEONLY_RELAYS } from "@stores/constants";
import { dateToUnix } from '@utils/date'; import { dateToUnix } from "@utils/date";
import { useActiveAccount } from '@utils/hooks/useActiveAccount'; import { useActiveAccount } from "@utils/hooks/useActiveAccount";
import { useAtom } from 'jotai'; import { useAtom } from "jotai";
import { useResetAtom } from 'jotai/utils'; import { useResetAtom } from "jotai/utils";
import { getEventHash, nip04, signEvent } from 'nostr-tools'; import { getEventHash, nip04, signEvent } from "nostr-tools";
import { useCallback, useContext } from 'react'; import { useCallback, useContext } from "react";
export default function ChatMessageForm({ receiverPubkey }: { receiverPubkey: string }) { export default function ChatMessageForm({
const pool: any = useContext(RelayContext); receiverPubkey,
const { account, isLoading, isError } = useActiveAccount(); }: { receiverPubkey: string }) {
const pool: any = useContext(RelayContext);
const { account, isLoading, isError } = useActiveAccount();
const [value, setValue] = useAtom(chatContentAtom); const [value, setValue] = useAtom(chatContentAtom);
const resetValue = useResetAtom(chatContentAtom); const resetValue = useResetAtom(chatContentAtom);
const encryptMessage = useCallback( const encryptMessage = useCallback(
async (privkey: string) => { async (privkey: string) => {
return await nip04.encrypt(privkey, receiverPubkey, value); return await nip04.encrypt(privkey, receiverPubkey, value);
}, },
[receiverPubkey, value] [receiverPubkey, value],
); );
const submitEvent = () => { const submitEvent = () => {
if (!isError && !isLoading && account) { if (!isError && !isLoading && account) {
encryptMessage(account.privkey) encryptMessage(account.privkey)
.then((encryptedContent) => { .then((encryptedContent) => {
const event: any = { const event: any = {
content: encryptedContent, content: encryptedContent,
created_at: dateToUnix(), created_at: dateToUnix(),
kind: 4, kind: 4,
pubkey: account.pubkey, pubkey: account.pubkey,
tags: [['p', receiverPubkey]], tags: [["p", receiverPubkey]],
}; };
event.id = getEventHash(event); event.id = getEventHash(event);
event.sig = signEvent(event, account.privkey); event.sig = signEvent(event, account.privkey);
// publish note // publish note
pool.publish(event, WRITEONLY_RELAYS); pool.publish(event, WRITEONLY_RELAYS);
// reset state // reset state
resetValue(); resetValue();
}) })
.catch(console.error); .catch(console.error);
} }
}; };
const handleEnterPress = (e) => { const handleEnterPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
submitEvent(); submitEvent();
} }
}; };
return ( 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 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> <div>
<textarea <textarea
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
onKeyDown={handleEnterPress} onKeyDown={handleEnterPress}
spellCheck={false} spellCheck={false}
placeholder="Message" 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" 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>
<div className="absolute bottom-2 w-full px-2"> <div className="absolute bottom-2 w-full px-2">
<div className="flex w-full items-center justify-between bg-zinc-800"> <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 divide-x divide-zinc-700">
<ImagePicker type="chat" /> <ImagePicker type="chat" />
<div className="flex items-center gap-2 pl-2"></div> <div className="flex items-center gap-2 pl-2" />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => submitEvent()} type="button"
disabled={value.length === 0 ? true : false} onClick={() => submitEvent()}
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" 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> Send
</div> </button>
</div> </div>
</div> </div>
</div> </div>
); </div>
);
} }

View File

@@ -1,39 +1,49 @@
import ChatMessageUser from '@app/chat/components/messages/user'; import ChatMessageUser from "@app/chat/components/messages/user";
import { useDecryptMessage } from '@app/chat/hooks/useDecryptMessage'; import { useDecryptMessage } from "@app/chat/hooks/useDecryptMessage";
import ImagePreview from '@app/note/components/preview/image'; import ImagePreview from "@app/note/components/preview/image";
import VideoPreview from '@app/note/components/preview/video'; 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({ export const ChatMessageItem = memo(function MessageListItem({
data, data,
userPubkey, userPubkey,
userPrivkey, userPrivkey,
}: { }: {
data: any; data: any;
userPubkey: string; userPubkey: string;
userPrivkey: string; userPrivkey: string;
}) { }) {
const decryptedContent = useDecryptMessage(userPubkey, userPrivkey, data); const decryptedContent = useDecryptMessage(userPubkey, userPrivkey, data);
// if we have decrypted content, use it instead of the encrypted content // if we have decrypted content, use it instead of the encrypted content
if (decryptedContent) { if (decryptedContent) {
data['content'] = decryptedContent; data["content"] = decryptedContent;
} }
// parse the note content // parse the note content
const content = noteParser(data); const content = noteParser(data);
return ( 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 h-min min-h-min w-full select-text flex-col px-5 py-2 hover:bg-black/20">
<div className="flex flex-col"> <div className="flex flex-col">
<ChatMessageUser pubkey={data.pubkey} time={data.created_at} /> <ChatMessageUser pubkey={data.pubkey} time={data.created_at} />
<div className="-mt-[17px] pl-[48px]"> <div className="-mt-[17px] pl-[48px]">
<div className="whitespace-pre-line break-words text-sm leading-tight">{content.parsed}</div> <div className="whitespace-pre-line break-words text-sm leading-tight">
{Array.isArray(content.images) && content.images.length ? <ImagePreview urls={content.images} /> : <></>} {content.parsed}
{Array.isArray(content.videos) && content.videos.length ? <VideoPreview urls={content.videos} /> : <></>} </div>
</div> {Array.isArray(content.images) && content.images.length ? (
</div> <ImagePreview urls={content.images} />
</div> ) : (
); <></>
)}
{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 { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from '@utils/shortenKey'; import { shortenKey } from "@utils/shortenKey";
import dayjs from 'dayjs'; import dayjs from "dayjs";
import relativeTime from 'dayjs/plugin/relativeTime'; import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
export default function ChatMessageUser({ pubkey, time }: { pubkey: string; time: number }) { export default function ChatMessageUser({
const { user, isError, isLoading } = useProfile(pubkey); pubkey,
time,
}: { pubkey: string; time: number }) {
const { user, isError, isLoading } = useProfile(pubkey);
return ( return (
<div className="group flex items-start gap-3"> <div className="group flex items-start gap-3">
{isError || isLoading ? ( {isError || isLoading ? (
<> <>
<div className="relative h-9 w-9 shrink animate-pulse rounded-md bg-zinc-800"></div> <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 w-full flex-1 items-start justify-between">
<div className="flex items-baseline gap-2 text-sm"> <div className="flex items-baseline gap-2 text-sm">
<div className="h-4 w-20 animate-pulse rounded bg-zinc-800"></div> <div className="h-4 w-20 animate-pulse rounded bg-zinc-800" />
</div> </div>
</div> </div>
</> </>
) : ( ) : (
<> <>
<div className="relative h-9 w-9 shrink rounded-md"> <div className="relative h-9 w-9 shrink rounded-md">
<Image <Image
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${user?.picture ? user.picture : DEFAULT_AVATAR}`} src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${
alt={pubkey} user?.picture ? user.picture : DEFAULT_AVATAR
className="h-9 w-9 rounded-md object-cover" }`}
/> alt={pubkey}
</div> className="h-9 w-9 rounded-md object-cover"
<div className="flex w-full flex-1 items-start justify-between"> />
<div className="flex items-baseline gap-2 text-sm"> </div>
<span className="font-semibold leading-none text-zinc-200 group-hover:underline"> <div className="flex w-full flex-1 items-start justify-between">
{user?.display_name || user?.name || shortenKey(pubkey)} <div className="flex items-baseline gap-2 text-sm">
</span> <span className="font-semibold leading-none text-zinc-200 group-hover:underline">
<span className="leading-none text-zinc-500">·</span> {user?.display_name || user?.name || shortenKey(pubkey)}
<span className="leading-none text-zinc-500">{dayjs().to(dayjs.unix(time))}</span> </span>
</div> <span className="leading-none text-zinc-500">·</span>
</div> <span className="leading-none text-zinc-500">
</> {dayjs().to(dayjs.unix(time))}
)} </span>
</div> </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 { useActiveAccount } from "@utils/hooks/useActiveAccount";
import { usePageContext } from '@utils/hooks/usePageContext'; import { usePageContext } from "@utils/hooks/usePageContext";
import { shortenKey } from '@utils/shortenKey'; import { shortenKey } from "@utils/shortenKey";
import { twMerge } from 'tailwind-merge'; import { twMerge } from "tailwind-merge";
export default function ChatsListSelfItem() { export default function ChatsListSelfItem() {
const pageContext = usePageContext(); const pageContext = usePageContext();
const searchParams: any = pageContext.urlParsed.search; const searchParams: any = pageContext.urlParsed.search;
const pagePubkey = searchParams.pubkey; const pagePubkey = searchParams.pubkey;
const { account, isLoading, isError } = useActiveAccount(); const { account, isLoading, isError } = useActiveAccount();
const profile = account ? JSON.parse(account.metadata) : null; const profile = account ? JSON.parse(account.metadata) : null;
return ( return (
<> <>
{isError && <div>error</div>} {isError && <div>error</div>}
{isLoading && !account ? ( {isLoading && !account ? (
<div className="inline-flex h-8 items-center gap-2.5 rounded-md px-2.5"> <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="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 className="h-2.5 w-full animate-pulse truncate rounded bg-zinc-800 text-sm font-medium" />
</div> </div>
</div> </div>
) : ( ) : (
<a <a
href={`/app/chat?pubkey=${account.pubkey}`} href={`/app/chat?pubkey=${account.pubkey}`}
className={twMerge( className={twMerge(
'inline-flex h-8 items-center gap-2.5 rounded-md px-2.5 hover:bg-zinc-900', "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' : '' 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} <div className="relative h-5 w-5 shrink-0 rounded">
alt={account.pubkey} <Image
className="h-5 w-5 rounded bg-white object-cover" src={profile?.picture || DEFAULT_AVATAR}
/> alt={account.pubkey}
</div> className="h-5 w-5 rounded bg-white object-cover"
<div> />
<h5 className="truncate text-[13px] font-semibold text-zinc-400"> </div>
{profile?.display_name || profile?.name || shortenKey(account.pubkey)}{' '} <div>
<span className="text-zinc-500">(you)</span> <h5 className="truncate text-[13px] font-semibold text-zinc-400">
</h5> {profile?.display_name ||
</div> profile?.name ||
</a> 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 { nip04 } from "nostr-tools";
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from "react";
export function useDecryptMessage(userKey: string, userPriv: string, data: any) { export function useDecryptMessage(
const [content, setContent] = useState(null); userKey: string,
userPriv: string,
data: any,
) {
const [content, setContent] = useState(null);
const extractSenderKey = useCallback(() => { const extractSenderKey = useCallback(() => {
const keyInTags = data.tags.find(([k, v]) => k === 'p' && v && v !== '')[1]; const keyInTags = data.tags.find(([k, v]) => k === "p" && v && v !== "")[1];
if (keyInTags === userKey) { if (keyInTags === userKey) {
return data.pubkey; return data.pubkey;
} else { } else {
return keyInTags; return keyInTags;
} }
}, [data.pubkey, data.tags, userKey]); }, [data.pubkey, data.tags, userKey]);
const decrypt = useCallback(async () => { const decrypt = useCallback(async () => {
const senderKey = extractSenderKey(); const senderKey = extractSenderKey();
const result = await nip04.decrypt(userPriv, senderKey, data.content); const result = await nip04.decrypt(userPriv, senderKey, data.content);
// update state with decrypt content // update state with decrypt content
setContent(result); setContent(result);
}, [extractSenderKey, userPriv, data.content]); }, [extractSenderKey, userPriv, data.content]);
useEffect(() => { useEffect(() => {
decrypt().catch(console.error); decrypt().catch(console.error);
}, [decrypt]); }, [decrypt]);
return content ? content : null; return content ? content : null;
} }

View File

@@ -1,29 +1,31 @@
import AppHeader from '@shared/appHeader'; import AppHeader from "@shared/appHeader";
import MultiAccounts from '@shared/multiAccounts'; import MultiAccounts from "@shared/multiAccounts";
import Navigation from '@shared/navigation'; import Navigation from "@shared/navigation";
export function LayoutChat({ children }: { children: React.ReactNode }) { export function LayoutChat({ children }: { children: React.ReactNode }) {
return ( return (
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white"> <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 className="flex h-screen w-full flex-col">
<div <div
data-tauri-drag-region data-tauri-drag-region
className="relative h-9 shrink-0 border-b border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black" className="relative h-9 shrink-0 border-b border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
> >
<AppHeader /> <AppHeader />
</div> </div>
<div className="relative flex min-h-0 w-full flex-1"> <div className="relative flex min-h-0 w-full flex-1">
<div className="relative w-[68px] shrink-0 border-r border-zinc-900"> <div className="relative w-[68px] shrink-0 border-r border-zinc-900">
<MultiAccounts /> <MultiAccounts />
</div> </div>
<div className="grid w-full grid-cols-4 xl:grid-cols-5"> <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"> <div className="scrollbar-hide col-span-1 overflow-y-auto overflow-x-hidden border-r border-zinc-900">
<Navigation /> <Navigation />
</div> </div>
<div className="col-span-3 m-3 overflow-hidden xl:col-span-4">{children}</div> <div className="col-span-3 m-3 overflow-hidden xl:col-span-4">
</div> {children}
</div> </div>
</div> </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 { chatMessagesAtom } from "@stores/chat";
import { READONLY_RELAYS } from '@stores/constants'; import { READONLY_RELAYS } from "@stores/constants";
import { useActiveAccount } from '@utils/hooks/useActiveAccount'; import { useActiveAccount } from "@utils/hooks/useActiveAccount";
import { usePageContext } from '@utils/hooks/usePageContext'; import { usePageContext } from "@utils/hooks/usePageContext";
import { useSetAtom } from 'jotai'; import { useSetAtom } from "jotai";
import { useResetAtom } from 'jotai/utils'; import { useResetAtom } from "jotai/utils";
import { Suspense, lazy, useContext, useEffect } from 'react'; import { Suspense, lazy, useContext, useEffect } from "react";
import useSWRSubscription from 'swr/subscription'; import useSWRSubscription from "swr/subscription";
const ChatMessageList = lazy(() => import('@app/chat/components/messageList')); const ChatMessageList = lazy(() => import("@app/chat/components/messageList"));
export function Page() { export function Page() {
const pool: any = useContext(RelayContext); const pool: any = useContext(RelayContext);
const pageContext = usePageContext(); const pageContext = usePageContext();
const searchParams: any = pageContext.urlParsed.search; 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 setChatMessages = useSetAtom(chatMessagesAtom);
const resetChatMessages = useResetAtom(chatMessagesAtom); const resetChatMessages = useResetAtom(chatMessagesAtom);
useSWRSubscription(account ? ['chat', pubkey] : null, ([, key], {}: any) => { useSWRSubscription(account ? ["chat", pubkey] : null, ([, key]) => {
const unsubscribe = pool.subscribe( const unsubscribe = pool.subscribe(
[ [
{ {
kinds: [4], kinds: [4],
authors: [key], authors: [key],
'#p': [account.pubkey], "#p": [account.pubkey],
limit: 20, limit: 20,
}, },
{ {
kinds: [4], kinds: [4],
authors: [account.pubkey], authors: [account.pubkey],
'#p': [key], "#p": [key],
limit: 20, limit: 20,
}, },
], ],
READONLY_RELAYS, READONLY_RELAYS,
(event: any) => { (event: any) => {
setChatMessages((prev) => [...prev, event]); setChatMessages((prev) => [...prev, event]);
} },
); );
return () => { return () => {
unsubscribe(); unsubscribe();
}; };
}); });
useEffect(() => { useEffect(() => {
let ignore = false; let ignore = false;
if (!ignore) { if (!ignore) {
// reset chat messages // reset chat messages
resetChatMessages(); resetChatMessages();
} }
return () => { return () => {
ignore = true; ignore = true;
}; };
}); });
return ( 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"> <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>}> <Suspense fallback={<p>Loading...</p>}>
<ChatMessageList /> <ChatMessageList />
</Suspense> </Suspense>
<div className="shrink-0 p-3"> <div className="shrink-0 p-3">
<ChatMessageForm receiverPubkey={pubkey} /> <ChatMessageForm receiverPubkey={pubkey} />
</div> </div>
</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 useSWR from "swr";
import { navigate } from 'vite-plugin-ssr/client/router'; import { navigate } from "vite-plugin-ssr/client/router";
const fetcher = () => getActiveAccount(); const fetcher = () => getActiveAccount();
export function Page() { export function Page() {
const { data, isLoading } = useSWR('account', fetcher, { const { data, isLoading } = useSWR("account", fetcher, {
revalidateIfStale: false, revalidateIfStale: false,
revalidateOnFocus: false, revalidateOnFocus: false,
revalidateOnReconnect: false, revalidateOnReconnect: false,
}); });
if (!isLoading && !data) { if (!isLoading && !data) {
navigate('/auth', { overwriteLastHistoryEntry: true }); navigate("/auth", { overwriteLastHistoryEntry: true });
} }
if (!isLoading && data) { if (!isLoading && data) {
navigate('/app/inital-data', { overwriteLastHistoryEntry: true }); 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 { import {
addToBlacklist, addToBlacklist,
countTotalLongNotes, countTotalLongNotes,
countTotalNotes, countTotalNotes,
createChat, createChat,
createNote, createNote,
getActiveAccount, getActiveAccount,
getLastLogin, getLastLogin,
updateLastLogin, updateLastLogin,
} from '@utils/storage'; } from "@utils/storage";
import { getParentID, nip02ToArray } from '@utils/transform'; import { getParentID, nip02ToArray } from "@utils/transform";
import { useContext, useEffect, useRef } from 'react'; import { useContext, useEffect, useRef } from "react";
import { navigate } from 'vite-plugin-ssr/client/router'; import { navigate } from "vite-plugin-ssr/client/router";
export function Page() { export function Page() {
const pool: any = useContext(RelayContext); const pool: any = useContext(RelayContext);
const now = useRef(new Date()); const now = useRef(new Date());
useEffect(() => { useEffect(() => {
let unsubscribe: () => void; let unsubscribe: () => void;
let timeout: any; let timeout: any;
const fetchInitalData = async () => { const fetchInitalData = async () => {
const account = await getActiveAccount(); const account = await getActiveAccount();
const lastLogin = await getLastLogin(); const lastLogin = await getLastLogin();
const notes = await countTotalNotes(); const notes = await countTotalNotes();
const longNotes = await countTotalLongNotes(); const longNotes = await countTotalLongNotes();
const follows = nip02ToArray(JSON.parse(account.follows)); const follows = nip02ToArray(JSON.parse(account.follows));
const query = []; const query = [];
let sinceNotes: number; let sinceNotes: number;
let sinceLongNotes: number; let sinceLongNotes: number;
if (notes === 0) { if (notes === 0) {
sinceNotes = dateToUnix(getHourAgo(48, now.current)); sinceNotes = dateToUnix(getHourAgo(48, now.current));
} else { } else {
if (parseInt(lastLogin) > 0) { if (parseInt(lastLogin) > 0) {
sinceNotes = parseInt(lastLogin); sinceNotes = parseInt(lastLogin);
} else { } else {
sinceNotes = dateToUnix(getHourAgo(48, now.current)); sinceNotes = dateToUnix(getHourAgo(48, now.current));
} }
} }
if (longNotes === 0) { if (longNotes === 0) {
sinceLongNotes = 0; sinceLongNotes = 0;
} else { } else {
if (parseInt(lastLogin) > 0) { if (parseInt(lastLogin) > 0) {
sinceLongNotes = parseInt(lastLogin); sinceLongNotes = parseInt(lastLogin);
} else { } else {
sinceLongNotes = 0; sinceLongNotes = 0;
} }
} }
// kind 1 (notes) query // kind 1 (notes) query
query.push({ query.push({
kinds: [1, 6, 1063], kinds: [1, 6, 1063],
authors: follows, authors: follows,
since: sinceNotes, since: sinceNotes,
until: dateToUnix(now.current), until: dateToUnix(now.current),
}); });
// kind 4 (chats) query // kind 4 (chats) query
query.push({ query.push({
kinds: [4], kinds: [4],
'#p': [account.pubkey], "#p": [account.pubkey],
since: 0, since: 0,
until: dateToUnix(now.current), until: dateToUnix(now.current),
}); });
// kind 43, 43 (mute user, hide message) query // kind 43, 43 (mute user, hide message) query
query.push({ query.push({
authors: [account.pubkey], authors: [account.pubkey],
kinds: [43, 44], kinds: [43, 44],
since: 0, since: 0,
until: dateToUnix(now.current), until: dateToUnix(now.current),
}); });
// kind 30023 (long post) query // kind 30023 (long post) query
query.push({ query.push({
kinds: [30023], kinds: [30023],
since: sinceLongNotes, since: sinceLongNotes,
until: dateToUnix(now.current), until: dateToUnix(now.current),
}); });
// subscribe relays // subscribe relays
unsubscribe = pool.subscribe( unsubscribe = pool.subscribe(
query, query,
READONLY_RELAYS, READONLY_RELAYS,
(event: any) => { (event: any) => {
switch (event.kind) { switch (event.kind) {
// short text note // short text note
case 1: case 1: {
const parentID = getParentID(event.tags, event.id); const parentID = getParentID(event.tags, event.id);
// insert event to local database // insert event to local database
createNote( createNote(
event.id, event.id,
account.id, account.id,
event.pubkey, event.pubkey,
event.kind, event.kind,
event.tags, event.tags,
event.content, event.content,
event.created_at, event.created_at,
parentID parentID,
); );
break; break;
// chat }
case 4: // chat
if (event.pubkey !== account.pubkey) { case 4:
createChat(account.id, event.pubkey, event.created_at); if (event.pubkey !== account.pubkey) {
} createChat(account.id, event.pubkey, event.created_at);
break; }
// repost break;
case 6: // repost
createNote( case 6:
event.id, createNote(
account.id, event.id,
event.pubkey, account.id,
event.kind, event.pubkey,
event.tags, event.kind,
event.content, event.tags,
event.created_at, event.content,
event.id event.created_at,
); event.id,
break; );
// hide message (channel only) break;
case 43: // hide message (channel only)
if (event.tags[0][0] === 'e') { case 43:
addToBlacklist(account.id, event.tags[0][1], 43, 1); if (event.tags[0][0] === "e") {
} addToBlacklist(account.id, event.tags[0][1], 43, 1);
break; }
// mute user (channel only) break;
case 44: // mute user (channel only)
if (event.tags[0][0] === 'p') { case 44:
addToBlacklist(account.id, event.tags[0][1], 44, 1); if (event.tags[0][0] === "p") {
} addToBlacklist(account.id, event.tags[0][1], 44, 1);
break; }
case 1063: break;
createNote( case 1063:
event.id, createNote(
account.id, event.id,
event.pubkey, account.id,
event.kind, event.pubkey,
event.tags, event.kind,
event.content, event.tags,
event.created_at, event.content,
'' event.created_at,
); "",
break; );
// long post break;
case 30023: // long post
// insert event to local database case 30023:
createNote( // insert event to local database
event.id, createNote(
account.id, event.id,
event.pubkey, account.id,
event.kind, event.pubkey,
event.tags, event.kind,
event.content, event.tags,
event.created_at, event.content,
'' event.created_at,
); "",
break; );
default: break;
break; default:
} break;
}, }
undefined, },
() => { undefined,
updateLastLogin(dateToUnix(now.current)); () => {
timeout = setTimeout(() => { updateLastLogin(dateToUnix(now.current));
navigate('/app/today', { overwriteLastHistoryEntry: true }); timeout = setTimeout(() => {
}, 5000); navigate("/app/today", { overwriteLastHistoryEntry: true });
} }, 5000);
); },
}; );
};
fetchInitalData().catch(console.error); fetchInitalData().catch(console.error);
return () => { return () => {
if (unsubscribe) { if (unsubscribe) {
unsubscribe(); unsubscribe();
} }
clearTimeout(timeout); clearTimeout(timeout);
}; };
}, [pool]); }, [pool]);
return ( return (
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-white"> <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"> <div className="relative h-full overflow-hidden">
{/* dragging area */} {/* dragging area */}
<div data-tauri-drag-region className="absolute left-0 top-0 z-20 h-16 w-full bg-transparent" /> <div
{/* end dragging area */} data-tauri-drag-region
<div className="relative flex h-full flex-col items-center justify-center"> className="absolute left-0 top-0 z-20 h-16 w-full bg-transparent"
<div className="flex flex-col items-center gap-2"> />
<LumeIcon className="h-16 w-16 text-black dark:text-white" /> {/* end dragging area */}
<div className="text-center"> <div className="relative flex h-full flex-col items-center justify-center">
<h3 className="text-lg font-semibold leading-tight text-zinc-900 dark:text-zinc-100"> <div className="flex flex-col items-center gap-2">
Here&apos;s an interesting fact: <LumeIcon className="h-16 w-16 text-black dark:text-white" />
</h3> <div className="text-center">
<p className="font-medium text-zinc-300 dark:text-zinc-600"> <h3 className="text-lg font-semibold leading-tight text-zinc-900 dark:text-zinc-100">
Bitcoin and Nostr can be used by anyone, and no one can stop you! Here&apos;s an interesting fact:
</p> </h3>
</div> <p className="font-medium text-zinc-300 dark:text-zinc-600">
</div> Bitcoin and Nostr can be used by anyone, and no one can stop
<div className="absolute bottom-16 left-1/2 -translate-x-1/2 transform"> you!
<svg </p>
className="h-5 w-5 animate-spin text-black dark:text-white" </div>
xmlns="http://www.w3.org/2000/svg" </div>
fill="none" <div className="absolute bottom-16 left-1/2 -translate-x-1/2 transform">
viewBox="0 0 24 24" <svg
> className="h-5 w-5 animate-spin text-black dark:text-white"
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> xmlns="http://www.w3.org/2000/svg"
<path fill="none"
className="opacity-75" viewBox="0 0 24 24"
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" <title id="loading">Loading</title>
></path> <circle
</svg> className="opacity-25"
</div> cx="12"
</div> cy="12"
</div> r="10"
</div> 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 { Kind1 } from "@app/note/components/kind1";
import { Kind1063 } from '@app/note/components/kind1063'; import { Kind1063 } from "@app/note/components/kind1063";
import NoteMetadata from '@app/note/components/metadata'; import NoteMetadata from "@app/note/components/metadata";
import { NoteParent } from '@app/note/components/parent'; import { NoteParent } from "@app/note/components/parent";
import { NoteDefaultUser } from '@app/note/components/user/default'; import { NoteDefaultUser } from "@app/note/components/user/default";
import { NoteWrapper } from '@app/note/components/wrapper'; import { NoteWrapper } from "@app/note/components/wrapper";
import { noteParser } from '@utils/parser'; import { noteParser } from "@utils/parser";
import { isTagsIncludeID } from '@utils/transform'; import { isTagsIncludeID } from "@utils/transform";
import { useMemo } from 'react'; import { useMemo } from "react";
export function NoteBase({ event }: { event: any }) { export function NoteBase({ event }: { event: any }) {
const content = useMemo(() => noteParser(event), [event]); const content = useMemo(() => noteParser(event), [event]);
const checkParentID = isTagsIncludeID(event.parent_id, event.tags); 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 ( return (
<NoteWrapper href={href} className="h-min w-full px-3 py-1.5"> <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"> <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) ? ( {event.parent_id &&
<NoteParent id={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="flex flex-col">
<div className="mt-3 pl-[46px]"> <NoteDefaultUser pubkey={event.pubkey} time={event.created_at} />
{event.kind === 1 && <Kind1 content={content} />} <div className="mt-3 pl-[46px]">
{event.kind === 1063 && <Kind1063 metadata={event.tags} />} {event.kind === 1 && <Kind1 content={content} />}
<NoteMetadata id={event.event_id} eventPubkey={event.pubkey} /> {event.kind === 1063 && <Kind1063 metadata={event.tags} />}
</div> <NoteMetadata id={event.event_id} eventPubkey={event.pubkey} />
</div> </div>
</div> </div>
</NoteWrapper> </div>
); </NoteWrapper>
);
} }

View File

@@ -1,31 +1,41 @@
import { MentionNote } from '@app/note/components/mentions/note'; import { MentionNote } from "@app/note/components/mentions/note";
import { MentionUser } from '@app/note/components/mentions/user'; import { MentionUser } from "@app/note/components/mentions/user";
import ImagePreview from '@app/note/components/preview/image'; import ImagePreview from "@app/note/components/preview/image";
import VideoPreview from '@app/note/components/preview/video'; import VideoPreview from "@app/note/components/preview/video";
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from "react-markdown";
import remarkGfm from 'remark-gfm'; import remarkGfm from "remark-gfm";
export function Kind1({ content }: { content: any }) { export function Kind1({ content }: { content: any }) {
return ( return (
<> <>
<ReactMarkdown <ReactMarkdown
remarkPlugins={[[remarkGfm]]} remarkPlugins={[[remarkGfm]]}
linkTarget="_blank" 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" 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={{ components={{
em: ({ ...props }) => <MentionUser {...props} />, em: ({ ...props }) => <MentionUser {...props} />,
}} }}
> >
{content.parsed} {content.parsed}
</ReactMarkdown> </ReactMarkdown>
{Array.isArray(content.images) && content.images.length ? <ImagePreview urls={content.images} /> : <></>} {Array.isArray(content.images) && content.images.length ? (
{Array.isArray(content.videos) && content.videos.length ? <VideoPreview urls={content.videos} /> : <></>} <ImagePreview urls={content.images} />
{Array.isArray(content.notes) && content.notes.length ? ( ) : (
content.notes.map((note: string) => <MentionNote key={note} id={note} />) <></>
) : ( )}
<></> {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) { 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[] }) { export function Kind1063({ metadata }: { metadata: string[] }) {
const url = metadata[0][1]; const url = metadata[0][1];
return ( return (
<div className="mt-3"> <div className="mt-3">
{isImage(url) && <Image src={url} alt="image" className="h-auto w-full rounded-lg object-cover" />} {isImage(url) && (
</div> <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 { Kind1 } from "@app/note/components/kind1";
import { Kind1063 } from '@app/note/components/kind1063'; import { Kind1063 } from "@app/note/components/kind1063";
import { NoteSkeleton } from '@app/note/components/skeleton'; import { NoteSkeleton } from "@app/note/components/skeleton";
import { NoteDefaultUser } from '@app/note/components/user/default'; import { NoteDefaultUser } from "@app/note/components/user/default";
import { NoteWrapper } from '@app/note/components/wrapper'; 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 { memo, useContext } from "react";
import useSWRSubscription from 'swr/subscription'; import useSWRSubscription from "swr/subscription";
export const MentionNote = memo(function MentionNote({ id }: { id: string }) { 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 { data, error } = useSWRSubscription(
const unsubscribe = pool.subscribe( id ? id : null,
[ (key, { next }) => {
{ const unsubscribe = pool.subscribe(
ids: [key], [
}, {
], ids: [key],
READONLY_RELAYS, },
(event: any) => { ],
next(null, event); READONLY_RELAYS,
}, (event: any) => {
undefined, next(null, event);
undefined, },
{ undefined,
unsubscribeOnEose: true, undefined,
} {
); unsubscribeOnEose: true,
},
);
return () => { return () => {
unsubscribe(); unsubscribe();
}; };
}); },
);
const kind1 = !error && data?.kind === 1 ? noteParser(data) : null; const kind1 = !error && data?.kind === 1 ? noteParser(data) : null;
const kind1063 = !error && data?.kind === 1063 ? data.tags : null; const kind1063 = !error && data?.kind === 1063 ? data.tags : null;
return ( return (
<NoteWrapper href={`/app/note?id=${id}`} className="mt-3 rounded-lg border border-zinc-800 px-3 py-3"> <NoteWrapper
{data ? ( href={`/app/note?id=${id}`}
<> className="mt-3 rounded-lg border border-zinc-800 px-3 py-3"
<NoteDefaultUser pubkey={data.pubkey} time={data.created_at} /> >
<div className="mt-1 pl-[46px]"> {data ? (
{kind1 && <Kind1 content={kind1} />} <>
{kind1063 && <Kind1063 metadata={kind1063} />} <NoteDefaultUser pubkey={data.pubkey} time={data.created_at} />
</div> <div className="mt-1 pl-[46px]">
</> {kind1 && <Kind1 content={kind1} />}
) : ( {kind1063 && <Kind1063 metadata={kind1063} />}
<NoteSkeleton /> </div>
)} </>
</NoteWrapper> ) : (
); <NoteSkeleton />
)}
</NoteWrapper>
);
}); });

View File

@@ -1,9 +1,13 @@
import { useProfile } from '@utils/hooks/useProfile'; import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from '@utils/shortenKey'; import { shortenKey } from "@utils/shortenKey";
export function MentionUser(props: { children: any[] }) { export function MentionUser(props: { children: any[] }) {
const pubkey = props.children[0]; const pubkey = props.children[0];
const { user } = useProfile(pubkey); 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 NoteLike from "@app/note/components/metadata/like";
import NoteReply from '@app/note/components/metadata/reply'; import NoteReply from "@app/note/components/metadata/reply";
import NoteRepost from '@app/note/components/metadata/repost'; 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 { useContext, useState } from "react";
import useSWRSubscription from 'swr/subscription'; import useSWRSubscription from "swr/subscription";
export default function NoteMetadata({ id, eventPubkey }: { id: string; eventPubkey: string }) { export default function NoteMetadata({
const pool: any = useContext(RelayContext); id,
eventPubkey,
}: { id: string; eventPubkey: string }) {
const pool: any = useContext(RelayContext);
const [replies, setReplies] = useState(0); const [replies, setReplies] = useState(0);
const [reposts, setReposts] = useState(0); const [reposts, setReposts] = useState(0);
const [likes, setLikes] = useState(0); const [likes, setLikes] = useState(0);
useSWRSubscription(id ? ['note-metadata', id] : null, ([, key], {}) => { useSWRSubscription(id ? ["note-metadata", id] : null, ([, key]) => {
const unsubscribe = pool.subscribe( const unsubscribe = pool.subscribe(
[ [
{ {
'#e': [key], "#e": [key],
since: 0, since: 0,
kinds: [1, 6, 7], kinds: [1, 6, 7],
limit: 20, limit: 20,
}, },
], ],
READONLY_RELAYS, READONLY_RELAYS,
(event: any) => { (event: any) => {
switch (event.kind) { switch (event.kind) {
case 1: case 1:
setReplies((replies) => replies + 1); setReplies((replies) => replies + 1);
break; break;
case 6: case 6:
setReposts((reposts) => reposts + 1); setReposts((reposts) => reposts + 1);
break; break;
case 7: case 7:
if (event.content === '🤙' || event.content === '+') { if (event.content === "🤙" || event.content === "+") {
setLikes((likes) => likes + 1); setLikes((likes) => likes + 1);
} }
break; break;
default: default:
break; break;
} }
} },
); );
return () => { return () => {
unsubscribe(); unsubscribe();
}; };
}); });
return ( return (
<div className="mt-4 flex h-12 items-center gap-16 border-t border-zinc-800/50"> <div className="mt-4 flex h-12 items-center gap-16 border-t border-zinc-800/50">
<NoteReply id={id} replies={replies} /> <NoteReply id={id} replies={replies} />
<NoteLike id={id} pubkey={eventPubkey} likes={likes} /> <NoteLike id={id} pubkey={eventPubkey} likes={likes} />
<NoteRepost id={id} pubkey={eventPubkey} reposts={reposts} /> <NoteRepost id={id} pubkey={eventPubkey} reposts={reposts} />
<button className="group inline-flex w-min items-center gap-1.5"> <button
<ZapIcon width={20} height={20} className="text-zinc-400 group-hover:text-orange-400" /> type="button"
<span className="text-sm leading-none text-zinc-400 group-hover:text-zinc-200">{0}</span> className="group inline-flex w-min items-center gap-1.5"
</button> >
</div> <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 { dateToUnix } from "@utils/date";
import { useActiveAccount } from '@utils/hooks/useActiveAccount'; import { useActiveAccount } from "@utils/hooks/useActiveAccount";
import { getEventHash, signEvent } from 'nostr-tools'; import { getEventHash, signEvent } from "nostr-tools";
import { useContext, useEffect, useState } from 'react'; import { useContext, useEffect, useState } from "react";
export default function NoteLike({ id, pubkey, likes }: { id: string; pubkey: string; likes: number }) { export default function NoteLike({
const pool: any = useContext(RelayContext); id,
const { account, isLoading, isError } = useActiveAccount(); 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) => { const submitEvent = (e: any) => {
e.stopPropagation(); e.stopPropagation();
if (!isLoading && !isError && account) { if (!isLoading && !isError && account) {
const event: any = { const event: any = {
content: '+', content: "+",
kind: 7, kind: 7,
tags: [ tags: [
['e', id], ["e", id],
['p', pubkey], ["p", pubkey],
], ],
created_at: dateToUnix(), created_at: dateToUnix(),
pubkey: account.pubkey, pubkey: account.pubkey,
}; };
event.id = getEventHash(event); event.id = getEventHash(event);
event.sig = signEvent(event, account.privkey); event.sig = signEvent(event, account.privkey);
// publish event to all relays // publish event to all relays
pool.publish(event, WRITEONLY_RELAYS); pool.publish(event, WRITEONLY_RELAYS);
// update state // update state
setCount(count + 1); setCount(count + 1);
} else { } else {
console.log('error'); console.log("error");
} }
}; };
useEffect(() => { useEffect(() => {
setCount(likes); setCount(likes);
}, [likes]); }, [likes]);
return ( return (
<button type="button" onClick={(e) => submitEvent(e)} className="group inline-flex w-min items-center gap-1.5"> <button
<LikeIcon width={16} height={16} className="text-zinc-400 group-hover:text-rose-400" /> type="button"
<span className="text-sm leading-none text-zinc-400 group-hover:text-zinc-200">{count}</span> onClick={(e) => submitEvent(e)}
</button> 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 { Image } from "@shared/image";
import { RelayContext } from '@shared/relayProvider'; 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 { dateToUnix } from "@utils/date";
import { useActiveAccount } from '@utils/hooks/useActiveAccount'; import { useActiveAccount } from "@utils/hooks/useActiveAccount";
import { Dialog, Transition } from '@headlessui/react'; import { Dialog, Transition } from "@headlessui/react";
import { getEventHash, signEvent } from 'nostr-tools'; import { getEventHash, signEvent } from "nostr-tools";
import { Fragment, useContext, useEffect, useState } from 'react'; import { Fragment, useContext, useEffect, useState } from "react";
export default function NoteReply({ id, replies }: { id: string; replies: number }) { export default function NoteReply({
const pool: any = useContext(RelayContext); id,
replies,
}: { id: string; replies: number }) {
const pool: any = useContext(RelayContext);
const [count, setCount] = useState(0); const [count, setCount] = useState(0);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [value, setValue] = useState(''); const [value, setValue] = useState("");
const { account, isLoading, isError } = useActiveAccount(); const { account, isLoading, isError } = useActiveAccount();
const profile = account ? JSON.parse(account.metadata) : null; const profile = account ? JSON.parse(account.metadata) : null;
const closeModal = () => { const closeModal = () => {
setIsOpen(false); setIsOpen(false);
}; };
const openModal = () => { const openModal = () => {
setIsOpen(true); setIsOpen(true);
}; };
const submitEvent = () => { const submitEvent = () => {
if (!isLoading && !isError && account) { if (!isLoading && !isError && account) {
const event: any = { const event: any = {
content: value, content: value,
created_at: dateToUnix(), created_at: dateToUnix(),
kind: 1, kind: 1,
pubkey: account.pubkey, pubkey: account.pubkey,
tags: [['e', id]], tags: [["e", id]],
}; };
event.id = getEventHash(event); event.id = getEventHash(event);
event.sig = signEvent(event, account.privkey); event.sig = signEvent(event, account.privkey);
// publish event // publish event
pool.publish(event, WRITEONLY_RELAYS); pool.publish(event, WRITEONLY_RELAYS);
// close modal // close modal
setIsOpen(false); setIsOpen(false);
setCount(count + 1); setCount(count + 1);
} else { } else {
console.log('error'); console.log("error");
} }
}; };
useEffect(() => { useEffect(() => {
setCount(replies); setCount(replies);
}, [replies]); }, [replies]);
return ( return (
<> <>
<button type="button" onClick={() => openModal()} className="group inline-flex w-min items-center gap-1.5"> <button
<ReplyIcon width={16} height={16} className="text-zinc-400 group-hover:text-green-400" /> type="button"
<span className="text-sm leading-none text-zinc-400 group-hover:text-zinc-200">{count}</span> onClick={() => openModal()}
</button> className="group inline-flex w-min items-center gap-1.5"
<Transition appear show={isOpen} as={Fragment}> >
<Dialog as="div" className="relative z-10" onClose={closeModal}> <ReplyIcon
<Transition.Child width={16}
as={Fragment} height={16}
enter="ease-out duration-300" className="text-zinc-400 group-hover:text-green-400"
enterFrom="opacity-0" />
enterTo="opacity-100" <span className="text-sm leading-none text-zinc-400 group-hover:text-zinc-200">
leave="ease-in duration-200" {count}
leaveFrom="opacity-100" </span>
leaveTo="opacity-0" </button>
> <Transition appear show={isOpen} as={Fragment}>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" /> <Dialog as="div" className="relative z-10" onClose={closeModal}>
</Transition.Child> <Transition.Child
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center"> as={Fragment}
<Transition.Child enter="ease-out duration-300"
as={Fragment} enterFrom="opacity-0"
enter="ease-out duration-300" enterTo="opacity-100"
enterFrom="opacity-0 scale-95" leave="ease-in duration-200"
enterTo="opacity-100 scale-100" leaveFrom="opacity-100"
leave="ease-in duration-200" leaveTo="opacity-0"
leaveFrom="opacity-100 scale-100" >
leaveTo="opacity-0 scale-95" <div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
> </Transition.Child>
<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"> <div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
{/* root note */} <Transition.Child
{/* comment form */} as={Fragment}
<div className="flex gap-2"> enter="ease-out duration-300"
<div> enterFrom="opacity-0 scale-95"
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-md border border-white/10"> enterTo="opacity-100 scale-100"
<Image src={profile?.picture} alt="user's avatar" className="h-11 w-11 rounded-md object-cover" /> leave="ease-in duration-200"
</div> leaveFrom="opacity-100 scale-100"
</div> leaveTo="opacity-0 scale-95"
<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> <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">
<textarea {/* root note */}
name="content" {/* comment form */}
onChange={(e) => setValue(e.target.value)} <div className="flex gap-2">
placeholder="Send your comment" <div>
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" <div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-md border border-white/10">
spellCheck={false} <Image
/> src={profile?.picture}
</div> alt="user's avatar"
<div className="absolute bottom-2 w-full px-2"> className="h-11 w-11 rounded-md object-cover"
<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 pl-2"></div> </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 className="flex items-center gap-2"> <div>
<button <textarea
onClick={() => submitEvent()} name="content"
disabled={value.length === 0 ? true : false} onChange={(e) => setValue(e.target.value)}
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" 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"
<span className="text-white drop-shadow">Send</span> spellCheck={false}
</button> />
</div> </div>
</div> <div className="absolute bottom-2 w-full px-2">
</div> <div className="flex w-full items-center justify-between bg-zinc-800">
</div> <div className="flex items-center gap-2 divide-x divide-zinc-700">
</div> <div className="flex items-center gap-2 pl-2" />
</Dialog.Panel> </div>
</Transition.Child> <div className="flex items-center gap-2">
</div> <button
</Dialog> type="button"
</Transition> 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 { dateToUnix } from "@utils/date";
import { useActiveAccount } from '@utils/hooks/useActiveAccount'; import { useActiveAccount } from "@utils/hooks/useActiveAccount";
import { getEventHash, signEvent } from 'nostr-tools'; import { getEventHash, signEvent } from "nostr-tools";
import { useContext, useEffect, useState } from 'react'; import { useContext, useEffect, useState } from "react";
export default function NoteRepost({ id, pubkey, reposts }: { id: string; pubkey: string; reposts: number }) { export default function NoteRepost({
const pool: any = useContext(RelayContext); id,
const { account, isLoading, isError } = useActiveAccount(); 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) => { const submitEvent = (e: any) => {
e.stopPropagation(); e.stopPropagation();
if (!isLoading && !isError && account) { if (!isLoading && !isError && account) {
const event: any = { const event: any = {
content: '', content: "",
kind: 6, kind: 6,
tags: [ tags: [
['e', id], ["e", id],
['p', pubkey], ["p", pubkey],
], ],
created_at: dateToUnix(), created_at: dateToUnix(),
pubkey: account.pubkey, pubkey: account.pubkey,
}; };
event.id = getEventHash(event); event.id = getEventHash(event);
event.sig = signEvent(event, account.privkey); event.sig = signEvent(event, account.privkey);
// publish event to all relays // publish event to all relays
pool.publish(event, WRITEONLY_RELAYS); pool.publish(event, WRITEONLY_RELAYS);
// update state // update state
setCount(count + 1); setCount(count + 1);
} else { } else {
console.log('error'); console.log("error");
} }
}; };
useEffect(() => { useEffect(() => {
setCount(reposts); setCount(reposts);
}, [reposts]); }, [reposts]);
return ( return (
<button type="button" onClick={(e) => submitEvent(e)} className="group inline-flex w-min items-center gap-1.5"> <button
<RepostIcon width={16} height={16} className="text-zinc-400 group-hover:text-blue-400" /> type="button"
<span className="text-sm leading-none text-zinc-400 group-hover:text-zinc-200">{count}</span> onClick={(e) => submitEvent(e)}
</button> 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 { Kind1 } from "@app/note/components/kind1";
import { Kind1063 } from '@app/note/components/kind1063'; import { Kind1063 } from "@app/note/components/kind1063";
import NoteMetadata from '@app/note/components/metadata'; import NoteMetadata from "@app/note/components/metadata";
import { NoteSkeleton } from '@app/note/components/skeleton'; import { NoteSkeleton } from "@app/note/components/skeleton";
import { NoteDefaultUser } from '@app/note/components/user/default'; 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 { memo, useContext } from "react";
import useSWRSubscription from 'swr/subscription'; import useSWRSubscription from "swr/subscription";
export const NoteParent = memo(function NoteParent({ id }: { id: string }) { 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 { data, error } = useSWRSubscription(
const unsubscribe = pool.subscribe( id ? id : null,
[ (key, { next }) => {
{ const unsubscribe = pool.subscribe(
ids: [key], [
}, {
], ids: [key],
READONLY_RELAYS, },
(event: any) => { ],
next(null, event); READONLY_RELAYS,
}, (event: any) => {
undefined, next(null, event);
undefined, },
{ undefined,
unsubscribeOnEose: true, undefined,
} {
); unsubscribeOnEose: true,
},
);
return () => { return () => {
unsubscribe(); unsubscribe();
}; };
}); },
);
const kind1 = !error && data?.kind === 1 ? noteParser(data) : null; const kind1 = !error && data?.kind === 1 ? noteParser(data) : null;
const kind1063 = !error && data?.kind === 1063 ? data.tags : null; const kind1063 = !error && data?.kind === 1063 ? data.tags : null;
return ( return (
<div className="relative flex flex-col pb-6"> <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> <div className="absolute left-[16px] top-0 h-full w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600" />
{data ? ( {data ? (
<> <>
<NoteDefaultUser pubkey={data.pubkey} time={data.created_at} /> <NoteDefaultUser pubkey={data.pubkey} time={data.created_at} />
<div className="mt-3 pl-[46px]"> <div className="mt-3 pl-[46px]">
{kind1 && <Kind1 content={kind1} />} {kind1 && <Kind1 content={kind1} />}
{kind1063 && <Kind1063 metadata={kind1063} />} {kind1063 && <Kind1063 metadata={kind1063} />}
<NoteMetadata id={data.id} eventPubkey={data.pubkey} /> <NoteMetadata id={data.id} eventPubkey={data.pubkey} />
</div> </div>
</> </>
) : ( ) : (
<NoteSkeleton /> <NoteSkeleton />
)} )}
</div> </div>
); );
}); });

View File

@@ -1,11 +1,15 @@
import { Image } from '@shared/image'; import { Image } from "@shared/image";
export default function ImagePreview({ urls }: { urls: string[] }) { export default function ImagePreview({ urls }: { urls: string[] }) {
return ( return (
<div className="mt-3 grid h-full w-full grid-cols-3"> <div className="mt-3 grid h-full w-full grid-cols-3">
<div className="col-span-3"> <div className="col-span-3">
<Image src={urls[0]} alt="image" className="h-auto w-full rounded-lg object-cover" /> <Image
</div> src={urls[0]}
</div> 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[] }) { export default function VideoPreview({ urls }: { urls: string[] }) {
return ( return (
<div <div
onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}
className="relative mt-2 flex w-full flex-col overflow-hidden rounded-lg bg-zinc-950" className="relative mt-2 flex w-full flex-col overflow-hidden rounded-lg bg-zinc-950"
> >
<MediaPlayer src={urls[0]} poster="" controls> <MediaPlayer src={urls[0]} poster="" controls>
<MediaOutlet /> <MediaOutlet />
</MediaPlayer> </MediaPlayer>
</div> </div>
); );
} }

View File

@@ -1,21 +1,24 @@
import { RootNote } from '@app/note/components/rootNote'; import { RootNote } from "@app/note/components/rootNote";
import { NoteRepostUser } from '@app/note/components/user/repost'; import { NoteRepostUser } from "@app/note/components/user/repost";
import { NoteWrapper } from '@app/note/components/wrapper'; import { NoteWrapper } from "@app/note/components/wrapper";
import { getQuoteID } from '@utils/transform'; import { getQuoteID } from "@utils/transform";
export function NoteQuoteRepost({ event }: { event: any }) { export function NoteQuoteRepost({ event }: { event: any }) {
const rootID = getQuoteID(event.tags); const rootID = getQuoteID(event.tags);
return ( return (
<NoteWrapper href={`/app/note?id=${rootID}`} className="h-min w-full px-3 py-1.5"> <NoteWrapper
<div className="rounded-md border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20"> href={`/app/note?id=${rootID}`}
<div className="relative px-3 pb-5 pt-3"> className="h-min w-full px-3 py-1.5"
<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 className="rounded-md border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
</div> <div className="relative px-3 pb-5 pt-3">
<RootNote id={rootID} fallback={event.content} /> <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} />
</NoteWrapper> </div>
); <RootNote id={rootID} fallback={event.content} />
</div>
</NoteWrapper>
);
} }

View File

@@ -1,74 +1,79 @@
import { Image } from '@shared/image'; import { Image } from "@shared/image";
import { RelayContext } from '@shared/relayProvider'; 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 { useActiveAccount } from '@utils/hooks/useActiveAccount'; import { useActiveAccount } from "@utils/hooks/useActiveAccount";
import { getEventHash, signEvent } from 'nostr-tools'; import { getEventHash, signEvent } from "nostr-tools";
import { useContext, useState } from 'react'; import { useContext, useState } from "react";
export default function NoteReplyForm({ id }: { id: string }) { export default function NoteReplyForm({ id }: { id: string }) {
const pool: any = useContext(RelayContext); const pool: any = useContext(RelayContext);
const { account, isLoading, isError } = useActiveAccount(); const { account, isLoading, isError } = useActiveAccount();
const [value, setValue] = useState(''); const [value, setValue] = useState("");
const profile = account ? JSON.parse(account.metadata) : null; const profile = account ? JSON.parse(account.metadata) : null;
const submitEvent = () => { const submitEvent = () => {
if (!isLoading && !isError && account) { if (!isLoading && !isError && account) {
const event: any = { const event: any = {
content: value, content: value,
created_at: dateToUnix(), created_at: dateToUnix(),
kind: 1, kind: 1,
pubkey: account.pubkey, pubkey: account.pubkey,
tags: [['e', id]], tags: [["e", id]],
}; };
event.id = getEventHash(event); event.id = getEventHash(event);
event.sig = signEvent(event, account.privkey); event.sig = signEvent(event, account.privkey);
// publish note // publish note
pool.publish(event, WRITEONLY_RELAYS); pool.publish(event, WRITEONLY_RELAYS);
// reset form // reset form
setValue(''); setValue("");
} else { } else {
console.log('error'); console.log("error");
} }
}; };
return ( return (
<div className="flex gap-2.5 px-3 py-4"> <div className="flex gap-2.5 px-3 py-4">
<div> <div>
<div className="relative h-9 w-9 shrink-0 overflow-hidden rounded-md"> <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" /> <Image
</div> src={profile?.picture}
</div> alt={account?.pubkey}
<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"> className="h-9 w-9 rounded-md object-cover"
<div> />
<textarea </div>
name="content" </div>
onChange={(e) => setValue(e.target.value)} <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">
placeholder="Reply to this thread..." <div>
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" <textarea
spellCheck={false} name="content"
/> onChange={(e) => setValue(e.target.value)}
</div> placeholder="Reply to this thread..."
<div className="absolute bottom-2 w-full px-2"> 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"
<div className="flex w-full items-center justify-between bg-zinc-800"> spellCheck={false}
<div className="flex items-center gap-2 divide-x divide-zinc-700"></div> />
<div className="flex items-center gap-2"> </div>
<button <div className="absolute bottom-2 w-full px-2">
onClick={() => submitEvent()} <div className="flex w-full items-center justify-between bg-zinc-800">
disabled={value.length === 0 ? true : false} <div className="flex items-center gap-2 divide-x divide-zinc-700" />
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" <div className="flex items-center gap-2">
> <button
Reply type="button"
</button> onClick={() => submitEvent()}
</div> disabled={value.length === 0 ? true : false}
</div> 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"
</div> >
</div> Reply
</div> </button>
); </div>
</div>
</div>
</div>
</div>
);
} }

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,20 @@
export function NoteSkeleton() { export function NoteSkeleton() {
return ( return (
<div className="flex h-min flex-col pb-3"> <div className="flex h-min flex-col pb-3">
<div className="flex items-center gap-2.5"> <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="relative h-9 w-9 shrink overflow-hidden rounded-md bg-zinc-700" />
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<div className="h-3 w-20 rounded-sm bg-zinc-700" /> <div className="h-3 w-20 rounded-sm bg-zinc-700" />
<div className="h-2 w-12 rounded-sm bg-zinc-700" /> <div className="h-2 w-12 rounded-sm bg-zinc-700" />
</div> </div>
</div> </div>
<div className="mt-3 animate-pulse pl-[46px]"> <div className="mt-3 animate-pulse pl-[46px]">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="h-4 w-full rounded-sm bg-zinc-700" /> <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-2/3 rounded-sm bg-zinc-700" />
<div className="h-4 w-1/2 rounded-sm bg-zinc-700" /> <div className="h-4 w-1/2 rounded-sm bg-zinc-700" />
</div> </div>
</div> </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 { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from '@utils/shortenKey'; import { shortenKey } from "@utils/shortenKey";
import { Popover, Transition } from '@headlessui/react'; import { Popover, Transition } from "@headlessui/react";
import dayjs from 'dayjs'; import dayjs from "dayjs";
import relativeTime from 'dayjs/plugin/relativeTime'; import relativeTime from "dayjs/plugin/relativeTime";
import { Fragment } from 'react'; import { Fragment } from "react";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
export function NoteDefaultUser({ pubkey, time }: { pubkey: string; time: number }) { export function NoteDefaultUser({
const { user } = useProfile(pubkey); pubkey,
time,
}: { pubkey: string; time: number }) {
const { user } = useProfile(pubkey);
return ( return (
<Popover className="relative flex items-center gap-2.5"> <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"> <Popover.Button className="h-9 w-9 shrink-0 overflow-hidden rounded-md bg-zinc-900">
<Image <Image
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${user?.picture ? user.picture : DEFAULT_AVATAR}`} src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${
alt={pubkey} user?.picture ? user.picture : DEFAULT_AVATAR
className="h-9 w-9 object-cover" }`}
/> alt={pubkey}
</Popover.Button> className="h-9 w-9 object-cover"
<div className="flex w-full flex-1 items-start justify-between"> />
<div className="flex flex-col gap-0.5"> </Popover.Button>
<h5 className="text-sm font-semibold leading-none"> <div className="flex w-full flex-1 items-start justify-between">
{user?.display_name || user?.name || <div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700"></div>} <div className="flex flex-col gap-0.5">
</h5> <h5 className="text-sm font-semibold leading-none">
<div className="flex items-baseline gap-1.5 text-sm leading-none text-zinc-500"> {user?.display_name || user?.name || (
<span>{user?.nip05 || shortenKey(pubkey)}</span> <div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700" />
<span></span> )}
<span>{dayjs().to(dayjs.unix(time), true)}</span> </h5>
</div> <div className="flex items-baseline gap-1.5 text-sm leading-none text-zinc-500">
</div> <span>{user?.nip05 || shortenKey(pubkey)}</span>
</div> <span></span>
<Transition <span>{dayjs().to(dayjs.unix(time), true)}</span>
as={Fragment} </div>
enter="transition ease-out duration-200" </div>
enterFrom="opacity-0 translate-y-1" </div>
enterTo="opacity-100 translate-y-0" <Transition
leave="transition ease-in duration-150" as={Fragment}
leaveFrom="opacity-100 translate-y-0" enter="transition ease-out duration-200"
leaveTo="opacity-0 translate-y-1" enterFrom="opacity-0 translate-y-1"
> enterTo="opacity-100 translate-y-0"
<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"> leave="transition ease-in duration-150"
<div leaveFrom="opacity-100 translate-y-0"
onClick={(e) => e.stopPropagation()} leaveTo="opacity-0 translate-y-1"
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" >
> <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 className="flex items-start gap-2.5 border-b border-zinc-800 px-3 py-3"> <div
<Image onKeyDown={(e) => e.stopPropagation()}
src={`${IMGPROXY_URL}/rs:fit:200:200/plain/${user?.picture ? user.picture : DEFAULT_AVATAR}`} 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"
alt={pubkey} >
className="h-14 w-14 shrink-0 rounded-lg object-cover" <div className="flex items-start gap-2.5 border-b border-zinc-800 px-3 py-3">
/> <Image
<div className="flex w-full flex-1 flex-col gap-2"> src={`${IMGPROXY_URL}/rs:fit:200:200/plain/${
<div className="inline-flex w-2/3 flex-col gap-0.5"> user?.picture ? user.picture : DEFAULT_AVATAR
<h5 className="text-sm font-semibold leading-none"> }`}
{user?.display_name || user?.name || ( alt={pubkey}
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700"></div> className="h-14 w-14 shrink-0 rounded-lg object-cover"
)} />
</h5> <div className="flex w-full flex-1 flex-col gap-2">
<span className="truncate text-sm leading-none text-zinc-500"> <div className="inline-flex w-2/3 flex-col gap-0.5">
{user?.nip05 || shortenKey(pubkey)} <h5 className="text-sm font-semibold leading-none">
</span> {user?.display_name || user?.name || (
</div> <div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700" />
<div> )}
<p className="line-clamp-3 text-sm leading-tight text-zinc-100">{user?.about}</p> </h5>
</div> <span className="truncate text-sm leading-none text-zinc-500">
</div> {user?.nip05 || shortenKey(pubkey)}
</div> </span>
<div className="flex items-center gap-2 px-3 py-3"> </div>
<a <div>
href={`/app/user?pubkey=${pubkey}`} <p className="line-clamp-3 text-sm leading-tight text-zinc-100">
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-800 text-sm font-medium" {user?.about}
> </p>
View full profile </div>
</a> </div>
<a </div>
href={`/app/chat?pubkey=${pubkey}`} <div className="flex items-center gap-2 px-3 py-3">
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-800 text-sm font-medium" <a
> href={`/app/user?pubkey=${pubkey}`}
Message className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-800 text-sm font-medium"
</a> >
</div> View full profile
</div> </a>
</Popover.Panel> <a
</Transition> href={`/app/chat?pubkey=${pubkey}`}
</Popover> 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 { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from '@utils/shortenKey'; import { shortenKey } from "@utils/shortenKey";
import dayjs from 'dayjs'; import dayjs from "dayjs";
import relativeTime from 'dayjs/plugin/relativeTime'; import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
export default function NoteReplyUser({ pubkey, time }: { pubkey: string; time: number }) { export default function NoteReplyUser({
const { user } = useProfile(pubkey); pubkey,
time,
}: { pubkey: string; time: number }) {
const { user } = useProfile(pubkey);
return ( return (
<div className="group flex items-start gap-2.5"> <div className="group flex items-start gap-2.5">
<div className="relative h-9 w-9 shrink-0 rounded-md"> <div className="relative h-9 w-9 shrink-0 rounded-md">
<Image <Image
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${user?.picture ? user.picture : DEFAULT_AVATAR}`} src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${
alt={pubkey} user?.picture ? user.picture : DEFAULT_AVATAR
className="h-9 w-9 rounded-md object-cover" }`}
/> alt={pubkey}
</div> className="h-9 w-9 rounded-md object-cover"
<div className="flex w-full flex-1 items-start justify-between"> />
<div className="flex items-baseline gap-2 text-sm"> </div>
<span className="font-semibold leading-none text-zinc-200 group-hover:underline"> <div className="flex w-full flex-1 items-start justify-between">
{user?.display_name || user?.name || shortenKey(pubkey)} <div className="flex items-baseline gap-2 text-sm">
</span> <span className="font-semibold leading-none text-zinc-200 group-hover:underline">
<span className="leading-none text-zinc-500">·</span> {user?.display_name || user?.name || shortenKey(pubkey)}
<span className="leading-none text-zinc-500">{dayjs().to(dayjs.unix(time), true)}</span> </span>
</div> <span className="leading-none text-zinc-500">·</span>
</div> <span className="leading-none text-zinc-500">
</div> {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 { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from '@utils/shortenKey'; import { shortenKey } from "@utils/shortenKey";
import { Popover, Transition } from '@headlessui/react'; import { Popover, Transition } from "@headlessui/react";
import dayjs from 'dayjs'; import dayjs from "dayjs";
import relativeTime from 'dayjs/plugin/relativeTime'; import relativeTime from "dayjs/plugin/relativeTime";
import { Fragment } from 'react'; import { Fragment } from "react";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
export function NoteRepostUser({ pubkey, time }: { pubkey: string; time: number }) { export function NoteRepostUser({
const { user } = useProfile(pubkey); pubkey,
time,
}: { pubkey: string; time: number }) {
const { user } = useProfile(pubkey);
return ( return (
<Popover className="relative flex items-center gap-2.5"> <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"> <Popover.Button className="h-9 w-9 shrink-0 overflow-hidden rounded-md bg-zinc-900">
<Image <Image
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${user?.picture ? user.picture : DEFAULT_AVATAR}`} src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${
alt={pubkey} user?.picture ? user.picture : DEFAULT_AVATAR
className="h-9 w-9 rounded-md object-cover" }`}
/> alt={pubkey}
</Popover.Button> className="h-9 w-9 rounded-md object-cover"
<div className="flex items-baseline gap-1.5 text-sm"> />
<h5 className="font-semibold leading-tight group-hover:underline"> </Popover.Button>
{user?.display_name || user?.name || <div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700"></div>} <div className="flex items-baseline gap-1.5 text-sm">
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-transparent"> <h5 className="font-semibold leading-tight group-hover:underline">
{' '} {user?.display_name || user?.name || (
reposted <div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700" />
</span> )}
</h5> <span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-transparent">
<span className="leading-tight text-zinc-500">·</span> {" "}
<span className="text-zinc-500">{dayjs().to(dayjs.unix(time), true)}</span> reposted
</div> </span>
<Transition </h5>
as={Fragment} <span className="leading-tight text-zinc-500">·</span>
enter="transition ease-out duration-200" <span className="text-zinc-500">
enterFrom="opacity-0 translate-y-1" {dayjs().to(dayjs.unix(time), true)}
enterTo="opacity-100 translate-y-0" </span>
leave="transition ease-in duration-150" </div>
leaveFrom="opacity-100 translate-y-0" <Transition
leaveTo="opacity-0 translate-y-1" as={Fragment}
> enter="transition ease-out duration-200"
<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"> enterFrom="opacity-0 translate-y-1"
<div enterTo="opacity-100 translate-y-0"
onClick={(e) => e.stopPropagation()} leave="transition ease-in duration-150"
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" leaveFrom="opacity-100 translate-y-0"
> leaveTo="opacity-0 translate-y-1"
<div className="flex items-start gap-2.5 border-b border-zinc-800 px-3 py-3"> >
<Image <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">
src={`${IMGPROXY_URL}/rs:fit:200:200/plain/${user?.picture ? user.picture : DEFAULT_AVATAR}`} <div
alt={pubkey} onKeyDown={(e) => e.stopPropagation()}
className="h-14 w-14 shrink-0 rounded-lg object-cover" 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 w-full flex-1 flex-col gap-2"> <div className="flex items-start gap-2.5 border-b border-zinc-800 px-3 py-3">
<div className="inline-flex w-2/3 flex-col gap-0.5"> <Image
<h5 className="text-sm font-semibold leading-none"> src={`${IMGPROXY_URL}/rs:fit:200:200/plain/${
{user?.display_name || user?.name || ( user?.picture ? user.picture : DEFAULT_AVATAR
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700"></div> }`}
)} alt={pubkey}
</h5> className="h-14 w-14 shrink-0 rounded-lg object-cover"
<span className="truncate text-sm leading-none text-zinc-500"> />
{user?.nip05 || shortenKey(pubkey)} <div className="flex w-full flex-1 flex-col gap-2">
</span> <div className="inline-flex w-2/3 flex-col gap-0.5">
</div> <h5 className="text-sm font-semibold leading-none">
<div> {user?.display_name || user?.name || (
<p className="line-clamp-3 text-sm leading-tight text-zinc-100">{user?.about}</p> <div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700" />
</div> )}
</div> </h5>
</div> <span className="truncate text-sm leading-none text-zinc-500">
<div className="flex items-center gap-2 px-3 py-3"> {user?.nip05 || shortenKey(pubkey)}
<a </span>
href={`/app/user?pubkey=${pubkey}`} </div>
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-800 text-sm font-medium" <div>
> <p className="line-clamp-3 text-sm leading-tight text-zinc-100">
View full profile {user?.about}
</a> </p>
<a </div>
href={`/app/chat?pubkey=${pubkey}`} </div>
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-800 text-sm font-medium" </div>
> <div className="flex items-center gap-2 px-3 py-3">
Message <a
</a> href={`/app/user?pubkey=${pubkey}`}
</div> className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-800 text-sm font-medium"
</div> >
</Popover.Panel> View full profile
</Transition> </a>
</Popover> <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({ export function NoteWrapper({
children, children,
href, href,
className, className,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
href: string; href: string;
className: string; className: string;
}) { }) {
const openThread = (event: any, href: string) => { const openThread = (event: any, href: string) => {
const selection = window.getSelection(); const selection = window.getSelection();
if (selection.toString().length === 0) { if (selection.toString().length === 0) {
navigate(href, { keepScrollPosition: true }); navigate(href, { keepScrollPosition: true });
} else { } else {
event.stopPropagation(); event.stopPropagation();
} }
}; };
return ( return (
<div onClick={(event) => openThread(event, href)} className={className}> <div onKeyDown={(event) => openThread(event, href)} className={className}>
{children} {children}
</div> </div>
); );
} }

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
export function Page() { export function Page() {
return ( return (
<div> <div>
<p>MySpace</p> <p>MySpace</p>
</div> </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() { export function Header() {
return ( return (
<div className="flex w-full gap-4"> <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"> <button
<span className="px-2 text-sm font-semibold text-zinc-300">Following</span> type="button"
</button> className="from-zinc-90 inline-flex h-11 items-center overflow-hidden border-b border-fuchsia-500 hover:bg-zinc-900"
<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="" /> <span className="px-2 text-sm font-semibold text-zinc-300">
<img Following
className="inline-block h-6 w-6 rounded ring-2 ring-zinc-950" </span>
src="https://void.cat/d/3Bp6jSHURFNQ9u3pK8nwtq.webp" </button>
alt="" <div className="flex h-11 items-center -space-x-1 overflow-hidden">
/> <img
<img className="inline-block h-6 w-6 rounded ring-2 ring-zinc-950"
className="inline-block h-6 w-6 rounded ring-2 ring-zinc-950" src="https://133332.xyz/p.jpg"
src="https://void.cat/d/8zE9T8a39YfUVjrLM4xcpE.webp" alt=""
alt="" />
/> <img
<img className="inline-block h-6 w-6 rounded ring-2 ring-zinc-950"
className="ring-zinc-95 ring-20 inline-block h-6 w-6 rounded" src="https://void.cat/d/3Bp6jSHURFNQ9u3pK8nwtq.webp"
src="https://nostr.build/i/p/nostr.build_0e412058980ed2ac4adf3de639304c9e970e2745ba9ca19c75f984f4f6da4971.jpeg" alt=""
alt="" />
/> <img
<img className="inline-block h-6 w-6 rounded ring-2 ring-zinc-950"
className="ring-zinc-95 ring-20 inline-block h-6 w-6 rounded" src="https://void.cat/d/8zE9T8a39YfUVjrLM4xcpE.webp"
src="https://davidcoen.it/wp-content/uploads/2020/11/7004972-taglio.jpg" alt=""
alt="" />
/> <img
</div> className="ring-zinc-95 ring-20 inline-block h-6 w-6 rounded"
<div className="flex h-11 items-center overflow-hidden"> src="https://nostr.build/i/p/nostr.build_0e412058980ed2ac4adf3de639304c9e970e2745ba9ca19c75f984f4f6da4971.jpeg"
<img alt=""
className="ring-zinc-95 ring-20 inline-block h-6 w-6 rounded" />
src="https://void.cat/d/KvAEMvYNmy1rfCH6a7HZzh.webp" <img
alt="" 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"
</div> alt=""
<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>
</div> <div className="flex h-11 items-center overflow-hidden">
<CreateViewModal /> <img
</div> 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 CancelIcon from "@icons/cancel";
import PlusIcon from '@icons/plus'; import PlusIcon from "@icons/plus";
import { Dialog, Transition } from '@headlessui/react'; import { Dialog, Transition } from "@headlessui/react";
import { Fragment, useState } from 'react'; import { Fragment, useState } from "react";
export function CreateViewModal() { export function CreateViewModal() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const closeModal = () => { const closeModal = () => {
setIsOpen(false); setIsOpen(false);
}; };
const openModal = () => { const openModal = () => {
setIsOpen(true); setIsOpen(true);
}; };
return ( return (
<> <>
<button <button
type="button" type="button"
onClick={openModal} onClick={openModal}
className="inline-flex h-11 items-center overflow-hidden border-b border-transparent hover:bg-zinc-900" 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"> <span className="inline-flex items-center gap-1 px-2 text-sm font-medium text-zinc-500">
<PlusIcon width={14} height={14} /> <PlusIcon width={14} height={14} />
View View
</span> </span>
</button> </button>
<Transition appear show={isOpen} as={Fragment}> <Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}> <Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" /> <div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child> </Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center"> <div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0 scale-95" enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100" enterTo="opacity-100 scale-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95" 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"> <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="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 flex-col gap-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Dialog.Title <Dialog.Title
as="h3" as="h3"
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-xl font-semibold leading-none text-transparent" 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 Create a view
</Dialog.Title> </Dialog.Title>
<button <button
type="button" type="button"
onClick={closeModal} onClick={closeModal}
autoFocus={false} className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900" >
> <CancelIcon
<CancelIcon width={16} height={16} className="text-zinc-400" /> width={16}
</button> height={16}
</div> className="text-zinc-400"
<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 </button>
people in a view. </div>
</Dialog.Description> <Dialog.Description className="text-sm leading-tight text-zinc-400">
</div> View is specific feature help you pin who you want to see
</div> in your feed. You can add maximum 5 people in a view.
<div className="flex h-full w-full flex-col overflow-y-auto pb-5 pt-3"></div> </Dialog.Description>
</Dialog.Panel> </div>
</Transition.Child> </div>
</div> <div className="flex h-full w-full flex-col overflow-y-auto pb-5 pt-3" />
</Dialog> </Dialog.Panel>
</Transition> </Transition.Child>
</> </div>
); </Dialog>
</Transition>
</>
);
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,7 @@
export function LayoutDefault({ children }: { children: React.ReactNode }) { 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 { LayoutDefault } from "./layoutDefault";
import { PageContext } from './types'; import { PageContext } from "./types";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
export function Shell({ children, pageContext }: { children: React.ReactNode; pageContext: PageContext }) { export function Shell({
const Layout = (pageContext.exports.Layout as React.ElementType) || (LayoutDefault as React.ElementType); children,
pageContext,
}: { children: React.ReactNode; pageContext: PageContext }) {
const Layout =
(pageContext.exports.Layout as React.ElementType) ||
(LayoutDefault as React.ElementType);
return ( return (
<PageContextProvider pageContext={pageContext}> <PageContextProvider pageContext={pageContext}>
<RelayProvider> <RelayProvider>
<Layout> <Layout>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider> <QueryClientProvider client={queryClient}>
</Layout> {children}
</RelayProvider> </QueryClientProvider>
</PageContextProvider> </Layout>
); </RelayProvider>
</PageContextProvider>
);
} }

View File

@@ -1,12 +1,12 @@
import type { import type {
PageContextBuiltIn, PageContextBuiltIn,
/* /*
// When using Client Routing https://vite-plugin-ssr.com/clientRouting // When using Client Routing https://vite-plugin-ssr.com/clientRouting
PageContextBuiltInClientWithClientRouting as PageContextBuiltInClient PageContextBuiltInClientWithClientRouting as PageContextBuiltInClient
/*/ /*/
// When using Server Routing // When using Server Routing
PageContextBuiltInClientWithServerRouting as PageContextBuiltInClient, //*/ PageContextBuiltInClientWithServerRouting as PageContextBuiltInClient, //*/
} from 'vite-plugin-ssr/types'; } from "vite-plugin-ssr/types";
export type { PageContextServer }; export type { PageContextServer };
export type { PageContextClient }; export type { PageContextClient };
@@ -17,16 +17,16 @@ type Page = (pageProps: PageProps) => React.ReactElement;
type PageProps = Record<string, never>; type PageProps = Record<string, never>;
export type PageContextCustom = { export type PageContextCustom = {
Page: Page; Page: Page;
pageProps?: PageProps; pageProps?: PageProps;
redirectTo?: string; redirectTo?: string;
urlPathname: string; urlPathname: string;
exports: { exports: {
documentProps?: { documentProps?: {
title?: string; title?: string;
description?: string; description?: string;
}; };
}; };
}; };
type PageContextServer = PageContextBuiltIn<Page> & PageContextCustom; 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 }) { export default function ActiveAccount({ user }: { user: any }) {
const userData = JSON.parse(user.metadata); const userData = JSON.parse(user.metadata);
return ( return (
<button className="relative h-10 w-10 overflow-hidden rounded-lg"> <button
<Image type="button"
src={userData.picture || DEFAULT_AVATAR} className="relative h-10 w-10 overflow-hidden rounded-lg"
alt="user's avatar" >
loading="auto" <Image
className="h-10 w-10 object-cover" src={userData.picture || DEFAULT_AVATAR}
/> alt="user's avatar"
</button> 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 }) { export default function InactiveAccount({ user }: { user: any }) {
const userData = JSON.parse(user.metadata); const userData = JSON.parse(user.metadata);
return ( return (
<div className="relative h-10 w-10 shrink rounded-lg"> <div className="relative h-10 w-10 shrink rounded-lg">
<Image <Image
src={userData.picture || DEFAULT_AVATAR} src={userData.picture || DEFAULT_AVATAR}
alt="user's avatar" alt="user's avatar"
className="h-10 w-10 rounded-lg object-cover" className="h-10 w-10 rounded-lg object-cover"
/> />
</div> </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({ export default function ActiveLink({
href, href,
className, className,
activeClassName, activeClassName,
children, children,
}: { }: {
href: string; href: string;
className: string; className: string;
activeClassName: string; activeClassName: string;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const pageContext = usePageContext(); const pageContext = usePageContext();
const pathName = pageContext.urlPathname; const pathName = pageContext.urlPathname;
return ( return (
<a href={href} className={twMerge(className, href === pathName ? activeClassName : '')}> <a
{children} href={href}
</a> className={twMerge(className, href === pathName ? activeClassName : "")}
); >
{children}
</a>
);
} }

View File

@@ -1,55 +1,75 @@
import ArrowLeftIcon from '@icons/arrowLeft'; import ArrowLeftIcon from "@icons/arrowLeft";
import ArrowRightIcon from '@icons/arrowRight'; import ArrowRightIcon from "@icons/arrowRight";
import RefreshIcon from '@icons/refresh'; import RefreshIcon from "@icons/refresh";
export default function AppHeader() { export default function AppHeader() {
const goBack = () => { const goBack = () => {
window.history.back(); window.history.back();
}; };
const goForward = () => { const goForward = () => {
window.history.forward(); window.history.forward();
}; };
const reload = () => { const reload = () => {
window.location.reload(); window.location.reload();
}; };
return ( return (
<div data-tauri-drag-region className="flex h-full w-full flex-1 items-center px-2"> <div
<div data-tauri-drag-region className="flex w-full items-center justify-center gap-2"> data-tauri-drag-region
<div className="flex h-full items-center gap-2"> className="flex h-full w-full flex-1 items-center px-2"
<button >
onClick={() => goBack()} <div
className="group inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900" data-tauri-drag-region
> className="flex w-full items-center justify-center gap-2"
<ArrowLeftIcon width={14} height={14} className="text-zinc-500 group-hover:text-zinc-300" /> >
</button> <div className="flex h-full items-center gap-2">
<button <button
onClick={() => goForward()} type="button"
className="group inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900" onClick={() => goBack()}
> 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> <ArrowLeftIcon
</div> width={14}
<div> height={14}
<input className="text-zinc-500 group-hover:text-zinc-300"
autoCapitalize="none" />
autoCorrect="off" </button>
autoFocus={false} <button
placeholder="Search..." type="button"
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" onClick={() => goForward()}
/> className="group inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
</div> >
<div className="flex h-full items-center gap-2"> <ArrowRightIcon
<button width={14}
onClick={() => reload()} height={14}
className="group inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900" className="text-zinc-500 group-hover:text-zinc-300"
> />
<RefreshIcon width={14} height={14} className="text-zinc-500 group-hover:text-zinc-300" /> </button>
</button> </div>
</div> <div>
</div> <input
</div> 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 { open } from "@tauri-apps/api/dialog";
import { Body, fetch } from '@tauri-apps/api/http'; import { Body, fetch } from "@tauri-apps/api/http";
import { useState } from 'react'; import { useState } from "react";
export function AvatarUploader({ valueState }: { valueState: any }) { export function AvatarUploader({ valueState }: { valueState: any }) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const openFileDialog = async () => { const openFileDialog = async () => {
const selected: any = await open({ const selected: any = await open({
multiple: false, multiple: false,
filters: [ filters: [
{ {
name: 'Image', name: "Image",
extensions: ['png', 'jpeg', 'jpg', 'gif'], extensions: ["png", "jpeg", "jpg", "gif"],
}, },
], ],
}); });
if (Array.isArray(selected)) { if (Array.isArray(selected)) {
// user selected multiple files // user selected multiple files
} else if (selected === null) { } else if (selected === null) {
// user cancelled the selection // user cancelled the selection
} else { } else {
setLoading(true); setLoading(true);
const filename = selected.split('/').pop(); const filename = selected.split("/").pop();
const file = await createBlobFromFile(selected); const file = await createBlobFromFile(selected);
const buf = await file.arrayBuffer(); const buf = await file.arrayBuffer();
const res: { data: { file: { id: string } } } = await fetch('https://void.cat/upload?cli=false', { const res: { data: { file: { id: string } } } = await fetch(
method: 'POST', "https://void.cat/upload?cli=false",
timeout: 5, {
headers: { method: "POST",
accept: '*/*', timeout: 5,
'Content-Type': 'application/octet-stream', headers: {
'V-Filename': filename, accept: "*/*",
'V-Description': 'Upload from https://lume.nu', "Content-Type": "application/octet-stream",
'V-Strip-Metadata': 'true', "V-Filename": filename,
}, "V-Description": "Upload from https://lume.nu",
body: Body.bytes(buf), "V-Strip-Metadata": "true",
}); },
const webpImage = 'https://void.cat/d/' + res.data.file.id + '.webp'; body: Body.bytes(buf),
},
);
const webpImage = `https://void.cat/d/${res.data.file.id}.webp`;
valueState(webpImage); valueState(webpImage);
setLoading(false); setLoading(false);
} }
}; };
return ( return (
<button <button
onClick={() => openFileDialog()} onClick={() => openFileDialog()}
type="button" 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" 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 ? ( {loading ? (
<svg <svg
className="h-4 w-4 animate-spin text-black dark:text-white" className="h-4 w-4 animate-spin text-black dark:text-white"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> <title id="loading">Loading</title>
<path <circle
className="opacity-75" className="opacity-25"
fill="currentColor" cx="12"
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" cy="12"
></path> r="10"
</svg> stroke="currentColor"
) : ( strokeWidth="4"
<span className="leading-none">Upload</span> />
)} <path
</button> 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 { open } from "@tauri-apps/api/dialog";
import { listen } from '@tauri-apps/api/event'; import { listen } from "@tauri-apps/api/event";
import { Body, fetch } from '@tauri-apps/api/http'; import { Body, fetch } from "@tauri-apps/api/http";
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from "react";
import { Transforms } from 'slate'; import { Transforms } from "slate";
import { useSlateStatic } from 'slate-react'; import { useSlateStatic } from "slate-react";
export function ImageUploader() { export function ImageUploader() {
const editor = useSlateStatic(); const editor = useSlateStatic();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const insertImage = (editor, url) => { const insertImage = (editor, url) => {
const image = { type: 'image', url, children: [{ text: url }] }; const image = { type: "image", url, children: [{ text: url }] };
Transforms.insertNodes(editor, image); Transforms.insertNodes(editor, image);
}; };
const uploadToVoidCat = useCallback( const uploadToVoidCat = useCallback(
async (filepath) => { async (filepath) => {
const filename = filepath.split('/').pop(); const filename = filepath.split("/").pop();
const file = await createBlobFromFile(filepath); const file = await createBlobFromFile(filepath);
const buf = await file.arrayBuffer(); const buf = await file.arrayBuffer();
try { try {
const res: { data: { file: { id: string } } } = await fetch('https://void.cat/upload?cli=false', { const res: { data: { file: { id: string } } } = await fetch(
method: 'POST', "https://void.cat/upload?cli=false",
timeout: 5, {
headers: { method: "POST",
accept: '*/*', timeout: 5,
'Content-Type': 'application/octet-stream', headers: {
'V-Filename': filename, accept: "*/*",
'V-Description': 'Uploaded from https://lume.nu', "Content-Type": "application/octet-stream",
'V-Strip-Metadata': 'true', "V-Filename": filename,
}, "V-Description": "Uploaded from https://lume.nu",
body: Body.bytes(buf), "V-Strip-Metadata": "true",
}); },
const image = 'https://void.cat/d/' + res.data.file.id + '.webp'; body: Body.bytes(buf),
// update parent state },
insertImage(editor, image); );
// reset loading state const image = `https://void.cat/d/${res.data.file.id}.webp`;
setLoading(false); // update parent state
} catch (error) { insertImage(editor, image);
// reset loading state // reset loading state
setLoading(false); setLoading(false);
// handle error } catch (error) {
if (error instanceof SyntaxError) { // reset loading state
// Unexpected token < in JSON setLoading(false);
console.log('There was a SyntaxError', error); // handle error
} else { if (error instanceof SyntaxError) {
console.log('There was an error', error); // Unexpected token < in JSON
} console.log("There was a SyntaxError", error);
} } else {
}, console.log("There was an error", error);
[editor] }
); }
},
[editor],
);
const openFileDialog = async () => { const openFileDialog = async () => {
const selected: any = await open({ const selected: any = await open({
multiple: false, multiple: false,
filters: [ filters: [
{ {
name: 'Image', name: "Image",
extensions: ['png', 'jpeg', 'jpg', 'gif'], extensions: ["png", "jpeg", "jpg", "gif"],
}, },
], ],
}); });
if (Array.isArray(selected)) { if (Array.isArray(selected)) {
// user selected multiple files // user selected multiple files
} else if (selected === null) { } else if (selected === null) {
// user cancelled the selection // user cancelled the selection
} else { } else {
setLoading(true); setLoading(true);
// upload file // upload file
uploadToVoidCat(selected); uploadToVoidCat(selected);
} }
}; };
useEffect(() => { useEffect(() => {
async function initFileDrop() { async function initFileDrop() {
const unlisten = await listen('tauri://file-drop', (event) => { const unlisten = await listen("tauri://file-drop", (event) => {
// set loading state // set loading state
setLoading(true); setLoading(true);
// upload file // upload file
uploadToVoidCat(event.payload[0]); uploadToVoidCat(event.payload[0]);
}); });
return () => { return () => {
unlisten(); unlisten();
}; };
} }
initFileDrop(); initFileDrop();
}, [uploadToVoidCat]); }, [uploadToVoidCat]);
return ( return (
<button <button
type="button" type="button"
autoFocus={false} onClick={() => openFileDialog()}
onClick={() => openFileDialog()} className="inline-flex h-8 w-8 items-center justify-center rounded hover:bg-zinc-800"
className="inline-flex h-8 w-8 items-center justify-center rounded hover:bg-zinc-800" >
> {loading ? (
{loading ? ( <svg
<svg className="h-4 w-4 animate-spin text-black dark:text-white"
className="h-4 w-4 animate-spin text-black dark:text-white" xmlns="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg" fill="none"
fill="none" viewBox="0 0 24 24"
viewBox="0 0 24 24" >
> <title id="loading">Loading</title>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> <circle
<path className="opacity-25"
className="opacity-75" cx="12"
fill="currentColor" cy="12"
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" r="10"
></path> stroke="currentColor"
</svg> strokeWidth="4"
) : ( />
<PlusCircleIcon width={20} height={20} className="text-zinc-500" /> <path
)} className="opacity-75"
</button> 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 { Post } from "@shared/composer/types/post";
import { User } from '@shared/composer/user'; import { User } from "@shared/composer/user";
import CancelIcon from '@icons/cancel'; import CancelIcon from "@icons/cancel";
import ChevronDownIcon from '@icons/chevronDown'; import ChevronDownIcon from "@icons/chevronDown";
import ChevronRightIcon from '@icons/chevronRight'; import ChevronRightIcon from "@icons/chevronRight";
import ComposeIcon from '@icons/compose'; 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 { Dialog, Transition } from "@headlessui/react";
import { useAtom } from 'jotai'; import { useAtom } from "jotai";
import { Fragment, useState } from 'react'; import { Fragment, useState } from "react";
export function ComposerModal() { export function ComposerModal() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [composer] = useAtom(composerAtom); const [composer] = useAtom(composerAtom);
const { account, isLoading, isError } = useActiveAccount(); const { account, isLoading, isError } = useActiveAccount();
const closeModal = () => { const closeModal = () => {
setIsOpen(false); setIsOpen(false);
}; };
const openModal = () => { const openModal = () => {
setIsOpen(true); setIsOpen(true);
}; };
return ( return (
<> <>
<button <button
type="button" type="button"
autoFocus={false} onClick={() => openModal()}
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"
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} />
<ComposeIcon width={14} height={14} /> Compose
Compose </button>
</button> <Transition appear show={isOpen} as={Fragment}>
<Transition appear show={isOpen} as={Fragment}> <Dialog as="div" className="relative z-10" onClose={closeModal}>
<Dialog as="div" className="relative z-10" onClose={closeModal}> <Transition.Child
<Transition.Child as={Fragment}
as={Fragment} enter="ease-out duration-300"
enter="ease-out duration-300" enterFrom="opacity-0"
enterFrom="opacity-0" enterTo="opacity-100"
enterTo="opacity-100" leave="ease-in duration-200"
leave="ease-in duration-200" leaveFrom="opacity-100"
leaveFrom="opacity-100" leaveTo="opacity-0"
leaveTo="opacity-0" >
> <div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" /> </Transition.Child>
</Transition.Child> <div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center"> <Transition.Child
<Transition.Child as={Fragment}
as={Fragment} enter="ease-out duration-300"
enter="ease-out duration-300" enterFrom="opacity-0 scale-95"
enterFrom="opacity-0 scale-95" enterTo="opacity-100 scale-100"
enterTo="opacity-100 scale-100" leave="ease-in duration-200"
leave="ease-in duration-200" leaveFrom="opacity-100 scale-100"
leaveFrom="opacity-100 scale-100" leaveTo="opacity-0 scale-95"
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">
<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 justify-between px-4 py-4"> <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> <div>
<div>{!isLoading && !isError && account && <User data={account} />}</div> {!isLoading && !isError && account && (
<span> <User data={account} />
<ChevronRightIcon width={14} height={14} className="text-zinc-500" /> )}
</span> </div>
<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"> <span>
New Post <ChevronRightIcon
<ChevronDownIcon width={14} height={14} /> width={14}
</div> height={14}
</div> className="text-zinc-500"
<div />
onClick={closeModal} </span>
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-800" <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
<CancelIcon width={16} height={16} className="text-zinc-500" /> <ChevronDownIcon width={14} height={14} />
</div> </div>
</div> </div>
{composer.type === 'post' && account && <Post pubkey={account.pubkey} privkey={account.privkey} />} <div
</Dialog.Panel> onKeyDown={closeModal}
</Transition.Child> className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-800"
</div> >
</Dialog> <CancelIcon
</Transition> 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 { ImageUploader } from "@shared/composer/imageUploader";
import TrashIcon from '@shared/icons/trash'; import TrashIcon from "@shared/icons/trash";
import { RelayContext } from '@shared/relayProvider'; 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 { getEventHash, signEvent } from "nostr-tools";
import { useCallback, useContext, useMemo, useState } from 'react'; import { useCallback, useContext, useMemo, useState } from "react";
import { Node, Transforms, createEditor } from 'slate'; import { Node, Transforms, createEditor } from "slate";
import { withHistory } from 'slate-history'; import { withHistory } from "slate-history";
import { Editable, ReactEditor, Slate, useSlateStatic, withReact } from 'slate-react'; import {
Editable,
ReactEditor,
Slate,
useSlateStatic,
withReact,
} from "slate-react";
const withImages = (editor) => { const withImages = (editor) => {
const { isVoid } = editor; const { isVoid } = editor;
editor.isVoid = (element) => { editor.isVoid = (element) => {
return element.type === 'image' ? true : 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 ImagePreview = ({
const editor: any = useSlateStatic(); attributes,
const path = ReactEditor.findPath(editor, element); children,
element,
}: { attributes: any; children: any; element: any }) => {
const editor: any = useSlateStatic();
const path = ReactEditor.findPath(editor, element);
return ( return (
<figure {...attributes} className="m-0 mt-3"> <figure {...attributes} className="m-0 mt-3">
{children} {children}
<div contentEditable={false} className="relative"> <div contentEditable={false} className="relative">
<img src={element.url} className="m-0 h-auto w-full rounded-md" /> <img
<button alt={element.url}
onClick={() => Transforms.removeNodes(editor, { at: path })} src={element.url}
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" className="m-0 h-auto w-full rounded-md"
> />
<TrashIcon width={14} height={14} className="text-zinc-100" /> <button
</button> type="button"
</div> onClick={() => Transforms.removeNodes(editor, { at: path })}
</figure> 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 }) { 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 editor = useMemo(
const [content, setContent] = useState<Node[]>([ () => withReact(withImages(withHistory(createEditor()))),
{ [],
children: [ );
{ const [content, setContent] = useState<Node[]>([
text: '', {
}, children: [
], {
}, text: "",
]); },
],
},
]);
const serialize = useCallback((nodes: Node[]) => { const serialize = useCallback((nodes: Node[]) => {
return nodes.map((n) => Node.string(n)).join('\n'); return nodes.map((n) => Node.string(n)).join("\n");
}, []); }, []);
const submit = () => { const submit = () => {
// serialize content // serialize content
const serializedContent = serialize(content); const serializedContent = serialize(content);
console.log(serializedContent); console.log(serializedContent);
const event: any = { const event: any = {
content: serializedContent, content: serializedContent,
created_at: dateToUnix(), created_at: dateToUnix(),
kind: 1, kind: 1,
pubkey: pubkey, pubkey: pubkey,
tags: [], tags: [],
}; };
event.id = getEventHash(event); event.id = getEventHash(event);
event.sig = signEvent(event, privkey); event.sig = signEvent(event, privkey);
// publish note // publish note
pool.publish(event, WRITEONLY_RELAYS); pool.publish(event, WRITEONLY_RELAYS);
}; };
const renderElement = useCallback((props: any) => { const renderElement = useCallback((props: any) => {
switch (props.element.type) { switch (props.element.type) {
case 'image': case "image":
if (props.element.url) { if (props.element.url) {
return <ImagePreview {...props} />; return <ImagePreview {...props} />;
} }
default: default:
return <p {...props.attributes}>{props.children}</p>; return <p {...props.attributes}>{props.children}</p>;
} }
}, []); }, []);
return ( return (
<Slate editor={editor} value={content} onChange={setContent}> <Slate editor={editor} value={content} onChange={setContent}>
<div className="flex h-full flex-col px-4 pb-4"> <div className="flex h-full flex-col px-4 pb-4">
<div className="flex h-full w-full gap-2"> <div className="flex h-full w-full gap-2">
<div className="flex w-8 shrink-0 items-center justify-center"> <div className="flex w-8 shrink-0 items-center justify-center">
<div className="h-full w-[2px] bg-zinc-800"></div> <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"> <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 <Editable
autoFocus autoFocus
placeholder="What's on your mind?" placeholder="What's on your mind?"
spellCheck="false" spellCheck="false"
className="!min-h-[86px]" className="!min-h-[86px]"
renderElement={renderElement} renderElement={renderElement}
/> />
</div> </div>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<ImageUploader /> <ImageUploader />
<button <button
type="button" type="button"
autoFocus={false} onClick={submit}
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"
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
Post </button>
</button> </div>
</div> </div>
</div> </Slate>
</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 }) { export function User({ data }: { data: any }) {
const metadata = JSON.parse(data.metadata); const metadata = JSON.parse(data.metadata);
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-8 w-8 shrink-0 overflow-hidden rounded bg-zinc-900"> <div className="h-8 w-8 shrink-0 overflow-hidden rounded bg-zinc-900">
<Image <Image
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${metadata?.picture ? metadata.picture : DEFAULT_AVATAR}`} src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${
alt={data.pubkey} metadata?.picture ? metadata.picture : DEFAULT_AVATAR
className="h-8 w-8 object-cover" }`}
loading="auto" alt={data.pubkey}
/> className="h-8 w-8 object-cover"
</div> loading="auto"
<h5 className="text-sm font-semibold leading-none text-zinc-100"> />
{metadata?.display_name || metadata?.name || ( </div>
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700"></div> <h5 className="text-sm font-semibold leading-none text-zinc-100">
)} {metadata?.display_name || metadata?.name || (
</h5> <div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700" />
</div> )}
); </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 { READONLY_RELAYS } from "@stores/constants";
import { hasNewerNoteAtom } from '@stores/note'; import { hasNewerNoteAtom } from "@stores/note";
import { dateToUnix } from '@utils/date'; import { dateToUnix } from "@utils/date";
import { useActiveAccount } from '@utils/hooks/useActiveAccount'; import { useActiveAccount } from "@utils/hooks/useActiveAccount";
import { createChat, createNote, updateAccount } from '@utils/storage'; import { createChat, createNote, updateAccount } from "@utils/storage";
import { getParentID, nip02ToArray } from '@utils/transform'; import { getParentID, nip02ToArray } from "@utils/transform";
import { useSetAtom } from 'jotai'; import { useSetAtom } from "jotai";
import { useContext, useRef } from 'react'; import { useContext, useRef } from "react";
import useSWRSubscription from 'swr/subscription'; import useSWRSubscription from "swr/subscription";
export default function EventCollector() { export default function EventCollector() {
const pool: any = useContext(RelayContext); const pool: any = useContext(RelayContext);
const setHasNewerNote = useSetAtom(hasNewerNoteAtom); const setHasNewerNote = useSetAtom(hasNewerNoteAtom);
const now = useRef(new Date()); const now = useRef(new Date());
const { account, isLoading, isError } = useActiveAccount(); const { account, isLoading, isError } = useActiveAccount();
useSWRSubscription(!isLoading && !isError && account ? ['eventCollector', account] : null, ([, key], {}) => { useSWRSubscription(
const follows = JSON.parse(key.follows); !isLoading && !isError && account ? ["eventCollector", account] : null,
const followsAsArray = nip02ToArray(follows); ([, key]) => {
const unsubscribe = pool.subscribe( const follows = JSON.parse(key.follows);
[ const followsAsArray = nip02ToArray(follows);
{ const unsubscribe = pool.subscribe(
kinds: [1, 6], [
authors: followsAsArray, {
since: dateToUnix(now.current), kinds: [1, 6],
}, authors: followsAsArray,
{ since: dateToUnix(now.current),
kinds: [0, 3], },
authors: [key.pubkey], {
}, kinds: [0, 3],
{ authors: [key.pubkey],
kinds: [4], },
'#p': [key.pubkey], {
since: dateToUnix(now.current), kinds: [4],
}, "#p": [key.pubkey],
{ since: dateToUnix(now.current),
kinds: [30023], },
since: dateToUnix(now.current), {
}, kinds: [30023],
], since: dateToUnix(now.current),
READONLY_RELAYS, },
(event: any) => { ],
switch (event.kind) { READONLY_RELAYS,
// metadata (event: any) => {
case 0: switch (event.kind) {
updateAccount('metadata', event.content, event.pubkey); // metadata
break; case 0:
// short text note updateAccount("metadata", event.content, event.pubkey);
case 1: break;
const parentID = getParentID(event.tags, event.id); // short text note
createNote( case 1: {
event.id, const parentID = getParentID(event.tags, event.id);
account.id, createNote(
event.pubkey, event.id,
event.kind, account.id,
event.tags, event.pubkey,
event.content, event.kind,
event.created_at, event.tags,
parentID event.content,
); event.created_at,
// notify user reload to get newer note parentID,
setHasNewerNote(true); );
break; // notify user reload to get newer note
// contacts setHasNewerNote(true);
case 3: break;
// update account's folllows with NIP-02 tag list }
updateAccount('follows', event.tags, event.pubkey); // contacts
break; case 3:
// chat // update account's folllows with NIP-02 tag list
case 4: updateAccount("follows", event.tags, event.pubkey);
if (event.pubkey !== key.pubkey) { break;
createChat(key.id, event.pubkey, event.created_at); // chat
} case 4:
break; if (event.pubkey !== key.pubkey) {
// repost createChat(key.id, event.pubkey, event.created_at);
case 6: }
createNote( break;
event.id, // repost
key.id, case 6:
event.pubkey, createNote(
event.kind, event.id,
event.tags, key.id,
event.content, event.pubkey,
event.created_at, event.kind,
event.id event.tags,
); event.content,
break; event.created_at,
// long post event.id,
case 30023: );
// insert event to local database break;
createNote(event.id, account.id, event.pubkey, event.kind, event.tags, event.content, event.created_at, ''); // long post
break; case 30023:
default: // insert event to local database
break; createNote(
} event.id,
} account.id,
); event.pubkey,
event.kind,
event.tags,
event.content,
event.created_at,
"",
);
break;
default:
break;
}
},
);
return () => { return () => {
unsubscribe(); unsubscribe();
}; };
}); },
);
return ( 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"> <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} /> <HeartBeatIcon width={14} height={14} />
</div> </div>
); );
} }

View File

@@ -1,98 +1,110 @@
import PlusIcon from '@icons/plus'; import PlusIcon from "@icons/plus";
import { channelContentAtom } from '@stores/channel'; import { channelContentAtom } from "@stores/channel";
import { chatContentAtom } from '@stores/chat'; import { chatContentAtom } from "@stores/chat";
import { noteContentAtom } from '@stores/note'; import { noteContentAtom } from "@stores/note";
import { createBlobFromFile } from '@utils/createBlobFromFile'; import { createBlobFromFile } from "@utils/createBlobFromFile";
import { open } from '@tauri-apps/api/dialog'; import { open } from "@tauri-apps/api/dialog";
import { Body, fetch } from '@tauri-apps/api/http'; import { Body, fetch } from "@tauri-apps/api/http";
import { useSetAtom } from 'jotai'; import { useSetAtom } from "jotai";
import { useState } from 'react'; import { useState } from "react";
export function ImagePicker({ type }: { type: string }) { export function ImagePicker({ type }: { type: string }) {
let atom; let atom;
switch (type) { switch (type) {
case 'note': case "note":
atom = noteContentAtom; atom = noteContentAtom;
break; break;
case 'chat': case "chat":
atom = chatContentAtom; atom = chatContentAtom;
break; break;
case 'channel': case "channel":
atom = channelContentAtom; atom = channelContentAtom;
break; break;
default: default:
throw new Error('Invalid type'); throw new Error("Invalid type");
} }
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const setValue = useSetAtom(atom); const setValue = useSetAtom(atom);
const openFileDialog = async () => { const openFileDialog = async () => {
const selected: any = await open({ const selected: any = await open({
multiple: false, multiple: false,
filters: [ filters: [
{ {
name: 'Image', name: "Image",
extensions: ['png', 'jpeg', 'jpg', 'gif'], extensions: ["png", "jpeg", "jpg", "gif"],
}, },
], ],
}); });
if (Array.isArray(selected)) { if (Array.isArray(selected)) {
// user selected multiple files // user selected multiple files
} else if (selected === null) { } else if (selected === null) {
// user cancelled the selection // user cancelled the selection
} else { } else {
setLoading(true); setLoading(true);
const filename = selected.split('/').pop(); const filename = selected.split("/").pop();
const file = await createBlobFromFile(selected); const file = await createBlobFromFile(selected);
const buf = await file.arrayBuffer(); const buf = await file.arrayBuffer();
const res: { data: { file: { id: string } } } = await fetch('https://void.cat/upload?cli=false', { const res: { data: { file: { id: string } } } = await fetch(
method: 'POST', "https://void.cat/upload?cli=false",
timeout: 5, {
headers: { method: "POST",
accept: '*/*', timeout: 5,
'Content-Type': 'application/octet-stream', headers: {
'V-Filename': filename, accept: "*/*",
'V-Description': 'Upload from https://lume.nu', "Content-Type": "application/octet-stream",
'V-Strip-Metadata': 'true', "V-Filename": filename,
}, "V-Description": "Upload from https://lume.nu",
body: Body.bytes(buf), "V-Strip-Metadata": "true",
}); },
const webpImage = 'https://void.cat/d/' + res.data.file.id + '.webp'; body: Body.bytes(buf),
},
);
const webpImage = `https://void.cat/d/${res.data.file.id}.webp`;
setValue((content: string) => content + ' ' + webpImage); setValue((content: string) => `${content} ${webpImage}`);
setLoading(false); setLoading(false);
} }
}; };
return ( return (
<button <button
onClick={() => openFileDialog()} type="button"
className="inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-700" onClick={() => openFileDialog()}
> className="inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-700"
{loading ? ( >
<svg {loading ? (
className="h-4 w-4 animate-spin text-black dark:text-white" <svg
xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 animate-spin text-black dark:text-white"
fill="none" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" fill="none"
> viewBox="0 0 24 24"
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> >
<path <title id="loading">Loading</title>
className="opacity-75" <circle
fill="currentColor" className="opacity-25"
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" cx="12"
></path> cy="12"
</svg> r="10"
) : ( stroke="currentColor"
<PlusIcon width={16} height={16} className="text-zinc-400" /> strokeWidth="4"
)} />
</button> <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>) { export default function ArrowLeftIcon(
return ( props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}> ) {
<path return (
d="M10 18.25L3.75 12M3.75 12L10 5.75M3.75 12H20.25" <svg
stroke="currentColor" viewBox="0 0 24 24"
strokeWidth={1.5} fill="none"
strokeLinecap="round" xmlns="http://www.w3.org/2000/svg"
strokeLinejoin="round" {...props}
/> >
</svg> <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>) { export default function ArrowRightIcon(
return ( props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}> ) {
<path return (
d="M14 5.75L20.25 12M20.25 12L14 18.25M20.25 12H3.75" <svg
stroke="currentColor" viewBox="0 0 24 24"
strokeWidth={1.5} fill="none"
strokeLinecap="round" xmlns="http://www.w3.org/2000/svg"
strokeLinejoin="round" {...props}
/> >
</svg> <path
); d="M14 5.75L20.25 12M20.25 12L14 18.25M20.25 12H3.75"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
} }

Some files were not shown because too many files have changed in this diff Show More