rome -> eslint + prettier

This commit is contained in:
Ren Amamiya
2023-07-04 13:24:42 +07:00
parent 744fbd5683
commit a30cf66c2e
187 changed files with 10179 additions and 10066 deletions

View File

@@ -1,44 +1,43 @@
import { Image } from "@shared/image";
import { DEFAULT_AVATAR } from "@stores/constants";
import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey";
import { Image } from '@shared/image';
export function User({
pubkey,
fallback,
}: { pubkey: string; fallback?: string }) {
const { status, user } = useProfile(pubkey, fallback);
import { DEFAULT_AVATAR } from '@stores/constants';
if (status === "loading") {
return (
<div className="flex items-center gap-2">
<div className="relative h-10 w-10 shrink-0 rounded-md bg-zinc-800 animate-pulse" />
<div className="flex w-full flex-1 flex-col items-start gap-1 text-start">
<span className="w-1/2 h-4 rounded bg-zinc-800 animate-pulse" />
<span className="w-1/3 h-3 rounded bg-zinc-800 animate-pulse" />
</div>
</div>
);
}
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
return (
<div className="flex items-center gap-2">
<div className="relative h-10 w-10 shrink rounded-md">
<Image
src={user.picture || user.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className="h-10 w-10 rounded-md object-cover"
/>
</div>
<div className="flex w-full flex-1 flex-col items-start text-start">
<span className="truncate font-medium leading-tight text-zinc-100">
{user.name || user.displayName || user.display_name}
</span>
<span className="text-base leading-tight text-zinc-400">
{user.nip05?.toLowerCase() || shortenKey(pubkey)}
</span>
</div>
</div>
);
export function User({ pubkey, fallback }: { pubkey: string; fallback?: string }) {
const { status, user } = useProfile(pubkey, fallback);
if (status === 'loading') {
return (
<div className="flex items-center gap-2">
<div className="relative h-10 w-10 shrink-0 animate-pulse rounded-md bg-zinc-800" />
<div className="flex w-full flex-1 flex-col items-start gap-1 text-start">
<span className="h-4 w-1/2 animate-pulse rounded bg-zinc-800" />
<span className="h-3 w-1/3 animate-pulse rounded bg-zinc-800" />
</div>
</div>
);
}
return (
<div className="flex items-center gap-2">
<div className="relative h-10 w-10 shrink rounded-md">
<Image
src={user.picture || user.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className="h-10 w-10 rounded-md object-cover"
/>
</div>
<div className="flex w-full flex-1 flex-col items-start text-start">
<span className="truncate font-medium leading-tight text-zinc-100">
{user.name || user.displayName || user.display_name}
</span>
<span className="text-base leading-tight text-zinc-400">
{user.nip05?.toLowerCase() || shortenKey(pubkey)}
</span>
</div>
</div>
);
}

View File

@@ -1,9 +1,9 @@
import { Outlet } from "react-router-dom";
import { Outlet } from 'react-router-dom';
export function AuthCreateScreen() {
return (
<div className="flex h-full w-full items-center justify-center">
<Outlet />
</div>
);
return (
<div className="flex h-full w-full items-center justify-center">
<Outlet />
</div>
);
}

View File

@@ -1,114 +1,118 @@
import { createAccount, createBlock } from "@libs/storage";
import { Button } from "@shared/button";
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from "@shared/icons";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { generatePrivateKey, getPublicKey, nip19 } from "nostr-tools";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools';
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { createAccount } from '@libs/storage';
import { Button } from '@shared/button';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
export function CreateStep1Screen() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [type, setType] = useState("password");
const [loading, setLoading] = useState(false);
const [type, setType] = useState('password');
const [loading, setLoading] = useState(false);
const privkey = useMemo(() => generatePrivateKey(), []);
const pubkey = getPublicKey(privkey);
const npub = nip19.npubEncode(pubkey);
const nsec = nip19.nsecEncode(privkey);
const privkey = useMemo(() => generatePrivateKey(), []);
const pubkey = getPublicKey(privkey);
const npub = nip19.npubEncode(pubkey);
const nsec = nip19.nsecEncode(privkey);
// toggle private key
const showPrivateKey = () => {
if (type === "password") {
setType("text");
} else {
setType("password");
}
};
// toggle private key
const showPrivateKey = () => {
if (type === 'password') {
setType('text');
} else {
setType('password');
}
};
const account = useMutation({
mutationFn: (data: any) => {
return createAccount(data.npub, data.pubkey, data.privkey, null, 1);
},
onSuccess: (data: any) => {
queryClient.setQueryData(["currentAccount"], data);
},
});
const account = useMutation({
mutationFn: (data: {
npub: string;
pubkey: string;
privkey: string;
follows: null | string[][];
is_active: number;
}) => {
return createAccount(data.npub, data.pubkey, data.privkey, null, 1);
},
onSuccess: (data) => {
queryClient.setQueryData(['currentAccount'], data);
},
});
const submit = () => {
setLoading(true);
const submit = () => {
setLoading(true);
account.mutate({
npub,
pubkey,
privkey,
follows: null,
is_active: 1,
});
account.mutate({
npub,
pubkey,
privkey,
follows: null,
is_active: 1,
});
// redirect to next step
setTimeout(() => navigate("/auth/create/step-2", { replace: true }), 1200);
};
// redirect to next step
setTimeout(() => navigate('/auth/create/step-2', { replace: true }), 1200);
};
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100">
Lume is auto-generated key for you
</h1>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label className="text-base font-semibold text-zinc-400">
Public Key
</label>
<input
readOnly
value={npub}
className="relative w-full rounded-lg py-3 pl-3.5 pr-11 !outline-none placeholder:text-zinc-400 bg-zinc-800 text-zinc-100"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-base font-semibold text-zinc-400">
Private Key
</label>
<div className="relative">
<input
readOnly
type={type}
value={nsec}
className="relative w-full rounded-lg py-3 pl-3.5 pr-11 !outline-none placeholder:text-zinc-400 bg-zinc-800 text-zinc-100"
/>
<button
type="button"
onClick={() => showPrivateKey()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
>
{type === "password" ? (
<EyeOffIcon
width={20}
height={20}
className="text-zinc-500 group-hover:text-zinc-100"
/>
) : (
<EyeOnIcon
width={20}
height={20}
className="text-zinc-500 group-hover:text-zinc-100"
/>
)}
</button>
</div>
</div>
<Button preset="large" onClick={() => submit()}>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
"Continue →"
)}
</Button>
</div>
</div>
);
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100">
Lume is auto-generated key for you
</h1>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<span className="text-base font-semibold text-zinc-400">Public Key</span>
<input
readOnly
value={npub}
className="relative w-full rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-zinc-100 !outline-none placeholder:text-zinc-400"
/>
</div>
<div className="flex flex-col gap-1">
<span className="text-base font-semibold text-zinc-400">Private Key</span>
<div className="relative">
<input
readOnly
type={type}
value={nsec}
className="relative w-full rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-zinc-100 !outline-none placeholder:text-zinc-400"
/>
<button
type="button"
onClick={() => showPrivateKey()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
>
{type === 'password' ? (
<EyeOffIcon
width={20}
height={20}
className="text-zinc-500 group-hover:text-zinc-100"
/>
) : (
<EyeOnIcon
width={20}
height={20}
className="text-zinc-500 group-hover:text-zinc-100"
/>
)}
</button>
</div>
</div>
<Button preset="large" onClick={() => submit()}>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
'Continue →'
)}
</Button>
</div>
</div>
);
}

View File

@@ -1,146 +1,152 @@
import { AvatarUploader } from "@shared/avatarUploader";
import { BannerUploader } from "@shared/bannerUploader";
import { LoaderIcon } from "@shared/icons";
import { Image } from "@shared/image";
import { DEFAULT_AVATAR } from "@stores/constants";
import { useOnboarding } from "@stores/onboarding";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { AvatarUploader } from '@shared/avatarUploader';
import { BannerUploader } from '@shared/bannerUploader';
import { LoaderIcon } from '@shared/icons';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useOnboarding } from '@stores/onboarding';
export function CreateStep2Screen() {
const navigate = useNavigate();
const createProfile = useOnboarding((state: any) => state.createProfile);
const navigate = useNavigate();
const createProfile = useOnboarding((state: any) => state.createProfile);
const [picture, setPicture] = useState(DEFAULT_AVATAR);
const [banner, setBanner] = useState("");
const [loading, setLoading] = useState(false);
const [picture, setPicture] = useState(DEFAULT_AVATAR);
const [banner, setBanner] = useState('');
const [loading, setLoading] = useState(false);
const {
register,
handleSubmit,
formState: { isDirty, isValid },
} = useForm();
const {
register,
handleSubmit,
formState: { isDirty, isValid },
} = useForm();
const onSubmit = (data: any) => {
setLoading(true);
try {
const profile = {
...data,
username: data.name,
display_name: data.name,
bio: data.about,
};
createProfile(profile);
// redirect to next step
setTimeout(
() => navigate("/auth/create/step-3", { replace: true }),
1200,
);
} catch {
console.log("error");
}
};
const onSubmit = (data: any) => {
setLoading(true);
try {
const profile = {
...data,
username: data.name,
display_name: data.name,
bio: data.about,
};
createProfile(profile);
// redirect to next step
setTimeout(() => navigate('/auth/create/step-3', { replace: true }), 1200);
} catch {
console.log('error');
}
};
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100">
Create your profile
</h1>
</div>
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900 overflow-hidden">
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col mb-0">
<input
type={"hidden"}
{...register("picture")}
value={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-100 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
<input
type={"hidden"}
{...register("banner")}
value={banner}
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-100 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
<div className="relative">
<div className="relative w-full h-44 bg-zinc-800">
<Image
src={banner}
fallback="https://void.cat/d/QY1myro5tkHVs2nY7dy74b.jpg"
alt="user's banner"
className="h-full w-full object-cover"
/>
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-10 w-full h-full">
<BannerUploader setBanner={setBanner} />
</div>
</div>
<div className="px-4 mb-5">
<div className="z-10 relative h-14 w-14 -mt-7">
<Image
src={picture}
fallback={DEFAULT_AVATAR}
alt="user's avatar"
className="h-14 w-14 object-cover ring-2 ring-zinc-900 rounded-lg"
/>
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-10 w-full h-full">
<AvatarUploader setPicture={setPicture} />
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-4 px-4 pb-4">
<div className="flex flex-col gap-1">
<label className="text-sm font-semibold uppercase tracking-wider text-zinc-400">
Name *
</label>
<input
type={"text"}
{...register("name", {
required: true,
minLength: 4,
})}
spellCheck={false}
className="relative h-10 w-full rounded-lg px-3 py-2 !outline-none bg-zinc-800 text-zinc-100 placeholder:text-zinc-500"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-semibold uppercase tracking-wider text-zinc-400">
Bio
</label>
<textarea
{...register("about")}
spellCheck={false}
className="resize-none relative h-20 w-full rounded-lg px-3 py-2 !outline-none bg-zinc-800 text-zinc-100 placeholder:text-zinc-500"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-semibold uppercase tracking-wider text-zinc-400">
Website
</label>
<input
type={"text"}
{...register("website", {
required: false,
})}
spellCheck={false}
className="relative h-10 w-full rounded-lg px-3 py-2 !outline-none bg-zinc-800 text-zinc-100 placeholder:text-zinc-500"
/>
</div>
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex items-center justify-center h-11 w-full bg-fuchsia-500 rounded-md font-medium text-zinc-100 hover:bg-fuchsia-600"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
"Continue →"
)}
</button>
</div>
</form>
</div>
</div>
);
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100">Create your profile</h1>
</div>
<div className="w-full overflow-hidden rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col">
<input
type={'hidden'}
{...register('picture')}
value={picture}
className="shadow-input relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
<input
type={'hidden'}
{...register('banner')}
value={banner}
className="shadow-input relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
<div className="relative">
<div className="relative h-44 w-full bg-zinc-800">
<Image
src={banner}
fallback="https://void.cat/d/QY1myro5tkHVs2nY7dy74b.jpg"
alt="user's banner"
className="h-full w-full object-cover"
/>
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
<BannerUploader setBanner={setBanner} />
</div>
</div>
<div className="mb-5 px-4">
<div className="relative z-10 -mt-7 h-14 w-14">
<Image
src={picture}
fallback={DEFAULT_AVATAR}
alt="user's avatar"
className="h-14 w-14 rounded-lg object-cover ring-2 ring-zinc-900"
/>
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
<AvatarUploader setPicture={setPicture} />
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-4 px-4 pb-4">
<div className="flex flex-col gap-1">
<label
htmlFor="name"
className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
>
Name *
</label>
<input
type={'text'}
{...register('name', {
required: true,
minLength: 4,
})}
spellCheck={false}
className="relative h-10 w-full rounded-lg bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="about"
className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
>
Bio
</label>
<textarea
{...register('about')}
spellCheck={false}
className="relative h-20 w-full resize-none rounded-lg bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="website"
className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
>
Website
</label>
<input
type={'text'}
{...register('website', {
required: false,
})}
spellCheck={false}
className="relative h-10 w-full rounded-lg bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
/>
</div>
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full items-center justify-center rounded-md bg-fuchsia-500 font-medium text-zinc-100 hover:bg-fuchsia-600"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
'Continue →'
)}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -1,99 +1,98 @@
import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { Button } from "@shared/button";
import { LoaderIcon } from "@shared/icons";
import { RelayContext } from "@shared/relayProvider";
import { useOnboarding } from "@stores/onboarding";
import { Body, fetch } from "@tauri-apps/api/http";
import { useAccount } from "@utils/hooks/useAccount";
import { useContext, useState } from "react";
import { useNavigate } from "react-router-dom";
import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { Body, fetch } from '@tauri-apps/api/http';
import { useContext, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@shared/button';
import { LoaderIcon } from '@shared/icons';
import { RelayContext } from '@shared/relayProvider';
import { useOnboarding } from '@stores/onboarding';
import { useAccount } from '@utils/hooks/useAccount';
export function CreateStep3Screen() {
const ndk = useContext(RelayContext);
const profile = useOnboarding((state: any) => state.profile);
const navigate = useNavigate();
const ndk = useContext(RelayContext);
const profile = useOnboarding((state: any) => state.profile);
const navigate = useNavigate();
const { account } = useAccount();
const { account } = useAccount();
const [username, setUsername] = useState("");
const [loading, setLoading] = useState(false);
const [username, setUsername] = useState('');
const [loading, setLoading] = useState(false);
const createNIP05 = async () => {
try {
setLoading(true);
const createNIP05 = async () => {
try {
setLoading(true);
const response = await fetch("https://lume.nu/api/user-create", {
method: "POST",
timeout: 30,
headers: {
"Content-Type": "application/json; charset=utf-8",
},
body: Body.json({
username: username,
pubkey: account.pubkey,
lightningAddress: "",
}),
});
const response = await fetch('https://lume.nu/api/user-create', {
method: 'POST',
timeout: 30,
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
body: Body.json({
username: username,
pubkey: account.pubkey,
lightningAddress: '',
}),
});
if (response.ok) {
const data = { ...profile, nip05: `${username}@lume.nu` };
if (response.ok) {
const data = { ...profile, nip05: `${username}@lume.nu` };
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const event = new NDKEvent(ndk);
// build event
event.content = JSON.stringify(data);
event.kind = 0;
event.pubkey = account.pubkey;
event.tags = [];
// publish event
event.publish();
const event = new NDKEvent(ndk);
// build event
event.content = JSON.stringify(data);
event.kind = 0;
event.pubkey = account.pubkey;
event.tags = [];
// publish event
event.publish();
// redirect to step 4
navigate("/auth/create/step-4", { replace: true });
}
} catch (error) {
setLoading(false);
console.error("Error:", error);
}
};
// redirect to step 4
navigate('/auth/create/step-4', { replace: true });
}
} catch (error) {
setLoading(false);
console.error('Error:', error);
}
};
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100">
Create your Lume ID
</h1>
</div>
<div className="w-full flex flex-col justify-center items-center gap-4">
<div className="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-zinc-800">
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoCapitalize="false"
autoCorrect="none"
spellCheck="false"
placeholder="satoshi"
className="relative w-full py-3 pl-3.5 !outline-none placeholder:text-zinc-500 bg-transparent text-zinc-100"
/>
<span className="text-fuchsia-500 font-semibold pr-3.5">
@lume.nu
</span>
</div>
<Button
preset="large"
onClick={() => createNIP05()}
disabled={username.length === 0}
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
"Continue →"
)}
</Button>
</div>
</div>
);
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100">Create your Lume ID</h1>
</div>
<div className="flex w-full flex-col items-center justify-center gap-4">
<div className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-zinc-800">
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoCapitalize="false"
autoCorrect="none"
spellCheck="false"
placeholder="satoshi"
className="relative w-full bg-transparent py-3 pl-3.5 text-zinc-100 !outline-none placeholder:text-zinc-500"
/>
<span className="pr-3.5 font-semibold text-fuchsia-500">@lume.nu</span>
</div>
<Button
preset="large"
onClick={() => createNIP05()}
disabled={username.length === 0}
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
'Continue →'
)}
</Button>
</div>
</div>
);
}

View File

@@ -1,236 +1,235 @@
import { User } from "@app/auth/components/user";
import { updateAccount } from "@libs/storage";
import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { CheckCircleIcon, LoaderIcon } from "@shared/icons";
import { RelayContext } from "@shared/relayProvider";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useAccount } from "@utils/hooks/useAccount";
import { arrayToNIP02 } from "@utils/transform";
import { useContext, useState } from "react";
import { useNavigate } from "react-router-dom";
import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useContext, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { updateAccount } from '@libs/storage';
import { CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { RelayContext } from '@shared/relayProvider';
import { useAccount } from '@utils/hooks/useAccount';
import { arrayToNIP02 } from '@utils/transform';
const INITIAL_LIST = [
{
pubkey: "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2",
},
{
pubkey: "a341f45ff9758f570a21b000c17d4e53a3a497c8397f26c0e6d61e5acffc7a98",
},
{
pubkey: "04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9",
},
{
pubkey: "c4eabae1be3cf657bc1855ee05e69de9f059cb7a059227168b80b89761cbc4e0",
},
{
pubkey: "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93",
},
{
pubkey: "e88a691e98d9987c964521dff60025f60700378a4879180dcbbb4a5027850411",
},
{
pubkey: "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
},
{
pubkey: "c49d52a573366792b9a6e4851587c28042fb24fa5625c6d67b8c95c8751aca15",
},
{
pubkey: "e33fe65f1fde44c6dc17eeb38fdad0fceaf1cae8722084332ed1e32496291d42",
},
{
pubkey: "84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240",
},
{
pubkey: "703e26b4f8bc0fa57f99d815dbb75b086012acc24fc557befa310f5aa08d1898",
},
{
pubkey: "bf2376e17ba4ec269d10fcc996a4746b451152be9031fa48e74553dde5526bce",
},
{
pubkey: "4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0",
},
{
pubkey: "c9b19ffcd43e6a5f23b3d27106ce19e4ad2df89ba1031dd4617f1b591e108965",
},
{
pubkey: "c7dccba4fe4426a7b1ea239a5637ba40fab9862c8c86b3330fe65e9f667435f6",
},
{
pubkey: "6e1534f56fc9e937e06237c8ba4b5662bcacc4e1a3cfab9c16d89390bec4fca3",
},
{
pubkey: "50d94fc2d8580c682b071a542f8b1e31a200b0508bab95a33bef0855df281d63",
},
{
pubkey: "3d2e51508699f98f0f2bdbe7a45b673c687fe6420f466dc296d90b908d51d594",
},
{
pubkey: "6e3f51664e19e082df5217fd4492bb96907405a0b27028671dd7f297b688608c",
},
{
pubkey: "2edbcea694d164629854a52583458fd6d965b161e3c48b57d3aff01940558884",
},
{
pubkey: "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24",
},
{
pubkey: "eab0e756d32b80bcd464f3d844b8040303075a13eabc3599a762c9ac7ab91f4f",
},
{
pubkey: "be1d89794bf92de5dd64c1e60f6a2c70c140abac9932418fee30c5c637fe9479",
},
{
pubkey: "a5e93aef8e820cbc7ab7b6205f854b87aed4b48c5f6b30fbbeba5c99e40dcf3f",
},
{
pubkey: "1989034e56b8f606c724f45a12ce84a11841621aaf7182a1f6564380b9c4276b",
},
{
pubkey: "c48b5cced5ada74db078df6b00fa53fc1139d73bf0ed16de325d52220211dbd5",
},
{
pubkey: "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
},
{
pubkey: "7f3b464b9ff3623630485060cbda3a7790131c5339a7803bde8feb79a5e1b06a",
},
{
pubkey: "b99dbca0184a32ce55904cb267b22e434823c97f418f36daf5d2dff0dd7b5c27",
},
{
pubkey: "e9e4276490374a0daf7759fd5f475deff6ffb9b0fc5fa98c902b5f4b2fe3bba2",
},
{
pubkey: "ea2e3c814d08a378f8a5b8faecb2884d05855975c5ca4b5c25e2d6f936286f14",
},
{
pubkey: "ff04a0e6cd80c141b0b55825fed127d4532a6eecdb7e743a38a3c28bf9f44609",
},
{
pubkey: '82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2',
},
{
pubkey: 'a341f45ff9758f570a21b000c17d4e53a3a497c8397f26c0e6d61e5acffc7a98',
},
{
pubkey: '04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9',
},
{
pubkey: 'c4eabae1be3cf657bc1855ee05e69de9f059cb7a059227168b80b89761cbc4e0',
},
{
pubkey: '6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93',
},
{
pubkey: 'e88a691e98d9987c964521dff60025f60700378a4879180dcbbb4a5027850411',
},
{
pubkey: '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d',
},
{
pubkey: 'c49d52a573366792b9a6e4851587c28042fb24fa5625c6d67b8c95c8751aca15',
},
{
pubkey: 'e33fe65f1fde44c6dc17eeb38fdad0fceaf1cae8722084332ed1e32496291d42',
},
{
pubkey: '84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240',
},
{
pubkey: '703e26b4f8bc0fa57f99d815dbb75b086012acc24fc557befa310f5aa08d1898',
},
{
pubkey: 'bf2376e17ba4ec269d10fcc996a4746b451152be9031fa48e74553dde5526bce',
},
{
pubkey: '4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0',
},
{
pubkey: 'c9b19ffcd43e6a5f23b3d27106ce19e4ad2df89ba1031dd4617f1b591e108965',
},
{
pubkey: 'c7dccba4fe4426a7b1ea239a5637ba40fab9862c8c86b3330fe65e9f667435f6',
},
{
pubkey: '6e1534f56fc9e937e06237c8ba4b5662bcacc4e1a3cfab9c16d89390bec4fca3',
},
{
pubkey: '50d94fc2d8580c682b071a542f8b1e31a200b0508bab95a33bef0855df281d63',
},
{
pubkey: '3d2e51508699f98f0f2bdbe7a45b673c687fe6420f466dc296d90b908d51d594',
},
{
pubkey: '6e3f51664e19e082df5217fd4492bb96907405a0b27028671dd7f297b688608c',
},
{
pubkey: '2edbcea694d164629854a52583458fd6d965b161e3c48b57d3aff01940558884',
},
{
pubkey: '3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24',
},
{
pubkey: 'eab0e756d32b80bcd464f3d844b8040303075a13eabc3599a762c9ac7ab91f4f',
},
{
pubkey: 'be1d89794bf92de5dd64c1e60f6a2c70c140abac9932418fee30c5c637fe9479',
},
{
pubkey: 'a5e93aef8e820cbc7ab7b6205f854b87aed4b48c5f6b30fbbeba5c99e40dcf3f',
},
{
pubkey: '1989034e56b8f606c724f45a12ce84a11841621aaf7182a1f6564380b9c4276b',
},
{
pubkey: 'c48b5cced5ada74db078df6b00fa53fc1139d73bf0ed16de325d52220211dbd5',
},
{
pubkey: '460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c',
},
{
pubkey: '7f3b464b9ff3623630485060cbda3a7790131c5339a7803bde8feb79a5e1b06a',
},
{
pubkey: 'b99dbca0184a32ce55904cb267b22e434823c97f418f36daf5d2dff0dd7b5c27',
},
{
pubkey: 'e9e4276490374a0daf7759fd5f475deff6ffb9b0fc5fa98c902b5f4b2fe3bba2',
},
{
pubkey: 'ea2e3c814d08a378f8a5b8faecb2884d05855975c5ca4b5c25e2d6f936286f14',
},
{
pubkey: 'ff04a0e6cd80c141b0b55825fed127d4532a6eecdb7e743a38a3c28bf9f44609',
},
];
export function CreateStep4Screen() {
const ndk = useContext(RelayContext);
const queryClient = useQueryClient();
const navigate = useNavigate();
const ndk = useContext(RelayContext);
const queryClient = useQueryClient();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [follows, setFollows] = useState([]);
const [loading, setLoading] = useState(false);
const [follows, setFollows] = useState([]);
const { account } = useAccount();
const { status, data } = useQuery(["trending-profiles"], async () => {
const res = await fetch("https://api.nostr.band/v0/trending/profiles");
if (!res.ok) {
throw new Error("Error");
}
return res.json();
});
const { account } = useAccount();
const { status, data } = useQuery(['trending-profiles'], async () => {
const res = await fetch('https://api.nostr.band/v0/trending/profiles');
if (!res.ok) {
throw new Error('Error');
}
return res.json();
});
// toggle follow state
const toggleFollow = (pubkey: string) => {
const arr = follows.includes(pubkey)
? follows.filter((i) => i !== pubkey)
: [...follows, pubkey];
setFollows(arr);
};
// toggle follow state
const toggleFollow = (pubkey: string) => {
const arr = follows.includes(pubkey)
? follows.filter((i) => i !== pubkey)
: [...follows, pubkey];
setFollows(arr);
};
const update = useMutation({
mutationFn: (follows: any) => {
return updateAccount("follows", follows, account.pubkey);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["currentAccount"] });
},
});
const update = useMutation({
mutationFn: (follows: any) => {
return updateAccount('follows', follows, account.pubkey);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currentAccount'] });
},
});
// save follows to database then broadcast
const submit = async () => {
try {
setLoading(true);
// save follows to database then broadcast
const submit = async () => {
try {
setLoading(true);
const tags = arrayToNIP02([...follows, account.pubkey]);
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const tags = arrayToNIP02([...follows, account.pubkey]);
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const event = new NDKEvent(ndk);
// build event
event.content = "";
event.kind = 3;
event.pubkey = account.pubkey;
event.tags = tags;
// publish event
event.publish();
const event = new NDKEvent(ndk);
// build event
event.content = '';
event.kind = 3;
event.pubkey = account.pubkey;
event.tags = tags;
// publish event
event.publish();
// update
update.mutate([...follows, account.pubkey]);
// update
update.mutate([...follows, account.pubkey]);
// redirect to next step
setTimeout(() => navigate("/auth/onboarding", { replace: true }), 1200);
} catch {
console.log("error");
}
};
// redirect to next step
setTimeout(() => navigate('/auth/onboarding', { replace: true }), 1200);
} catch {
console.log('error');
}
};
const list = data ? data.profiles.concat(INITIAL_LIST) : [];
const list = data ? data.profiles.concat(INITIAL_LIST) : [];
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100">
Personalized your newsfeed
</h1>
</div>
<div className="flex flex-col gap-4">
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900 overflow-hidden">
<div className="inline-flex h-10 w-full items-center gap-1 border-b border-zinc-800 px-4 text-base font-medium text-zinc-400">
Follow at least
<span className="text-fuchsia-500 font-semibold">
{follows.length}/10
</span>{" "}
plebs
</div>
{status === "loading" ? (
<div className="py-2 px-4 w-full h-11 inline-flex items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
</div>
) : (
<div className="scrollbar-hide flex h-96 flex-col overflow-y-auto py-2">
{list.map(
(item: { pubkey: string; profile: { content: string } }) => (
<button
key={item.pubkey}
type="button"
onClick={() => toggleFollow(item.pubkey)}
className="inline-flex transform items-center justify-between bg-zinc-900 px-4 py-2 hover:bg-zinc-800 active:translate-y-1"
>
<User
pubkey={item.pubkey}
fallback={item.profile?.content}
/>
{follows.includes(item.pubkey) && (
<div>
<CheckCircleIcon className="w-4 h-4 text-green-400" />
</div>
)}
</button>
),
)}
</div>
)}
</div>
{follows.length >= 10 && (
<button
type="button"
onClick={() => submit()}
className="inline-flex items-center justify-center h-11 w-full bg-fuchsia-500 rounded-md font-medium text-zinc-100 hover:bg-fuchsia-600"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
"Finish →"
)}
</button>
)}
</div>
</div>
);
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100">
Personalized your newsfeed
</h1>
</div>
<div className="flex flex-col gap-4">
<div className="w-full overflow-hidden rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="inline-flex h-10 w-full items-center gap-1 border-b border-zinc-800 px-4 text-base font-medium text-zinc-400">
Follow at least
<span className="font-semibold text-fuchsia-500">
{follows.length}/10
</span>{' '}
plebs
</div>
{status === 'loading' ? (
<div className="inline-flex h-11 w-full items-center justify-center px-4 py-2">
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
</div>
) : (
<div className="scrollbar-hide flex h-96 flex-col overflow-y-auto py-2">
{list.map((item: { pubkey: string; profile: { content: string } }) => (
<button
key={item.pubkey}
type="button"
onClick={() => toggleFollow(item.pubkey)}
className="inline-flex transform items-center justify-between bg-zinc-900 px-4 py-2 hover:bg-zinc-800 active:translate-y-1"
>
<User pubkey={item.pubkey} fallback={item.profile?.content} />
{follows.includes(item.pubkey) && (
<div>
<CheckCircleIcon className="h-4 w-4 text-green-400" />
</div>
)}
</button>
))}
</div>
)}
</div>
{follows.length >= 10 && (
<button
type="button"
onClick={() => submit()}
className="inline-flex h-11 w-full items-center justify-center rounded-md bg-fuchsia-500 font-medium text-zinc-100 hover:bg-fuchsia-600"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
'Finish →'
)}
</button>
)}
</div>
</div>
);
}

View File

@@ -1,9 +1,9 @@
import { Outlet } from "react-router-dom";
import { Outlet } from 'react-router-dom';
export function AuthImportScreen() {
return (
<div className="flex h-full w-full items-center justify-center">
<Outlet />
</div>
);
return (
<div className="flex h-full w-full items-center justify-center">
<Outlet />
</div>
);
}

View File

@@ -1,120 +1,119 @@
import { createAccount, createBlock } from "@libs/storage";
import { LoaderIcon } from "@shared/icons";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { getPublicKey, nip19 } from "nostr-tools";
import { useState } from "react";
import { Resolver, useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { getPublicKey, nip19 } from 'nostr-tools';
import { useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { createAccount } from '@libs/storage';
import { LoaderIcon } from '@shared/icons';
type FormValues = {
key: string;
key: string;
};
const resolver: Resolver<FormValues> = async (values) => {
return {
values: values.key ? values : {},
errors: !values.key
? {
key: {
type: "required",
message: "This is required.",
},
}
: {},
};
return {
values: values.key ? values : {},
errors: !values.key
? {
key: {
type: 'required',
message: 'This is required.',
},
}
: {},
};
};
export function ImportStep1Screen() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(false);
const account = useMutation({
mutationFn: (data: any) => {
return createAccount(data.npub, data.pubkey, data.privkey, null, 1);
},
onSuccess: (data: any) => {
queryClient.setQueryData(["currentAccount"], data);
},
});
const account = useMutation({
mutationFn: (data: any) => {
return createAccount(data.npub, data.pubkey, data.privkey, null, 1);
},
onSuccess: (data: any) => {
queryClient.setQueryData(['currentAccount'], data);
},
});
const {
register,
setError,
handleSubmit,
formState: { errors, isDirty, isValid },
} = useForm<FormValues>({ resolver });
const {
register,
setError,
handleSubmit,
formState: { errors, isDirty, isValid },
} = useForm<FormValues>({ resolver });
const onSubmit = async (data: any) => {
try {
setLoading(true);
const onSubmit = async (data: any) => {
try {
setLoading(true);
let privkey = data["key"];
if (privkey.substring(0, 4) === "nsec") {
privkey = nip19.decode(privkey).data;
}
let privkey = data['key'];
if (privkey.substring(0, 4) === 'nsec') {
privkey = nip19.decode(privkey).data;
}
if (typeof getPublicKey(privkey) === "string") {
const pubkey = getPublicKey(privkey);
const npub = nip19.npubEncode(pubkey);
if (typeof getPublicKey(privkey) === 'string') {
const pubkey = getPublicKey(privkey);
const npub = nip19.npubEncode(pubkey);
// update
account.mutate({
npub,
pubkey,
privkey,
follows: null,
is_active: 1,
});
// update
account.mutate({
npub,
pubkey,
privkey,
follows: null,
is_active: 1,
});
// redirect to step 2
setTimeout(
() => navigate("/auth/import/step-2", { replace: true }),
1200,
);
}
} catch (error) {
setError("key", {
type: "custom",
message: "Private Key is invalid, please check again",
});
}
};
// redirect to step 2
setTimeout(() => navigate('/auth/import/step-2', { replace: true }), 1200);
}
} catch (error) {
setError('key', {
type: 'custom',
message: 'Private Key is invalid, please check again',
});
}
};
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100">Import your key</h1>
</div>
<div className="flex flex-col gap-4">
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3">
<div className="flex flex-col gap-0.5">
<input
{...register("key", { required: true, minLength: 32 })}
type={"password"}
placeholder="Paste private key here..."
className="relative w-full rounded-lg px-3 py-3 !outline-none bg-zinc-800 text-zinc-100 placeholder:text-zinc-500"
/>
<span className="text-base text-red-400">
{errors.key && <p>{errors.key.message}</p>}
</span>
</div>
<div className="flex items-center justify-center">
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex items-center justify-center h-11 w-full bg-fuchsia-500 rounded-md font-medium text-zinc-100 hover:bg-fuchsia-600"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
"Continue →"
)}
</button>
</div>
</form>
</div>
</div>
);
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100">Import your key</h1>
</div>
<div className="flex flex-col gap-4">
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3">
<div className="flex flex-col gap-0.5">
<input
{...register('key', { required: true, minLength: 32 })}
type={'password'}
placeholder="Paste private key here..."
className="relative w-full rounded-lg bg-zinc-800 px-3 py-3 text-zinc-100 !outline-none placeholder:text-zinc-500"
/>
<span className="text-base text-red-400">
{errors.key && <p>{errors.key.message}</p>}
</span>
</div>
<div className="flex items-center justify-center">
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full items-center justify-center rounded-md bg-fuchsia-500 font-medium text-zinc-100 hover:bg-fuchsia-600"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
'Continue →'
)}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -1,83 +1,87 @@
import { User } from "@app/auth/components/user";
import { updateAccount } from "@libs/storage";
import { Button } from "@shared/button";
import { LoaderIcon } from "@shared/icons";
import { RelayContext } from "@shared/relayProvider";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useAccount } from "@utils/hooks/useAccount";
import { setToArray } from "@utils/transform";
import { useContext, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useContext, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { updateAccount } from '@libs/storage';
import { Button } from '@shared/button';
import { LoaderIcon } from '@shared/icons';
import { RelayContext } from '@shared/relayProvider';
import { useAccount } from '@utils/hooks/useAccount';
import { setToArray } from '@utils/transform';
export function ImportStep2Screen() {
const ndk = useContext(RelayContext);
const queryClient = useQueryClient();
const navigate = useNavigate();
const ndk = useContext(RelayContext);
const queryClient = useQueryClient();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const { status, account } = useAccount();
const [loading, setLoading] = useState(false);
const { status, account } = useAccount();
const update = useMutation({
mutationFn: (follows: any) => {
return updateAccount("follows", follows, account.pubkey);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["currentAccount"] });
},
});
const update = useMutation({
mutationFn: (follows: any) => {
return updateAccount('follows', follows, account.pubkey);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currentAccount'] });
},
});
const submit = async () => {
try {
// show loading indicator
setLoading(true);
const submit = async () => {
try {
// show loading indicator
setLoading(true);
const user = ndk.getUser({ hexpubkey: account.pubkey });
const follows = await user.follows();
const user = ndk.getUser({ hexpubkey: account.pubkey });
const follows = await user.follows();
// follows as list
const followsList = setToArray(follows);
// follows as list
const followsList = setToArray(follows);
// update
update.mutate([...followsList, account.pubkey]);
// update
update.mutate([...followsList, account.pubkey]);
// redirect to next step
setTimeout(() => navigate("/auth/onboarding", { replace: true }), 1200);
} catch {
console.log("error");
}
};
// redirect to next step
setTimeout(() => navigate('/auth/onboarding', { replace: true }), 1200);
} catch {
console.log('error');
}
};
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold">
{loading ? "Creating..." : "Continue with"}
</h1>
</div>
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900 p-4">
{status === "loading" ? (
<div className="w-full">
<div className="flex items-center gap-2">
<div className="h-11 w-11 animate-pulse rounded-lg bg-zinc-800" />
<div>
<h3 className="mb-1 h-4 w-16 animate-pulse rounded bg-zinc-800" />
<p className="h-3 w-36 animate-pulse rounded bg-zinc-800" />
</div>
</div>
</div>
) : (
<div className="flex flex-col gap-3">
<User pubkey={account.pubkey} />
<Button preset="large" onClick={() => submit()}>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
"Continue →"
)}
</Button>
</div>
)}
</div>
</div>
);
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold">
{loading ? 'Creating...' : 'Continue with'}
</h1>
</div>
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900 p-4">
{status === 'loading' ? (
<div className="w-full">
<div className="flex items-center gap-2">
<div className="h-11 w-11 animate-pulse rounded-lg bg-zinc-800" />
<div>
<div className="mb-1 h-4 w-16 animate-pulse rounded bg-zinc-800" />
<div className="h-3 w-36 animate-pulse rounded bg-zinc-800" />
</div>
</div>
</div>
) : (
<div className="flex flex-col gap-3">
<User pubkey={account.pubkey} />
<Button preset="large" onClick={() => submit()}>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
'Continue →'
)}
</Button>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,103 +1,103 @@
import { usePublish } from "@libs/ndk";
import { LoaderIcon } from "@shared/icons";
import { ArrowRightCircleIcon } from "@shared/icons/arrowRightCircle";
import { User } from "@shared/user";
import { useAccount } from "@utils/hooks/useAccount";
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { usePublish } from '@libs/ndk';
import { LoaderIcon } from '@shared/icons';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
import { User } from '@shared/user';
import { useAccount } from '@utils/hooks/useAccount';
export function OnboardingScreen() {
const publish = usePublish();
const navigate = useNavigate();
const publish = usePublish();
const navigate = useNavigate();
const { status, account } = useAccount();
const [loading, setLoading] = useState(false);
const { status, account } = useAccount();
const [loading, setLoading] = useState(false);
const submit = async () => {
try {
setLoading(true);
const submit = async () => {
try {
setLoading(true);
// publish event
publish({
content:
"Running Lume, fighting for better future, join us here: https://lume.nu",
kind: 1,
tags: [],
});
// publish event
publish({
content:
'Running Lume, fighting for better future, join us here: https://lume.nu',
kind: 1,
tags: [],
});
// redirect to home
setTimeout(() => navigate("/", { replace: true }), 1200);
} catch (error) {
console.log(error);
}
};
// redirect to home
setTimeout(() => navigate('/', { replace: true }), 1200);
} catch (error) {
console.log(error);
}
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="mb-2 text-xl font-semibold text-zinc-100">
👋 Hello, welcome you to Lume
</h1>
<p className="text-sm text-zinc-300">
You're a part of better future that we're fighting
</p>
<p className="text-sm text-zinc-300">
If Lume gets your attention, please help us spread via button below
</p>
</div>
<div className="w-full border-t border-zinc-800/50 bg-zinc-900 rounded-xl">
<div className="h-min w-full px-5 py-3">
{status === "success" && (
<User
pubkey={account.pubkey}
time={Math.floor(Date.now() / 1000)}
/>
)}
<div className="-mt-6 pl-[49px] select-text whitespace-pre-line break-words text-base text-zinc-100">
<p>Running Lume, fighting for better future</p>
<p>
join us here:{" "}
<a
href="https://lume.nu"
className="text-fuchsia-500 hover:text-fuchsia-600 no-underline font-normal"
target="_blank"
rel="noreferrer"
>
https://lume.nu
</a>
</p>
</div>
</div>
</div>
<div className="mt-4 w-full flex flex-col gap-2">
<button
type="button"
onClick={() => submit()}
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg px-6 font-medium text-zinc-100 bg-fuchsia-500 hover:bg-fuchsia-600"
>
{loading ? (
<>
<span className="w-5" />
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
<span className="w-5" />
</>
) : (
<>
<span className="w-5" />
<span>Publish</span>
<ArrowRightCircleIcon className="w-5 h-5" />
</>
)}
</button>
<Link
to="/"
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-lg px-6 text-sm font-medium text-zinc-200"
>
Skip for now
</Link>
</div>
</div>
</div>
);
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="mb-2 text-xl font-semibold text-zinc-100">
👋 Hello, welcome you to Lume
</h1>
<p className="text-sm text-zinc-300">
You&apos;re a part of better future that we&apos;re fighting
</p>
<p className="text-sm text-zinc-300">
If Lume gets your attention, please help us spread via button below
</p>
</div>
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="h-min w-full px-5 py-3">
{status === 'success' && (
<User pubkey={account.pubkey} time={Math.floor(Date.now() / 1000)} />
)}
<div className="-mt-6 select-text whitespace-pre-line break-words pl-[49px] text-base text-zinc-100">
<p>Running Lume, fighting for better future</p>
<p>
join us here:{' '}
<a
href="https://lume.nu"
className="font-normal text-fuchsia-500 no-underline hover:text-fuchsia-600"
target="_blank"
rel="noreferrer"
>
https://lume.nu
</a>
</p>
</div>
</div>
</div>
<div className="mt-4 flex w-full flex-col gap-2">
<button
type="button"
onClick={() => submit()}
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium text-zinc-100 hover:bg-fuchsia-600"
>
{loading ? (
<>
<span className="w-5" />
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
<span className="w-5" />
</>
) : (
<>
<span className="w-5" />
<span>Publish</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
)}
</button>
<Link
to="/"
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-lg px-6 text-sm font-medium text-zinc-200"
>
Skip for now
</Link>
</div>
</div>
</div>
);
}

View File

@@ -1,53 +1,54 @@
import { ArrowRightCircleIcon } from "@shared/icons/arrowRightCircle";
import { Link } from "react-router-dom";
import { Link } from 'react-router-dom';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
export function WelcomeScreen() {
return (
<div className="w-full h-full grid grid-cols-12 gap-4 px-4 py-4">
<div className="col-span-5 border-t border-zinc-800/50 bg-zinc-900 rounded-xl flex flex-col">
<div className="w-full h-full flex flex-col justify-center px-4 py-4 gap-2">
<h1 className="text-zinc-700 text-4xl font-bold leading-none text-transparent">
Preserve your <span className="text-fuchsia-300">freedom</span>
</h1>
<h2 className="text-zinc-700 text-4xl font-bold leading-none text-transparent">
Protect your <span className="text-red-300">future</span>
</h2>
<h3 className="text-zinc-700 text-4xl font-bold leading-none text-transparent">
Stack <span className="text-orange-300">bitcoin</span>
</h3>
<h3 className="text-zinc-700 text-4xl font-bold leading-none text-transparent">
Use <span className="text-purple-300">nostr</span>
</h3>
</div>
<div className="mt-auto w-full flex flex-col gap-2 px-4 py-4">
<Link
to="/auth/import"
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg px-6 font-medium text-zinc-100 bg-fuchsia-500 hover:bg-fuchsia-600"
>
<span className="w-5" />
<span>Login with private key</span>
<ArrowRightCircleIcon className="w-5 h-5" />
</Link>
<Link
to="/auth/create"
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-lg px-6 font-medium text-zinc-200 bg-zinc-800 hover:bg-zinc-700"
>
Create new key
</Link>
</div>
</div>
<div
className="col-span-5 bg-zinc-900 rounded-xl bg-cover bg-center"
style={{
backgroundImage: `url("https://void.cat/d/Ps1b36vu5pdkEA2w75usuB")`,
}}
/>
<div
className="col-span-2 bg-zinc-900 rounded-xl bg-cover bg-center"
style={{
backgroundImage: `url("https://void.cat/d/5FdJcBP5ZXKAjYqV8hpcp3")`,
}}
/>
</div>
);
return (
<div className="grid h-full w-full grid-cols-12 gap-4 px-4 py-4">
<div className="col-span-5 flex flex-col rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="flex h-full w-full flex-col justify-center gap-2 px-4 py-4">
<h1 className="text-4xl font-bold leading-none text-transparent text-zinc-700">
Preserve your <span className="text-fuchsia-300">freedom</span>
</h1>
<h2 className="text-4xl font-bold leading-none text-transparent text-zinc-700">
Protect your <span className="text-red-300">future</span>
</h2>
<h3 className="text-4xl font-bold leading-none text-transparent text-zinc-700">
Stack <span className="text-orange-300">bitcoin</span>
</h3>
<h3 className="text-4xl font-bold leading-none text-transparent text-zinc-700">
Use <span className="text-purple-300">nostr</span>
</h3>
</div>
<div className="mt-auto flex w-full flex-col gap-2 px-4 py-4">
<Link
to="/auth/import"
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium text-zinc-100 hover:bg-fuchsia-600"
>
<span className="w-5" />
<span>Login with private key</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</Link>
<Link
to="/auth/create"
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-lg bg-zinc-800 px-6 font-medium text-zinc-200 hover:bg-zinc-700"
>
Create new key
</Link>
</div>
</div>
<div
className="col-span-5 rounded-xl bg-zinc-900 bg-cover bg-center"
style={{
backgroundImage: `url("https://void.cat/d/Ps1b36vu5pdkEA2w75usuB")`,
}}
/>
<div
className="col-span-2 rounded-xl bg-zinc-900 bg-cover bg-center"
style={{
backgroundImage: `url("https://void.cat/d/5FdJcBP5ZXKAjYqV8hpcp3")`,
}}
/>
</div>
);
}

View File

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

View File

@@ -1,263 +1,269 @@
import { Dialog, Transition } from "@headlessui/react";
import { createChannel } from "@libs/storage";
import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { AvatarUploader } from "@shared/avatarUploader";
import { CancelIcon, LoaderIcon, PlusIcon } from "@shared/icons";
import { Image } from "@shared/image";
import { RelayContext } from "@shared/relayProvider";
import { DEFAULT_AVATAR } from "@stores/constants";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { dateToUnix } from "@utils/date";
import { useAccount } from "@utils/hooks/useAccount";
import { Fragment, useContext, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { Dialog, Transition } from '@headlessui/react';
import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Fragment, useContext, useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { createChannel } from '@libs/storage';
import { AvatarUploader } from '@shared/avatarUploader';
import { CancelIcon, LoaderIcon, PlusIcon } from '@shared/icons';
import { Image } from '@shared/image';
import { RelayContext } from '@shared/relayProvider';
import { DEFAULT_AVATAR } from '@stores/constants';
import { dateToUnix } from '@utils/date';
import { useAccount } from '@utils/hooks/useAccount';
export function ChannelCreateModal() {
const ndk = useContext(RelayContext);
const queryClient = useQueryClient();
const navigate = useNavigate();
const ndk = useContext(RelayContext);
const queryClient = useQueryClient();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [image, setImage] = useState(DEFAULT_AVATAR);
const [isOpen, setIsOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [image, setImage] = useState(DEFAULT_AVATAR);
const { account } = useAccount();
const { account } = useAccount();
const closeModal = () => {
setIsOpen(false);
};
const closeModal = () => {
setIsOpen(false);
};
const openModal = () => {
setIsOpen(true);
};
const openModal = () => {
setIsOpen(true);
};
const {
register,
handleSubmit,
reset,
setValue,
formState: { isDirty, isValid },
} = useForm();
const {
register,
handleSubmit,
reset,
setValue,
formState: { isDirty, isValid },
} = useForm();
const addChannel = useMutation({
mutationFn: (event: any) => {
return createChannel(
event.id,
event.pubkey,
event.name,
event.picture,
event.about,
event.created_at,
);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["channels"] });
},
});
const addChannel = useMutation({
mutationFn: (event: any) => {
return createChannel(
event.id,
event.pubkey,
event.name,
event.picture,
event.about,
event.created_at
);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['channels'] });
},
});
const onSubmit = (data: any) => {
setLoading(true);
const onSubmit = (data: any) => {
setLoading(true);
try {
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
try {
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const event = new NDKEvent(ndk);
// build event
event.content = JSON.stringify(data);
event.kind = 40;
event.created_at = dateToUnix();
event.pubkey = account.pubkey;
event.tags = [];
const event = new NDKEvent(ndk);
// build event
event.content = JSON.stringify(data);
event.kind = 40;
event.created_at = dateToUnix();
event.pubkey = account.pubkey;
event.tags = [];
// publish event
event.publish();
// publish event
event.publish();
// insert to database
addChannel.mutate({
...event,
name: data.name,
picture: data.picture,
about: data.about,
});
// insert to database
addChannel.mutate({
...event,
name: data.name,
picture: data.picture,
about: data.about,
});
// reset form
reset();
// reset form
reset();
setTimeout(() => {
// close modal
setIsOpen(false);
// redirect to channel page
navigate(`/app/channel/${event.id}`);
}, 1000);
} catch (e) {
console.log("error: ", e);
}
};
setTimeout(() => {
// close modal
setIsOpen(false);
// redirect to channel page
navigate(`/app/channel/${event.id}`);
}, 1000);
} catch (e) {
console.log('error: ', e);
}
};
useEffect(() => {
setValue("picture", image);
}, [setValue, image]);
useEffect(() => {
setValue('picture', image);
}, [setValue, image]);
return (
<>
<button
type="button"
onClick={() => openModal()}
className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5"
>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<PlusIcon width={12} height={12} className="text-zinc-500" />
</div>
<div>
<h5 className="font-medium text-zinc-400">Create channel</h5>
</div>
</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-lg border-t border-zinc-800/50 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-5">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Dialog.Title
as="h3"
className="text-lg font-semibold leading-none text-zinc-100"
>
Create channel
</Dialog.Title>
<button
type="button"
onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon
width={20}
height={20}
className="text-zinc-300"
/>
</button>
</div>
<Dialog.Description className="text-sm leading-tight text-zinc-400">
Channels are freedom square, everyone can speech freely,
no one can stop you or deceive what to speech
</Dialog.Description>
</div>
</div>
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
<form
onSubmit={handleSubmit(onSubmit)}
className="flex h-full w-full flex-col gap-4 mb-0"
>
<input
type={"hidden"}
{...register("picture")}
value={image}
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
<div className="flex flex-col gap-1">
<label className="text-sm font-medium uppercase tracking-wider text-zinc-400">
Picture
</label>
<div className="relative inline-flex h-36 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950">
<Image
src={image}
fallback={DEFAULT_AVATAR}
alt="channel picture"
className="relative z-10 h-11 w-11 rounded-md"
/>
<div className="absolute bottom-3 right-3 z-10">
<AvatarUploader valueState={setImage} />
</div>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-semibold uppercase tracking-wider text-zinc-400">
Channel name *
</label>
<input
type={"text"}
{...register("name", {
required: true,
minLength: 4,
})}
spellCheck={false}
className="relative h-10 w-full rounded-lg px-3 py-2 !outline-none bg-zinc-800 text-zinc-100 placeholder:text-zinc-500"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-semibold uppercase tracking-wider text-zinc-400">
Description
</label>
<textarea
{...register("about")}
spellCheck={false}
className="relative resize-none h-20 w-full rounded-lg px-3 py-2 !outline-none bg-zinc-800 text-zinc-100 placeholder:text-zinc-500"
/>
</div>
<div className="flex h-20 items-center justify-between gap-1 rounded-lg bg-zinc-800 px-4 py-2">
<div className="flex flex-col gap-1">
<span className="font-semibold leading-none text-zinc-100">
Encrypted
</span>
<p className="w-4/5 text-sm leading-none text-zinc-400">
All messages are encrypted and only invited members
can view and send message
</p>
</div>
<div>
<button
type="button"
disabled
className="relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent bg-zinc-900 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-fuchsia-600 focus:ring-offset-2"
role="switch"
aria-checked="false"
>
<span className="pointer-events-none inline-block h-5 w-5 translate-x-0 transform rounded-full bg-zinc-600 shadow ring-0 transition duration-200 ease-in-out" />
</button>
</div>
</div>
<div>
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex items-center justify-center gap-1 transform active:translate-y-1 disabled:pointer-events-none disabled:opacity-50 focus:outline-none h-11 w-full bg-fuchsia-500 rounded-md font-medium text-zinc-100 hover:bg-fuchsia-600"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
"Create channel →"
)}
</button>
</div>
</form>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
return (
<>
<button
type="button"
onClick={() => openModal()}
className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5"
>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<PlusIcon width={12} height={12} className="text-zinc-500" />
</div>
<div>
<h5 className="font-medium text-zinc-400">Create channel</h5>
</div>
</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-lg border-t border-zinc-800/50 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-5">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Dialog.Title
as="h3"
className="text-lg font-semibold leading-none text-zinc-100"
>
Create channel
</Dialog.Title>
<button
type="button"
onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon width={20} height={20} className="text-zinc-300" />
</button>
</div>
<Dialog.Description className="text-sm leading-tight text-zinc-400">
Channels are freedom square, everyone can speech freely, no one can
stop you or deceive what to speech
</Dialog.Description>
</div>
</div>
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
<form
onSubmit={handleSubmit(onSubmit)}
className="mb-0 flex h-full w-full flex-col gap-4"
>
<input
type={'hidden'}
{...register('picture')}
value={image}
className="shadow-input relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
<div className="flex flex-col gap-1">
<span className="text-sm font-medium uppercase tracking-wider text-zinc-400">
Picture
</span>
<div className="relative inline-flex h-36 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950">
<Image
src={image}
fallback={DEFAULT_AVATAR}
alt="channel picture"
className="relative z-10 h-11 w-11 rounded-md"
/>
<div className="absolute bottom-3 right-3 z-10">
<AvatarUploader setPicture={setImage} />
</div>
</div>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="name"
className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
>
Channel name *
</label>
<input
type={'text'}
{...register('name', {
required: true,
minLength: 4,
})}
spellCheck={false}
className="relative h-10 w-full rounded-lg bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="about"
className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
>
Description
</label>
<textarea
{...register('about')}
spellCheck={false}
className="relative h-20 w-full resize-none rounded-lg bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
/>
</div>
<div className="flex h-20 items-center justify-between gap-1 rounded-lg bg-zinc-800 px-4 py-2">
<div className="flex flex-col gap-1">
<span className="font-semibold leading-none text-zinc-100">
Encrypted
</span>
<p className="w-4/5 text-sm leading-none text-zinc-400">
All messages are encrypted and only invited members can view and
send message
</p>
</div>
<div>
<button
type="button"
disabled
className="relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent bg-zinc-900 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-fuchsia-600 focus:ring-offset-2"
role="switch"
aria-checked="false"
>
<span className="pointer-events-none inline-block h-5 w-5 translate-x-0 transform rounded-full bg-zinc-600 shadow ring-0 transition duration-200 ease-in-out" />
</button>
</div>
</div>
<div>
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full transform items-center justify-center gap-1 rounded-md bg-fuchsia-500 font-medium text-zinc-100 hover:bg-fuchsia-600 focus:outline-none active:translate-y-1 disabled:pointer-events-none disabled:opacity-50"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
'Create channel →'
)}
</button>
</div>
</form>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
}

View File

@@ -1,33 +1,34 @@
import { useChannelProfile } from "@app/channel/hooks/useChannelProfile";
import { NavLink } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { NavLink } from 'react-router-dom';
import { twMerge } from 'tailwind-merge';
import { useChannelProfile } from '@app/channel/hooks/useChannelProfile';
export function ChannelsListItem({ data }: { data: any }) {
const channel = useChannelProfile(data.event_id);
return (
<NavLink
to={`/app/channel/${data.event_id}`}
preventScrollReset={true}
className={({ isActive }) =>
twMerge(
"inline-flex h-9 items-center gap-2.5 rounded-md px-2.5",
isActive ? "bg-zinc-900/50 text-zinc-100" : "",
)
}
>
<div className="inline-flex shrink-0 h-6 w-6 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<span className="text-xs text-zinc-100">#</span>
</div>
<div className="w-full inline-flex items-center justify-between">
<h5 className="truncate font-medium text-zinc-200">{channel?.name}</h5>
<div className="flex items-center">
{data.new_messages && (
<span className="inline-flex items-center justify-center rounded bg-fuchsia-400/10 w-8 px-1 py-1 text-xs font-medium text-fuchsia-500 ring-1 ring-inset ring-fuchsia-400/20">
{data.new_messages}
</span>
)}
</div>
</div>
</NavLink>
);
const channel = useChannelProfile(data.event_id);
return (
<NavLink
to={`/app/channel/${data.event_id}`}
preventScrollReset={true}
className={({ isActive }) =>
twMerge(
'inline-flex h-9 items-center gap-2.5 rounded-md px-2.5',
isActive ? 'bg-zinc-900/50 text-zinc-100' : ''
)
}
>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<span className="text-xs text-zinc-100">#</span>
</div>
<div className="inline-flex w-full items-center justify-between">
<h5 className="truncate font-medium text-zinc-200">{channel?.name}</h5>
<div className="flex items-center">
{data.new_messages && (
<span className="inline-flex w-8 items-center justify-center rounded bg-fuchsia-400/10 px-1 py-1 text-xs font-medium text-fuchsia-500 ring-1 ring-inset ring-fuchsia-400/20">
{data.new_messages}
</span>
)}
</div>
</div>
</NavLink>
);
}

View File

@@ -1,50 +1,52 @@
import { ChannelCreateModal } from "@app/channel/components/createModal";
import { ChannelsListItem } from "@app/channel/components/item";
import { getChannels } from "@libs/storage";
import { useQuery } from "@tanstack/react-query";
import { useQuery } from '@tanstack/react-query';
import { ChannelCreateModal } from '@app/channel/components/createModal';
import { ChannelsListItem } from '@app/channel/components/item';
import { getChannels } from '@libs/storage';
export function ChannelsList() {
const {
status,
data: channels,
isFetching,
} = useQuery(
["channels"],
async () => {
return await getChannels();
},
{
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
},
);
const {
status,
data: channels,
isFetching,
} = useQuery(
['channels'],
async () => {
return await getChannels();
},
{
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
}
);
return (
<div className="flex flex-col">
{status === "loading" ? (
<>
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3.5 w-full animate-pulse rounded-sm bg-zinc-800" />
</div>
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3.5 w-full animate-pulse rounded-sm bg-zinc-800" />
</div>
</>
) : (
channels.map((item: { event_id: string }) => (
<ChannelsListItem key={item.event_id} data={item} />
))
)}
{isFetching && (
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3.5 w-full animate-pulse rounded-sm bg-zinc-800" />
</div>
)}
<ChannelCreateModal />
</div>
);
return (
<div className="flex flex-col">
{status === 'loading' ? (
<>
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3.5 w-full animate-pulse rounded-sm bg-zinc-800" />
</div>
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3.5 w-full animate-pulse rounded-sm bg-zinc-800" />
</div>
</>
) : (
channels.map((item: { event_id: string }) => (
<ChannelsListItem key={item.event_id} data={item} />
))
)}
{isFetching && (
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3.5 w-full animate-pulse rounded-sm bg-zinc-800" />
</div>
)}
<ChannelCreateModal />
</div>
);
}

View File

@@ -1,22 +1,24 @@
import { Image } from "@shared/image";
import { DEFAULT_AVATAR } from "@stores/constants";
import { useProfile } from "@utils/hooks/useProfile";
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
export function Member({ pubkey }: { pubkey: string }) {
const { user, isError, isLoading } = useProfile(pubkey);
const { user, isError, isLoading } = useProfile(pubkey);
return (
<>
{isError || isLoading ? (
<div className="h-7 w-7 animate-pulse rounded bg-zinc-800" />
) : (
<Image
className="inline-block h-7 w-7 rounded"
src={user?.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
/>
)}
</>
);
return (
<>
{isError || isLoading ? (
<div className="h-7 w-7 animate-pulse rounded bg-zinc-800" />
) : (
<Image
className="inline-block h-7 w-7 rounded"
src={user?.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
/>
)}
</>
);
}

View File

@@ -1,29 +1,28 @@
import { Member } from "@app/channel/components/member";
import { getChannelUsers } from "@libs/storage";
import { useQuery } from "@tanstack/react-query";
import { useQuery } from '@tanstack/react-query';
import { Member } from '@app/channel/components/member';
import { getChannelUsers } from '@libs/storage';
export function ChannelMembers({ id }: { id: string }) {
const { status, data, isFetching } = useQuery(
["channel-members", id],
async () => {
return await getChannelUsers(id);
},
);
const { status, data, isFetching } = useQuery(['channel-members', id], async () => {
return await getChannelUsers(id);
});
return (
<div className="mt-3">
<h5 className="border-b border-zinc-900 pb-1 font-semibold text-zinc-200">
Members
</h5>
<div className="mt-3 w-full flex flex-wrap gap-1.5">
{status === "loading" || isFetching ? (
<p>Loading...</p>
) : (
data.map((member: { pubkey: string }) => (
<Member key={member.pubkey} pubkey={member.pubkey} />
))
)}
</div>
</div>
);
return (
<div className="mt-3">
<h5 className="border-b border-zinc-900 pb-1 font-semibold text-zinc-200">
Members
</h5>
<div className="mt-3 flex w-full flex-wrap gap-1.5">
{status === 'loading' || isFetching ? (
<p>Loading...</p>
) : (
data.map((member: { pubkey: string }) => (
<Member key={member.pubkey} pubkey={member.pubkey} />
))
)}
</div>
</div>
);
}

View File

@@ -1,110 +1,114 @@
import { UserReply } from "@app/channel/components/messages/userReply";
import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { CancelIcon, EnterIcon } from "@shared/icons";
import { MediaUploader } from "@shared/mediaUploader";
import { RelayContext } from "@shared/relayProvider";
import { useChannelMessages } from "@stores/channels";
import { dateToUnix } from "@utils/date";
import { useAccount } from "@utils/hooks/useAccount";
import { useContext, useState } from "react";
import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { useContext, useState } from 'react';
import { UserReply } from '@app/channel/components/messages/userReply';
import { CancelIcon, EnterIcon } from '@shared/icons';
import { MediaUploader } from '@shared/mediaUploader';
import { RelayContext } from '@shared/relayProvider';
import { useChannelMessages } from '@stores/channels';
import { dateToUnix } from '@utils/date';
import { useAccount } from '@utils/hooks/useAccount';
export function ChannelMessageForm({ channelID }: { channelID: string }) {
const ndk = useContext(RelayContext);
const ndk = useContext(RelayContext);
const [value, setValue] = useState("");
const [replyTo, closeReply] = useChannelMessages((state: any) => [
state.replyTo,
state.closeReply,
]);
const [value, setValue] = useState('');
const [replyTo, closeReply] = useChannelMessages((state: any) => [
state.replyTo,
state.closeReply,
]);
const { account } = useAccount();
const { account } = useAccount();
const submit = () => {
let tags: string[][];
const submit = () => {
let tags: string[][];
if (replyTo.id !== null) {
tags = [
["e", channelID, "", "root"],
["e", replyTo.id, "", "reply"],
["p", replyTo.pubkey, ""],
];
} else {
tags = [["e", channelID, "", "root"]];
}
if (replyTo.id !== null) {
tags = [
['e', channelID, '', 'root'],
['e', replyTo.id, '', 'reply'],
['p', replyTo.pubkey, ''],
];
} else {
tags = [['e', channelID, '', 'root']];
}
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const event = new NDKEvent(ndk);
// build event
event.content = value;
event.kind = 42;
event.created_at = dateToUnix();
event.pubkey = account.pubkey;
event.tags = tags;
const event = new NDKEvent(ndk);
// build event
event.content = value;
event.kind = 42;
event.created_at = dateToUnix();
event.pubkey = account.pubkey;
event.tags = tags;
// publish event
event.publish();
// publish event
event.publish();
// reset state
setValue("");
};
// reset state
setValue('');
};
const handleEnterPress = (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
};
const handleEnterPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submit();
}
};
const stopReply = () => {
closeReply();
};
const stopReply = () => {
closeReply();
};
return (
<div className={`relative w-full ${replyTo.id ? "h-36" : "h-24"}`}>
{replyTo.id && (
<div className="absolute left-0 top-0 z-10 h-16 w-full p-[2px]">
<div className="flex h-full w-full items-center justify-between rounded-t-md border-b border-zinc-700/70 bg-zinc-900 px-3">
<div className="flex w-full flex-col">
<UserReply pubkey={replyTo.pubkey} />
<div className="-mt-5 pl-[38px]">
<div className="text-base text-zinc-100">{replyTo.content}</div>
</div>
</div>
<button
type="button"
onClick={() => stopReply()}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-800"
>
<CancelIcon width={12} height={12} className="text-zinc-100" />
</button>
</div>
</div>
)}
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleEnterPress}
spellCheck={false}
placeholder="Message"
className={`relative ${
replyTo.id ? "h-36 pt-16" : "h-24 pt-3"
} w-full resize-none rounded-md px-5 !outline-none bg-zinc-800 placeholder:text-zinc-500`}
/>
<div className="absolute right-2 bottom-0 h-11">
<div className="h-full flex gap-3 items-center justify-end text-zinc-500">
<MediaUploader setState={setValue} />
<button
type="button"
onClick={submit}
className="inline-flex items-center gap-1 text-sm leading-none"
>
<EnterIcon width={14} height={14} className="" />
Send
</button>
</div>
</div>
</div>
);
return (
<div className={`relative w-full ${replyTo.id ? 'h-36' : 'h-24'}`}>
{replyTo.id && (
<div className="absolute left-0 top-0 z-10 h-16 w-full p-[2px]">
<div className="flex h-full w-full items-center justify-between rounded-t-md border-b border-zinc-700/70 bg-zinc-900 px-3">
<div className="flex w-full flex-col">
<UserReply pubkey={replyTo.pubkey} />
<div className="-mt-5 pl-[38px]">
<div className="text-base text-zinc-100">{replyTo.content}</div>
</div>
</div>
<button
type="button"
onClick={() => stopReply()}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-800"
>
<CancelIcon width={12} height={12} className="text-zinc-100" />
</button>
</div>
</div>
)}
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleEnterPress}
spellCheck={false}
placeholder="Message"
className={`relative ${
replyTo.id ? 'h-36 pt-16' : 'h-24 pt-3'
} w-full resize-none rounded-md bg-zinc-800 px-5 !outline-none placeholder:text-zinc-500`}
/>
<div className="absolute bottom-0 right-2 h-11">
<div className="flex h-full items-center justify-end gap-3 text-zinc-500">
<MediaUploader setState={setValue} />
<button
type="button"
onClick={submit}
className="inline-flex items-center gap-1 text-sm leading-none"
>
<EnterIcon width={14} height={14} className="" />
Send
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,135 +1,134 @@
import { Dialog, Transition } from "@headlessui/react";
import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { CancelIcon, HideIcon } from "@shared/icons";
import { RelayContext } from "@shared/relayProvider";
import { Tooltip } from "@shared/tooltip_dep";
import { useChannelMessages } from "@stores/channels";
import { dateToUnix } from "@utils/date";
import { useAccount } from "@utils/hooks/useAccount";
import { Fragment, useContext, useState } from "react";
import { Dialog, Transition } from '@headlessui/react';
import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { Fragment, useContext, useState } from 'react';
import { CancelIcon, HideIcon } from '@shared/icons';
import { RelayContext } from '@shared/relayProvider';
import { Tooltip } from '@shared/tooltip_dep';
import { useChannelMessages } from '@stores/channels';
import { dateToUnix } from '@utils/date';
import { useAccount } from '@utils/hooks/useAccount';
export function MessageHideButton({ id }: { id: string }) {
const ndk = useContext(RelayContext);
const hide = useChannelMessages((state: any) => state.hideMessage);
const ndk = useContext(RelayContext);
const hide = useChannelMessages((state: any) => state.hideMessage);
const [isOpen, setIsOpen] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const { account } = useAccount();
const { account } = useAccount();
const closeModal = () => {
setIsOpen(false);
};
const closeModal = () => {
setIsOpen(false);
};
const openModal = () => {
setIsOpen(true);
};
const openModal = () => {
setIsOpen(true);
};
const hideMessage = () => {
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const hideMessage = () => {
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const event = new NDKEvent(ndk);
// build event
event.content = "";
event.kind = 43;
event.created_at = dateToUnix();
event.pubkey = account.pubkey;
event.tags = [["e", id]];
const event = new NDKEvent(ndk);
// build event
event.content = '';
event.kind = 43;
event.created_at = dateToUnix();
event.pubkey = account.pubkey;
event.tags = [['e', id]];
// publish event
event.publish();
// publish event
event.publish();
// update state
hide(id);
// update state
hide(id);
// close modal
closeModal();
};
// close modal
closeModal();
};
return (
<>
<Tooltip message="Hide this message">
<button
type="button"
onClick={openModal}
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800"
>
<HideIcon width={16} height={16} className="text-zinc-200" />
</button>
</Tooltip>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col rounded-lg border border-zinc-800 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Dialog.Title
as="h3"
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-xl font-semibold leading-none text-transparent"
>
Are you sure!
</Dialog.Title>
<button
type="button"
onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon
width={20}
height={20}
className="text-zinc-300"
/>
</button>
</div>
<Dialog.Description className="leading-tight text-zinc-400">
This message will be hidden from your feed.
</Dialog.Description>
</div>
</div>
<div className="flex h-full w-full flex-col items-end justify-center overflow-y-auto px-5 py-2.5">
<div className="flex items-center gap-2">
<button
type="button"
onClick={closeModal}
className="inline-flex h-9 items-center justify-center rounded-md px-2 text-base font-medium text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
>
Cancel
</button>
<button
type="button"
onClick={() => hideMessage()}
className="inline-flex h-9 items-center justify-center rounded-md bg-red-500 px-2 text-base font-medium text-zinc-100 hover:bg-red-600"
>
Confirm
</button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
return (
<>
<Tooltip message="Hide this message">
<button
type="button"
onClick={openModal}
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800"
>
<HideIcon width={16} height={16} className="text-zinc-200" />
</button>
</Tooltip>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col rounded-lg border border-zinc-800 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Dialog.Title
as="h3"
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-xl font-semibold leading-none text-transparent"
>
Are you sure!
</Dialog.Title>
<button
type="button"
onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon width={20} height={20} className="text-zinc-300" />
</button>
</div>
<Dialog.Description className="leading-tight text-zinc-400">
This message will be hidden from your feed.
</Dialog.Description>
</div>
</div>
<div className="flex h-full w-full flex-col items-end justify-center overflow-y-auto px-5 py-2.5">
<div className="flex items-center gap-2">
<button
type="button"
onClick={closeModal}
className="inline-flex h-9 items-center justify-center rounded-md px-2 text-base font-medium text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
>
Cancel
</button>
<button
type="button"
onClick={() => hideMessage()}
className="inline-flex h-9 items-center justify-center rounded-md bg-red-500 px-2 text-base font-medium text-zinc-100 hover:bg-red-600"
>
Confirm
</button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
}

View File

@@ -1,60 +1,56 @@
import { MessageHideButton } from "@app/channel/components/messages/hideButton";
import { MessageMuteButton } from "@app/channel/components/messages/muteButton";
import { MessageReplyButton } from "@app/channel/components/messages/replyButton";
import { MentionNote } from "@shared/notes/mentions/note";
import { ImagePreview } from "@shared/notes/preview/image";
import { LinkPreview } from "@shared/notes/preview/link";
import { VideoPreview } from "@shared/notes/preview/video";
import { User } from "@shared/user";
import { parser } from "@utils/parser";
import { LumeEvent } from "@utils/types";
import { MessageHideButton } from '@app/channel/components/messages/hideButton';
import { MessageMuteButton } from '@app/channel/components/messages/muteButton';
import { MessageReplyButton } from '@app/channel/components/messages/replyButton';
import { MentionNote } from '@shared/notes/mentions/note';
import { ImagePreview } from '@shared/notes/preview/image';
import { LinkPreview } from '@shared/notes/preview/link';
import { VideoPreview } from '@shared/notes/preview/video';
import { User } from '@shared/user';
import { parser } from '@utils/parser';
import { LumeEvent } from '@utils/types';
export function ChannelMessageItem({ data }: { data: LumeEvent }) {
const content = parser(data);
const content = parser(data);
return (
<div className="group relative flex h-min min-h-min w-full select-text flex-col px-5 py-3 hover:bg-black/20">
<div className="flex flex-col">
<User pubkey={data.pubkey} time={data.created_at} isChat={true} />
<div className="-mt-[20px] pl-[49px]">
<p className="select-text whitespace-pre-line break-words text-base text-zinc-100">
{content.parsed}
</p>
{Array.isArray(content.images) && content.images.length ? (
<ImagePreview urls={content.images} />
) : (
<></>
)}
{Array.isArray(content.videos) && content.videos.length ? (
<VideoPreview urls={content.videos} />
) : (
<></>
)}
{Array.isArray(content.links) && content.links.length ? (
<LinkPreview urls={content.links} />
) : (
<></>
)}
{Array.isArray(content.notes) && content.notes.length ? (
content.notes.map((note: string) => (
<MentionNote key={note} id={note} />
))
) : (
<></>
)}
</div>
</div>
<div className="absolute -top-4 right-4 z-10 hidden group-hover:inline-flex">
<div className="inline-flex h-8 items-center justify-center gap-1.5 rounded bg-zinc-900 px-0.5 shadow-md shadow-black/20 ring-1 ring-zinc-800">
<MessageReplyButton
id={data.id}
pubkey={data.pubkey}
content={data.content}
/>
<MessageHideButton id={data.id} />
<MessageMuteButton pubkey={data.pubkey} />
</div>
</div>
</div>
);
return (
<div className="group relative flex h-min min-h-min w-full select-text flex-col px-5 py-3 hover:bg-black/20">
<div className="flex flex-col">
<User pubkey={data.pubkey} time={data.created_at} isChat={true} />
<div className="-mt-[20px] pl-[49px]">
<p className="select-text whitespace-pre-line break-words text-base text-zinc-100">
{content.parsed}
</p>
{Array.isArray(content.images) && content.images.length ? (
<ImagePreview urls={content.images} />
) : (
<></>
)}
{Array.isArray(content.videos) && content.videos.length ? (
<VideoPreview urls={content.videos} />
) : (
<></>
)}
{Array.isArray(content.links) && content.links.length ? (
<LinkPreview urls={content.links} />
) : (
<></>
)}
{Array.isArray(content.notes) && content.notes.length ? (
content.notes.map((note: string) => <MentionNote key={note} id={note} />)
) : (
<></>
)}
</div>
</div>
<div className="absolute -top-4 right-4 z-10 hidden group-hover:inline-flex">
<div className="inline-flex h-8 items-center justify-center gap-1.5 rounded bg-zinc-900 px-0.5 shadow-md shadow-black/20 ring-1 ring-zinc-800">
<MessageReplyButton id={data.id} pubkey={data.pubkey} content={data.content} />
<MessageHideButton id={data.id} />
<MessageMuteButton pubkey={data.pubkey} />
</div>
</div>
</div>
);
}

View File

@@ -1,135 +1,134 @@
import { Dialog, Transition } from "@headlessui/react";
import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { CancelIcon, MuteIcon } from "@shared/icons";
import { RelayContext } from "@shared/relayProvider";
import { Tooltip } from "@shared/tooltip_dep";
import { useChannelMessages } from "@stores/channels";
import { dateToUnix } from "@utils/date";
import { useAccount } from "@utils/hooks/useAccount";
import { Fragment, useContext, useState } from "react";
import { Dialog, Transition } from '@headlessui/react';
import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { Fragment, useContext, useState } from 'react';
import { CancelIcon, MuteIcon } from '@shared/icons';
import { RelayContext } from '@shared/relayProvider';
import { Tooltip } from '@shared/tooltip_dep';
import { useChannelMessages } from '@stores/channels';
import { dateToUnix } from '@utils/date';
import { useAccount } from '@utils/hooks/useAccount';
export function MessageMuteButton({ pubkey }: { pubkey: string }) {
const ndk = useContext(RelayContext);
const mute = useChannelMessages((state: any) => state.muteUser);
const ndk = useContext(RelayContext);
const mute = useChannelMessages((state: any) => state.muteUser);
const [isOpen, setIsOpen] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const { account } = useAccount();
const { account } = useAccount();
const closeModal = () => {
setIsOpen(false);
};
const closeModal = () => {
setIsOpen(false);
};
const openModal = () => {
setIsOpen(true);
};
const openModal = () => {
setIsOpen(true);
};
const muteUser = () => {
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const muteUser = () => {
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const event = new NDKEvent(ndk);
// build event
event.content = "";
event.kind = 44;
event.created_at = dateToUnix();
event.pubkey = account.pubkey;
event.tags = [["p", pubkey]];
const event = new NDKEvent(ndk);
// build event
event.content = '';
event.kind = 44;
event.created_at = dateToUnix();
event.pubkey = account.pubkey;
event.tags = [['p', pubkey]];
// publish event
event.publish();
// publish event
event.publish();
// update state
mute(pubkey);
// update state
mute(pubkey);
// close modal
closeModal();
};
// close modal
closeModal();
};
return (
<>
<Tooltip message="Mute this user">
<button
type="button"
onClick={() => openModal()}
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800"
>
<MuteIcon width={16} height={16} className="text-zinc-200" />
</button>
</Tooltip>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col rounded-lg border border-zinc-800 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Dialog.Title
as="h3"
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-xl font-semibold leading-none text-transparent"
>
Are you sure!
</Dialog.Title>
<button
type="button"
onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon
width={20}
height={20}
className="text-zinc-300"
/>
</button>
</div>
<Dialog.Description className="leading-tight text-zinc-400">
You will no longer see messages from this user.
</Dialog.Description>
</div>
</div>
<div className="flex h-full w-full flex-col items-end justify-center overflow-y-auto px-5 py-2.5">
<div className="flex items-center gap-2">
<button
type="button"
onClick={closeModal}
className="inline-flex h-9 items-center justify-center rounded-md px-2 text-base font-medium text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
>
Cancel
</button>
<button
type="button"
onClick={() => muteUser()}
className="inline-flex h-9 items-center justify-center rounded-md bg-red-500 px-2 text-base font-medium text-zinc-100 hover:bg-red-600"
>
Confirm
</button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
return (
<>
<Tooltip message="Mute this user">
<button
type="button"
onClick={() => openModal()}
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800"
>
<MuteIcon width={16} height={16} className="text-zinc-200" />
</button>
</Tooltip>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col rounded-lg border border-zinc-800 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Dialog.Title
as="h3"
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-xl font-semibold leading-none text-transparent"
>
Are you sure!
</Dialog.Title>
<button
type="button"
onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon width={20} height={20} className="text-zinc-300" />
</button>
</div>
<Dialog.Description className="leading-tight text-zinc-400">
You will no longer see messages from this user.
</Dialog.Description>
</div>
</div>
<div className="flex h-full w-full flex-col items-end justify-center overflow-y-auto px-5 py-2.5">
<div className="flex items-center gap-2">
<button
type="button"
onClick={closeModal}
className="inline-flex h-9 items-center justify-center rounded-md px-2 text-base font-medium text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
>
Cancel
</button>
<button
type="button"
onClick={() => muteUser()}
className="inline-flex h-9 items-center justify-center rounded-md bg-red-500 px-2 text-base font-medium text-zinc-100 hover:bg-red-600"
>
Confirm
</button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
}

View File

@@ -1,27 +1,32 @@
import { ReplyMessageIcon } from "@shared/icons";
import { Tooltip } from "@shared/tooltip_dep";
import { useChannelMessages } from "@stores/channels";
import { ReplyMessageIcon } from '@shared/icons';
import { Tooltip } from '@shared/tooltip_dep';
import { useChannelMessages } from '@stores/channels';
export function MessageReplyButton({
id,
pubkey,
content,
}: { id: string; pubkey: string; content: string }) {
const openReply = useChannelMessages((state: any) => state.openReply);
id,
pubkey,
content,
}: {
id: string;
pubkey: string;
content: string;
}) {
const openReply = useChannelMessages((state: any) => state.openReply);
const createReply = () => {
openReply(id, pubkey, content);
};
const createReply = () => {
openReply(id, pubkey, content);
};
return (
<Tooltip message="Reply to message">
<button
type="button"
onClick={() => createReply()}
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800"
>
<ReplyMessageIcon width={16} height={16} className="text-zinc-200" />
</button>
</Tooltip>
);
return (
<Tooltip message="Reply to message">
<button
type="button"
onClick={() => createReply()}
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800"
>
<ReplyMessageIcon width={16} height={16} className="text-zinc-200" />
</button>
</Tooltip>
);
}

View File

@@ -1,42 +1,40 @@
import { Image } from "@shared/image";
import { DEFAULT_AVATAR } from "@stores/constants";
import { useProfile } from "@utils/hooks/useProfile";
import { Image } from '@shared/image';
export function ChannelMessageUserMute({
pubkey,
}: {
pubkey: string;
}) {
const { user, isError, isLoading } = useProfile(pubkey);
import { DEFAULT_AVATAR } from '@stores/constants';
return (
<div className="flex items-center gap-3">
{isError || isLoading ? (
<>
<div className="relative h-11 w-11 shrink animate-pulse rounded-md bg-zinc-800" />
<div className="flex w-full flex-1 items-center justify-between">
<div className="flex items-baseline gap-2 text-base">
<div className="h-4 w-20 animate-pulse rounded bg-zinc-800" />
</div>
</div>
</>
) : (
<>
<div className="relative h-11 w-11 shrink-0 rounded-md">
<Image
src={user?.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className="h-11 w-11 rounded-md object-cover"
/>
</div>
<div className="flex w-full flex-1 items-center justify-between">
<span className="leading-none text-zinc-300">
You has been muted this user
</span>
</div>
</>
)}
</div>
);
import { useProfile } from '@utils/hooks/useProfile';
export function ChannelMessageUserMute({ pubkey }: { pubkey: string }) {
const { user, isError, isLoading } = useProfile(pubkey);
return (
<div className="flex items-center gap-3">
{isError || isLoading ? (
<>
<div className="relative h-11 w-11 shrink animate-pulse rounded-md bg-zinc-800" />
<div className="flex w-full flex-1 items-center justify-between">
<div className="flex items-baseline gap-2 text-base">
<div className="h-4 w-20 animate-pulse rounded bg-zinc-800" />
</div>
</div>
</>
) : (
<>
<div className="relative h-11 w-11 shrink-0 rounded-md">
<Image
src={user?.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className="h-11 w-11 rounded-md object-cover"
/>
</div>
<div className="flex w-full flex-1 items-center justify-between">
<span className="leading-none text-zinc-300">
You has been muted this user
</span>
</div>
</>
)}
</div>
);
}

View File

@@ -1,33 +1,35 @@
import { Image } from "@shared/image";
import { DEFAULT_AVATAR } from "@stores/constants";
import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey";
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
export function UserReply({ pubkey }: { pubkey: string }) {
const { user, isError, isLoading } = useProfile(pubkey);
const { user, isError, isLoading } = useProfile(pubkey);
return (
<div className="group flex items-start gap-2">
{isError || isLoading ? (
<>
<div className="relative h-9 w-9 shrink animate-pulse overflow-hidden rounded bg-zinc-800" />
<span className="h-2 w-10 animate-pulse rounded bg-zinc-800 text-base font-medium leading-none text-zinc-500" />
</>
) : (
<>
<div className="relative h-9 w-9 shrink overflow-hidden rounded">
<Image
src={user?.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className="h-9 w-9 rounded object-cover"
/>
</div>
<span className="max-w-[10rem] truncate text-sm font-medium leading-none text-zinc-500">
Replying to {user?.name || shortenKey(pubkey)}
</span>
</>
)}
</div>
);
return (
<div className="group flex items-start gap-2">
{isError || isLoading ? (
<>
<div className="relative h-9 w-9 shrink animate-pulse overflow-hidden rounded bg-zinc-800" />
<span className="h-2 w-10 animate-pulse rounded bg-zinc-800 text-base font-medium leading-none text-zinc-500" />
</>
) : (
<>
<div className="relative h-9 w-9 shrink overflow-hidden rounded">
<Image
src={user?.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className="h-9 w-9 rounded object-cover"
/>
</div>
<span className="max-w-[10rem] truncate text-sm font-medium leading-none text-zinc-500">
Replying to {user?.name || shortenKey(pubkey)}
</span>
</>
)}
</div>
);
}

View File

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

View File

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

View File

@@ -1,35 +1,37 @@
import { getChannel, updateChannelMetadata } from "@libs/storage";
import { RelayContext } from "@shared/relayProvider";
import { useQuery } from "@tanstack/react-query";
import { useContext, useEffect } from "react";
import { useQuery } from '@tanstack/react-query';
import { useContext, useEffect } from 'react';
import { getChannel, updateChannelMetadata } from '@libs/storage';
import { RelayContext } from '@shared/relayProvider';
export function useChannelProfile(id: string) {
const ndk = useContext(RelayContext);
const { data } = useQuery(["channel-metadata", id], async () => {
return await getChannel(id);
});
const ndk = useContext(RelayContext);
const { data } = useQuery(['channel-metadata', id], async () => {
return await getChannel(id);
});
useEffect(() => {
// subscribe to channel
const sub = ndk.subscribe(
{
"#e": [id],
kinds: [41],
},
{
closeOnEose: true,
},
);
useEffect(() => {
// subscribe to channel
const sub = ndk.subscribe(
{
'#e': [id],
kinds: [41],
},
{
closeOnEose: true,
}
);
sub.addListener("event", (event: { content: string }) => {
// update in local database
updateChannelMetadata(id, event.content);
});
sub.addListener('event', (event: { content: string }) => {
// update in local database
updateChannelMetadata(id, event.content);
});
return () => {
sub.stop();
};
}, []);
return () => {
sub.stop();
};
}, []);
return data;
return data;
}

View File

@@ -1,154 +1,149 @@
import { ChannelMessageItem } from "./components/messages/item";
import { ChannelMembers } from "@app/channel/components/members";
import { ChannelMessageForm } from "@app/channel/components/messages/form";
import { ChannelMetadata } from "@app/channel/components/metadata";
import { RelayContext } from "@shared/relayProvider";
import { useChannelMessages } from "@stores/channels";
import { dateToUnix, getHourAgo } from "@utils/date";
import { LumeEvent } from "@utils/types";
import {
useCallback,
useContext,
useEffect,
useLayoutEffect,
useRef,
} from "react";
import { useParams } from "react-router-dom";
import { Virtuoso } from "react-virtuoso";
import { useCallback, useContext, useEffect, useLayoutEffect, useRef } from 'react';
import { useParams } from 'react-router-dom';
import { Virtuoso } from 'react-virtuoso';
import { ChannelMembers } from '@app/channel/components/members';
import { ChannelMessageForm } from '@app/channel/components/messages/form';
import { ChannelMetadata } from '@app/channel/components/metadata';
import { RelayContext } from '@shared/relayProvider';
import { useChannelMessages } from '@stores/channels';
import { dateToUnix, getHourAgo } from '@utils/date';
import { LumeEvent } from '@utils/types';
import { ChannelMessageItem } from './components/messages/item';
const now = new Date();
const Header = (
<div className="relative py-4">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-zinc-800" />
</div>
<div className="relative flex justify-center">
<div className="inline-flex items-center gap-x-1.5 rounded-full bg-zinc-900 px-3 py-1.5 text-sm font-medium text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-800">
{getHourAgo(24, now).toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
})}
</div>
</div>
</div>
<div className="relative py-4">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-zinc-800" />
</div>
<div className="relative flex justify-center">
<div className="inline-flex items-center gap-x-1.5 rounded-full bg-zinc-900 px-3 py-1.5 text-sm font-medium text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-800">
{getHourAgo(24, now).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</div>
</div>
</div>
);
const Empty = (
<div className="flex flex-col gap-1 text-center">
<h3 className="text-base font-semibold leading-none text-white">
Nothing to see here yet
</h3>
<p className="text-base leading-none text-zinc-400">
Be the first to share a message in this channel.
</p>
</div>
<div className="flex flex-col gap-1 text-center">
<h3 className="text-base font-semibold leading-none text-white">
Nothing to see here yet
</h3>
<p className="text-base leading-none text-zinc-400">
Be the first to share a message in this channel.
</p>
</div>
);
export function ChannelScreen() {
const ndk = useContext(RelayContext);
const virtuosoRef = useRef(null);
const ndk = useContext(RelayContext);
const virtuosoRef = useRef(null);
const { id } = useParams();
const { id } = useParams();
const [messages, fetchMessages, addMessage, clearMessages] =
useChannelMessages((state: any) => [
state.messages,
state.fetch,
state.add,
state.clear,
]);
const [messages, fetchMessages, addMessage, clearMessages] = useChannelMessages(
(state: any) => [state.messages, state.fetch, state.add, state.clear]
);
useLayoutEffect(() => {
fetchMessages(id);
}, [fetchMessages]);
useLayoutEffect(() => {
fetchMessages(id);
}, [fetchMessages]);
useEffect(() => {
// subscribe to channel
const sub = ndk.subscribe(
{
"#e": [id],
kinds: [42],
since: dateToUnix(),
},
{ closeOnEose: false },
);
useEffect(() => {
// subscribe to channel
const sub = ndk.subscribe(
{
'#e': [id],
kinds: [42],
since: dateToUnix(),
},
{ closeOnEose: false }
);
sub.addListener("event", (event: LumeEvent) => {
addMessage(id, event);
});
sub.addListener('event', (event: LumeEvent) => {
addMessage(id, event);
});
return () => {
clearMessages();
sub.stop();
};
}, []);
return () => {
clearMessages();
sub.stop();
};
}, []);
const itemContent: any = useCallback(
(index: string | number) => {
return <ChannelMessageItem data={messages[index]} />;
},
[messages],
);
const itemContent: any = useCallback(
(index: string | number) => {
return <ChannelMessageItem data={messages[index]} />;
},
[messages]
);
const computeItemKey = useCallback(
(index: string | number) => {
return messages[index].event_id;
},
[messages],
);
const computeItemKey = useCallback(
(index: string | number) => {
return messages[index].event_id;
},
[messages]
);
return (
<div className="h-full w-full grid grid-cols-3">
<div className="col-span-2 flex flex-col justify-between border-r border-zinc-900">
<div
data-tauri-drag-region
className="h-11 w-full shrink-0 inline-flex items-center justify-center border-b border-zinc-900"
>
<h3 className="font-semibold text-zinc-100">Public Channel</h3>
</div>
<div className="w-full h-full flex-1 p-3">
<div className="h-full flex flex-col justify-between rounded-xl border-t border-zinc-800/50 bg-zinc-900 overflow-hidden">
<div className="flex-1 w-full h-full">
{!messages ? (
<p>Loading...</p>
) : (
<Virtuoso
ref={virtuosoRef}
data={messages}
itemContent={itemContent}
computeItemKey={computeItemKey}
initialTopMostItemIndex={messages.length - 1}
alignToBottom={true}
followOutput={true}
overscan={50}
increaseViewportBy={{ top: 200, bottom: 200 }}
className="scrollbar-hide overflow-y-auto"
components={{
Header: () => Header,
EmptyPlaceholder: () => Empty,
}}
/>
)}
</div>
<div className="shrink-0 px-5 p-3 rounded-b-xl border-t border-zinc-800 bg-zinc-900 z-50">
<ChannelMessageForm channelID={id} />
</div>
</div>
</div>
</div>
<div className="col-span-1 flex flex-col">
<div
data-tauri-drag-region
className="h-11 w-full shrink-0 inline-flex items-center justify-center border-b border-zinc-900"
/>
<div className="p-3 flex flex-col gap-3">
<ChannelMetadata id={id} />
<ChannelMembers id={id} />
</div>
</div>
</div>
);
return (
<div className="grid h-full w-full grid-cols-3">
<div className="col-span-2 flex flex-col justify-between border-r border-zinc-900">
<div
data-tauri-drag-region
className="inline-flex h-11 w-full shrink-0 items-center justify-center border-b border-zinc-900"
>
<h3 className="font-semibold text-zinc-100">Public Channel</h3>
</div>
<div className="h-full w-full flex-1 p-3">
<div className="flex h-full flex-col justify-between overflow-hidden rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="h-full w-full flex-1">
{!messages ? (
<p>Loading...</p>
) : (
<Virtuoso
ref={virtuosoRef}
data={messages}
itemContent={itemContent}
computeItemKey={computeItemKey}
initialTopMostItemIndex={messages.length - 1}
alignToBottom={true}
followOutput={true}
overscan={50}
increaseViewportBy={{ top: 200, bottom: 200 }}
className="scrollbar-hide overflow-y-auto"
components={{
Header: () => Header,
EmptyPlaceholder: () => Empty,
}}
/>
)}
</div>
<div className="z-50 shrink-0 rounded-b-xl border-t border-zinc-800 bg-zinc-900 p-3 px-5">
<ChannelMessageForm channelID={id} />
</div>
</div>
</div>
</div>
<div className="col-span-1 flex flex-col">
<div
data-tauri-drag-region
className="inline-flex h-11 w-full shrink-0 items-center justify-center border-b border-zinc-900"
/>
<div className="flex flex-col gap-3 p-3">
<ChannelMetadata id={id} />
<ChannelMembers id={id} />
</div>
</div>
</div>
);
}

View File

@@ -1,58 +1,61 @@
import { Image } from "@shared/image";
import { DEFAULT_AVATAR } from "@stores/constants";
import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey";
import { NavLink } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { NavLink } from 'react-router-dom';
import { twMerge } from 'tailwind-merge';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
export function ChatsListItem({ data }: { data: any }) {
const { status, user } = useProfile(data.sender_pubkey);
const { status, user } = useProfile(data.sender_pubkey);
if (status === "loading") {
return (
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-2.5 w-2/3 animate-pulse rounded bg-zinc-800" />
</div>
);
}
if (status === 'loading') {
return (
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-2.5 w-2/3 animate-pulse rounded bg-zinc-800" />
</div>
);
}
return (
<NavLink
to={`/app/chat/${data.sender_pubkey}`}
preventScrollReset={true}
className={({ isActive }) =>
twMerge(
"inline-flex h-9 items-center gap-2.5 rounded-md px-2.5",
isActive ? "bg-zinc-900/50 text-zinc-100" : "",
)
}
>
<div className="inline-flex shrink-0 h-6 w-6 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<Image
src={user?.image}
fallback={DEFAULT_AVATAR}
alt={data.sender_pubkey}
className="h-6 w-6 rounded object-cover"
/>
</div>
<div className="w-full inline-flex items-center justify-between">
<div className="inline-flex items-baseline gap-1">
<h5 className="max-w-[10rem] truncate font-medium text-zinc-200">
{user?.nip05 ||
user?.name ||
user?.displayName ||
shortenKey(data.sender_pubkey)}
</h5>
</div>
<div className="flex items-center">
{data.new_messages > 0 && (
<span className="inline-flex items-center justify-center rounded bg-fuchsia-400/10 w-8 px-1 py-1 text-xs font-medium text-fuchsia-500 ring-1 ring-inset ring-fuchsia-400/20">
{data.new_messages}
</span>
)}
</div>
</div>
</NavLink>
);
return (
<NavLink
to={`/app/chat/${data.sender_pubkey}`}
preventScrollReset={true}
className={({ isActive }) =>
twMerge(
'inline-flex h-9 items-center gap-2.5 rounded-md px-2.5',
isActive ? 'bg-zinc-900/50 text-zinc-100' : ''
)
}
>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<Image
src={user?.image}
fallback={DEFAULT_AVATAR}
alt={data.sender_pubkey}
className="h-6 w-6 rounded object-cover"
/>
</div>
<div className="inline-flex w-full items-center justify-between">
<div className="inline-flex items-baseline gap-1">
<h5 className="max-w-[10rem] truncate font-medium text-zinc-200">
{user?.nip05 ||
user?.name ||
user?.displayName ||
shortenKey(data.sender_pubkey)}
</h5>
</div>
<div className="flex items-center">
{data.new_messages > 0 && (
<span className="inline-flex w-8 items-center justify-center rounded bg-fuchsia-400/10 px-1 py-1 text-xs font-medium text-fuchsia-500 ring-1 ring-inset ring-fuchsia-400/20">
{data.new_messages}
</span>
)}
</div>
</div>
</NavLink>
);
}

View File

@@ -1,68 +1,71 @@
import { ChatsListItem } from "@app/chat/components/item";
import { NewMessageModal } from "@app/chat/components/modal";
import { ChatsListSelfItem } from "@app/chat/components/self";
import { getChatsByPubkey } from "@libs/storage";
import { useQuery } from "@tanstack/react-query";
import { useAccount } from "@utils/hooks/useAccount";
import { useQuery } from '@tanstack/react-query';
import { ChatsListItem } from '@app/chat/components/item';
import { NewMessageModal } from '@app/chat/components/modal';
import { ChatsListSelfItem } from '@app/chat/components/self';
import { getChatsByPubkey } from '@libs/storage';
import { useAccount } from '@utils/hooks/useAccount';
export function ChatsList() {
const { account } = useAccount();
const { account } = useAccount();
const {
status,
data: chats,
isFetching,
} = useQuery(
["chats"],
async () => {
const chats = await getChatsByPubkey(account.pubkey);
const sorted = chats.sort(
(a, b) => parseInt(a.new_messages) - parseInt(b.new_messages),
);
return sorted;
},
{
enabled: account ? true : false,
},
);
const {
status,
data: chats,
isFetching,
} = useQuery(
['chats'],
async () => {
const chats = await getChatsByPubkey(account.pubkey);
const sorted = chats.sort(
(a, b) => parseInt(a.new_messages) - parseInt(b.new_messages)
);
return sorted;
},
{
enabled: account ? true : false,
}
);
if (status === "loading") {
return (
<div className="flex flex-col">
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3 w-full rounded-sm animate-pulse bg-zinc-800" />
</div>
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3 w-full rounded-sm animate-pulse bg-zinc-800" />
</div>
</div>
);
}
if (status === 'loading') {
return (
<div className="flex flex-col">
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3 w-full animate-pulse rounded-sm bg-zinc-800" />
</div>
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3 w-full animate-pulse rounded-sm bg-zinc-800" />
</div>
</div>
);
}
return (
<div className="flex flex-col">
<NewMessageModal />
{account ? (
<ChatsListSelfItem data={account} />
) : (
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3 w-full rounded-sm animate-pulse bg-zinc-800" />
</div>
)}
{chats.map((item) => {
if (account.pubkey !== item.sender_pubkey) {
return <ChatsListItem key={item.sender_pubkey} data={item} />;
}
})}
{isFetching && (
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3 w-full rounded-sm animate-pulse bg-zinc-800" />
</div>
)}
</div>
);
return (
<div className="flex flex-col">
<NewMessageModal />
{account ? (
<ChatsListSelfItem data={account} />
) : (
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3 w-full animate-pulse rounded-sm bg-zinc-800" />
</div>
)}
{chats.map((item) => {
if (account.pubkey !== item.sender_pubkey) {
return <ChatsListItem key={item.sender_pubkey} data={item} />;
}
})}
{isFetching && (
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3 w-full animate-pulse rounded-sm bg-zinc-800" />
</div>
)}
</div>
);
}

View File

@@ -1,65 +1,71 @@
import { usePublish } from "@libs/ndk";
import { EnterIcon } from "@shared/icons";
import { MediaUploader } from "@shared/mediaUploader";
import { nip04 } from "nostr-tools";
import { useCallback, useState } from "react";
import { nip04 } from 'nostr-tools';
import { useCallback, useState } from 'react';
import { usePublish } from '@libs/ndk';
import { EnterIcon } from '@shared/icons';
import { MediaUploader } from '@shared/mediaUploader';
export function ChatMessageForm({
receiverPubkey,
userPrivkey,
}: { receiverPubkey: string; userPubkey: string; userPrivkey: string }) {
const publish = usePublish();
const [value, setValue] = useState("");
receiverPubkey,
userPrivkey,
}: {
receiverPubkey: string;
userPubkey: string;
userPrivkey: string;
}) {
const publish = usePublish();
const [value, setValue] = useState('');
const encryptMessage = useCallback(async () => {
return await nip04.encrypt(userPrivkey, receiverPubkey, value);
}, [receiverPubkey, value]);
const encryptMessage = useCallback(async () => {
return await nip04.encrypt(userPrivkey, receiverPubkey, value);
}, [receiverPubkey, value]);
const submit = async () => {
const message = await encryptMessage();
const tags = [["p", receiverPubkey]];
const submit = async () => {
const message = await encryptMessage();
const tags = [['p', receiverPubkey]];
// publish message
await publish({ content: message, kind: 4, tags });
// publish message
await publish({ content: message, kind: 4, tags });
// reset state
setValue("");
};
// reset state
setValue('');
};
const handleEnterPress = (e: {
key: string;
shiftKey: any;
preventDefault: () => void;
}) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
};
const handleEnterPress = (e: {
key: string;
shiftKey: any;
preventDefault: () => void;
}) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submit();
}
};
return (
<div className="relative h-11 w-full">
<input
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleEnterPress}
spellCheck={false}
placeholder="Message"
className="relative h-11 w-full resize-none rounded-md px-5 !outline-none bg-zinc-800 placeholder:text-zinc-500"
/>
<div className="absolute right-2 top-0 h-11">
<div className="h-full flex gap-3 items-center justify-end text-zinc-500">
<MediaUploader setState={setValue} />
<button
type="button"
onClick={submit}
className="inline-flex items-center gap-1 text-sm leading-none"
>
<EnterIcon width={14} height={14} className="" />
Send
</button>
</div>
</div>
</div>
);
return (
<div className="relative h-11 w-full">
<input
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleEnterPress}
spellCheck={false}
placeholder="Message"
className="relative h-11 w-full resize-none rounded-md bg-zinc-800 px-5 !outline-none placeholder:text-zinc-500"
/>
<div className="absolute right-2 top-0 h-11">
<div className="flex h-full items-center justify-end gap-3 text-zinc-500">
<MediaUploader setState={setValue} />
<button
type="button"
onClick={submit}
className="inline-flex items-center gap-1 text-sm leading-none"
>
<EnterIcon width={14} height={14} className="" />
Send
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,49 +1,45 @@
import { useDecryptMessage } from "@app/chat/hooks/useDecryptMessage";
import { MentionNote } from "@shared/notes/mentions/note";
import { ImagePreview } from "@shared/notes/preview/image";
import { LinkPreview } from "@shared/notes/preview/link";
import { VideoPreview } from "@shared/notes/preview/video";
import { User } from "@shared/user";
import { parser } from "@utils/parser";
import { useDecryptMessage } from '@app/chat/hooks/useDecryptMessage';
import { MentionNote } from '@shared/notes/mentions/note';
import { ImagePreview } from '@shared/notes/preview/image';
import { LinkPreview } from '@shared/notes/preview/link';
import { VideoPreview } from '@shared/notes/preview/video';
import { User } from '@shared/user';
import { parser } from '@utils/parser';
export function ChatMessageItem({
data,
userPubkey,
userPrivkey,
data,
userPubkey,
userPrivkey,
}: {
data: any;
userPubkey: string;
userPrivkey: string;
data: any;
userPubkey: string;
userPrivkey: string;
}) {
const decryptedContent = useDecryptMessage(data, userPubkey, userPrivkey);
// if we have decrypted content, use it instead of the encrypted content
if (decryptedContent) {
data["content"] = decryptedContent;
}
// parse the note content
const content = parser(data);
const decryptedContent = useDecryptMessage(data, userPubkey, userPrivkey);
// if we have decrypted content, use it instead of the encrypted content
if (decryptedContent) {
data['content'] = decryptedContent;
}
// parse the note content
const content = parser(data);
return (
<div className="flex h-min min-h-min w-full select-text flex-col px-5 py-3 hover:bg-black/20">
<div className="flex flex-col">
<User
pubkey={data.sender_pubkey}
time={data.created_at}
isChat={true}
/>
<div className="-mt-[20px] pl-[49px]">
<p className="select-text whitespace-pre-line break-words text-base text-zinc-100">
{content.parsed}
</p>
{content.images.length > 0 && <ImagePreview urls={content.images} />}
{content.videos.length > 0 && <VideoPreview urls={content.videos} />}
{content.links.length > 0 && <LinkPreview urls={content.links} />}
{content.notes.length > 0 &&
content.notes.map((note: string) => (
<MentionNote key={note} id={note} />
))}
</div>
</div>
</div>
);
return (
<div className="flex h-min min-h-min w-full select-text flex-col px-5 py-3 hover:bg-black/20">
<div className="flex flex-col">
<User pubkey={data.sender_pubkey} time={data.created_at} isChat={true} />
<div className="-mt-[20px] pl-[49px]">
<p className="select-text whitespace-pre-line break-words text-base text-zinc-100">
{content.parsed}
</p>
{content.images.length > 0 && <ImagePreview urls={content.images} />}
{content.videos.length > 0 && <VideoPreview urls={content.videos} />}
{content.links.length > 0 && <LinkPreview urls={content.links} />}
{content.notes.length > 0 &&
content.notes.map((note: string) => <MentionNote key={note} id={note} />)}
</div>
</div>
</div>
);
}

View File

@@ -1,125 +1,123 @@
import { User } from "@app/auth/components/user";
import { Dialog, Transition } from "@headlessui/react";
import { CancelIcon, LoaderIcon, PlusIcon } from "@shared/icons";
import { useAccount } from "@utils/hooks/useAccount";
import { Fragment, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Dialog, Transition } from '@headlessui/react';
import { Fragment, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { CancelIcon, LoaderIcon, PlusIcon } from '@shared/icons';
import { useAccount } from '@utils/hooks/useAccount';
export function NewMessageModal() {
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const { status, account } = useAccount();
const follows = account ? JSON.parse(account.follows) : [];
const { status, account } = useAccount();
const follows = account ? JSON.parse(account.follows) : [];
const closeModal = () => {
setIsOpen(false);
};
const closeModal = () => {
setIsOpen(false);
};
const openModal = () => {
setIsOpen(true);
};
const openModal = () => {
setIsOpen(true);
};
const openChat = (pubkey: string) => {
closeModal();
navigate(`/app/chat/${pubkey}`);
};
const openChat = (pubkey: string) => {
closeModal();
navigate(`/app/chat/${pubkey}`);
};
return (
<>
<button
type="button"
onClick={() => openModal()}
className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5"
>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<PlusIcon width={12} height={12} className="text-zinc-500" />
</div>
<div>
<h5 className="font-medium text-zinc-400">New chat</h5>
</div>
</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-lg border-t border-zinc-800/50 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-5">
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<Dialog.Title
as="h3"
className="text-lg font-semibold leading-none text-zinc-100"
>
New chat
</Dialog.Title>
<button
type="button"
onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon
width={20}
height={20}
className="text-zinc-300"
/>
</button>
</div>
<Dialog.Description className="text-sm leading-tight text-zinc-400">
All messages will be encrypted, but anyone can see who you
chat
</Dialog.Description>
</div>
</div>
<div className="h-[500px] flex flex-col pb-5 overflow-x-hidden overflow-y-auto">
{status === "loading" ? (
<div className="px-4 py-3 inline-flex items-center justify-center">
<LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-zinc-100" />
</div>
) : (
follows.map((follow) => (
<div
key={follow}
className="group flex items-center justify-between px-4 py-3 hover:bg-zinc-800"
>
<User pubkey={follow} />
<div>
<button
type="button"
onClick={() => openChat(follow)}
className="inline-flex text-sm w-max px-3 py-1.5 rounded border-t border-zinc-600/50 bg-zinc-700 hover:bg-fuchsia-500 transform translate-x-20 group-hover:translate-x-0 transition-transform ease-in-out duration-150"
>
Chat
</button>
</div>
</div>
))
)}
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
return (
<>
<button
type="button"
onClick={() => openModal()}
className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5"
>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<PlusIcon width={12} height={12} className="text-zinc-500" />
</div>
<div>
<h5 className="font-medium text-zinc-400">New chat</h5>
</div>
</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-lg border-t border-zinc-800/50 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-5">
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<Dialog.Title
as="h3"
className="text-lg font-semibold leading-none text-zinc-100"
>
New chat
</Dialog.Title>
<button
type="button"
onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon width={20} height={20} className="text-zinc-300" />
</button>
</div>
<Dialog.Description className="text-sm leading-tight text-zinc-400">
All messages will be encrypted, but anyone can see who you chat
</Dialog.Description>
</div>
</div>
<div className="flex h-[500px] flex-col overflow-y-auto overflow-x-hidden pb-5">
{status === 'loading' ? (
<div className="inline-flex items-center justify-center px-4 py-3">
<LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-zinc-100" />
</div>
) : (
follows.map((follow) => (
<div
key={follow}
className="group flex items-center justify-between px-4 py-3 hover:bg-zinc-800"
>
<User pubkey={follow} />
<div>
<button
type="button"
onClick={() => openChat(follow)}
className="inline-flex w-max translate-x-20 transform rounded border-t border-zinc-600/50 bg-zinc-700 px-3 py-1.5 text-sm transition-transform duration-150 ease-in-out hover:bg-fuchsia-500 group-hover:translate-x-0"
>
Chat
</button>
</div>
</div>
))
)}
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
}

View File

@@ -1,49 +1,52 @@
import { Image } from "@shared/image";
import { DEFAULT_AVATAR } from "@stores/constants";
import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey";
import { NavLink } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { NavLink } from 'react-router-dom';
import { twMerge } from 'tailwind-merge';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
export function ChatsListSelfItem({ data }: { data: any }) {
const { status, user } = useProfile(data.pubkey);
const { status, user } = useProfile(data.pubkey);
if (status === "loading") {
return (
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div>
<div className="h-2.5 w-full animate-pulse truncate rounded bg-zinc-800 text-base font-medium" />
</div>
</div>
);
}
if (status === 'loading') {
return (
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div>
<div className="h-2.5 w-full animate-pulse truncate rounded bg-zinc-800 text-base font-medium" />
</div>
</div>
);
}
return (
<NavLink
to={`/app/chat/${data.pubkey}`}
preventScrollReset={true}
className={({ isActive }) =>
twMerge(
"inline-flex h-9 items-center gap-2.5 rounded-md px-2.5",
isActive ? "bg-zinc-900/50 text-zinc-100" : "",
)
}
>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<Image
src={user?.image}
fallback={DEFAULT_AVATAR}
alt={data.pubkey}
className="h-6 w-6 rounded bg-white object-cover"
/>
</div>
<div className="inline-flex items-baseline gap-1">
<h5 className="max-w-[9rem] truncate font-medium text-zinc-200">
{user?.nip05 || user?.name || shortenKey(data.pubkey)}
</h5>
<span className="text-zinc-500">(you)</span>
</div>
</NavLink>
);
return (
<NavLink
to={`/app/chat/${data.pubkey}`}
preventScrollReset={true}
className={({ isActive }) =>
twMerge(
'inline-flex h-9 items-center gap-2.5 rounded-md px-2.5',
isActive ? 'bg-zinc-900/50 text-zinc-100' : ''
)
}
>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<Image
src={user?.image}
fallback={DEFAULT_AVATAR}
alt={data.pubkey}
className="h-6 w-6 rounded bg-white object-cover"
/>
</div>
<div className="inline-flex items-baseline gap-1">
<h5 className="max-w-[9rem] truncate font-medium text-zinc-200">
{user?.nip05 || user?.name || shortenKey(data.pubkey)}
</h5>
<span className="text-zinc-500">(you)</span>
</div>
</NavLink>
);
}

View File

@@ -1,43 +1,46 @@
import { Image } from "@shared/image";
import { DEFAULT_AVATAR } from "@stores/constants";
import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey";
import { Link } from "react-router-dom";
import { Link } from 'react-router-dom';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
export function ChatSidebar({ pubkey }: { pubkey: string }) {
const { user } = useProfile(pubkey);
const { user } = useProfile(pubkey);
return (
<div className="px-3 py-2">
<div className="flex flex-col gap-3">
<div className="relative h-11 w-11 shrink rounded-md">
<Image
src={user?.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className="h-11 w-11 rounded-md object-cover"
/>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<h3 className="leading-none text-lg font-semibold">
{user?.displayName || user?.name}
</h3>
<h5 className="leading-none text-zinc-400">
{user?.nip05 || shortenKey(pubkey)}
</h5>
</div>
<div>
<p className="leading-tight">{user?.bio || user?.about}</p>
<Link
to={`/app/user/${pubkey}`}
className="mt-3 inline-flex w-full h-10 items-center justify-center rounded-md bg-zinc-900 hover:bg-zinc-800 text-sm text-zinc-300 hover:text-zinc-100 font-medium"
>
View full profile
</Link>
</div>
</div>
</div>
</div>
);
return (
<div className="px-3 py-2">
<div className="flex flex-col gap-3">
<div className="relative h-11 w-11 shrink rounded-md">
<Image
src={user?.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className="h-11 w-11 rounded-md object-cover"
/>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<h3 className="text-lg font-semibold leading-none">
{user?.displayName || user?.name}
</h3>
<h5 className="leading-none text-zinc-400">
{user?.nip05 || shortenKey(pubkey)}
</h5>
</div>
<div>
<p className="leading-tight">{user?.bio || user?.about}</p>
<Link
to={`/app/user/${pubkey}`}
className="mt-3 inline-flex h-10 w-full items-center justify-center rounded-md bg-zinc-900 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100"
>
View full profile
</Link>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,25 +1,19 @@
import { nip04 } from "nostr-tools";
import { useEffect, useState } from "react";
import { nip04 } from 'nostr-tools';
import { useEffect, useState } from 'react';
export function useDecryptMessage(
data: any,
userPubkey: string,
userPriv: string,
) {
const [content, setContent] = useState(data.content);
export function useDecryptMessage(data: any, userPubkey: string, userPriv: string) {
const [content, setContent] = useState(data.content);
useEffect(() => {
async function decrypt() {
const pubkey =
userPubkey === data.sender_pubkey
? data.receiver_pubkey
: data.sender_pubkey;
const result = await nip04.decrypt(userPriv, pubkey, data.content);
setContent(result);
}
useEffect(() => {
async function decrypt() {
const pubkey =
userPubkey === data.sender_pubkey ? data.receiver_pubkey : data.sender_pubkey;
const result = await nip04.decrypt(userPriv, pubkey, data.content);
setContent(result);
}
decrypt().catch(console.error);
}, []);
decrypt().catch(console.error);
}, []);
return content;
return content;
}

View File

@@ -1,155 +1,159 @@
import { ChatMessageForm } from "@app/chat/components/messages/form";
import { ChatMessageItem } from "@app/chat/components/messages/item";
import { ChatSidebar } from "@app/chat/components/sidebar";
import { createChat, getChatMessages } from "@libs/storage";
import { NDKSubscription } from "@nostr-dev-kit/ndk";
import { RelayContext } from "@shared/relayProvider";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useAccount } from "@utils/hooks/useAccount";
import { useCallback, useContext, useEffect, useRef } from "react";
import { useParams } from "react-router-dom";
import { Virtuoso } from "react-virtuoso";
import { NDKSubscription } from '@nostr-dev-kit/ndk';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useContext, useEffect, useRef } from 'react';
import { useParams } from 'react-router-dom';
import { Virtuoso } from 'react-virtuoso';
import { ChatMessageForm } from '@app/chat/components/messages/form';
import { ChatMessageItem } from '@app/chat/components/messages/item';
import { ChatSidebar } from '@app/chat/components/sidebar';
import { createChat, getChatMessages } from '@libs/storage';
import { RelayContext } from '@shared/relayProvider';
import { useAccount } from '@utils/hooks/useAccount';
export function ChatScreen() {
const ndk = useContext(RelayContext);
const queryClient = useQueryClient();
const virtuosoRef = useRef(null);
const ndk = useContext(RelayContext);
const queryClient = useQueryClient();
const virtuosoRef = useRef(null);
const { pubkey } = useParams();
const { account } = useAccount();
const { status, data } = useQuery(
["chat", pubkey],
async () => {
return await getChatMessages(account.pubkey, pubkey);
},
{
enabled: account ? true : false,
},
);
const { pubkey } = useParams();
const { account } = useAccount();
const { status, data } = useQuery(
['chat', pubkey],
async () => {
return await getChatMessages(account.pubkey, pubkey);
},
{
enabled: account ? true : false,
}
);
const itemContent: any = useCallback(
(index: string | number) => {
return (
<ChatMessageItem
data={data[index]}
userPubkey={account.pubkey}
userPrivkey={account.privkey}
/>
);
},
[data],
);
const itemContent: any = useCallback(
(index: string | number) => {
return (
<ChatMessageItem
data={data[index]}
userPubkey={account.pubkey}
userPrivkey={account.privkey}
/>
);
},
[data]
);
const computeItemKey = useCallback(
(index: string | number) => {
return data[index].id;
},
[data],
);
const computeItemKey = useCallback(
(index: string | number) => {
return data[index].id;
},
[data]
);
const chat = useMutation({
mutationFn: (data: any) => {
return createChat(
data.id,
data.receiver_pubkey,
data.sender_pubkey,
data.content,
data.tags,
data.created_at,
);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["chat", pubkey] });
},
});
const chat = useMutation({
mutationFn: (data: any) => {
return createChat(
data.id,
data.receiver_pubkey,
data.sender_pubkey,
data.content,
data.tags,
data.created_at
);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['chat', pubkey] });
},
});
useEffect(() => {
const sub: NDKSubscription = ndk.subscribe(
{
kinds: [4],
authors: [account.pubkey],
"#p": [pubkey],
since: Math.floor(Date.now() / 1000),
},
{
closeOnEose: false,
},
);
useEffect(() => {
const sub: NDKSubscription = ndk.subscribe(
{
kinds: [4],
authors: [account.pubkey],
'#p': [pubkey],
since: Math.floor(Date.now() / 1000),
},
{
closeOnEose: false,
}
);
sub.addListener("event", (event) => {
chat.mutate({
id: event.id,
receiver_pubkey: pubkey,
sender_pubkey: event.pubkey,
content: event.content,
tags: event.tags,
created_at: event.created_at,
});
});
sub.addListener('event', (event) => {
chat.mutate({
id: event.id,
receiver_pubkey: pubkey,
sender_pubkey: event.pubkey,
content: event.content,
tags: event.tags,
created_at: event.created_at,
});
});
return () => {
sub.stop();
};
}, [pubkey]);
return () => {
sub.stop();
};
}, [pubkey]);
return (
<div className="h-full w-full grid grid-cols-3">
<div className="col-span-2 flex flex-col justify-between border-r border-zinc-900">
<div
data-tauri-drag-region
className="h-11 w-full shrink-0 inline-flex items-center justify-center border-b border-zinc-900"
>
<h3 className="font-semibold text-zinc-100">Encrypted Chat</h3>
</div>
<div className="w-full h-full flex-1 p-3">
<div className="h-full flex flex-col justify-between rounded-xl border-t border-zinc-800/50 bg-zinc-900 overflow-hidden">
<div className="flex-1 w-full h-full">
{status === "loading" ? (
<p>Loading...</p>
) : (
<Virtuoso
ref={virtuosoRef}
data={data}
itemContent={itemContent}
computeItemKey={computeItemKey}
initialTopMostItemIndex={data.length - 1}
alignToBottom={true}
followOutput={true}
overscan={50}
increaseViewportBy={{ top: 200, bottom: 200 }}
className="relative scrollbar-hide overflow-y-auto"
components={{
EmptyPlaceholder: () => Empty,
}}
/>
)}
</div>
<div className="shrink-0 px-5 p-3 rounded-b-xl border-t border-zinc-800 bg-zinc-900 z-50">
<ChatMessageForm
receiverPubkey={pubkey}
userPubkey={account.pubkey}
userPrivkey={account.privkey}
/>
</div>
</div>
</div>
</div>
<div className="col-span-1">
<div
data-tauri-drag-region
className="h-11 w-full shrink-0 inline-flex items-center justify-center border-b border-zinc-900"
/>
<ChatSidebar pubkey={pubkey} />
</div>
</div>
);
return (
<div className="grid h-full w-full grid-cols-3">
<div className="col-span-2 flex flex-col justify-between border-r border-zinc-900">
<div
data-tauri-drag-region
className="inline-flex h-11 w-full shrink-0 items-center justify-center border-b border-zinc-900"
>
<h3 className="font-semibold text-zinc-100">Encrypted Chat</h3>
</div>
<div className="h-full w-full flex-1 p-3">
<div className="flex h-full flex-col justify-between overflow-hidden rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="h-full w-full flex-1">
{status === 'loading' ? (
<p>Loading...</p>
) : (
<Virtuoso
ref={virtuosoRef}
data={data}
itemContent={itemContent}
computeItemKey={computeItemKey}
initialTopMostItemIndex={data.length - 1}
alignToBottom={true}
followOutput={true}
overscan={50}
increaseViewportBy={{ top: 200, bottom: 200 }}
className="scrollbar-hide relative overflow-y-auto"
components={{
EmptyPlaceholder: () => Empty,
}}
/>
)}
</div>
<div className="z-50 shrink-0 rounded-b-xl border-t border-zinc-800 bg-zinc-900 p-3 px-5">
<ChatMessageForm
receiverPubkey={pubkey}
userPubkey={account.pubkey}
userPrivkey={account.privkey}
/>
</div>
</div>
</div>
</div>
<div className="col-span-1">
<div
data-tauri-drag-region
className="inline-flex h-11 w-full shrink-0 items-center justify-center border-b border-zinc-900"
/>
<ChatSidebar pubkey={pubkey} />
</div>
</div>
);
}
const Empty = (
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-full flex flex-col gap-1 text-center">
<h3 className="mb-2 text-4xl">🙌</h3>
<p className="leading-none text-zinc-400">
You two didn't talk yet, let's send first message
</p>
</div>
<div className="absolute left-1/2 top-1/2 flex w-full -translate-x-1/2 -translate-y-1/2 transform flex-col gap-1 text-center">
<h3 className="mb-2 text-4xl">🙌</h3>
<p className="leading-none text-zinc-400">
You two didn&apos;t talk yet, let&apos;s send first message
</p>
</div>
);

View File

@@ -1,17 +1,17 @@
import { useRouteError } from "react-router-dom";
import { useRouteError } from 'react-router-dom';
export function ErrorScreen() {
const error: any = useRouteError();
const error: any = useRouteError();
return (
<div className="w-full h-full flex items-center justify-center">
<div>
<h1>Oops!</h1>
<p>Sorry, an unexpected error has occurred.</p>
<p>
<i>{error.statusText || error.message}</i>
</p>
</div>
</div>
);
return (
<div className="flex h-full w-full items-center justify-center">
<div>
<h1>Oops!</h1>
<p>Sorry, an unexpected error has occurred.</p>
<p>
<i>{error.statusText || error.message}</i>
</p>
</div>
</div>
);
}

View File

@@ -1,185 +1,187 @@
import { prefetchEvents } from "@libs/ndk";
import { NDKFilter } from '@nostr-dev-kit/ndk';
import { useContext, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { prefetchEvents } from '@libs/ndk';
import {
countTotalNotes,
createChannelMessage,
createChat,
createNote,
getChannels,
getLastLogin,
updateLastLogin,
} from "@libs/storage";
import { NDKFilter } from "@nostr-dev-kit/ndk";
import { LoaderIcon, LumeIcon } from "@shared/icons";
import { RelayContext } from "@shared/relayProvider";
import { dateToUnix, getHourAgo } from "@utils/date";
import { useAccount } from "@utils/hooks/useAccount";
import { useContext, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
countTotalNotes,
createChannelMessage,
createChat,
createNote,
getChannels,
getLastLogin,
updateLastLogin,
} from '@libs/storage';
import { LoaderIcon, LumeIcon } from '@shared/icons';
import { RelayContext } from '@shared/relayProvider';
import { dateToUnix, getHourAgo } from '@utils/date';
import { useAccount } from '@utils/hooks/useAccount';
const totalNotes = await countTotalNotes();
const lastLogin = await getLastLogin();
export function Root() {
const ndk = useContext(RelayContext);
const now = useRef(new Date());
const navigate = useNavigate();
const ndk = useContext(RelayContext);
const now = useRef(new Date());
const navigate = useNavigate();
const { status, account } = useAccount();
const { status, account } = useAccount();
async function fetchNotes() {
try {
const follows = JSON.parse(account.follows);
let since: number;
async function fetchNotes() {
try {
const follows = JSON.parse(account.follows);
let since: number;
if (totalNotes === 0 || lastLogin === 0) {
since = dateToUnix(getHourAgo(48, now.current));
} else {
since = lastLogin;
}
if (totalNotes === 0 || lastLogin === 0) {
since = dateToUnix(getHourAgo(48, now.current));
} else {
since = lastLogin;
}
const filter: NDKFilter = {
kinds: [1, 6],
authors: follows,
since: since,
};
const filter: NDKFilter = {
kinds: [1, 6],
authors: follows,
since: since,
};
const events = await prefetchEvents(ndk, filter);
events.forEach((event) => {
createNote(
event.id,
event.pubkey,
event.kind,
event.tags,
event.content,
event.created_at,
);
});
const events = await prefetchEvents(ndk, filter);
events.forEach((event) => {
createNote(
event.id,
event.pubkey,
event.kind,
event.tags,
event.content,
event.created_at
);
});
return true;
} catch (e) {
console.log("error: ", e);
}
}
return true;
} catch (e) {
console.log('error: ', e);
}
}
async function fetchChats() {
try {
const sendFilter: NDKFilter = {
kinds: [4],
authors: [account.pubkey],
since: lastLogin,
};
const receiveFilter: NDKFilter = {
kinds: [4],
"#p": [account.pubkey],
since: lastLogin,
};
async function fetchChats() {
try {
const sendFilter: NDKFilter = {
kinds: [4],
authors: [account.pubkey],
since: lastLogin,
};
const receiveFilter: NDKFilter = {
kinds: [4],
'#p': [account.pubkey],
since: lastLogin,
};
const sendMessages = await prefetchEvents(ndk, sendFilter);
const receiveMessages = await prefetchEvents(ndk, receiveFilter);
const events = [...sendMessages, ...receiveMessages];
const sendMessages = await prefetchEvents(ndk, sendFilter);
const receiveMessages = await prefetchEvents(ndk, receiveFilter);
const events = [...sendMessages, ...receiveMessages];
events.forEach((event) => {
const receiverPubkey =
event.tags.find((t) => t[0] === "p")[1] || account.pubkey;
createChat(
event.id,
receiverPubkey,
event.pubkey,
event.content,
event.tags,
event.created_at,
);
});
events.forEach((event) => {
const receiverPubkey = event.tags.find((t) => t[0] === 'p')[1] || account.pubkey;
createChat(
event.id,
receiverPubkey,
event.pubkey,
event.content,
event.tags,
event.created_at
);
});
return true;
} catch (e) {
console.log("error: ", e);
}
}
return true;
} catch (e) {
console.log('error: ', e);
}
}
async function fetchChannelMessages() {
try {
const ids = [];
const channels: any = await getChannels();
channels.forEach((channel) => {
ids.push(channel.event_id);
});
/*
async function fetchChannelMessages() {
try {
const ids = [];
const channels: any = await getChannels();
channels.forEach((channel) => {
ids.push(channel.event_id);
});
const since =
lastLogin === 0 ? dateToUnix(getHourAgo(48, now.current)) : lastLogin;
const since = lastLogin === 0 ? dateToUnix(getHourAgo(48, now.current)) : lastLogin;
const filter: NDKFilter = {
"#e": ids,
kinds: [42],
since: since,
};
const filter: NDKFilter = {
'#e': ids,
kinds: [42],
since: since,
};
const events = await prefetchEvents(ndk, filter);
events.forEach((event) => {
const channel_id = event.tags[0][1];
if (channel_id) {
createChannelMessage(
channel_id,
event.id,
event.pubkey,
event.kind,
event.content,
event.tags,
event.created_at,
);
}
});
const events = await prefetchEvents(ndk, filter);
events.forEach((event) => {
const channel_id = event.tags[0][1];
if (channel_id) {
createChannelMessage(
channel_id,
event.id,
event.pubkey,
event.kind,
event.content,
event.tags,
event.created_at
);
}
});
return true;
} catch (e) {
console.log("error: ", e);
}
}
return true;
} catch (e) {
console.log('error: ', e);
}
}
*/
useEffect(() => {
async function prefetch() {
const notes = await fetchNotes();
if (notes) {
const chats = await fetchChats();
// const channels = await fetchChannelMessages();
if (chats) {
const now = Math.floor(Date.now() / 1000);
await updateLastLogin(now);
navigate("/app/space", { replace: true });
}
}
}
useEffect(() => {
async function prefetch() {
const notes = await fetchNotes();
if (notes) {
const chats = await fetchChats();
// const channels = await fetchChannelMessages();
if (chats) {
const now = Math.floor(Date.now() / 1000);
await updateLastLogin(now);
navigate('/app/space', { replace: true });
}
}
}
if (status === "success" && account) {
prefetch();
}
}, [status]);
if (status === 'success' && account) {
prefetch();
}
}, [status]);
return (
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-zinc-100">
<div className="relative h-full overflow-hidden">
<div
data-tauri-drag-region
className="absolute left-0 top-0 z-20 h-16 w-full bg-transparent"
/>
<div className="relative flex h-full flex-col items-center justify-center">
<div className="flex flex-col items-center gap-2">
<LumeIcon className="h-16 w-16 text-black dark:text-zinc-100" />
<div className="text-center">
<h3 className="text-lg font-semibold leading-tight text-zinc-900 dark:text-zinc-100">
Here&apos;s an interesting fact:
</h3>
<p className="font-medium text-zinc-300 dark:text-zinc-600">
Bitcoin and Nostr can be used by anyone, and no one can stop
you!
</p>
</div>
</div>
<div className="absolute bottom-16 left-1/2 -translate-x-1/2 transform">
<LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-zinc-100" />
</div>
</div>
</div>
</div>
);
return (
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-zinc-100">
<div className="relative h-full overflow-hidden">
<div
data-tauri-drag-region
className="absolute left-0 top-0 z-20 h-16 w-full bg-transparent"
/>
<div className="relative flex h-full flex-col items-center justify-center">
<div className="flex flex-col items-center gap-2">
<LumeIcon className="h-16 w-16 text-black dark:text-zinc-100" />
<div className="text-center">
<h3 className="text-lg font-semibold leading-tight text-zinc-900 dark:text-zinc-100">
Here&apos;s an interesting fact:
</h3>
<p className="font-medium text-zinc-300 dark:text-zinc-600">
Bitcoin and Nostr can be used by anyone, and no one can stop you!
</p>
</div>
</div>
<div className="absolute bottom-16 left-1/2 -translate-x-1/2 transform">
<LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-zinc-100" />
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,84 +1,89 @@
import { EyeOffIcon, EyeOnIcon } from "@shared/icons";
import { useAccount } from "@utils/hooks/useAccount";
import { useState } from "react";
import { useState } from 'react';
import { EyeOffIcon, EyeOnIcon } from '@shared/icons';
import { useAccount } from '@utils/hooks/useAccount';
export function AccountSettingsScreen() {
const { status, account } = useAccount();
const [type, setType] = useState("password");
const { status, account } = useAccount();
const [type, setType] = useState('password');
const showPrivateKey = () => {
if (type === "password") {
setType("text");
} else {
setType("password");
}
};
const showPrivateKey = () => {
if (type === 'password') {
setType('text');
} else {
setType('password');
}
};
return (
<div className="w-full h-full px-3 pt-12">
<div className="flex flex-col gap-2">
<h1 className="text-lg font-semibold text-zinc-100">Account</h1>
<div className="">
{status === "loading" ? (
<p>Loading...</p>
) : (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label className="text-base font-semibold text-zinc-400">
Public Key
</label>
<input
readOnly
value={account.pubkey}
className="relative w-2/3 rounded-lg py-3 pl-3.5 pr-11 !outline-none placeholder:text-zinc-400 bg-zinc-800 text-zinc-100"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-base font-semibold text-zinc-400">
Npub
</label>
<input
readOnly
value={account.npub}
className="relative w-2/3 rounded-lg py-3 pl-3.5 pr-11 !outline-none placeholder:text-zinc-400 bg-zinc-800 text-zinc-100"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-base font-semibold text-zinc-400">
Private Key
</label>
<div className="relative w-2/3">
<input
readOnly
type={type}
value={account.privkey}
className="relative w-full rounded-lg py-3 pl-3.5 pr-11 !outline-none placeholder:text-zinc-400 bg-zinc-800 text-zinc-100"
/>
<button
type="button"
onClick={() => showPrivateKey()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
>
{type === "password" ? (
<EyeOffIcon
width={20}
height={20}
className="text-zinc-500 group-hover:text-zinc-100"
/>
) : (
<EyeOnIcon
width={20}
height={20}
className="text-zinc-500 group-hover:text-zinc-100"
/>
)}
</button>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
return (
<div className="h-full w-full px-3 pt-12">
<div className="flex flex-col gap-2">
<h1 className="text-lg font-semibold text-zinc-100">Account</h1>
<div className="">
{status === 'loading' ? (
<p>Loading...</p>
) : (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label htmlFor="pubkey" className="text-base font-semibold text-zinc-400">
Public Key
</label>
<input
readOnly
value={account.pubkey}
className="relative w-2/3 rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-zinc-100 !outline-none placeholder:text-zinc-400"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="npub" className="text-base font-semibold text-zinc-400">
Npub
</label>
<input
readOnly
value={account.npub}
className="relative w-2/3 rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-zinc-100 !outline-none placeholder:text-zinc-400"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="privkey"
className="text-base font-semibold text-zinc-400"
>
Private Key
</label>
<div className="relative w-2/3">
<input
readOnly
type={type}
value={account.privkey}
className="relative w-full rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-zinc-100 !outline-none placeholder:text-zinc-400"
/>
<button
type="button"
onClick={() => showPrivateKey()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
>
{type === 'password' ? (
<EyeOffIcon
width={20}
height={20}
className="text-zinc-500 group-hover:text-zinc-100"
/>
) : (
<EyeOnIcon
width={20}
height={20}
className="text-zinc-500 group-hover:text-zinc-100"
/>
)}
</button>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,61 +1,58 @@
import { Switch } from "@headlessui/react";
import { getSetting, updateSetting } from "@libs/storage";
import { useEffect, useState } from "react";
import { twMerge } from "tailwind-merge";
import { disable, enable, isEnabled } from "tauri-plugin-autostart-api";
import { Switch } from '@headlessui/react';
import { useEffect, useState } from 'react';
import { twMerge } from 'tailwind-merge';
import { disable, enable, isEnabled } from 'tauri-plugin-autostart-api';
import { getSetting, updateSetting } from '@libs/storage';
export function AutoStartSetting() {
const [enabled, setEnabled] = useState(false);
const [enabled, setEnabled] = useState(false);
const toggle = async () => {
if (!enabled) {
await enable();
await updateSetting("auto_start", 1);
console.log(`registered for autostart? ${await isEnabled()}`);
} else {
await disable();
await updateSetting("auto_start", 0);
}
setEnabled(!enabled);
};
const toggle = async () => {
if (!enabled) {
await enable();
await updateSetting('auto_start', 1);
console.log(`registered for autostart? ${await isEnabled()}`);
} else {
await disable();
await updateSetting('auto_start', 0);
}
setEnabled(!enabled);
};
useEffect(() => {
async function getAppSetting() {
const setting = await getSetting("auto_start");
if (parseInt(setting) === 0) {
setEnabled(false);
} else {
setEnabled(true);
}
}
getAppSetting();
}, []);
useEffect(() => {
async function getAppSetting() {
const setting = await getSetting('auto_start');
if (parseInt(setting) === 0) {
setEnabled(false);
} else {
setEnabled(true);
}
}
getAppSetting();
}, []);
return (
<div className="px-5 py-4 inline-flex items-center justify-between">
<div className="flex flex-col gap-1">
<span className="leading-none font-medium text-zinc-200">
Auto start
</span>
<span className="leading-none text-sm text-zinc-400">
Auto start at login
</span>
</div>
<Switch
checked={enabled}
onChange={toggle}
className={twMerge(
"relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-fuchsia-500 focus:ring-offset-2",
enabled ? "bg-fuchsia-500" : "bg-zinc-700",
)}
>
<span
className={twMerge(
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-zinc-900 shadow ring-0 transition duration-200 ease-in-out",
enabled ? "translate-x-5" : "translate-x-0",
)}
/>
</Switch>
</div>
);
return (
<div className="inline-flex items-center justify-between px-5 py-4">
<div className="flex flex-col gap-1">
<span className="font-medium leading-none text-zinc-200">Auto start</span>
<span className="text-sm leading-none text-zinc-400">Auto start at login</span>
</div>
<Switch
checked={enabled}
onChange={toggle}
className={twMerge(
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-fuchsia-500 focus:ring-offset-2',
enabled ? 'bg-fuchsia-500' : 'bg-zinc-700'
)}
>
<span
className={twMerge(
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-zinc-900 shadow ring-0 transition duration-200 ease-in-out',
enabled ? 'translate-x-5' : 'translate-x-0'
)}
/>
</Switch>
</div>
);
}

View File

@@ -1,43 +1,43 @@
import { getSetting, updateSetting } from "@libs/storage";
import { CheckCircleIcon } from "@shared/icons";
import { useState } from "react";
import { useState } from 'react';
const setting = await getSetting("cache_time");
import { getSetting, updateSetting } from '@libs/storage';
import { CheckCircleIcon } from '@shared/icons';
const setting = await getSetting('cache_time');
const cacheTime = setting;
export function CacheTimeSetting() {
const [time, setTime] = useState(cacheTime);
const [time, setTime] = useState(cacheTime);
const update = async () => {
await updateSetting("cache_time", time);
};
const update = async () => {
await updateSetting('cache_time', time);
};
return (
<div className="px-5 py-4 inline-flex items-center justify-between">
<div className="flex flex-col gap-1">
<span className="leading-none font-medium text-zinc-200">
Cache time
</span>
<span className="leading-none text-sm text-zinc-400">
The length of time before inactive data gets removed from the cache
</span>
</div>
<div className="inline-flex items-center gap-2">
<input
value={time}
onChange={(e) => setTime(e.currentTarget.value)}
autoCapitalize="none"
autoCorrect="none"
className="w-24 h-8 rounded-md px-2 bg-zinc-800 text-zinc-300 text-right font-medium focus:outline-none"
/>
<button
type="button"
onClick={() => update()}
className="w-8 h-8 inline-flex items-center justify-center font-medium bg-zinc-800 hover:bg-fuchsia-500 rounded-md"
>
<CheckCircleIcon className="w-4 h-4 text-zinc-100" />
</button>
</div>
</div>
);
return (
<div className="inline-flex items-center justify-between px-5 py-4">
<div className="flex flex-col gap-1">
<span className="font-medium leading-none text-zinc-200">Cache time</span>
<span className="text-sm leading-none text-zinc-400">
The length of time before inactive data gets removed from the cache
</span>
</div>
<div className="inline-flex items-center gap-2">
<input
value={time}
onChange={(e) => setTime(e.currentTarget.value)}
autoCapitalize="none"
autoCorrect="none"
className="h-8 w-24 rounded-md bg-zinc-800 px-2 text-right font-medium text-zinc-300 focus:outline-none"
/>
<button
type="button"
onClick={() => update()}
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-zinc-800 font-medium hover:bg-fuchsia-500"
>
<CheckCircleIcon className="h-4 w-4 text-zinc-100" />
</button>
</div>
</div>
);
}

View File

@@ -1,26 +1,27 @@
import { RefreshIcon } from "@shared/icons";
import { getVersion } from "@tauri-apps/api/app";
import { getVersion } from '@tauri-apps/api/app';
import { RefreshIcon } from '@shared/icons';
const appVersion = await getVersion();
export function VersionSetting() {
return (
<div className="px-5 py-4 inline-flex items-center justify-between">
<div className="flex flex-col gap-1">
<span className="leading-none font-medium text-zinc-200">Version</span>
<span className="leading-none text-sm text-zinc-400">
You're using latest version
</span>
</div>
<div className="inline-flex items-center gap-2">
<span className="text-zinc-300 font-medium">{appVersion}</span>
<button
type="button"
className="w-8 h-8 inline-flex items-center justify-center font-medium bg-zinc-800 hover:bg-fuchsia-500 rounded-md"
>
<RefreshIcon className="w-4 h-4 text-zinc-100" />
</button>
</div>
</div>
);
return (
<div className="inline-flex items-center justify-between px-5 py-4">
<div className="flex flex-col gap-1">
<span className="font-medium leading-none text-zinc-200">Version</span>
<span className="text-sm leading-none text-zinc-400">
You&apos;re using latest version
</span>
</div>
<div className="inline-flex items-center gap-2">
<span className="font-medium text-zinc-300">{appVersion}</span>
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-zinc-800 font-medium hover:bg-fuchsia-500"
>
<RefreshIcon className="h-4 w-4 text-zinc-100" />
</button>
</div>
</div>
);
}

View File

@@ -1,20 +1,20 @@
import { AutoStartSetting } from "@app/settings/components/autoStart";
import { CacheTimeSetting } from "@app/settings/components/cacheTime";
import { VersionSetting } from "@app/settings/components/version";
import { AutoStartSetting } from '@app/settings/components/autoStart';
import { CacheTimeSetting } from '@app/settings/components/cacheTime';
import { VersionSetting } from '@app/settings/components/version';
export function GeneralSettingsScreen() {
return (
<div className="w-full h-full px-3 pt-12">
<div className="flex flex-col gap-2">
<h1 className="text-lg font-semibold text-zinc-100">General</h1>
<div className="w-full bg-zinc-900 border-t border-zinc-800/50 rounded-xl">
<div className="w-full h-full flex flex-col divide-y divide-zinc-800">
<AutoStartSetting />
<CacheTimeSetting />
<VersionSetting />
</div>
</div>
</div>
</div>
);
return (
<div className="h-full w-full px-3 pt-12">
<div className="flex flex-col gap-2">
<h1 className="text-lg font-semibold text-zinc-100">General</h1>
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="flex h-full w-full flex-col divide-y divide-zinc-800">
<AutoStartSetting />
<CacheTimeSetting />
<VersionSetting />
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,110 +1,90 @@
import { CommandIcon } from "@shared/icons";
import { CommandIcon } from '@shared/icons';
export function ShortcutsSettingsScreen() {
return (
<div className="w-full h-full px-3 pt-12">
<div className="flex flex-col gap-2">
<h1 className="text-lg font-semibold text-zinc-100">Shortcuts</h1>
<div className="w-full bg-zinc-900 border-t border-zinc-800/50 rounded-xl">
<div className="w-full h-full flex flex-col divide-y divide-zinc-800">
<div className="px-5 py-4 inline-flex items-center justify-between">
<div className="flex flex-col gap-1">
<span className="leading-none font-medium text-zinc-200">
Open composer
</span>
</div>
<div className="flex items-center gap-2">
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
<CommandIcon
width={12}
height={12}
className="text-zinc-500"
/>
</div>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
<span className="text-zinc-500 text-sm leading-none">N</span>
</div>
</div>
</div>
<div className="px-5 py-4 inline-flex items-center justify-between">
<div className="flex flex-col gap-1">
<span className="leading-none font-medium text-zinc-200">
Add image block
</span>
</div>
<div className="flex items-center gap-2">
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
<CommandIcon
width={12}
height={12}
className="text-zinc-500"
/>
</div>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
<span className="text-zinc-500 text-sm leading-none">I</span>
</div>
</div>
</div>
<div className="px-5 py-4 inline-flex items-center justify-between">
<div className="flex flex-col gap-1">
<span className="leading-none font-medium text-zinc-200">
Add newsfeed block
</span>
</div>
<div className="flex items-center gap-2">
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
<CommandIcon
width={12}
height={12}
className="text-zinc-500"
/>
</div>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
<span className="text-zinc-500 text-sm leading-none">F</span>
</div>
</div>
</div>
<div className="px-5 py-4 inline-flex items-center justify-between">
<div className="flex flex-col gap-1">
<span className="leading-none font-medium text-zinc-200">
Open personal page
</span>
</div>
<div className="flex items-center gap-2">
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
<CommandIcon
width={12}
height={12}
className="text-zinc-500"
/>
</div>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
<span className="text-zinc-500 text-sm leading-none">P</span>
</div>
</div>
</div>
<div className="px-5 py-4 inline-flex items-center justify-between">
<div className="flex flex-col gap-1">
<span className="leading-none font-medium text-zinc-200">
Open notification
</span>
</div>
<div className="flex items-center gap-2">
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
<CommandIcon
width={12}
height={12}
className="text-zinc-500"
/>
</div>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
<span className="text-zinc-500 text-sm leading-none">B</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
return (
<div className="h-full w-full px-3 pt-12">
<div className="flex flex-col gap-2">
<h1 className="text-lg font-semibold text-zinc-100">Shortcuts</h1>
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="flex h-full w-full flex-col divide-y divide-zinc-800">
<div className="inline-flex items-center justify-between px-5 py-4">
<div className="flex flex-col gap-1">
<span className="font-medium leading-none text-zinc-200">
Open composer
</span>
</div>
<div className="flex items-center gap-2">
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
<CommandIcon width={12} height={12} className="text-zinc-500" />
</div>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
<span className="text-sm leading-none text-zinc-500">N</span>
</div>
</div>
</div>
<div className="inline-flex items-center justify-between px-5 py-4">
<div className="flex flex-col gap-1">
<span className="font-medium leading-none text-zinc-200">
Add image block
</span>
</div>
<div className="flex items-center gap-2">
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
<CommandIcon width={12} height={12} className="text-zinc-500" />
</div>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
<span className="text-sm leading-none text-zinc-500">I</span>
</div>
</div>
</div>
<div className="inline-flex items-center justify-between px-5 py-4">
<div className="flex flex-col gap-1">
<span className="font-medium leading-none text-zinc-200">
Add newsfeed block
</span>
</div>
<div className="flex items-center gap-2">
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
<CommandIcon width={12} height={12} className="text-zinc-500" />
</div>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
<span className="text-sm leading-none text-zinc-500">F</span>
</div>
</div>
</div>
<div className="inline-flex items-center justify-between px-5 py-4">
<div className="flex flex-col gap-1">
<span className="font-medium leading-none text-zinc-200">
Open personal page
</span>
</div>
<div className="flex items-center gap-2">
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
<CommandIcon width={12} height={12} className="text-zinc-500" />
</div>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
<span className="text-sm leading-none text-zinc-500">P</span>
</div>
</div>
</div>
<div className="inline-flex items-center justify-between px-5 py-4">
<div className="flex flex-col gap-1">
<span className="font-medium leading-none text-zinc-200">
Open notification
</span>
</div>
<div className="flex items-center gap-2">
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
<CommandIcon width={12} height={12} className="text-zinc-500" />
</div>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
<span className="text-sm leading-none text-zinc-500">B</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,11 +1,11 @@
import { AddFeedBlock } from "@app/space/components/addFeed";
import { AddImageBlock } from "@app/space/components/addImage";
import { AddFeedBlock } from '@app/space/components/addFeed';
import { AddImageBlock } from '@app/space/components/addImage';
export function AddBlock() {
return (
<div className="flex flex-col gap-1">
<AddImageBlock />
<AddFeedBlock />
</div>
);
return (
<div className="flex flex-col gap-1">
<AddImageBlock />
<AddFeedBlock />
</div>
);
}

View File

@@ -1,274 +1,252 @@
import { User } from "@app/auth/components/user";
import { Dialog, Transition } from "@headlessui/react";
import { Combobox } from "@headlessui/react";
import { createBlock } from "@libs/storage";
import { CancelIcon, CheckCircleIcon, CommandIcon } from "@shared/icons";
import { DEFAULT_AVATAR } from "@stores/constants";
import { ADD_FEEDBLOCK_SHORTCUT } from "@stores/shortcuts";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useAccount } from "@utils/hooks/useAccount";
import { nip19 } from "nostr-tools";
import { Fragment, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useHotkeys } from "react-hotkeys-hook";
import { Dialog, Transition } from '@headlessui/react';
import { Combobox } from '@headlessui/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools';
import { Fragment, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook';
import { User } from '@app/auth/components/user';
import { createBlock } from '@libs/storage';
import { CancelIcon, CheckCircleIcon, CommandIcon, LoaderIcon } from '@shared/icons';
import { DEFAULT_AVATAR } from '@stores/constants';
import { ADD_FEEDBLOCK_SHORTCUT } from '@stores/shortcuts';
import { useAccount } from '@utils/hooks/useAccount';
export function AddFeedBlock() {
const queryClient = useQueryClient();
const queryClient = useQueryClient();
const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [selected, setSelected] = useState([]);
const [query, setQuery] = useState("");
const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [selected, setSelected] = useState([]);
const [query, setQuery] = useState('');
const { status, account } = useAccount();
const { status, account } = useAccount();
const openModal = () => {
setIsOpen(true);
};
const openModal = () => {
setIsOpen(true);
};
const closeModal = () => {
setIsOpen(false);
};
const closeModal = () => {
setIsOpen(false);
};
useHotkeys(ADD_FEEDBLOCK_SHORTCUT, () => openModal());
useHotkeys(ADD_FEEDBLOCK_SHORTCUT, () => openModal());
const block = useMutation({
mutationFn: (data: any) => {
return createBlock(data.kind, data.title, data.content);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["blocks"] });
},
});
const block = useMutation({
mutationFn: (data: any) => {
return createBlock(data.kind, data.title, data.content);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['blocks'] });
},
});
const {
register,
handleSubmit,
reset,
formState: { isDirty, isValid },
} = useForm();
const {
register,
handleSubmit,
reset,
formState: { isDirty, isValid },
} = useForm();
const onSubmit = (data: any) => {
setLoading(true);
const onSubmit = (data: any) => {
setLoading(true);
selected.forEach((item, index) => {
if (item.substring(0, 4) === "npub") {
selected[index] = nip19.decode(item).data;
}
});
selected.forEach((item, index) => {
if (item.substring(0, 4) === 'npub') {
selected[index] = nip19.decode(item).data;
}
});
// insert to database
block.mutate({
kind: 1,
title: data.title,
content: JSON.stringify(selected),
});
// insert to database
block.mutate({
kind: 1,
title: data.title,
content: JSON.stringify(selected),
});
setLoading(false);
// reset form
reset();
// close modal
closeModal();
};
setLoading(false);
// reset form
reset();
// close modal
closeModal();
};
return (
<>
<button
type="button"
onClick={() => openModal()}
className="inline-flex w-56 h-9 items-center justify-start gap-2.5 rounded-md px-2.5"
>
<div className="flex items-center gap-2">
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<CommandIcon width={12} height={12} className="text-zinc-500" />
</div>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<span className="text-zinc-500 text-sm leading-none">F</span>
</div>
</div>
<div>
<h5 className="font-medium text-zinc-400">New feed block</h5>
</div>
</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-5">
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<Dialog.Title
as="h3"
className="text-lg font-semibold leading-none text-zinc-100"
>
Create feed block
</Dialog.Title>
<button
type="button"
onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon
width={14}
height={14}
className="text-zinc-300"
/>
</button>
</div>
<Dialog.Description className="text-sm leading-tight text-zinc-400">
Specific newsfeed space for people you want to keep up to
date
</Dialog.Description>
</div>
</div>
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
<form
onSubmit={handleSubmit(onSubmit)}
className="flex h-full w-full flex-col gap-4 mb-0"
>
<div className="flex flex-col gap-1">
<label className="text-sm font-medium uppercase tracking-wider text-zinc-400">
Title *
</label>
<input
type={"text"}
{...register("title", {
required: true,
})}
spellCheck={false}
className="relative h-10 w-full rounded-md px-3 py-2 !outline-none placeholder:text-zinc-500 bg-zinc-800 text-zinc-100"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-medium uppercase tracking-wider text-zinc-400">
Choose at least 1 user *
</label>
<div className="w-full h-[300px] flex flex-col rounded-lg border-t border-zinc-700/50 bg-zinc-800 overflow-x-hidden overflow-y-auto">
<div className="w-full px-3 py-2">
<Combobox
value={selected}
onChange={setSelected}
multiple
>
<Combobox.Input
onChange={(event) => setQuery(event.target.value)}
spellCheck={false}
autoFocus={false}
placeholder="Enter pubkey or npub..."
className="mb-2 relative h-10 w-full rounded-md px-3 py-2 !outline-none placeholder:text-zinc-500 bg-zinc-700 text-zinc-100"
/>
<Combobox.Options static>
{query.length > 0 && (
<Combobox.Option
value={query}
className="group w-full flex items-center justify-between px-2 py-2 rounded-md hover:bg-zinc-700"
>
{({ selected }) => (
<>
<div className="flex items-center gap-2">
<img
alt={query}
src={DEFAULT_AVATAR}
className="w-11 h-11 shrink-0 object-cover rounded"
/>
<div className="inline-flex flex-col gap-1">
<span className="text-base leading-tight text-zinc-400">
{query}
</span>
</div>
</div>
{selected && (
<CheckCircleIcon className="w-4 h-4 text-green-500" />
)}
</>
)}
</Combobox.Option>
)}
{status === "loading" ? (
<p>Loading...</p>
) : (
JSON.parse(account.follows).map((follow) => (
<Combobox.Option
key={follow}
value={follow}
className="group w-full flex items-center justify-between px-2 py-2 rounded-md hover:bg-zinc-700"
>
{({ selected }) => (
<>
<User pubkey={follow} />
{selected && (
<CheckCircleIcon className="w-4 h-4 text-green-500" />
)}
</>
)}
</Combobox.Option>
))
)}
</Combobox.Options>
</Combobox>
</div>
</div>
</div>
<div>
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 font-medium text-zinc-100 shadow-button active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30"
>
{loading ? (
<svg
className="h-4 w-4 animate-spin text-black dark:text-zinc-100"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<title id="loading">Loading</title>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
) : (
"Confirm"
)}
</button>
</div>
</form>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
return (
<>
<button
type="button"
onClick={() => openModal()}
className="inline-flex h-9 w-56 items-center justify-start gap-2.5 rounded-md px-2.5"
>
<div className="flex items-center gap-2">
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<CommandIcon width={12} height={12} className="text-zinc-500" />
</div>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<span className="text-sm leading-none text-zinc-500">F</span>
</div>
</div>
<div>
<h5 className="font-medium text-zinc-400">New feed block</h5>
</div>
</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-5">
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<Dialog.Title
as="h3"
className="text-lg font-semibold leading-none text-zinc-100"
>
Create feed block
</Dialog.Title>
<button
type="button"
onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon width={14} height={14} className="text-zinc-300" />
</button>
</div>
<Dialog.Description className="text-sm leading-tight text-zinc-400">
Specific newsfeed space for people you want to keep up to date
</Dialog.Description>
</div>
</div>
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
<form
onSubmit={handleSubmit(onSubmit)}
className="mb-0 flex h-full w-full flex-col gap-4"
>
<div className="flex flex-col gap-1">
<label
htmlFor="title"
className="text-sm font-medium uppercase tracking-wider text-zinc-400"
>
Title *
</label>
<input
type={'text'}
{...register('title', {
required: true,
})}
spellCheck={false}
className="relative h-10 w-full rounded-md bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
/>
</div>
<div className="flex flex-col gap-1">
<span className="text-sm font-medium uppercase tracking-wider text-zinc-400">
Choose at least 1 user *
</span>
<div className="flex h-[300px] w-full flex-col overflow-y-auto overflow-x-hidden rounded-lg border-t border-zinc-700/50 bg-zinc-800">
<div className="w-full px-3 py-2">
<Combobox value={selected} onChange={setSelected} multiple>
<Combobox.Input
onChange={(event) => setQuery(event.target.value)}
spellCheck={false}
placeholder="Enter pubkey or npub..."
className="relative mb-2 h-10 w-full rounded-md bg-zinc-700 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
/>
<Combobox.Options static>
{query.length > 0 && (
<Combobox.Option
value={query}
className="group flex w-full items-center justify-between rounded-md px-2 py-2 hover:bg-zinc-700"
>
{({ selected }) => (
<>
<div className="flex items-center gap-2">
<img
alt={query}
src={DEFAULT_AVATAR}
className="h-11 w-11 shrink-0 rounded object-cover"
/>
<div className="inline-flex flex-col gap-1">
<span className="text-base leading-tight text-zinc-400">
{query}
</span>
</div>
</div>
{selected && (
<CheckCircleIcon className="h-4 w-4 text-green-500" />
)}
</>
)}
</Combobox.Option>
)}
{status === 'loading' ? (
<p>Loading...</p>
) : (
JSON.parse(account.follows).map((follow) => (
<Combobox.Option
key={follow}
value={follow}
className="group flex w-full items-center justify-between rounded-md px-2 py-2 hover:bg-zinc-700"
>
{({ selected }) => (
<>
<User pubkey={follow} />
{selected && (
<CheckCircleIcon className="h-4 w-4 text-green-500" />
)}
</>
)}
</Combobox.Option>
))
)}
</Combobox.Options>
</Combobox>
</div>
</div>
</div>
<div>
<button
type="submit"
disabled={!isDirty || !isValid}
className="shadow-button inline-flex h-11 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 font-medium text-zinc-100 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
'Confirm'
)}
</button>
</div>
</form>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
}

View File

@@ -1,298 +1,303 @@
import { Dialog, Transition } from "@headlessui/react";
import { createBlock } from "@libs/storage";
import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { CancelIcon, CommandIcon } from "@shared/icons";
import { Image } from "@shared/image";
import { RelayContext } from "@shared/relayProvider";
import { DEFAULT_AVATAR } from "@stores/constants";
import { ADD_IMAGEBLOCK_SHORTCUT } from "@stores/shortcuts";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { open } from "@tauri-apps/api/dialog";
import { Body, fetch } from "@tauri-apps/api/http";
import { createBlobFromFile } from "@utils/createBlobFromFile";
import { dateToUnix } from "@utils/date";
import { useAccount } from "@utils/hooks/useAccount";
import { Fragment, useContext, useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useHotkeys } from "react-hotkeys-hook";
import { Dialog, Transition } from '@headlessui/react';
import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { open } from '@tauri-apps/api/dialog';
import { Body, fetch } from '@tauri-apps/api/http';
import { Fragment, useContext, useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook';
import { createBlock } from '@libs/storage';
import { CancelIcon, CommandIcon } from '@shared/icons';
import { Image } from '@shared/image';
import { RelayContext } from '@shared/relayProvider';
import { DEFAULT_AVATAR } from '@stores/constants';
import { ADD_IMAGEBLOCK_SHORTCUT } from '@stores/shortcuts';
import { createBlobFromFile } from '@utils/createBlobFromFile';
import { dateToUnix } from '@utils/date';
import { useAccount } from '@utils/hooks/useAccount';
export function AddImageBlock() {
const ndk = useContext(RelayContext);
const queryClient = useQueryClient();
const ndk = useContext(RelayContext);
const queryClient = useQueryClient();
const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [image, setImage] = useState("");
const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [image, setImage] = useState('');
const { account } = useAccount();
const { account } = useAccount();
const tags = useRef(null);
const tags = useRef(null);
const openModal = () => {
setIsOpen(true);
};
const openModal = () => {
setIsOpen(true);
};
const closeModal = () => {
setIsOpen(false);
};
const closeModal = () => {
setIsOpen(false);
};
useHotkeys(ADD_IMAGEBLOCK_SHORTCUT, () => openModal());
useHotkeys(ADD_IMAGEBLOCK_SHORTCUT, () => openModal());
const {
register,
handleSubmit,
reset,
setValue,
formState: { isDirty, isValid },
} = useForm();
const {
register,
handleSubmit,
reset,
setValue,
formState: { isDirty, isValid },
} = useForm();
const openFileDialog = async () => {
const selected: any = await open({
multiple: false,
filters: [
{
name: "Image",
extensions: ["png", "jpeg", "jpg"],
},
],
});
const openFileDialog = async () => {
const selected: any = await open({
multiple: false,
filters: [
{
name: 'Image',
extensions: ['png', 'jpeg', 'jpg'],
},
],
});
if (Array.isArray(selected)) {
// user selected multiple files
} else if (selected === null) {
// user cancelled the selection
} else {
const filename = selected.split("/").pop();
const file = await createBlobFromFile(selected);
const buf = await file.arrayBuffer();
if (Array.isArray(selected)) {
// user selected multiple files
} else if (selected === null) {
// user cancelled the selection
} else {
const filename = selected.split('/').pop();
const file = await createBlobFromFile(selected);
const buf = await file.arrayBuffer();
const res: any = await fetch("https://void.cat/upload?cli=false", {
method: "POST",
timeout: 5,
headers: {
accept: "*/*",
"Content-Type": "application/octet-stream",
"V-Filename": filename,
"V-Description": "Upload from https://lume.nu",
"V-Strip-Metadata": "true",
},
body: Body.bytes(buf),
});
const res: any = await fetch('https://void.cat/upload?cli=false', {
method: 'POST',
timeout: 5,
headers: {
accept: '*/*',
'Content-Type': 'application/octet-stream',
'V-Filename': filename,
'V-Description': 'Upload from https://lume.nu',
'V-Strip-Metadata': 'true',
},
body: Body.bytes(buf),
});
if (res.ok) {
const imageURL = `https://void.cat/d/${res.data.file.id}.webp`;
tags.current = [
["url", imageURL],
["m", res.data.file.metadata.mimeType],
["x", res.data.file.metadata.digest],
["size", res.data.file.metadata.size],
["magnet", res.data.file.metadata.magnetLink],
];
if (res.ok) {
const imageURL = `https://void.cat/d/${res.data.file.id}.webp`;
tags.current = [
['url', imageURL],
['m', res.data.file.metadata.mimeType],
['x', res.data.file.metadata.digest],
['size', res.data.file.metadata.size],
['magnet', res.data.file.metadata.magnetLink],
];
setImage(imageURL);
}
}
};
setImage(imageURL);
}
}
};
const block = useMutation({
mutationFn: (data: any) => {
return createBlock(data.kind, data.title, data.content);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["blocks"] });
},
});
const block = useMutation({
mutationFn: (data: any) => {
return createBlock(data.kind, data.title, data.content);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['blocks'] });
},
});
const onSubmit = (data: any) => {
setLoading(true);
const onSubmit = (data: any) => {
setLoading(true);
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const event = new NDKEvent(ndk);
// build event
event.content = data.title;
event.kind = 1063;
event.created_at = dateToUnix();
event.pubkey = account.pubkey;
event.tags = tags.current;
const event = new NDKEvent(ndk);
// build event
event.content = data.title;
event.kind = 1063;
event.created_at = dateToUnix();
event.pubkey = account.pubkey;
event.tags = tags.current;
// publish event
event.publish();
// publish event
event.publish();
// mutate
block.mutate({ kind: 0, title: data.title, content: data.content });
// mutate
block.mutate({ kind: 0, title: data.title, content: data.content });
setLoading(false);
// reset form
reset();
// close modal
closeModal();
};
setLoading(false);
// reset form
reset();
// close modal
closeModal();
};
useEffect(() => {
setValue("content", image);
}, [setValue, image]);
useEffect(() => {
setValue('content', image);
}, [setValue, image]);
return (
<>
<button
type="button"
onClick={() => openModal()}
className="inline-flex w-56 h-9 items-center justify-start gap-2.5 rounded-md px-2.5"
>
<div className="flex items-center gap-2">
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<CommandIcon width={12} height={12} className="text-zinc-500" />
</div>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<span className="text-zinc-500 text-sm leading-none">I</span>
</div>
</div>
<div>
<h5 className="font-medium text-zinc-400">New image block</h5>
</div>
</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-5">
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<Dialog.Title
as="h3"
className="text-lg font-semibold leading-none text-zinc-100"
>
Create image block
</Dialog.Title>
<button
type="button"
onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon
width={14}
height={14}
className="text-zinc-300"
/>
</button>
</div>
<Dialog.Description className="text-sm leading-tight text-zinc-400">
Pin your favorite image to Space then you can view every
time that you use Lume, your image will be broadcast to
Nostr Relay as well
</Dialog.Description>
</div>
</div>
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
<form
onSubmit={handleSubmit(onSubmit)}
className="flex h-full w-full flex-col gap-4 mb-0"
>
<input
type={"hidden"}
{...register("content")}
value={image}
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
<div className="flex flex-col gap-1">
<label className="text-sm font-medium uppercase tracking-wider text-zinc-400">
Title *
</label>
<div className="relative w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[6px] 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-[6px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
<input
type={"text"}
{...register("title", {
required: true,
})}
spellCheck={false}
className="relative h-10 w-full rounded-md 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-100 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-medium uppercase tracking-wider text-zinc-400">
Picture
</label>
<div className="relative inline-flex h-56 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950">
<Image
src={image}
fallback={DEFAULT_AVATAR}
alt="content"
className="relative z-10 max-h-[156px] h-auto w-[150px] object-cover rounded-md"
/>
<div className="absolute bottom-3 right-3 z-10">
<button
onClick={() => openFileDialog()}
type="button"
className="inline-flex h-6 items-center justify-center rounded bg-zinc-900 px-3 text-sm font-medium text-zinc-300 ring-1 ring-zinc-800 hover:bg-zinc-800"
>
Upload
</button>
</div>
</div>
</div>
<div>
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 font-medium text-zinc-100 shadow-button active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30"
>
{loading ? (
<svg
className="h-4 w-4 animate-spin text-black dark:text-zinc-100"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<title id="loading">Loading</title>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
) : (
"Confirm"
)}
</button>
</div>
</form>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
return (
<>
<button
type="button"
onClick={() => openModal()}
className="inline-flex h-9 w-56 items-center justify-start gap-2.5 rounded-md px-2.5"
>
<div className="flex items-center gap-2">
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<CommandIcon width={12} height={12} className="text-zinc-500" />
</div>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<span className="text-sm leading-none text-zinc-500">I</span>
</div>
</div>
<div>
<h5 className="font-medium text-zinc-400">New image block</h5>
</div>
</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-5">
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<Dialog.Title
as="h3"
className="text-lg font-semibold leading-none text-zinc-100"
>
Create image block
</Dialog.Title>
<button
type="button"
onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon width={14} height={14} className="text-zinc-300" />
</button>
</div>
<Dialog.Description className="text-sm leading-tight text-zinc-400">
Pin your favorite image to Space then you can view every time that
you use Lume, your image will be broadcast to Nostr Relay as well
</Dialog.Description>
</div>
</div>
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
<form
onSubmit={handleSubmit(onSubmit)}
className="mb-0 flex h-full w-full flex-col gap-4"
>
<input
type={'hidden'}
{...register('content')}
value={image}
className="shadow-input relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
<div className="flex flex-col gap-1">
<label
htmlFor="title"
className="text-sm font-medium uppercase tracking-wider text-zinc-400"
>
Title *
</label>
<div className="after:shadow-highlight relative w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[6px] 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-[6px] after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
<input
type={'text'}
{...register('title', {
required: true,
})}
spellCheck={false}
className="shadow-input relative h-10 w-full rounded-md border border-black/5 px-3 py-2 shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
</div>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="picture"
className="text-sm font-medium uppercase tracking-wider text-zinc-400"
>
Picture
</label>
<div className="relative inline-flex h-56 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950">
<Image
src={image}
fallback={DEFAULT_AVATAR}
alt="content"
className="relative z-10 h-auto max-h-[156px] w-[150px] rounded-md object-cover"
/>
<div className="absolute bottom-3 right-3 z-10">
<button
onClick={() => openFileDialog()}
type="button"
className="inline-flex h-6 items-center justify-center rounded bg-zinc-900 px-3 text-sm font-medium text-zinc-300 ring-1 ring-zinc-800 hover:bg-zinc-800"
>
Upload
</button>
</div>
</div>
</div>
<div>
<button
type="submit"
disabled={!isDirty || !isValid}
className="shadow-button inline-flex h-11 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 font-medium text-zinc-100 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30"
>
{loading ? (
<svg
className="h-4 w-4 animate-spin text-black dark:text-zinc-100"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<title id="loading">Loading</title>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
) : (
'Confirm'
)}
</button>
</div>
</form>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
}

View File

@@ -1,124 +1,113 @@
import { getNotesByAuthors, removeBlock } from "@libs/storage";
import { Note } from "@shared/notes/note";
import { NoteSkeleton } from "@shared/notes/skeleton";
import { TitleBar } from "@shared/titleBar";
import {
useInfiniteQuery,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useEffect, useRef } from "react";
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useEffect, useRef } from 'react';
import { getNotesByAuthors, removeBlock } from '@libs/storage';
import { Note } from '@shared/notes/note';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { TitleBar } from '@shared/titleBar';
const ITEM_PER_PAGE = 10;
export function FeedBlock({ params }: { params: any }) {
const queryClient = useQueryClient();
const { status, data, fetchNextPage, hasNextPage, isFetchingNextPage }: any =
useInfiniteQuery({
queryKey: ["newsfeed", params.content],
queryFn: async ({ pageParam = 0 }) => {
return await getNotesByAuthors(
params.content,
ITEM_PER_PAGE,
pageParam,
);
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const queryClient = useQueryClient();
const { status, data, fetchNextPage, hasNextPage, isFetchingNextPage }: any =
useInfiniteQuery({
queryKey: ['newsfeed', params.content],
queryFn: async ({ pageParam = 0 }) => {
return await getNotesByAuthors(params.content, ITEM_PER_PAGE, pageParam);
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const notes = data ? data.pages.flatMap((d: { data: any }) => d.data) : [];
const parentRef = useRef();
const notes = data ? data.pages.flatMap((d: { data: any }) => d.data) : [];
const parentRef = useRef();
const rowVirtualizer = useVirtualizer({
count: hasNextPage ? notes.length + 1 : notes.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 500,
overscan: 2,
});
const rowVirtualizer = useVirtualizer({
count: hasNextPage ? notes.length + 1 : notes.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 500,
overscan: 2,
});
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
useEffect(() => {
const [lastItem] = [...rowVirtualizer.getVirtualItems()].reverse();
useEffect(() => {
const [lastItem] = [...rowVirtualizer.getVirtualItems()].reverse();
if (!lastItem) {
return;
}
if (!lastItem) {
return;
}
if (
lastItem.index >= notes.length - 1 &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage();
}
}, [notes.length, fetchNextPage, rowVirtualizer.getVirtualItems()]);
if (lastItem.index >= notes.length - 1 && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [notes.length, fetchNextPage, rowVirtualizer.getVirtualItems()]);
const block = useMutation({
mutationFn: (id: string) => {
return removeBlock(id);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["blocks"] });
},
});
const block = useMutation({
mutationFn: (id: string) => {
return removeBlock(id);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['blocks'] });
},
});
const renderItem = (index: string | number) => {
const note = notes[index];
const renderItem = (index: string | number) => {
const note = notes[index];
if (!note) return;
return (
<div key={index} data-index={index} ref={rowVirtualizer.measureElement}>
<Note event={note} block={params.id} />
</div>
);
};
if (!note) return;
return (
<div key={index} data-index={index} ref={rowVirtualizer.measureElement}>
<Note event={note} block={params.id} />
</div>
);
};
return (
<div className="shrink-0 w-[400px] border-r border-zinc-900">
<TitleBar title={params.title} onClick={() => block.mutate(params.id)} />
<div
ref={parentRef}
className="scrollbar-hide flex w-full h-full flex-col justify-between gap-1.5 pt-1.5 pb-20 overflow-y-auto"
style={{ contain: "strict" }}
>
{status === "loading" ? (
<div className="px-3 py-1.5">
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3">
<NoteSkeleton />
</div>
</div>
) : (
<div
className="relative w-full"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
}}
>
<div
className="absolute left-0 top-0 w-full"
style={{
transform: `translateY(${
itemsVirtualizer[0].start -
rowVirtualizer.options.scrollMargin
}px)`,
}}
>
{rowVirtualizer
.getVirtualItems()
.map((virtualRow) => renderItem(virtualRow.index))}
</div>
</div>
)}
{isFetchingNextPage && (
<div className="px-3 py-1.5">
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3">
<NoteSkeleton />
</div>
</div>
)}
</div>
</div>
);
return (
<div className="w-[400px] shrink-0 border-r border-zinc-900">
<TitleBar title={params.title} onClick={() => block.mutate(params.id)} />
<div
ref={parentRef}
className="scrollbar-hide flex h-full w-full flex-col justify-between gap-1.5 overflow-y-auto pb-20 pt-1.5"
style={{ contain: 'strict' }}
>
{status === 'loading' ? (
<div className="px-3 py-1.5">
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3">
<NoteSkeleton />
</div>
</div>
) : (
<div
className="relative w-full"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
}}
>
<div
className="absolute left-0 top-0 w-full"
style={{
transform: `translateY(${
itemsVirtualizer[0].start - rowVirtualizer.options.scrollMargin
}px)`,
}}
>
{rowVirtualizer
.getVirtualItems()
.map((virtualRow) => renderItem(virtualRow.index))}
</div>
</div>
)}
{isFetchingNextPage && (
<div className="px-3 py-1.5">
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3">
<NoteSkeleton />
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,140 +1,133 @@
import { useNewsfeed } from "@app/space/hooks/useNewsfeed";
import { getNotes } from "@libs/storage";
import { Note } from "@shared/notes/note";
import { NoteSkeleton } from "@shared/notes/skeleton";
import { TitleBar } from "@shared/titleBar";
import { useNote } from "@stores/note";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useEffect, useRef } from "react";
import { useInfiniteQuery } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useEffect, useRef } from 'react';
import { useNewsfeed } from '@app/space/hooks/useNewsfeed';
import { getNotes } from '@libs/storage';
import { Note } from '@shared/notes/note';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { TitleBar } from '@shared/titleBar';
import { useNote } from '@stores/note';
const ITEM_PER_PAGE = 10;
export function FollowingBlock({ block }: { block: number }) {
// subscribe for live update
useNewsfeed();
const [hasNewNote, toggleHasNewNote] = useNote((state) => [
state.hasNewNote,
state.toggleHasNewNote,
]);
// subscribe for live update
useNewsfeed();
const [hasNewNote, toggleHasNewNote] = useNote((state) => [
state.hasNewNote,
state.toggleHasNewNote,
]);
const {
status,
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
refetch,
}: any = useInfiniteQuery({
queryKey: ["newsfeed-circle"],
queryFn: async ({ pageParam = 0 }) => {
return await getNotes(ITEM_PER_PAGE, pageParam);
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const { status, data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch }: any =
useInfiniteQuery({
queryKey: ['newsfeed-circle'],
queryFn: async ({ pageParam = 0 }) => {
return await getNotes(ITEM_PER_PAGE, pageParam);
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const notes = data ? data.pages.flatMap((d: { data: any }) => d.data) : [];
const parentRef = useRef();
const notes = data ? data.pages.flatMap((d: { data: any }) => d.data) : [];
const parentRef = useRef();
const rowVirtualizer = useVirtualizer({
count: hasNextPage ? notes.length + 1 : notes.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 500,
overscan: 2,
});
const rowVirtualizer = useVirtualizer({
count: hasNextPage ? notes.length + 1 : notes.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 500,
overscan: 2,
});
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
useEffect(() => {
const [lastItem] = [...rowVirtualizer.getVirtualItems()].reverse();
useEffect(() => {
const [lastItem] = [...rowVirtualizer.getVirtualItems()].reverse();
if (!lastItem) {
return;
}
if (!lastItem) {
return;
}
if (
lastItem.index >= notes.length - 1 &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage();
}
}, [notes.length, fetchNextPage, rowVirtualizer.getVirtualItems()]);
if (lastItem.index >= notes.length - 1 && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [notes.length, fetchNextPage, rowVirtualizer.getVirtualItems()]);
const refreshFirstPage = () => {
// refetch
refetch({ refetchPage: (_, index: number) => index === 0 });
// scroll to top
rowVirtualizer.scrollToIndex(1);
// stop notify
toggleHasNewNote(false);
};
const refreshFirstPage = () => {
// refetch
refetch({ refetchPage: (_, index: number) => index === 0 });
// scroll to top
rowVirtualizer.scrollToIndex(1);
// stop notify
toggleHasNewNote(false);
};
const renderItem = (index: string | number) => {
const note = notes[index];
if (!note) return;
return (
<div key={index} data-index={index} ref={rowVirtualizer.measureElement}>
<Note event={note} block={block} />
</div>
);
};
const renderItem = (index: string | number) => {
const note = notes[index];
if (!note) return;
return (
<div key={index} data-index={index} ref={rowVirtualizer.measureElement}>
<Note event={note} block={block} />
</div>
);
};
return (
<div className="shrink-0 relative w-[400px] border-r border-zinc-900">
<TitleBar title="Your Circle" />
{hasNewNote && (
<div className="z-50 absolute top-12 left-1/2 transform -translate-x-1/2">
<button
type="button"
onClick={() => refreshFirstPage()}
className="inline-flex items-center justify-center w-min px-3.5 py-1.5 rounded-full bg-fuchsia-500 hover:bg-fuchsia-600 border border-fuchsia-800/50 text-sm"
>
Newest
</button>
</div>
)}
<div
ref={parentRef}
className="scrollbar-hide flex w-full h-full flex-col justify-between gap-1.5 pt-1.5 pb-20 overflow-y-auto"
style={{ contain: "strict" }}
>
{status === "loading" ? (
<div className="px-3 py-1.5">
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3">
<NoteSkeleton />
</div>
</div>
) : (
<div
className="relative w-full"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
}}
>
<div
className="absolute left-0 top-0 w-full"
style={{
transform: `translateY(${
itemsVirtualizer[0].start -
rowVirtualizer.options.scrollMargin
}px)`,
}}
>
{rowVirtualizer
.getVirtualItems()
.map((virtualRow) => renderItem(virtualRow.index))}
</div>
</div>
)}
{isFetchingNextPage && (
<div className="px-3 py-1.5">
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3">
<NoteSkeleton />
</div>
</div>
)}
</div>
</div>
);
return (
<div className="relative w-[400px] shrink-0 border-r border-zinc-900">
<TitleBar title="Your Circle" />
{hasNewNote && (
<div className="absolute left-1/2 top-12 z-50 -translate-x-1/2 transform">
<button
type="button"
onClick={() => refreshFirstPage()}
className="inline-flex w-min items-center justify-center rounded-full border border-fuchsia-800/50 bg-fuchsia-500 px-3.5 py-1.5 text-sm hover:bg-fuchsia-600"
>
Newest
</button>
</div>
)}
<div
ref={parentRef}
className="scrollbar-hide flex h-full w-full flex-col justify-between gap-1.5 overflow-y-auto pb-20 pt-1.5"
style={{ contain: 'strict' }}
>
{status === 'loading' ? (
<div className="px-3 py-1.5">
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3">
<NoteSkeleton />
</div>
</div>
) : (
<div
className="relative w-full"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
}}
>
<div
className="absolute left-0 top-0 w-full"
style={{
transform: `translateY(${
itemsVirtualizer[0].start - rowVirtualizer.options.scrollMargin
}px)`,
}}
>
{rowVirtualizer
.getVirtualItems()
.map((virtualRow) => renderItem(virtualRow.index))}
</div>
</div>
)}
{isFetchingNextPage && (
<div className="px-3 py-1.5">
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3">
<NoteSkeleton />
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,45 +1,46 @@
import { removeBlock } from "@libs/storage";
import { CancelIcon } from "@shared/icons";
import { Image } from "@shared/image";
import { DEFAULT_AVATAR } from "@stores/constants";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { removeBlock } from '@libs/storage';
import { CancelIcon } from '@shared/icons';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
export function ImageBlock({ params }: { params: any }) {
const queryClient = useQueryClient();
const queryClient = useQueryClient();
const block = useMutation({
mutationFn: (id: string) => {
return removeBlock(id);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["blocks"] });
},
});
const block = useMutation({
mutationFn: (id: string) => {
return removeBlock(id);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['blocks'] });
},
});
return (
<div className="shrink-0 w-[350px] h-full flex flex-col justify-between border-r border-zinc-900">
<div className="relative flex-1 w-full h-full p-3 overflow-hidden">
<div className="absolute top-3 left-0 w-full h-16 px-3">
<div className="h-16 rounded-t-xl overflow-hidden flex items-center justify-between px-5">
<h3 className="text-white font-medium drop-shadow-lg">
{params.title}
</h3>
<button
type="button"
onClick={() => block.mutate(params.id)}
className="inline-flex h-7 w-7 rounded-md items-center justify-center bg-white/30 backdrop-blur-lg"
>
<CancelIcon width={16} height={16} className="text-white" />
</button>
</div>
</div>
<Image
src={params.content}
fallback={DEFAULT_AVATAR}
alt={params.title}
className="w-full h-full object-cover rounded-xl border-t border-zinc-800/50"
/>
</div>
</div>
);
return (
<div className="flex h-full w-[350px] shrink-0 flex-col justify-between border-r border-zinc-900">
<div className="relative h-full w-full flex-1 overflow-hidden p-3">
<div className="absolute left-0 top-3 h-16 w-full px-3">
<div className="flex h-16 items-center justify-between overflow-hidden rounded-t-xl px-5">
<h3 className="font-medium text-white drop-shadow-lg">{params.title}</h3>
<button
type="button"
onClick={() => block.mutate(params.id)}
className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-white/30 backdrop-blur-lg"
>
<CancelIcon width={16} height={16} className="text-white" />
</button>
</div>
</div>
<Image
src={params.content}
fallback={DEFAULT_AVATAR}
alt={params.title}
className="h-full w-full rounded-xl border-t border-zinc-800/50 object-cover"
/>
</div>
</div>
);
}

View File

@@ -1,75 +1,76 @@
import { useLiveThread } from "@app/space/hooks/useLiveThread";
import { getNoteByID, removeBlock } from "@libs/storage";
import { Kind1 } from "@shared/notes/contents/kind1";
import { Kind1063 } from "@shared/notes/contents/kind1063";
import { NoteMetadata } from "@shared/notes/metadata";
import { NoteReplyForm } from "@shared/notes/replies/form";
import { RepliesList } from "@shared/notes/replies/list";
import { NoteSkeleton } from "@shared/notes/skeleton";
import { TitleBar } from "@shared/titleBar";
import { User } from "@shared/user";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useAccount } from "@utils/hooks/useAccount";
import { parser } from "@utils/parser";
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useLiveThread } from '@app/space/hooks/useLiveThread';
import { getNoteByID, removeBlock } from '@libs/storage';
import { Kind1 } from '@shared/notes/contents/kind1';
import { Kind1063 } from '@shared/notes/contents/kind1063';
import { NoteMetadata } from '@shared/notes/metadata';
import { NoteReplyForm } from '@shared/notes/replies/form';
import { RepliesList } from '@shared/notes/replies/list';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { TitleBar } from '@shared/titleBar';
import { User } from '@shared/user';
import { useAccount } from '@utils/hooks/useAccount';
import { parser } from '@utils/parser';
export function ThreadBlock({ params }: { params: any }) {
useLiveThread(params.content);
useLiveThread(params.content);
const queryClient = useQueryClient();
const queryClient = useQueryClient();
const { account } = useAccount();
const { status, data } = useQuery(["thread", params.content], async () => {
const res = await getNoteByID(params.content);
res["content"] = parser(res);
return res;
});
const { account } = useAccount();
const { status, data } = useQuery(['thread', params.content], async () => {
const res = await getNoteByID(params.content);
res['content'] = parser(res);
return res;
});
const block = useMutation({
mutationFn: (id: string) => {
return removeBlock(id);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["blocks"] });
},
});
const block = useMutation({
mutationFn: (id: string) => {
return removeBlock(id);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['blocks'] });
},
});
return (
<div className="shrink-0 w-[400px] border-r border-zinc-900">
<TitleBar title={params.title} onClick={() => block.mutate(params.id)} />
<div className="scrollbar-hide flex w-full h-full flex-col gap-1.5 pt-1.5 pb-20 overflow-y-auto">
{status === "loading" ? (
<div className="px-3 py-1.5">
<div className="rounded-md bg-zinc-900 px-3 py-3 shadow-input shadow-black/20">
<NoteSkeleton />
</div>
</div>
) : (
<div className="h-min w-full px-3 py-1.5">
<div className="rounded-md bg-zinc-900 px-5 pt-5">
<User pubkey={data.pubkey} time={data.created_at} />
<div className="mt-3">
{data.kind === 1 && <Kind1 content={data.content} />}
{data.kind === 1063 && <Kind1063 metadata={data.tags} />}
<NoteMetadata
id={data.event_id || params.content}
eventPubkey={data.pubkey}
/>
</div>
</div>
<div className="mt-3 bg-zinc-900 rounded-md">
{account && (
<NoteReplyForm
rootID={params.content}
userPubkey={account.pubkey}
/>
)}
</div>
</div>
)}
<div className="px-3">
<RepliesList parent_id={params.content} />
</div>
</div>
</div>
);
return (
<div className="w-[400px] shrink-0 border-r border-zinc-900">
<TitleBar title={params.title} onClick={() => block.mutate(params.id)} />
<div className="scrollbar-hide flex h-full w-full flex-col gap-1.5 overflow-y-auto pb-20 pt-1.5">
{status === 'loading' ? (
<div className="px-3 py-1.5">
<div className="shadow-input rounded-md bg-zinc-900 px-3 py-3 shadow-black/20">
<NoteSkeleton />
</div>
</div>
) : (
<div className="h-min w-full px-3 py-1.5">
<div className="rounded-md bg-zinc-900 px-5 pt-5">
<User pubkey={data.pubkey} time={data.created_at} />
<div className="mt-3">
{data.kind === 1 && <Kind1 content={data.content} />}
{data.kind === 1063 && <Kind1063 metadata={data.tags} />}
<NoteMetadata
id={data.event_id || params.content}
eventPubkey={data.pubkey}
/>
</div>
</div>
<div className="mt-3 rounded-md bg-zinc-900">
{account && (
<NoteReplyForm rootID={params.content} userPubkey={account.pubkey} />
)}
</div>
</div>
)}
<div className="px-3">
<RepliesList parent_id={params.content} />
</div>
</div>
</div>
);
}

View File

@@ -1,46 +1,48 @@
import { createReplyNote } from "@libs/storage";
import { NDKEvent, NDKFilter } from "@nostr-dev-kit/ndk";
import { RelayContext } from "@shared/relayProvider";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useContext, useEffect, useRef } from "react";
import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useContext, useEffect, useRef } from 'react';
import { createReplyNote } from '@libs/storage';
import { RelayContext } from '@shared/relayProvider';
export function useLiveThread(id: string) {
const ndk = useContext(RelayContext);
const queryClient = useQueryClient();
const now = useRef(Math.floor(Date.now() / 1000));
const ndk = useContext(RelayContext);
const queryClient = useQueryClient();
const now = useRef(Math.floor(Date.now() / 1000));
const thread = useMutation({
mutationFn: (data: NDKEvent) => {
return createReplyNote(
id,
data.id,
data.pubkey,
data.kind,
data.tags,
data.content,
data.created_at,
);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["replies", id] });
},
});
const thread = useMutation({
mutationFn: (data: NDKEvent) => {
return createReplyNote(
id,
data.id,
data.pubkey,
data.kind,
data.tags,
data.content,
data.created_at
);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['replies', id] });
},
});
useEffect(() => {
const filter: NDKFilter = {
kinds: [1],
"#e": [id],
since: now.current,
};
useEffect(() => {
const filter: NDKFilter = {
kinds: [1],
'#e': [id],
since: now.current,
};
const sub = ndk.subscribe(filter, { closeOnEose: false });
const sub = ndk.subscribe(filter, { closeOnEose: false });
sub.addListener("event", (event: NDKEvent) => {
thread.mutate(event);
});
sub.addListener('event', (event: NDKEvent) => {
thread.mutate(event);
});
return () => {
sub.stop();
};
}, []);
return () => {
sub.stop();
};
}, []);
}

View File

@@ -1,48 +1,52 @@
import { createNote } from "@libs/storage";
import { NDKEvent, NDKFilter } from "@nostr-dev-kit/ndk";
import { RelayContext } from "@shared/relayProvider";
import { useNote } from "@stores/note";
import { useAccount } from "@utils/hooks/useAccount";
import { useContext, useEffect, useRef } from "react";
import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk';
import { useContext, useEffect, useRef } from 'react';
import { createNote } from '@libs/storage';
import { RelayContext } from '@shared/relayProvider';
import { useNote } from '@stores/note';
import { useAccount } from '@utils/hooks/useAccount';
export function useNewsfeed() {
const ndk = useContext(RelayContext);
const sub = useRef(null);
const now = useRef(Math.floor(Date.now() / 1000));
const toggleHasNewNote = useNote((state) => state.toggleHasNewNote);
const ndk = useContext(RelayContext);
const sub = useRef(null);
const now = useRef(Math.floor(Date.now() / 1000));
const toggleHasNewNote = useNote((state) => state.toggleHasNewNote);
const { status, account } = useAccount();
const { status, account } = useAccount();
useEffect(() => {
if (status === "success" && account) {
const follows = account ? JSON.parse(account.follows) : [];
useEffect(() => {
if (status === 'success' && account) {
const follows = account ? JSON.parse(account.follows) : [];
const filter: NDKFilter = {
kinds: [1, 6],
authors: follows,
since: now.current,
};
const filter: NDKFilter = {
kinds: [1, 6],
authors: follows,
since: now.current,
};
sub.current = ndk.subscribe(filter, { closeOnEose: false });
sub.current = ndk.subscribe(filter, { closeOnEose: false });
sub.current.addListener("event", (event: NDKEvent) => {
console.log("new note: ", event);
// add to db
createNote(
event.id,
event.pubkey,
event.kind,
event.tags,
event.content,
event.created_at,
);
// notify user about created note
toggleHasNewNote(true);
});
}
sub.current.addListener('event', (event: NDKEvent) => {
console.log('new note: ', event);
// add to db
createNote(
event.id,
event.pubkey,
event.kind,
event.tags,
event.content,
event.created_at
);
// notify user about created note
toggleHasNewNote(true);
});
}
return () => {
sub.current.stop();
};
}, [status]);
return () => {
sub.current.stop();
};
}, [status]);
}

View File

@@ -1,76 +1,79 @@
import { AddBlock } from "@app/space/components/add";
import { FeedBlock } from "@app/space/components/blocks/feed";
import { FollowingBlock } from "@app/space/components/blocks/following";
import { ImageBlock } from "@app/space/components/blocks/image";
import { ThreadBlock } from "@app/space/components/blocks/thread";
import { getBlocks } from "@libs/storage";
import { LoaderIcon } from "@shared/icons";
import { useQuery } from "@tanstack/react-query";
import { useQuery } from '@tanstack/react-query';
import { AddBlock } from '@app/space/components/add';
import { FeedBlock } from '@app/space/components/blocks/feed';
import { FollowingBlock } from '@app/space/components/blocks/following';
import { ImageBlock } from '@app/space/components/blocks/image';
import { ThreadBlock } from '@app/space/components/blocks/thread';
import { getBlocks } from '@libs/storage';
import { LoaderIcon } from '@shared/icons';
export function SpaceScreen() {
const {
status,
data: blocks,
isFetching,
} = useQuery(
["blocks"],
async () => {
return await getBlocks();
},
{
staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
},
);
const {
status,
data: blocks,
isFetching,
} = useQuery(
['blocks'],
async () => {
return await getBlocks();
},
{
staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
}
);
return (
<div className="h-full w-full flex flex-nowrap overflow-x-auto overflow-y-hidden scrollbar-hide">
<FollowingBlock block={1} />
{status === "loading" ? (
<div className="shrink-0 w-[350px] flex-col flex border-r border-zinc-900">
<div
data-tauri-drag-region
className="group overflow-hidden h-11 w-full flex items-center justify-between px-3 border-b border-zinc-900"
/>
return (
<div className="scrollbar-hide flex h-full w-full flex-nowrap overflow-x-auto overflow-y-hidden">
<FollowingBlock block={1} />
{status === 'loading' ? (
<div className="flex w-[350px] shrink-0 flex-col border-r border-zinc-900">
<div
data-tauri-drag-region
className="group flex h-11 w-full items-center justify-between overflow-hidden border-b border-zinc-900 px-3"
/>
<div className="w-full flex-1 flex items-center justify-center p-3">
<LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-zinc-100" />
</div>
</div>
) : (
blocks.map((block: any) => {
switch (block.kind) {
case 0:
return <ImageBlock key={block.id} params={block} />;
case 1:
return <FeedBlock key={block.id} params={block} />;
case 2:
return <ThreadBlock key={block.id} params={block} />;
default:
break;
}
})
)}
{isFetching && (
<div className="shrink-0 w-[350px] flex-col flex border-r border-zinc-900">
<div
data-tauri-drag-region
className="group overflow-hidden h-11 w-full flex items-center justify-between px-3 border-b border-zinc-900"
/>
<div className="flex w-full flex-1 items-center justify-center p-3">
<LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-zinc-100" />
</div>
</div>
) : (
blocks.map((block: any) => {
switch (block.kind) {
case 0:
return <ImageBlock key={block.id} params={block} />;
case 1:
return <FeedBlock key={block.id} params={block} />;
case 2:
return <ThreadBlock key={block.id} params={block} />;
default:
break;
}
})
)}
{isFetching && (
<div className="flex w-[350px] shrink-0 flex-col border-r border-zinc-900">
<div
data-tauri-drag-region
className="group flex h-11 w-full items-center justify-between overflow-hidden border-b border-zinc-900 px-3"
/>
<div className="w-full flex-1 flex items-center justify-center p-3">
<LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-zinc-100" />
</div>
</div>
)}
<div className="shrink-0 w-[350px] flex-col flex border-r border-zinc-900">
<div className="w-full h-full inline-flex items-center justify-center">
<AddBlock />
</div>
</div>
<div className="shrink-0 w-[350px]" />
</div>
);
<div className="flex w-full flex-1 items-center justify-center p-3">
<LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-zinc-100" />
</div>
</div>
)}
<div className="flex w-[350px] shrink-0 flex-col border-r border-zinc-900">
<div className="inline-flex h-full w-full items-center justify-center">
<AddBlock />
</div>
</div>
<div className="w-[350px] shrink-0" />
</div>
);
}

View File

@@ -1,152 +1,144 @@
import { FollowIcon, LoaderIcon, UnfollowIcon } from "@shared/icons";
import { Image } from "@shared/image";
import { DEFAULT_AVATAR } from "@stores/constants";
import { useQuery } from "@tanstack/react-query";
import { useSocial } from "@utils/hooks/useSocial";
import { compactNumber } from "@utils/number";
import { shortenKey } from "@utils/shortenKey";
import { useEffect, useState } from "react";
import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { FollowIcon, LoaderIcon, UnfollowIcon } from '@shared/icons';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useSocial } from '@utils/hooks/useSocial';
import { compactNumber } from '@utils/number';
import { shortenKey } from '@utils/shortenKey';
export function Profile({ data }: { data: any }) {
const { status, data: userStats } = useQuery(
["user-stats", data.pubkey],
async () => {
const res = await fetch(
`https://api.nostr.band/v0/stats/profile/${data.pubkey}`,
);
return res.json();
},
);
const { status, data: userStats } = useQuery(['user-stats', data.pubkey], async () => {
const res = await fetch(`https://api.nostr.band/v0/stats/profile/${data.pubkey}`);
return res.json();
});
const embedProfile = data.profile ? JSON.parse(data.profile.content) : null;
const profile = embedProfile;
const { status: socialStatus, userFollows, follow, unfollow } = useSocial();
const embedProfile = data.profile ? JSON.parse(data.profile.content) : null;
const profile = embedProfile;
const { status: socialStatus, userFollows, follow, unfollow } = useSocial();
const [followed, setFollowed] = useState(false);
const [followed, setFollowed] = useState(false);
const followUser = (pubkey: string) => {
try {
follow(pubkey);
// update state
setFollowed(true);
} catch (error) {
console.log(error);
}
};
const followUser = (pubkey: string) => {
try {
follow(pubkey);
// update state
setFollowed(true);
} catch (error) {
console.log(error);
}
};
const unfollowUser = (pubkey: string) => {
try {
unfollow(pubkey);
// update state
setFollowed(false);
} catch (error) {
console.log(error);
}
};
const unfollowUser = (pubkey: string) => {
try {
unfollow(pubkey);
// update state
setFollowed(false);
} catch (error) {
console.log(error);
}
};
useEffect(() => {
if (status === "success" && userFollows) {
if (userFollows.includes(data.pubkey)) {
setFollowed(true);
}
}
}, [status]);
useEffect(() => {
if (status === 'success' && userFollows) {
if (userFollows.includes(data.pubkey)) {
setFollowed(true);
}
}
}, [status]);
if (!profile)
return (
<div className="rounded-md bg-zinc-900 px-5 py-5">
<p>Can't fetch profile</p>
</div>
);
if (!profile)
return (
<div className="rounded-md bg-zinc-900 px-5 py-5">
<p>Can&apos;t fetch profile</p>
</div>
);
return (
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-5 py-5">
<div className="flex items-center justify-between">
<div className="inline-flex items-center gap-2">
<div className="w-11 h-11 shrink-0">
<Image
src={profile.picture}
fallback={DEFAULT_AVATAR}
className="w-11 h-11 object-cover rounded-lg"
/>
</div>
<div className="inline-flex flex-col gap-1">
<h3 className="max-w-[15rem] truncate font-semibold text-zinc-100 leading-none">
{profile.display_name || profile.name}
</h3>
<p className="max-w-[10rem] truncate text-sm text-zinc-400 leading-none">
{profile.nip05 || shortenKey(data.pubkey)}
</p>
</div>
</div>
<div className="inline-flex items-center gap-2">
{socialStatus === "loading" ? (
<button
type="button"
className="inline-flex w-8 h-8 items-center justify-center rounded-md bg-zinc-900 hover:bg-fuchsia-500"
>
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
</button>
) : followed ? (
<button
type="button"
onClick={() => unfollowUser(data.pubkey)}
className="inline-flex w-8 h-8 items-center justify-center rounded-md text-zinc-400 bg-zinc-800 hover:bg-fuchsia-500 hover:text-white"
>
<UnfollowIcon className="w-4 h-4" />
</button>
) : (
<button
type="button"
onClick={() => followUser(data.pubkey)}
className="inline-flex w-8 h-8 items-center justify-center rounded-md text-zinc-400 bg-zinc-800 hover:bg-fuchsia-500 hover:text-white"
>
<FollowIcon className="w-4 h-4" />
</button>
)}
</div>
</div>
<div className="mt-2">
<p className="whitespace-pre-line break-words text-zinc-100">
{profile.about || profile.bio}
</p>
</div>
<div className="mt-8">
{status === "loading" ? (
<p>Loading...</p>
) : (
<div className="w-full flex items-center gap-8">
<div className="inline-flex flex-col gap-1">
<span className="leading-none font-semibold text-zinc-100">
{userStats.stats[data.pubkey].followers_pubkey_count ?? 0}
</span>
<span className="leading-none text-sm text-zinc-400">
Followers
</span>
</div>
<div className="inline-flex flex-col gap-1">
<span className="leading-none font-semibold text-zinc-100">
{userStats.stats[data.pubkey].pub_following_pubkey_count ?? 0}
</span>
<span className="leading-none text-sm text-zinc-400">
Following
</span>
</div>
<div className="inline-flex flex-col gap-1">
<span className="leading-none font-semibold text-zinc-100">
{userStats.stats[data.pubkey].zaps_received
? compactNumber.format(
userStats.stats[data.pubkey].zaps_received.msats / 1000,
)
: 0}
</span>
<span className="leading-none text-sm text-zinc-400">
Zaps received
</span>
</div>
</div>
)}
</div>
</div>
);
return (
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-5 py-5">
<div className="flex items-center justify-between">
<div className="inline-flex items-center gap-2">
<div className="h-11 w-11 shrink-0">
<Image
src={profile.picture}
fallback={DEFAULT_AVATAR}
className="h-11 w-11 rounded-lg object-cover"
/>
</div>
<div className="inline-flex flex-col gap-1">
<h3 className="max-w-[15rem] truncate font-semibold leading-none text-zinc-100">
{profile.display_name || profile.name}
</h3>
<p className="max-w-[10rem] truncate text-sm leading-none text-zinc-400">
{profile.nip05 || shortenKey(data.pubkey)}
</p>
</div>
</div>
<div className="inline-flex items-center gap-2">
{socialStatus === 'loading' ? (
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-zinc-900 hover:bg-fuchsia-500"
>
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
</button>
) : followed ? (
<button
type="button"
onClick={() => unfollowUser(data.pubkey)}
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-zinc-800 text-zinc-400 hover:bg-fuchsia-500 hover:text-white"
>
<UnfollowIcon className="h-4 w-4" />
</button>
) : (
<button
type="button"
onClick={() => followUser(data.pubkey)}
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-zinc-800 text-zinc-400 hover:bg-fuchsia-500 hover:text-white"
>
<FollowIcon className="h-4 w-4" />
</button>
)}
</div>
</div>
<div className="mt-2">
<p className="whitespace-pre-line break-words text-zinc-100">
{profile.about || profile.bio}
</p>
</div>
<div className="mt-8">
{status === 'loading' ? (
<p>Loading...</p>
) : (
<div className="flex w-full items-center gap-8">
<div className="inline-flex flex-col gap-1">
<span className="font-semibold leading-none text-zinc-100">
{userStats.stats[data.pubkey].followers_pubkey_count ?? 0}
</span>
<span className="text-sm leading-none text-zinc-400">Followers</span>
</div>
<div className="inline-flex flex-col gap-1">
<span className="font-semibold leading-none text-zinc-100">
{userStats.stats[data.pubkey].pub_following_pubkey_count ?? 0}
</span>
<span className="text-sm leading-none text-zinc-400">Following</span>
</div>
<div className="inline-flex flex-col gap-1">
<span className="font-semibold leading-none text-zinc-100">
{userStats.stats[data.pubkey].zaps_received
? compactNumber.format(
userStats.stats[data.pubkey].zaps_received.msats / 1000
)
: 0}
</span>
<span className="text-sm leading-none text-zinc-400">Zaps received</span>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,36 +1,37 @@
import { Note } from "@shared/notes/note";
import { NoteSkeleton } from "@shared/notes/skeleton";
import { TitleBar } from "@shared/titleBar";
import { useQuery } from "@tanstack/react-query";
import { useQuery } from '@tanstack/react-query';
import { Note } from '@shared/notes/note';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { TitleBar } from '@shared/titleBar';
export function TrendingNotes() {
const { status, data, error } = useQuery(["trending-notes"], async () => {
const res = await fetch("https://api.nostr.band/v0/trending/notes");
if (!res.ok) {
throw new Error("Error");
}
return res.json();
});
const { status, data, error } = useQuery(['trending-notes'], async () => {
const res = await fetch('https://api.nostr.band/v0/trending/notes');
if (!res.ok) {
throw new Error('Error');
}
return res.json();
});
return (
<div className="shrink-0 w-[360px] flex-col flex border-r border-zinc-900">
<TitleBar title="Trending Posts" />
<div className="scrollbar-hide flex w-full h-full flex-col justify-between gap-1.5 pt-1.5 pb-20 overflow-y-auto">
{error && <p>Failed to fetch</p>}
{status === "loading" ? (
<div className="px-3 py-1.5">
<div className="rounded-md bg-zinc-900 px-3 py-3 shadow-input shadow-black/20">
<NoteSkeleton />
</div>
</div>
) : (
<div className="relative w-full flex flex-col pt-1.5">
{data.notes.map((item) => (
<Note key={item.id} event={item.event} />
))}
</div>
)}
</div>
</div>
);
return (
<div className="flex w-[360px] shrink-0 flex-col border-r border-zinc-900">
<TitleBar title="Trending Posts" />
<div className="scrollbar-hide flex h-full w-full flex-col justify-between gap-1.5 overflow-y-auto pb-20 pt-1.5">
{error && <p>Failed to fetch</p>}
{status === 'loading' ? (
<div className="px-3 py-1.5">
<div className="shadow-input rounded-md bg-zinc-900 px-3 py-3 shadow-black/20">
<NoteSkeleton />
</div>
</div>
) : (
<div className="relative flex w-full flex-col pt-1.5">
{data.notes.map((item) => (
<Note key={item.id} event={item.event} />
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,36 +1,38 @@
import { Profile } from "@app/trending/components/profile";
import { NoteSkeleton } from "@shared/notes/skeleton";
import { TitleBar } from "@shared/titleBar";
import { useQuery } from "@tanstack/react-query";
import { useQuery } from '@tanstack/react-query';
import { Profile } from '@app/trending/components/profile';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { TitleBar } from '@shared/titleBar';
export function TrendingProfiles() {
const { status, data, error } = useQuery(["trending-profiles"], async () => {
const res = await fetch("https://api.nostr.band/v0/trending/profiles");
if (!res.ok) {
throw new Error("Error");
}
return res.json();
});
const { status, data, error } = useQuery(['trending-profiles'], async () => {
const res = await fetch('https://api.nostr.band/v0/trending/profiles');
if (!res.ok) {
throw new Error('Error');
}
return res.json();
});
return (
<div className="shrink-0 w-[360px] flex-col flex border-r border-zinc-900">
<TitleBar title="Trending Profiles" />
<div className="scrollbar-hide flex w-full h-full flex-col justify-between gap-1.5 pt-1.5 pb-20 overflow-y-auto">
{error && <p>Failed to fetch</p>}
{status === "loading" ? (
<div className="px-3 py-1.5">
<div className="rounded-md bg-zinc-900 px-3 py-3 shadow-input shadow-black/20">
<NoteSkeleton />
</div>
</div>
) : (
<div className="relative w-full flex flex-col gap-3 px-3 pt-3">
{data.profiles.map((item) => (
<Profile key={item.pubkey} data={item} />
))}
</div>
)}
</div>
</div>
);
return (
<div className="flex w-[360px] shrink-0 flex-col border-r border-zinc-900">
<TitleBar title="Trending Profiles" />
<div className="scrollbar-hide flex h-full w-full flex-col justify-between gap-1.5 overflow-y-auto pb-20 pt-1.5">
{error && <p>Failed to fetch</p>}
{status === 'loading' ? (
<div className="px-3 py-1.5">
<div className="shadow-input rounded-md bg-zinc-900 px-3 py-3 shadow-black/20">
<NoteSkeleton />
</div>
</div>
) : (
<div className="relative flex w-full flex-col gap-3 px-3 pt-3">
{data.profiles.map((item) => (
<Profile key={item.pubkey} data={item} />
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,11 +1,11 @@
import { TrendingNotes } from "@app/trending/components/trendingNotes";
import { TrendingProfiles } from "@app/trending/components/trendingProfiles";
import { TrendingNotes } from '@app/trending/components/trendingNotes';
import { TrendingProfiles } from '@app/trending/components/trendingProfiles';
export function TrendingScreen() {
return (
<div className="h-full w-full flex flex-nowrap overflow-x-auto overflow-y-hidden scrollbar-hide">
<TrendingProfiles />
<TrendingNotes />
</div>
);
return (
<div className="scrollbar-hide flex h-full w-full flex-nowrap overflow-x-auto overflow-y-hidden">
<TrendingProfiles />
<TrendingNotes />
</div>
);
}

View File

@@ -1,33 +1,35 @@
import { NDKFilter } from "@nostr-dev-kit/ndk";
import { Note } from "@shared/notes/note";
import { RelayContext } from "@shared/relayProvider";
import { useQuery } from "@tanstack/react-query";
import { dateToUnix, getHourAgo } from "@utils/date";
import { LumeEvent } from "@utils/types";
import { useContext } from "react";
import { NDKFilter } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { useContext } from 'react';
import { Note } from '@shared/notes/note';
import { RelayContext } from '@shared/relayProvider';
import { dateToUnix, getHourAgo } from '@utils/date';
import { LumeEvent } from '@utils/types';
export function UserFeed({ pubkey }: { pubkey: string }) {
const ndk = useContext(RelayContext);
const { status, data } = useQuery(["user-feed", pubkey], async () => {
const now = new Date();
const filter: NDKFilter = {
kinds: [1],
authors: [pubkey],
since: dateToUnix(getHourAgo(48, now)),
};
const events = await ndk.fetchEvents(filter);
return [...events];
});
const ndk = useContext(RelayContext);
const { status, data } = useQuery(['user-feed', pubkey], async () => {
const now = new Date();
const filter: NDKFilter = {
kinds: [1],
authors: [pubkey],
since: dateToUnix(getHourAgo(48, now)),
};
const events = await ndk.fetchEvents(filter);
return [...events];
});
return (
<div className="w-full max-w-[400px] px-2 pb-10">
{status === "loading" ? (
<div className="px-3">
<p>Loading...</p>
</div>
) : (
data.map((note: LumeEvent) => <Note key={note.id} event={note} />)
)}
</div>
);
return (
<div className="w-full max-w-[400px] px-2 pb-10">
{status === 'loading' ? (
<div className="px-3">
<p>Loading...</p>
</div>
) : (
data.map((note: LumeEvent) => <Note key={note.id} event={note} />)
)}
</div>
);
}

View File

@@ -1,55 +1,50 @@
import { useQuery } from "@tanstack/react-query";
import { compactNumber } from "@utils/number";
import { useQuery } from '@tanstack/react-query';
import { compactNumber } from '@utils/number';
export function UserMetadata({ pubkey }: { pubkey: string }) {
const { status, data } = useQuery(["user-metadata", pubkey], async () => {
const res = await fetch(
`https://api.nostr.band/v0/stats/profile/${pubkey}`,
);
if (!res.ok) {
throw new Error("Error");
}
return await res.json();
});
const { status, data } = useQuery(['user-metadata', pubkey], async () => {
const res = await fetch(`https://api.nostr.band/v0/stats/profile/${pubkey}`);
if (!res.ok) {
throw new Error('Error');
}
return await res.json();
});
if (status === "loading") {
return <p>Loading...</p>;
}
if (status === 'loading') {
return <p>Loading...</p>;
}
return (
<div className="w-full flex items-center gap-10">
<div className="inline-flex flex-col gap-1">
<span className="leading-none font-semibold text-zinc-100">
{data.stats[pubkey].followers_pubkey_count ?? 0}
</span>
<span className="leading-none text-sm text-zinc-400">Followers</span>
</div>
<div className="inline-flex flex-col gap-1">
<span className="leading-none font-semibold text-zinc-100">
{data.stats[pubkey].pub_following_pubkey_count ?? 0}
</span>
<span className="leading-none text-sm text-zinc-400">Following</span>
</div>
<div className="inline-flex flex-col gap-1">
<span className="leading-none font-semibold text-zinc-100">
{data.stats[pubkey].zaps_received
? compactNumber.format(
data.stats[pubkey].zaps_received.msats / 1000,
)
: 0}
</span>
<span className="leading-none text-sm text-zinc-400">
Zaps received
</span>
</div>
<div className="inline-flex flex-col gap-1">
<span className="leading-none font-semibold text-zinc-100">
{data.stats[pubkey].zaps_sent
? compactNumber.format(data.stats[pubkey].zaps_sent.msats / 1000)
: 0}
</span>
<span className="leading-none text-sm text-zinc-400">Zaps sent</span>
</div>
</div>
);
return (
<div className="flex w-full items-center gap-10">
<div className="inline-flex flex-col gap-1">
<span className="font-semibold leading-none text-zinc-100">
{data.stats[pubkey].followers_pubkey_count ?? 0}
</span>
<span className="text-sm leading-none text-zinc-400">Followers</span>
</div>
<div className="inline-flex flex-col gap-1">
<span className="font-semibold leading-none text-zinc-100">
{data.stats[pubkey].pub_following_pubkey_count ?? 0}
</span>
<span className="text-sm leading-none text-zinc-400">Following</span>
</div>
<div className="inline-flex flex-col gap-1">
<span className="font-semibold leading-none text-zinc-100">
{data.stats[pubkey].zaps_received
? compactNumber.format(data.stats[pubkey].zaps_received.msats / 1000)
: 0}
</span>
<span className="text-sm leading-none text-zinc-400">Zaps received</span>
</div>
<div className="inline-flex flex-col gap-1">
<span className="font-semibold leading-none text-zinc-100">
{data.stats[pubkey].zaps_sent
? compactNumber.format(data.stats[pubkey].zaps_sent.msats / 1000)
: 0}
</span>
<span className="text-sm leading-none text-zinc-400">Zaps sent</span>
</div>
</div>
);
}

View File

@@ -1,163 +1,167 @@
import { UserFeed } from "@app/user/components/feed";
import { UserMetadata } from "@app/user/components/metadata";
import { Tab } from "@headlessui/react";
import { EditProfileModal } from "@shared/editProfileModal";
import { ThreadsIcon, ZapIcon } from "@shared/icons";
import { Image } from "@shared/image";
import { DEFAULT_AVATAR } from "@stores/constants";
import { useAccount } from "@utils/hooks/useAccount";
import { useProfile } from "@utils/hooks/useProfile";
import { useSocial } from "@utils/hooks/useSocial";
import { shortenKey } from "@utils/shortenKey";
import { Fragment, useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { Tab } from '@headlessui/react';
import { Fragment, useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { UserFeed } from '@app/user/components/feed';
import { UserMetadata } from '@app/user/components/metadata';
import { EditProfileModal } from '@shared/editProfileModal';
import { ThreadsIcon, ZapIcon } from '@shared/icons';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useAccount } from '@utils/hooks/useAccount';
import { useProfile } from '@utils/hooks/useProfile';
import { useSocial } from '@utils/hooks/useSocial';
import { shortenKey } from '@utils/shortenKey';
export function UserScreen() {
const { pubkey } = useParams();
const { user } = useProfile(pubkey);
const { account } = useAccount();
const { status, userFollows, follow, unfollow } = useSocial();
const { pubkey } = useParams();
const { user } = useProfile(pubkey);
const { account } = useAccount();
const { status, userFollows, follow, unfollow } = useSocial();
const [followed, setFollowed] = useState(false);
const [followed, setFollowed] = useState(false);
const followUser = (pubkey: string) => {
try {
follow(pubkey);
const followUser = (pubkey: string) => {
try {
follow(pubkey);
// update state
setFollowed(true);
} catch (error) {
console.log(error);
}
};
// update state
setFollowed(true);
} catch (error) {
console.log(error);
}
};
const unfollowUser = (pubkey: string) => {
try {
unfollow(pubkey);
const unfollowUser = (pubkey: string) => {
try {
unfollow(pubkey);
// update state
setFollowed(false);
} catch (error) {
console.log(error);
}
};
// update state
setFollowed(false);
} catch (error) {
console.log(error);
}
};
useEffect(() => {
if (status === "success" && userFollows) {
if (userFollows.includes(pubkey)) {
setFollowed(true);
}
}
}, [status]);
useEffect(() => {
if (status === 'success' && userFollows) {
if (userFollows.includes(pubkey)) {
setFollowed(true);
}
}
}, [status]);
return (
<div className="h-full w-full overflow-y-auto">
<div
data-tauri-drag-region
className="h-11 w-full flex items-center px-3 border-b border-zinc-900"
/>
<div className="w-full h-56 bg-zinc-100">
<Image
src={user?.banner}
fallback="https://void.cat/d/QY1myro5tkHVs2nY7dy74b.jpg"
alt={"banner"}
className="w-full h-full object-cover"
/>
</div>
<div className="w-full -mt-7">
<div className="px-5">
<Image
src={user?.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className="w-14 h-14 rounded-md ring-2 ring-black"
/>
<div className="flex-1 flex flex-col gap-4 mt-2">
<div className="flex items-center gap-16">
<div className="inline-flex flex-col gap-1.5">
<h5 className="font-semibold text-lg leading-none">
{user?.displayName || user?.name || "No name"}
</h5>
<span className="max-w-[15rem] text-sm truncate leading-none text-zinc-500">
{user?.nip05 || shortenKey(pubkey)}
</span>
</div>
<div className="inline-flex items-center gap-2">
{status === "loading" ? (
<button
type="button"
className="inline-flex w-36 h-10 items-center justify-center rounded-md bg-zinc-900 hover:bg-fuchsia-500 text-sm font-medium"
>
Loading...
</button>
) : followed ? (
<button
type="button"
onClick={() => unfollowUser(pubkey)}
className="inline-flex w-36 h-10 items-center justify-center rounded-md bg-zinc-900 hover:bg-fuchsia-500 text-sm font-medium"
>
Unfollow
</button>
) : (
<button
type="button"
onClick={() => followUser(pubkey)}
className="inline-flex w-36 h-10 items-center justify-center rounded-md bg-zinc-900 hover:bg-fuchsia-500 text-sm font-medium"
>
Follow
</button>
)}
<Link
to={`/app/chat/${pubkey}`}
className="inline-flex w-36 h-10 items-center justify-center rounded-md bg-zinc-900 hover:bg-fuchsia-500 text-sm font-medium"
>
Message
</Link>
<button
type="button"
className="inline-flex w-10 h-10 items-center justify-center rounded-md bg-zinc-900 group hover:bg-orange-500 text-sm font-medium"
>
<ZapIcon className="w-5 h-5" />
</button>
<span className="inline-flex mx-2 w-px h-4 bg-zinc-900" />
{account && account.pubkey === pubkey && <EditProfileModal />}
</div>
</div>
<div className="flex flex-col gap-8">
<p className="mt-2 max-w-[500px] break-words select-text text-zinc-100">
{user?.about}
</p>
<UserMetadata pubkey={pubkey} />
</div>
</div>
</div>
<div className="mt-8 w-full border-t border-zinc-900">
<Tab.Group>
<Tab.List className="px-5 mb-2">
<Tab as={Fragment}>
{({ selected }) => (
<button
type="button"
className={`${
selected
? "text-fuchsia-500 border-fuchsia-500"
: "text-zinc-200 border-transparent"
} font-medium inline-flex items-center gap-2 h-10 border-t`}
>
<ThreadsIcon className="w-4 h-4" />
Activities from 48 hours ago
</button>
)}
</Tab>
</Tab.List>
<Tab.Panels>
<Tab.Panel>
<UserFeed pubkey={pubkey} />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
</div>
</div>
);
return (
<div className="h-full w-full overflow-y-auto">
<div
data-tauri-drag-region
className="flex h-11 w-full items-center border-b border-zinc-900 px-3"
/>
<div className="h-56 w-full bg-zinc-100">
<Image
src={user?.banner}
fallback="https://void.cat/d/QY1myro5tkHVs2nY7dy74b.jpg"
alt={'banner'}
className="h-full w-full object-cover"
/>
</div>
<div className="-mt-7 w-full">
<div className="px-5">
<Image
src={user?.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className="h-14 w-14 rounded-md ring-2 ring-black"
/>
<div className="mt-2 flex flex-1 flex-col gap-4">
<div className="flex items-center gap-16">
<div className="inline-flex flex-col gap-1.5">
<h5 className="text-lg font-semibold leading-none">
{user?.displayName || user?.name || 'No name'}
</h5>
<span className="max-w-[15rem] truncate text-sm leading-none text-zinc-500">
{user?.nip05 || shortenKey(pubkey)}
</span>
</div>
<div className="inline-flex items-center gap-2">
{status === 'loading' ? (
<button
type="button"
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-zinc-900 text-sm font-medium hover:bg-fuchsia-500"
>
Loading...
</button>
) : followed ? (
<button
type="button"
onClick={() => unfollowUser(pubkey)}
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-zinc-900 text-sm font-medium hover:bg-fuchsia-500"
>
Unfollow
</button>
) : (
<button
type="button"
onClick={() => followUser(pubkey)}
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-zinc-900 text-sm font-medium hover:bg-fuchsia-500"
>
Follow
</button>
)}
<Link
to={`/app/chat/${pubkey}`}
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-zinc-900 text-sm font-medium hover:bg-fuchsia-500"
>
Message
</Link>
<button
type="button"
className="group inline-flex h-10 w-10 items-center justify-center rounded-md bg-zinc-900 text-sm font-medium hover:bg-orange-500"
>
<ZapIcon className="h-5 w-5" />
</button>
<span className="mx-2 inline-flex h-4 w-px bg-zinc-900" />
{account && account.pubkey === pubkey && <EditProfileModal />}
</div>
</div>
<div className="flex flex-col gap-8">
<p className="mt-2 max-w-[500px] select-text break-words text-zinc-100">
{user?.about}
</p>
<UserMetadata pubkey={pubkey} />
</div>
</div>
</div>
<div className="mt-8 w-full border-t border-zinc-900">
<Tab.Group>
<Tab.List className="mb-2 px-5">
<Tab as={Fragment}>
{({ selected }) => (
<button
type="button"
className={`${
selected
? 'border-fuchsia-500 text-fuchsia-500'
: 'border-transparent text-zinc-200'
} inline-flex h-10 items-center gap-2 border-t font-medium`}
>
<ThreadsIcon className="h-4 w-4" />
Activities from 48 hours ago
</button>
)}
</Tab>
</Tab.List>
<Tab.Panels>
<Tab.Panel>
<UserFeed pubkey={pubkey} />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
</div>
</div>
);
}