wip: restructure

This commit is contained in:
Ren Amamiya
2023-04-27 13:27:11 +07:00
parent 6918660a5c
commit 98a37d4618
102 changed files with 997 additions and 1041 deletions

View File

@@ -1,14 +0,0 @@
import { createContext } from 'react';
export const AccountContext = createContext({});
let activeAccount: any = { id: '', pubkey: '', follows: null, metadata: {} };
if (typeof window !== 'undefined') {
const { getActiveAccount } = await import('@utils/storage');
activeAccount = await getActiveAccount();
}
export default function AccountProvider({ children }: { children: React.ReactNode }) {
return <AccountContext.Provider value={activeAccount}>{children}</AccountContext.Provider>;
}

View File

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

View File

@@ -1,54 +0,0 @@
import EventCollector from '@components/eventCollector';
import { ArrowLeft, ArrowRight, Refresh } from 'iconoir-react';
let platformName = 'darwin';
if (typeof window !== 'undefined') {
const { platform } = await import('@tauri-apps/api/os');
platformName = await platform();
}
export default function AppHeader({ collector }: { collector: boolean }) {
const goBack = () => {
window.history.back();
};
const goForward = () => {
window.history.forward();
};
const reload = () => {
window.location.reload();
};
return (
<div data-tauri-drag-region className="flex h-full w-full flex-1 items-center px-2">
<div className={`flex h-full items-center gap-2 ${platformName === 'darwin' ? 'pl-[68px]' : ''}`}>
<button
onClick={() => goBack()}
className="group inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-900"
>
<ArrowLeft width={16} height={16} className="text-zinc-500 group-hover:text-zinc-300" />
</button>
<button
onClick={() => goForward()}
className="group inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-900"
>
<ArrowRight width={16} height={16} className="text-zinc-500 group-hover:text-zinc-300" />
</button>
<button
onClick={() => reload()}
className="group inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-900"
>
<Refresh width={16} height={16} className="text-zinc-500 group-hover:text-zinc-300" />
</button>
</div>
<div data-tauri-drag-region className="flex h-full w-full items-center justify-between">
<div className="flex h-full items-center divide-x divide-zinc-900 px-4 pt-px">
{collector && <EventCollector />}
</div>
</div>
</div>
);
}

View File

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

View File

@@ -1,52 +0,0 @@
import { UserMuted } from '@components/user/muted';
import { Popover, Transition } from '@headlessui/react';
import { MicMute } from 'iconoir-react';
import { Fragment } from 'react';
export const 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 ${
open ? 'bg-zinc-800 hover:bg-zinc-700' : 'bg-zinc-900 hover:bg-zinc-800'
}`}
>
<MicMute width={16} height={16} className="text-zinc-400 group-hover:text-zinc-200" />
</Popover.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute right-0 z-10 mt-1 w-screen max-w-xs transform px-4 sm:px-0">
<div className="flex flex-col gap-2 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900 shadow-popover">
<div className="h-min w-full shrink-0 border-b border-zinc-800 p-3">
<div className="flex flex-col gap-0.5">
<h3 className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text font-semibold leading-none text-transparent">
Your muted list
</h3>
<p className="text-xs leading-tight text-zinc-400">
Currently, unmute only affect locally, when you move to new client, muted list will loaded again
</p>
</div>
</div>
<div className="px-3 pb-3 pt-1">
{blacklist.map((item: any) => (
<UserMuted key={item.id} data={item} />
))}
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
);
};

View File

@@ -1,20 +0,0 @@
import { ChannelListItem } from '@components/channels/channelListItem';
import { CreateChannelModal } from '@components/channels/createChannelModal';
let channels: any = [];
if (typeof window !== 'undefined') {
const { getChannels } = await import('@utils/storage');
channels = await getChannels(100, 0);
}
export default function ChannelList() {
return (
<div className="flex flex-col gap-px">
{channels.map((item: { event_id: string }) => (
<ChannelListItem key={item.event_id} data={item} />
))}
<CreateChannelModal />
</div>
);
}

View File

@@ -1,35 +0,0 @@
import { DEFAULT_AVATAR } from '@stores/constants';
import { useChannelMetadata } from '@utils/hooks/useChannelMetadata';
import { usePageContext } from '@utils/hooks/usePageContext';
import { twMerge } from 'tailwind-merge';
export const ChannelListItem = ({ data }: { data: any }) => {
const channel: any = useChannelMetadata(data.event_id, data.pubkey);
const pageContext = usePageContext();
const searchParams: any = pageContext.urlParsed.search;
const pageID = searchParams.id;
return (
<a
href={`/channel?id=${data.event_id}&pubkey=${data.pubkey}`}
className={twMerge(
'inline-flex items-center gap-2 rounded-md px-2.5 py-1.5 hover:bg-zinc-900',
pageID === data.event_id ? 'dark:bg-zinc-900 dark:text-zinc-100 hover:dark:bg-zinc-800' : ''
)}
>
<div className="relative h-5 w-5 shrink-0 rounded">
<img
src={channel?.picture || DEFAULT_AVATAR}
alt={data.event_id}
className="h-5 w-5 rounded bg-white object-cover"
/>
</div>
<div>
<h5 className="truncate text-sm font-medium text-zinc-400">{channel?.name}</h5>
</div>
</a>
);
};

View File

@@ -1,41 +0,0 @@
import { DEFAULT_AVATAR } from '@stores/constants';
import { useChannelProfile } from '@utils/hooks/useChannelProfile';
import { Copy } from 'iconoir-react';
import { nip19 } from 'nostr-tools';
export const ChannelProfile = ({ id, pubkey }: { id: string; pubkey: string }) => {
const metadata = useChannelProfile(id, pubkey);
const noteID = id ? nip19.noteEncode(id) : null;
const copyNoteID = async () => {
const { writeText } = await import('@tauri-apps/api/clipboard');
if (noteID) {
await writeText(noteID);
}
};
return (
<div className="inline-flex items-center gap-2">
<div className="relative shrink-0 rounded-md">
<img
src={metadata?.picture || DEFAULT_AVATAR}
alt={id}
className="h-8 w-8 rounded bg-zinc-900 object-contain"
/>
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1">
<h5 className="truncate text-sm font-medium leading-none text-zinc-100">{metadata?.name}</h5>
<button onClick={() => copyNoteID()}>
<Copy width={14} height={14} className="text-zinc-400" />
</button>
</div>
<p className="text-xs leading-none text-zinc-400">
{metadata?.about || (noteID && noteID.substring(0, 24) + '...')}
</p>
</div>
</div>
);
};

View File

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

View File

@@ -1,42 +0,0 @@
import { AccountContext } from '@components/accountProvider';
import { RelayContext } from '@components/relaysProvider';
import Tooltip from '@components/tooltip';
import { WRITEONLY_RELAYS } from '@stores/constants';
import { dateToUnix } from '@utils/getDate';
import { EyeClose } from 'iconoir-react';
import { getEventHash, signEvent } from 'nostr-tools';
import { useCallback, useContext } from 'react';
export const HideMessageButton = ({ id }: { id: string }) => {
const pool: any = useContext(RelayContext);
const activeAccount: any = useContext(AccountContext);
const hideMessage = useCallback(() => {
const event: any = {
content: '',
created_at: dateToUnix(),
kind: 43,
pubkey: activeAccount.pubkey,
tags: [['e', id]],
};
event.id = getEventHash(event);
event.sig = signEvent(event, activeAccount.privkey);
// publish note
pool.publish(event, WRITEONLY_RELAYS);
}, [activeAccount.pubkey, activeAccount.privkey, id, pool]);
return (
<Tooltip message="Hide this message">
<button
onClick={() => hideMessage()}
className="inline-flex h-6 w-6 items-center justify-center rounded hover:bg-zinc-800"
>
<EyeClose width={16} height={16} className="text-zinc-400" />
</button>
</Tooltip>
);
};

View File

@@ -1,49 +0,0 @@
import { ChannelMessageItem } from '@components/channels/messages/item';
import { Placeholder } from '@components/note/placeholder';
import { sortedChannelMessagesAtom } from '@stores/channel';
import { useAtomValue } from 'jotai';
import { useCallback, useRef } from 'react';
import { Virtuoso } from 'react-virtuoso';
export default function ChannelMessages() {
const virtuosoRef = useRef(null);
const data = useAtomValue(sortedChannelMessagesAtom);
const itemContent: any = useCallback(
(index: string | number) => {
return <ChannelMessageItem data={data[index]} />;
},
[data]
);
const computeItemKey = useCallback(
(index: string | number) => {
return data[index].id;
},
[data]
);
return (
<div className="h-full w-full">
<Virtuoso
ref={virtuosoRef}
data={data}
components={COMPONENTS}
itemContent={itemContent}
computeItemKey={computeItemKey}
initialTopMostItemIndex={data.length - 1}
alignToBottom={true}
followOutput={true}
overscan={50}
increaseViewportBy={{ top: 200, bottom: 200 }}
className="scrollbar-hide h-full w-full overflow-y-auto"
/>
</div>
);
}
const COMPONENTS = {
EmptyPlaceholder: () => <Placeholder />,
};

View File

@@ -1,34 +0,0 @@
import { HideMessageButton } from '@components/channels/messages/hideMessageButton';
import { MuteButton } from '@components/channels/messages/muteButton';
import { ReplyButton } from '@components/channels/messages/replyButton';
import { MessageUser } from '@components/chats/messageUser';
import { messageParser } from '@utils/parser';
import { memo } from 'react';
export const ChannelMessageItem = memo(function ChannelMessageItem({ data }: { data: any }) {
const content = messageParser(data.content);
return (
<div className="group relative flex h-min min-h-min w-full select-text flex-col px-5 py-2 hover:bg-black/20">
<div className="flex flex-col">
<MessageUser pubkey={data.pubkey} time={data.created_at} />
<div className="-mt-[17px] pl-[48px]">
<div className="flex flex-col gap-2">
<div className="prose prose-zinc max-w-none break-words text-sm leading-tight dark:prose-invert prose-p:m-0 prose-p:text-sm prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-img:m-0 prose-video:m-0">
{content}
</div>
</div>
</div>
</div>
<div className="absolute -top-4 right-4 z-10 hidden group-hover:inline-flex">
<div className="inline-flex h-7 items-center justify-center gap-1 rounded bg-zinc-900 px-0.5 shadow-md shadow-black/20 ring-1 ring-zinc-800">
<ReplyButton id={data.id} pubkey={data.pubkey} content={data.content} />
<HideMessageButton id={data.id} />
<MuteButton pubkey={data.pubkey} />
</div>
</div>
</div>
);
});

View File

@@ -1,42 +0,0 @@
import { AccountContext } from '@components/accountProvider';
import { RelayContext } from '@components/relaysProvider';
import Tooltip from '@components/tooltip';
import { WRITEONLY_RELAYS } from '@stores/constants';
import { dateToUnix } from '@utils/getDate';
import { MicMute } from 'iconoir-react';
import { getEventHash, signEvent } from 'nostr-tools';
import { useContext } from 'react';
export const MuteButton = ({ pubkey }: { pubkey: string }) => {
const pool: any = useContext(RelayContext);
const activeAccount: any = useContext(AccountContext);
const muteUser = () => {
const event: any = {
content: '',
created_at: dateToUnix(),
kind: 44,
pubkey: activeAccount.pubkey,
tags: [['p', pubkey]],
};
event.id = getEventHash(event);
event.sig = signEvent(event, activeAccount.privkey);
// publish note
pool.publish(event, WRITEONLY_RELAYS);
};
return (
<Tooltip message="Mute this user">
<button
onClick={() => muteUser()}
className="inline-flex h-6 w-6 items-center justify-center rounded hover:bg-zinc-800"
>
<MicMute width={16} height={16} className="text-zinc-400" />
</button>
</Tooltip>
);
};

View File

@@ -1,25 +0,0 @@
import Tooltip from '@components/tooltip';
import { channelReplyAtom } from '@stores/channel';
import { Reply } from 'iconoir-react';
import { useSetAtom } from 'jotai';
export const ReplyButton = ({ id, pubkey, content }: { id: string; pubkey: string; content: string }) => {
const setChannelReplyAtom = useSetAtom(channelReplyAtom);
const createReply = () => {
setChannelReplyAtom({ id: id, pubkey: pubkey, content: content });
};
return (
<Tooltip message="Reply">
<button
onClick={() => createReply()}
className="inline-flex h-6 w-6 items-center justify-center rounded hover:bg-zinc-800"
>
<Reply width={16} height={16} className="text-zinc-400" />
</button>
</Tooltip>
);
};

View File

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

View File

@@ -1,47 +0,0 @@
import { AccountContext } from '@components/accountProvider';
import { ChatListItem } from '@components/chats/chatListItem';
import { ChatModal } from '@components/chats/chatModal';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useContext } from 'react';
let list: any = [];
if (typeof window !== 'undefined') {
const { getChats, getActiveAccount } = await import('@utils/storage');
const activeAccount = await getActiveAccount();
list = await getChats(activeAccount.id);
}
export default function ChatList() {
const activeAccount: any = useContext(AccountContext);
const profile = typeof window !== 'undefined' ? JSON.parse(activeAccount.metadata) : null;
return (
<div className="flex flex-col gap-px">
<a
href={`/chat?pubkey=${activeAccount?.pubkey}`}
className="inline-flex items-center gap-2 rounded-md px-2.5 py-1.5 hover:bg-zinc-900"
>
<div className="relative h-5 w-5 shrink rounded bg-white">
<img
src={profile?.picture || DEFAULT_AVATAR}
alt={activeAccount?.pubkey}
className="h-5 w-5 rounded object-cover"
/>
</div>
<div>
<h5 className="text-sm font-medium text-zinc-400">
{profile?.display_name || profile?.name} <span className="text-zinc-500">(you)</span>
</h5>
</div>
</a>
{list.map((item: { id: string; pubkey: string }) => (
<ChatListItem key={item.id} pubkey={item.pubkey} />
))}
<ChatModal />
</div>
);
}

View File

@@ -1,34 +0,0 @@
import { DEFAULT_AVATAR } from '@stores/constants';
import { usePageContext } from '@utils/hooks/usePageContext';
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
import { twMerge } from 'tailwind-merge';
export const ChatListItem = ({ pubkey }: { pubkey: string }) => {
const profile = useProfile(pubkey);
const pageContext = usePageContext();
const searchParams: any = pageContext.urlParsed.search;
const pagePubkey = searchParams.pubkey;
return (
<a
href={`/chat?pubkey=${pubkey}`}
className={twMerge(
'inline-flex items-center gap-2 rounded-md px-2.5 py-1.5 hover:bg-zinc-900',
pagePubkey === pubkey ? 'dark:bg-zinc-900 dark:text-zinc-100 hover:dark:bg-zinc-800' : ''
)}
>
<div className="relative h-5 w-5 shrink rounded">
<img src={profile?.picture || DEFAULT_AVATAR} alt={pubkey} className="h-5 w-5 rounded object-cover" />
</div>
<div>
<h5 className="text-sm font-medium text-zinc-400">
{profile?.display_name || profile?.name || shortenKey(pubkey)}
</h5>
</div>
</a>
);
};

View File

@@ -1,14 +0,0 @@
import { Plus } from 'iconoir-react';
export const ChatModal = () => {
return (
<div className="group inline-flex items-center gap-2 rounded-md px-2.5 py-1.5 hover:bg-zinc-900">
<div className="group-hover:800 inline-flex h-5 w-5 shrink items-center justify-center rounded bg-zinc-900 group-hover:bg-zinc-800">
<Plus width={12} height={12} className="text-zinc-500" />
</div>
<div>
<h5 className="text-sm font-medium text-zinc-500 group-hover:text-zinc-400">Add a new chat</h5>
</div>
</div>
);
};

View File

@@ -1,41 +0,0 @@
import { DEFAULT_AVATAR } from '@stores/constants';
import { shortenKey } from '@utils/shortenKey';
import { navigate } from 'vite-plugin-ssr/client/router';
export const ChatModalUser = ({ data }: { data: any }) => {
const profile = JSON.parse(data.metadata);
const openNewChat = () => {
navigate(`/chat?pubkey=${data.pubkey}`);
};
return (
<div className="group flex items-center justify-between px-3 py-2 hover:bg-zinc-800">
<div className="flex items-center gap-2">
<div className="relative h-10 w-10 shrink-0 rounded-md">
<img
src={profile?.picture || DEFAULT_AVATAR}
alt={data.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 text-sm font-semibold leading-tight text-zinc-200">
{profile?.display_name || profile?.name}
</span>
<span className="text-sm leading-tight text-zinc-400">{shortenKey(data.pubkey)}</span>
</div>
</div>
<div>
<button
onClick={() => openNewChat()}
className="hidden h-8 items-center justify-center rounded-md bg-fuchsia-500 px-3 text-sm font-medium shadow-button hover:bg-fuchsia-600 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 group-hover:inline-flex"
>
Send message
</button>
</div>
</div>
);
};

View File

@@ -1,54 +0,0 @@
import { AccountContext } from '@components/accountProvider';
import { MessageListItem } from '@components/chats/messageListItem';
import { Placeholder } from '@components/note/placeholder';
import { sortedChatMessagesAtom } from '@stores/chat';
import { useAtomValue } from 'jotai';
import { useCallback, useContext, useRef } from 'react';
import { Virtuoso } from 'react-virtuoso';
export default function MessageList() {
const activeAccount: any = useContext(AccountContext);
const virtuosoRef = useRef(null);
const data = useAtomValue(sortedChatMessagesAtom);
const itemContent: any = useCallback(
(index: string | number) => {
return (
<MessageListItem data={data[index]} userPubkey={activeAccount.pubkey} userPrivkey={activeAccount.privkey} />
);
},
[activeAccount.privkey, activeAccount.pubkey, data]
);
const computeItemKey = useCallback(
(index: string | number) => {
return data[index].id;
},
[data]
);
return (
<div className="h-full w-full">
<Virtuoso
ref={virtuosoRef}
data={data}
components={COMPONENTS}
itemContent={itemContent}
computeItemKey={computeItemKey}
initialTopMostItemIndex={data.length - 1}
alignToBottom={true}
followOutput={true}
overscan={50}
increaseViewportBy={{ top: 200, bottom: 200 }}
className="scrollbar-hide h-full w-full overflow-y-auto"
/>
</div>
);
}
const COMPONENTS = {
EmptyPlaceholder: () => <Placeholder />,
};

View File

@@ -1,32 +0,0 @@
import { MessageUser } from '@components/chats/messageUser';
import { useDecryptMessage } from '@utils/hooks/useDecryptMessage';
import { memo } from 'react';
export const MessageListItem = memo(function MessageListItem({
data,
userPubkey,
userPrivkey,
}: {
data: any;
userPubkey: string;
userPrivkey: string;
}) {
const content = useDecryptMessage(userPubkey, userPrivkey, data.pubkey, data.tags, data.content);
return (
<div className="flex h-min min-h-min w-full select-text flex-col px-5 py-2 hover:bg-black/20">
<div className="flex flex-col">
<MessageUser pubkey={data.pubkey} time={data.created_at} />
<div className="-mt-[17px] pl-[48px]">
<div className="flex flex-col gap-2">
<div className="prose prose-zinc max-w-none break-words text-sm leading-tight dark:prose-invert prose-p:m-0 prose-p:text-sm prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-img:m-0 prose-video:m-0">
{content}
</div>
</div>
</div>
</div>
</div>
);
});

View File

@@ -1,36 +0,0 @@
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
export const MessageUser = ({ pubkey, time }: { pubkey: string; time: number }) => {
const profile = useProfile(pubkey);
return (
<div className="group flex items-start gap-3">
<div className="relative h-9 w-9 shrink rounded-md">
<img
src={profile?.picture || DEFAULT_AVATAR}
alt={pubkey}
className="h-9 w-9 rounded-md object-cover"
loading="lazy"
fetchpriority="high"
/>
</div>
<div className="flex w-full flex-1 items-start justify-between">
<div className="flex items-baseline gap-2 text-sm">
<span className="font-semibold leading-none text-zinc-200 group-hover:underline">
{profile?.display_name || profile?.name || shortenKey(pubkey)}
</span>
<span className="leading-none text-zinc-500">·</span>
<span className="leading-none text-zinc-500">{dayjs().to(dayjs.unix(time))}</span>
</div>
</div>
</div>
);
};

View File

@@ -1,113 +0,0 @@
import { AccountContext } from '@components/accountProvider';
import { NetworkStatusIndicator } from '@components/networkStatusIndicator';
import { RelayContext } from '@components/relaysProvider';
import { READONLY_RELAYS } from '@stores/constants';
import { hasNewerNoteAtom } from '@stores/note';
import { dateToUnix } from '@utils/getDate';
import { createChat, createNote, updateAccount } from '@utils/storage';
import { getParentID, nip02ToArray } from '@utils/transform';
import { useSetAtom } from 'jotai';
import { useCallback, useContext, useEffect, useRef } from 'react';
export default function EventCollector() {
const pool: any = useContext(RelayContext);
const activeAccount: any = useContext(AccountContext);
const setHasNewerNote = useSetAtom(hasNewerNoteAtom);
const now = useRef(new Date());
const subscribe = useCallback(async () => {
const follows = activeAccount.follows ? JSON.parse(activeAccount.follows) : [];
const unsubscribe = pool.subscribe(
[
{
kinds: [1, 6],
authors: follows,
since: dateToUnix(now.current),
},
{
kinds: [0, 3],
authors: [activeAccount.pubkey],
},
{
kinds: [4],
'#p': [activeAccount.pubkey],
since: dateToUnix(now.current),
},
],
READONLY_RELAYS,
(event: { kind: number; tags: string[]; id: string; pubkey: string; content: string; created_at: number }) => {
switch (event.kind) {
// metadata
case 0:
updateAccount('metadata', event.content, event.pubkey);
break;
// short text note
case 1:
const parentID = getParentID(event.tags, event.id);
createNote(
event.id,
activeAccount.id,
event.pubkey,
event.kind,
event.tags,
event.content,
event.created_at,
parentID
);
// notify user reload to get newer note
setHasNewerNote(true);
break;
// contacts
case 3:
const arr = nip02ToArray(event.tags);
// update account's folllows with NIP-02 tag list
updateAccount('follows', arr, event.pubkey);
break;
// chat
case 4:
if (event.pubkey !== activeAccount.pubkey) {
createChat(activeAccount.id, event.pubkey, event.created_at);
}
break;
// repost
case 6:
createNote(
event.id,
activeAccount.id,
event.pubkey,
event.kind,
event.tags,
event.content,
event.created_at,
event.id
);
break;
default:
break;
}
}
);
return () => {
unsubscribe();
};
}, [activeAccount.id, activeAccount.pubkey, activeAccount.follows, pool, setHasNewerNote]);
useEffect(() => {
let ignore = false;
if (!ignore) {
subscribe();
}
return () => {
ignore = true;
};
}, [subscribe]);
return <NetworkStatusIndicator />;
}

View File

@@ -1,73 +0,0 @@
import { AccountContext } from '@components/accountProvider';
import { ImagePicker } from '@components/form/imagePicker';
import { RelayContext } from '@components/relaysProvider';
import { WRITEONLY_RELAYS } from '@stores/constants';
import { noteContentAtom } from '@stores/note';
import { dateToUnix } from '@utils/getDate';
import { useAtom } from 'jotai';
import { useResetAtom } from 'jotai/utils';
import { getEventHash, signEvent } from 'nostr-tools';
import { useContext } from 'react';
export default function FormBase() {
const pool: any = useContext(RelayContext);
const activeAccount: any = useContext(AccountContext);
const [value, setValue] = useAtom(noteContentAtom);
const resetValue = useResetAtom(noteContentAtom);
const submitEvent = () => {
const event: any = {
content: value,
created_at: dateToUnix(),
kind: 1,
pubkey: activeAccount.pubkey,
tags: [],
};
event.id = getEventHash(event);
event.sig = signEvent(event, activeAccount.privkey);
// publish note
pool.publish(event, WRITEONLY_RELAYS);
// reset form
resetValue();
// send notification
// sendNotification('Note has been published successfully');
};
return (
<div className="p-3">
<div className="relative h-32 w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
<div>
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
spellCheck={false}
placeholder="What's up?"
className="relative h-32 w-full resize-none rounded-lg border border-black/5 px-3.5 py-3 text-sm shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
</div>
<div className="absolute bottom-2 w-full px-2">
<div className="flex w-full items-center justify-between bg-zinc-800">
<div className="flex items-center gap-2 divide-x divide-zinc-700">
<ImagePicker type="note" />
<div className="flex items-center gap-2 pl-2"></div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => submitEvent()}
disabled={value.length === 0 ? true : false}
className="inline-flex h-8 w-16 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm font-medium shadow-button hover:bg-fuchsia-600 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
>
Send
</button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

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

View File

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

View File

@@ -1,72 +0,0 @@
import { AccountContext } from '@components/accountProvider';
import { RelayContext } from '@components/relaysProvider';
import { WRITEONLY_RELAYS } from '@stores/constants';
import { dateToUnix } from '@utils/getDate';
import { getEventHash, signEvent } from 'nostr-tools';
import { useContext, useState } from 'react';
export default function FormComment({ eventID }: { eventID: any }) {
const pool: any = useContext(RelayContext);
const activeAccount: any = useContext(AccountContext);
const [value, setValue] = useState('');
const profile = JSON.parse(activeAccount.metadata);
const submitEvent = () => {
const event: any = {
content: value,
created_at: dateToUnix(),
kind: 1,
pubkey: activeAccount.pubkey,
tags: [['e', eventID]],
};
event.id = getEventHash(event);
event.sig = signEvent(event, activeAccount.privkey);
// publish note
pool.publish(event, WRITEONLY_RELAYS);
// send notification
// sendNotification('Comment has been published successfully');
};
return (
<div className="p-3">
<div className="flex gap-1">
<div>
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-md border border-white/10">
<img src={profile?.picture} alt={activeAccount.pubkey} className="h-11 w-11 rounded-md object-cover" />
</div>
</div>
<div className="relative h-24 w-full flex-1 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
<div>
<textarea
name="content"
onChange={(e) => setValue(e.target.value)}
placeholder="Send your comment"
className="relative h-24 w-full resize-none rounded-md border border-black/5 px-3.5 py-3 text-sm shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
spellCheck={false}
/>
</div>
<div className="absolute bottom-2 w-full px-2">
<div className="flex w-full items-center justify-between bg-zinc-800">
<div className="flex items-center gap-2 divide-x divide-zinc-700"></div>
<div className="flex items-center gap-2">
<button
onClick={() => submitEvent()}
disabled={value.length === 0 ? true : false}
className="inline-flex h-8 w-16 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm font-medium shadow-button hover:bg-fuchsia-600 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
>
Send
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

@@ -1,17 +0,0 @@
import AppHeader from '@components/appHeader';
export default function OnboardingLayout({ children }: { children: React.ReactNode }) {
return (
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-white">
<div className="flex h-screen w-full flex-col">
<div
data-tauri-drag-region
className="relative h-11 shrink-0 border-b border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
>
<AppHeader collector={false} />
</div>
<div className="relative flex min-h-0 w-full flex-1">{children}</div>
</div>
</div>
);
}

View File

@@ -1,11 +0,0 @@
import { DEFAULT_AVATAR } from '@stores/constants';
export const ActiveAccount = ({ user }: { user: any }) => {
const userData = JSON.parse(user.metadata);
return (
<button className="relative h-11 w-11 rounded-lg">
<img src={userData.picture || DEFAULT_AVATAR} alt="user's avatar" className="h-11 w-11 rounded-lg object-cover" />
</button>
);
};

View File

@@ -1,17 +0,0 @@
import { DEFAULT_AVATAR } from '@stores/constants';
import { memo } from 'react';
export const InactiveAccount = memo(function InactiveAccount({ user }: { user: any }) {
const userData = JSON.parse(user.metadata);
const setCurrentUser = () => {
console.log('clicked');
};
return (
<button onClick={() => setCurrentUser()} className="relative h-11 w-11 shrink rounded-lg">
<img src={userData.picture || DEFAULT_AVATAR} alt="user's avatar" className="h-11 w-11 rounded-lg object-cover" />
</button>
);
});

View File

@@ -1,51 +0,0 @@
import { AccountContext } from '@components/accountProvider';
import { ActiveAccount } from '@components/multiAccounts/activeAccount';
import { InactiveAccount } from '@components/multiAccounts/inactiveAccount';
import { APP_VERSION } from '@stores/constants';
import LumeSymbol from '@assets/icons/Lume';
import { Plus } from 'iconoir-react';
import { useContext } from 'react';
let accounts: any = [];
if (typeof window !== 'undefined') {
const { getAccounts } = await import('@utils/storage');
accounts = await getAccounts();
}
export default function MultiAccounts() {
const activeAccount: any = useContext(AccountContext);
return (
<div className="flex h-full flex-col items-center justify-between px-2 pb-4 pt-3">
<div className="flex flex-col gap-3">
<a
href="/explore"
className="group relative flex h-11 w-11 shrink cursor-pointer items-center justify-center rounded-lg bg-zinc-900 hover:bg-zinc-800"
>
<LumeSymbol className="h-6 w-auto text-zinc-400 group-hover:text-zinc-200" />
</a>
{accounts.map((account: { pubkey: string }) => {
if (account.pubkey === activeAccount.pubkey) {
return <ActiveAccount key={account.pubkey} user={account} />;
} else {
return <InactiveAccount key={account.pubkey} user={account} />;
}
})}
<a
href="/onboarding"
className="group relative flex h-11 w-11 shrink cursor-pointer items-center justify-center rounded-lg border-2 border-dashed border-zinc-600 hover:border-zinc-400"
>
<Plus width={16} height={16} className="text-zinc-400 group-hover:text-zinc-200" />
</a>
</div>
<div className="flex flex-col gap-0.5 text-center">
<span className="text-sm font-black uppercase leading-tight text-zinc-600">Lume</span>
<span className="text-xs font-medium text-zinc-700">v{APP_VERSION}</span>
</div>
</div>
);
}

View File

@@ -1,91 +0,0 @@
import ActiveLink from '@components/activeLink';
import ChannelList from '@components/channels/channelList';
import ChatList from '@components/chats/chatList';
import { Disclosure } from '@headlessui/react';
import { Bonfire, NavArrowUp, PeopleTag } from 'iconoir-react';
import { Suspense } from 'react';
export default function Navigation() {
return (
<div className="relative flex h-full flex-col gap-1 overflow-hidden pt-3">
{/* Newsfeed */}
<Disclosure defaultOpen={true}>
{({ open }) => (
<div className="flex flex-col px-2">
<Disclosure.Button className="flex cursor-pointer items-center gap-1 px-1 py-1">
<div
className={`inline-flex h-5 w-5 transform items-center justify-center transition-transform duration-150 ease-in-out ${
open ? 'rotate-180' : ''
}`}
>
<NavArrowUp width={16} height={16} className="text-zinc-700" />
</div>
<h3 className="text-[11px] font-bold uppercase tracking-widest text-zinc-600">Newsfeed</h3>
</Disclosure.Button>
<Disclosure.Panel className="flex flex-col text-zinc-400">
<ActiveLink
href="/newsfeed/following"
className="flex h-8 items-center gap-2.5 rounded-md px-2.5 text-sm font-medium hover:text-zinc-200"
activeClassName="dark:bg-zinc-900 dark:text-zinc-100 hover:dark:bg-zinc-800"
>
<PeopleTag width={16} height={16} className="text-zinc-500" />
<span>Following</span>
</ActiveLink>
<ActiveLink
href="/newsfeed/circle"
className="flex h-8 items-center gap-2.5 rounded-md px-2.5 text-sm font-medium hover:text-zinc-200"
activeClassName="dark:bg-zinc-900 dark:text-zinc-100 hover:dark:bg-zinc-800"
>
<Bonfire width={16} height={16} className="text-zinc-500" />
<span>Circle</span>
</ActiveLink>
</Disclosure.Panel>
</div>
)}
</Disclosure>
{/* Channels */}
<Disclosure defaultOpen={true}>
{({ open }) => (
<div className="flex flex-col px-2">
<Disclosure.Button className="flex cursor-pointer items-center gap-1 px-1 py-1">
<div
className={`inline-flex h-5 w-5 transform items-center justify-center transition-transform duration-150 ease-in-out ${
open ? 'rotate-180' : ''
}`}
>
<NavArrowUp width={16} height={16} className="text-zinc-700" />
</div>
<h3 className="text-[11px] font-bold uppercase tracking-widest text-zinc-600">Channels</h3>
</Disclosure.Button>
<Disclosure.Panel>
<Suspense fallback={<p>Loading...</p>}>
<ChannelList />
</Suspense>
</Disclosure.Panel>
</div>
)}
</Disclosure>
{/* Chats */}
<Disclosure defaultOpen={true}>
{({ open }) => (
<div className="flex flex-col px-2">
<Disclosure.Button className="flex cursor-pointer items-center gap-1 px-1 py-1">
<div
className={`inline-flex h-5 w-5 transform items-center justify-center transition-transform duration-150 ease-in-out ${
open ? 'rotate-180' : ''
}`}
>
<NavArrowUp width={16} height={16} className="text-zinc-700" />
</div>
<h3 className="text-[11px] font-bold uppercase tracking-widest text-zinc-600">Chats</h3>
</Disclosure.Button>
<Disclosure.Panel>
<ChatList />
</Disclosure.Panel>
</div>
)}
</Disclosure>
</div>
);
}

View File

@@ -1,21 +0,0 @@
import { useNetworkStatus } from '@utils/hooks/useNetworkStatus';
export const NetworkStatusIndicator = () => {
const isOnline = useNetworkStatus();
return (
<div className="inline-flex items-center gap-1 rounded-md px-1.5 py-1 hover:bg-zinc-900">
<div className="relative flex h-1.5 w-1.5">
<span
className={`absolute inline-flex h-full w-full animate-ping rounded-full opacity-75 ${
isOnline ? 'bg-green-400' : 'bg-red-400'
}`}
></span>
<span
className={`relative inline-flex h-1.5 w-1.5 rounded-full ${isOnline ? 'bg-green-400' : 'bg-amber-400'}`}
></span>
</div>
<p className="text-xs font-medium text-zinc-500">{isOnline ? 'Online' : 'Offline'}</p>
</div>
);
};

View File

@@ -1,60 +0,0 @@
import { NoteMetadata } from '@components/note/metadata';
import { NoteParent } from '@components/note/parent';
import { UserExtend } from '@components/user/extend';
import { contentParser } from '@utils/parser';
import { memo } from 'react';
import { navigate } from 'vite-plugin-ssr/client/router';
export const NoteBase = memo(function NoteBase({ event }: { event: any }) {
const content = contentParser(event.content, event.tags);
const parentNote = () => {
if (event.parent_id) {
if (event.parent_id !== event.event_id && !event.content.includes('#[0]')) {
return <NoteParent key={event.parent_id} id={event.parent_id} />;
}
}
return <></>;
};
const openUserPage = (e) => {
e.stopPropagation();
navigate(`/user?pubkey=${event.pubkey}`);
};
const openThread = (e) => {
const selection = window.getSelection();
if (selection.toString().length === 0) {
navigate(`/newsfeed/note?id=${event.parent_id}`);
} else {
e.stopPropagation();
}
};
return (
<div
onClick={(e) => openThread(e)}
className="relative z-10 flex h-min min-h-min w-full select-text flex-col border-b border-zinc-800 px-3 py-5 hover:bg-black/20"
>
{parentNote()}
<div className="relative z-10 flex flex-col">
<div onClick={(e) => openUserPage(e)}>
<UserExtend pubkey={event.pubkey} time={event.created_at} />
</div>
<div className="mt-1 pl-[52px]">
<div className="whitespace-pre-line break-words text-[15px] leading-tight text-zinc-100">{content}</div>
</div>
<div onClick={(e) => e.stopPropagation()} className="mt-5 pl-[52px]">
<NoteMetadata
eventID={event.event_id}
eventPubkey={event.pubkey}
eventContent={event.content}
eventTime={event.created_at}
/>
</div>
</div>
</div>
);
});

View File

@@ -1,82 +0,0 @@
import { NoteMetadata } from '@components/note/metadata';
import { ImagePreview } from '@components/note/preview/image';
import { VideoPreview } from '@components/note/preview/video';
import { NoteQuote } from '@components/note/quote';
import { UserExtend } from '@components/user/extend';
import { UserMention } from '@components/user/mention';
import destr from 'destr';
import { memo, useMemo } from 'react';
import reactStringReplace from 'react-string-replace';
export const NoteComment = memo(function NoteComment({ event }: { event: any }) {
const content = useMemo(() => {
let parsedContent = event.content;
// get data tags
const tags = destr(event.tags);
// handle urls
parsedContent = reactStringReplace(parsedContent, /(https?:\/\/\S+)/g, (match, i) => {
if (match.match(/\.(jpg|jpeg|gif|png|webp)$/i)) {
// image url
return <ImagePreview key={match + i} url={match} size="large" />;
} else if (match.match(/(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/i)) {
// youtube
return <VideoPreview key={match + i} url={match} />;
} else if (match.match(/\.(mp4|webm)$/i)) {
// video
return <VideoPreview key={match + i} url={match} />;
} else {
return (
<a key={match + i} href={match} target="_blank" rel="noreferrer">
{match}
</a>
);
}
});
// handle #-hashtags
parsedContent = reactStringReplace(parsedContent, /#(\w+)/g, (match, i) => (
<span key={match + i} className="cursor-pointer text-fuchsia-500">
#{match}
</span>
));
// handle mentions
if (tags.length > 0) {
parsedContent = reactStringReplace(parsedContent, /\#\[(\d+)\]/gm, (match, i) => {
if (tags[match][0] === 'p') {
// @-mentions
return <UserMention key={match + i} pubkey={tags[match][1]} />;
} else if (tags[match][0] === 'e') {
// note-quotes
return <NoteQuote key={match + i} id={tags[match][1]} />;
} else {
return;
}
});
}
return parsedContent;
}, [event.content, event.tags]);
return (
<div className="relative z-10 flex h-min min-h-min w-full select-text flex-col border-b border-zinc-800 px-3 py-5 hover:bg-black/20">
<div className="relative z-10 flex flex-col">
<UserExtend pubkey={event.pubkey} time={event.created_at} />
<div className="-mt-5 pl-[52px]">
<div className="flex flex-col gap-2">
<div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-img:m-0 prose-video:m-0">
{content}
</div>
</div>
</div>
<div onClick={(e) => e.stopPropagation()} className="mt-5 pl-[52px]">
<NoteMetadata
eventID={event.event_id}
eventPubkey={event.pubkey}
eventContent={event.content}
eventTime={event.created_at}
/>
</div>
</div>
</div>
);
});

View File

@@ -1,82 +0,0 @@
import { NoteMetadata } from '@components/note/metadata';
import { ImagePreview } from '@components/note/preview/image';
import { VideoPreview } from '@components/note/preview/video';
import { NoteQuote } from '@components/note/quote';
import { UserLarge } from '@components/user/large';
import { UserMention } from '@components/user/mention';
import destr from 'destr';
import { memo, useMemo } from 'react';
import reactStringReplace from 'react-string-replace';
export const NoteExtend = memo(function NoteExtend({ event }: { event: any }) {
const content = useMemo(() => {
let parsedContent = event.content;
// get data tags
const tags = destr(event.tags);
// handle urls
parsedContent = reactStringReplace(parsedContent, /(https?:\/\/\S+)/g, (match, i) => {
if (match.match(/\.(jpg|jpeg|gif|png|webp)$/i)) {
// image url
return <ImagePreview key={match + i} url={match} size="large" />;
} else if (match.match(/(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/i)) {
// youtube
return <VideoPreview key={match + i} url={match} />;
} else if (match.match(/\.(mp4|webm)$/i)) {
// video
return <VideoPreview key={match + i} url={match} />;
} else {
return (
<a key={match + i} href={match} target="_blank" rel="noreferrer">
{match}
</a>
);
}
});
// handle #-hashtags
parsedContent = reactStringReplace(parsedContent, /#(\w+)/g, (match, i) => (
<span key={match + i} className="cursor-pointer text-fuchsia-500">
#{match}
</span>
));
// handle mentions
if (tags.length > 0) {
parsedContent = reactStringReplace(parsedContent, /\#\[(\d+)\]/gm, (match, i) => {
if (tags[match][0] === 'p') {
// @-mentions
return <UserMention key={match + i} pubkey={tags[match][1]} />;
} else if (tags[match][0] === 'e') {
// note-quotes
return <NoteQuote key={match + i} id={tags[match][1]} />;
} else {
return;
}
});
}
return parsedContent;
}, [event.content, event.tags]);
return (
<div className="relative z-10 flex h-min min-h-min w-full select-text flex-col">
<div className="relative z-10 flex flex-col">
<UserLarge pubkey={event.pubkey} time={event.created_at} />
<div className="mt-2">
<div className="flex flex-col gap-2">
<div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-img:m-0 prose-video:m-0">
{content}
</div>
</div>
</div>
<div className="mt-5 flex items-center border-b border-t border-zinc-800 py-2">
<NoteMetadata
eventID={event.event_id}
eventPubkey={event.pubkey}
eventContent={event.content}
eventTime={event.created_at}
/>
</div>
</div>
</div>
);
});

View File

@@ -1,160 +0,0 @@
import { AccountContext } from '@components/accountProvider';
import { RelayContext } from '@components/relaysProvider';
import { UserExtend } from '@components/user/extend';
import { WRITEONLY_RELAYS } from '@stores/constants';
import { dateToUnix } from '@utils/getDate';
import { Dialog, Transition } from '@headlessui/react';
import { ChatLines, OpenNewWindow } from 'iconoir-react';
import { getEventHash, signEvent } from 'nostr-tools';
import { Fragment, useContext, useState } from 'react';
import { navigate } from 'vite-plugin-ssr/client/router';
export const NoteComment = ({
count,
eventID,
eventPubkey,
eventContent,
eventTime,
}: {
count: number;
eventID: string;
eventPubkey: string;
eventTime: number;
eventContent: any;
}) => {
const pool: any = useContext(RelayContext);
const activeAccount: any = useContext(AccountContext);
const [isOpen, setIsOpen] = useState(false);
const [value, setValue] = useState('');
const profile = activeAccount ? JSON.parse(activeAccount.metadata) : null;
const closeModal = () => {
setIsOpen(false);
};
const openModal = () => {
setIsOpen(true);
};
const openThread = () => {
navigate(`/newsfeed/note?id=${eventID}`);
};
const submitEvent = () => {
const event: any = {
content: value,
created_at: dateToUnix(),
kind: 1,
pubkey: activeAccount.pubkey,
tags: [['e', eventID]],
};
event.id = getEventHash(event);
event.sig = signEvent(event, activeAccount.privkey);
pool.publish(event, WRITEONLY_RELAYS);
setIsOpen(false);
};
return (
<>
<button
type="button"
onClick={() => openModal()}
className="group flex w-16 items-center gap-1 text-sm text-zinc-500"
>
<div className="rounded-md p-1 group-hover:bg-zinc-800">
<ChatLines width={20} height={20} className="text-zinc-500" />
</div>
<span>{count}</span>
</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md data-[state=open]:animate-overlayShow" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900">
{/* root note */}
<div className="relative z-10 flex flex-col pb-6">
<div className="relative z-10">
<UserExtend pubkey={eventPubkey} time={eventTime} />
</div>
<div className="-mt-5 pl-[52px]">
<div className="prose prose-zinc max-w-none break-words leading-tight dark:prose-invert prose-headings:mb-2 prose-headings:mt-3 prose-p:m-0 prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-ul:mt-2 prose-li:my-1">
{eventContent}
</div>
</div>
{/* divider */}
<div className="absolute left-[21px] top-0 h-full w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600"></div>
</div>
{/* comment form */}
<div className="flex gap-2">
<div>
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-md border border-white/10">
<img src={profile?.picture} alt="user's avatar" className="h-11 w-11 rounded-md object-cover" />
</div>
</div>
<div className="relative h-36 w-full flex-1 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
<div>
<textarea
name="content"
onChange={(e) => setValue(e.target.value)}
placeholder="Send your comment"
className="relative h-36 w-full resize-none rounded-md border border-black/5 px-3.5 py-3 text-sm shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
spellCheck={false}
/>
</div>
<div className="absolute bottom-2 w-full px-2">
<div className="flex w-full items-center justify-between bg-zinc-800">
<div className="flex items-center gap-2 divide-x divide-zinc-700">
<button
onClick={() => openThread()}
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-zinc-700"
>
<OpenNewWindow width={16} height={16} className="text-zinc-400" />
</button>
<div className="flex items-center gap-2 pl-2"></div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => submitEvent()}
disabled={value.length === 0 ? true : false}
className="inline-flex h-8 w-16 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm font-medium shadow-md shadow-fuchsia-900/50 hover:bg-fuchsia-600"
>
<span className="text-white drop-shadow">Send</span>
</button>
</div>
</div>
</div>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
};

View File

@@ -1,71 +0,0 @@
import { AccountContext } from '@components/accountProvider';
import { RelayContext } from '@components/relaysProvider';
import { WRITEONLY_RELAYS } from '@stores/constants';
import { dateToUnix } from '@utils/getDate';
import { Heart } from 'iconoir-react';
import { getEventHash, signEvent } from 'nostr-tools';
import { useContext, useEffect, useState } from 'react';
export const NoteReaction = ({
count,
liked,
eventID,
eventPubkey,
}: {
count: number;
liked: boolean;
eventID: string;
eventPubkey: string;
}) => {
const pool: any = useContext(RelayContext);
const activeAccount: any = useContext(AccountContext);
const [isReact, setIsReact] = useState(false);
const [like, setLike] = useState(0);
const handleLike = (e: any) => {
e.stopPropagation();
if (liked === false || isReact === false) {
const event: any = {
content: '+',
kind: 7,
tags: [
['e', eventID],
['p', eventPubkey],
],
created_at: dateToUnix(),
pubkey: activeAccount.pubkey,
};
event.id = getEventHash(event);
event.sig = signEvent(event, activeAccount.privkey);
// publish event to all relays
pool.publish(event, WRITEONLY_RELAYS);
// update state to change icon to filled heart
setIsReact(true);
// update counter
setLike((like) => (like += 1));
}
};
useEffect(() => {
setIsReact(liked);
setLike(count);
}, [count, liked]);
return (
<button onClick={(e) => handleLike(e)} className="group flex w-16 items-center gap-1 text-sm text-zinc-500">
<div className="rounded-md p-1 group-hover:bg-zinc-800">
{isReact ? (
<Heart width={20} height={20} className="fill-red-500 text-transparent" />
) : (
<Heart width={20} height={20} className="text-zinc-500" />
)}
</div>
<span>{like}</span>
</button>
);
};

View File

@@ -1,5 +0,0 @@
import { memo } from 'react';
export const NoteMetadata = memo(function NoteMetadata() {
return <div className="relative z-10 -ml-1 flex items-center gap-8"></div>;
});

View File

@@ -1,94 +0,0 @@
import { NoteMetadata } from '@components/note/metadata';
import { RelayContext } from '@components/relaysProvider';
import { UserExtend } from '@components/user/extend';
import { READONLY_RELAYS } from '@stores/constants';
import { contentParser } from '@utils/parser';
import { memo, useContext } from 'react';
import useSWRSubscription from 'swr/subscription';
export const NoteParent = memo(function NoteParent({ id }: { id: string }) {
const pool: any = useContext(RelayContext);
const { data, error } = useSWRSubscription(
id
? [
{
ids: [id],
kinds: [1],
},
]
: null,
(key, { next }) => {
const unsubscribe = pool.subscribe(
key,
READONLY_RELAYS,
(event: any) => {
next(null, event);
},
undefined,
undefined,
{
unsubscribeOnEose: true,
}
);
return () => {
unsubscribe();
};
}
);
return (
<div className="relative pb-5">
{error && <div>failed to load</div>}
{!data ? (
<div className="animated-pulse">
<div className="flex items-start gap-2">
<div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-zinc-700" />
<div className="flex w-full flex-1 items-start justify-between">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2 text-sm">
<div className="h-4 w-16 rounded bg-zinc-700" />
<span className="text-zinc-500">·</span>
<div className="h-4 w-12 rounded bg-zinc-700" />
</div>
</div>
</div>
</div>
<div className="-mt-5 pl-[52px]">
<div className="flex flex-col gap-6">
<div className="h-16 w-full rounded bg-zinc-700" />
<div className="flex items-center gap-8">
<div className="h-4 w-12 rounded bg-zinc-700" />
<div className="h-4 w-12 rounded bg-zinc-700" />
</div>
</div>
</div>
</div>
) : (
<>
<div className="absolute left-[21px] top-0 h-full w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600"></div>
<div className="relative z-10 flex flex-col">
<UserExtend pubkey={data.pubkey} time={data.created_at} />
<div className="mt-1 pl-[52px]">
<div className="whitespace-pre-line break-words text-[15px] leading-tight text-zinc-100">
{contentParser(data.content, data.tags)}
</div>
</div>
<div onClick={(e) => e.stopPropagation()} className="mt-5 pl-[52px]">
<NoteMetadata
eventID={data.event_id}
eventPubkey={data.pubkey}
eventContent={data.content}
eventTime={data.created_at}
/>
</div>
</div>
</>
)}
</div>
);
});

View File

@@ -1,27 +0,0 @@
export const Placeholder = () => {
return (
<div className="relative z-10 flex h-min animate-pulse select-text flex-col px-3 py-5">
<div className="flex items-start gap-2">
<div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-zinc-700" />
<div className="flex w-full flex-1 items-start justify-between">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2 text-sm">
<div className="h-4 w-16 rounded bg-zinc-700" />
<span className="text-zinc-500">·</span>
<div className="h-4 w-12 rounded bg-zinc-700" />
</div>
</div>
</div>
</div>
<div className="-mt-5 pl-[52px]">
<div className="flex flex-col gap-6">
<div className="h-16 w-full rounded bg-zinc-700" />
<div className="flex items-center gap-8">
<div className="h-4 w-12 rounded bg-zinc-700" />
<div className="h-4 w-12 rounded bg-zinc-700" />
</div>
</div>
</div>
</div>
);
};

View File

@@ -1,14 +0,0 @@
export const ImagePreview = ({ url, size }: { url: string; size: string }) => {
return (
<div className={`relative h-full ${size === 'large' ? 'w-4/5' : 'w-1/2'} mt-2 rounded-lg border border-zinc-800`}>
<img
src={url}
alt={url}
className="h-auto w-full rounded-lg object-cover"
loading="lazy"
decoding="async"
style={{ contentVisibility: 'auto' }}
/>
</div>
);
};

View File

@@ -1,11 +0,0 @@
import { MediaOutlet, MediaPlayer } from '@vidstack/react';
export const VideoPreview = ({ url }: { url: string }) => {
return (
<div onClick={(e) => e.stopPropagation()} className="relative mt-2 flex flex-col overflow-hidden rounded-lg">
<MediaPlayer src={url} poster="" controls>
<MediaOutlet />
</MediaPlayer>
</div>
);
};

View File

@@ -1,16 +0,0 @@
import YouTube from 'react-youtube';
function getVideoId(url: string) {
const regex = /(youtu.*be.*)\/(watch\?v=|embed\/|v|shorts|)(.*?((?=[&#?])|$))/gm;
return regex.exec(url)[3];
}
export const YoutubePreview = ({ url }: { url: string }) => {
const id = getVideoId(url);
return (
<div onClick={(e) => e.stopPropagation()} className="relative mt-2 flex flex-col overflow-hidden rounded-lg">
<YouTube videoId={id} className="aspect-video xl:w-2/3" opts={{ width: '100%', height: '100%' }} />
</div>
);
};

View File

@@ -1,60 +0,0 @@
import { RelayContext } from '@components/relaysProvider';
import { UserExtend } from '@components/user/extend';
import { READONLY_RELAYS } from '@stores/constants';
import { contentParser } from '@utils/parser';
import { memo, useContext } from 'react';
import useSWRSubscription from 'swr/subscription';
export const NoteQuote = memo(function NoteQuote({ id }: { id: string }) {
const pool: any = useContext(RelayContext);
const { data, error } = useSWRSubscription(
id
? [
{
ids: [id],
kinds: [1],
},
]
: null,
(key, { next }) => {
const unsubscribe = pool.subscribe(
key,
READONLY_RELAYS,
(event: any) => {
next(null, event);
},
undefined,
undefined,
{
unsubscribeOnEose: true,
}
);
return () => {
unsubscribe();
};
}
);
return (
<div className="relative mb-2 mt-3 rounded-lg border border-zinc-700 bg-zinc-800 p-2 py-3">
{error && <div>failed to load</div>}
{!data ? (
<div className="h-6 w-full animate-pulse select-text flex-col rounded bg-zinc-800"></div>
) : (
<div className="relative z-10 flex flex-col">
<UserExtend pubkey={data.pubkey} time={data.created_at} />
<div className="mt-1 pl-[52px]">
<div className="whitespace-pre-line break-words text-[15px] leading-tight text-zinc-100">
{contentParser(data.content, data.tags)}
</div>
</div>
</div>
)}
</div>
);
});

View File

@@ -1,20 +0,0 @@
import { RootNote } from '@components/note/rootNote';
import { UserQuoteRepost } from '@components/user/quoteRepost';
import { getQuoteID } from '@utils/transform';
import { memo } from 'react';
export const NoteQuoteRepost = memo(function NoteQuoteRepost({ event }: { event: any }) {
const rootID = getQuoteID(event.tags);
return (
<div className="relative z-10 flex h-min min-h-min w-full select-text flex-col border-b border-zinc-800 px-3 py-5 hover:bg-black/20">
<div className="relative z-10 flex flex-col pb-5">
<div className="absolute left-[21px] top-0 h-full w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600"></div>
<UserQuoteRepost pubkey={event.pubkey} time={event.created_at} />
</div>
<RootNote id={rootID} />
</div>
);
});

View File

@@ -1,79 +0,0 @@
import { NoteMetadata } from '@components/note/metadata';
import { RelayContext } from '@components/relaysProvider';
import { UserExtend } from '@components/user/extend';
import { READONLY_RELAYS } from '@stores/constants';
import { contentParser } from '@utils/parser';
import { memo, useContext } from 'react';
import useSWRSubscription from 'swr/subscription';
import { navigate } from 'vite-plugin-ssr/client/router';
export const RootNote = memo(function RootNote({ id }: { id: string }) {
const pool: any = useContext(RelayContext);
const openThread = (e) => {
const selection = window.getSelection();
if (selection.toString().length === 0) {
navigate(`/newsfeed/note?id=${id}`);
} else {
e.stopPropagation();
}
};
const { data, error } = useSWRSubscription(
id
? [
{
ids: [id],
kinds: [1],
},
]
: null,
(key, { next }) => {
const unsubscribe = pool.subscribe(
key,
READONLY_RELAYS,
(event: any) => {
next(null, event);
},
undefined,
undefined,
{
unsubscribeOnEose: true,
}
);
return () => {
unsubscribe();
};
}
);
return (
<>
{error && <div>failed to load</div>}
{!data ? (
<div className="h-6 w-full animate-pulse select-text flex-col rounded bg-zinc-800"></div>
) : (
<div onClick={(e) => openThread(e)} className="relative z-10 flex flex-col">
<UserExtend pubkey={data.pubkey} time={data.created_at} />
<div className="mt-1 pl-[52px]">
<div className="whitespace-pre-line break-words text-[15px] leading-tight text-zinc-100">
{contentParser(data.content, data.tags)}
</div>
</div>
<div onClick={(e) => e.stopPropagation()} className="mt-5 pl-[52px]">
<NoteMetadata
eventID={data.id}
eventPubkey={data.pubkey}
eventContent={data.content}
eventTime={data.created_at}
/>
</div>
</div>
)}
</>
);
});

View File

@@ -1,24 +0,0 @@
import { RelayContext } from '@components/relaysProvider';
import { UserFollow } from '@components/user/follow';
import { READONLY_RELAYS } from '@stores/constants';
import destr from 'destr';
import { Author } from 'nostr-relaypool';
import { useContext, useEffect, useState } from 'react';
export default function ProfileFollowers({ id }: { id: string }) {
const pool: any = useContext(RelayContext);
const [followers, setFollowers] = useState(null);
useEffect(() => {
const user = new Author(pool, READONLY_RELAYS, id);
user.followers((res) => setFollowers(destr(res.tags)), 0, 100);
}, [id, pool]);
return (
<div className="flex flex-col gap-3 px-3 py-5">
{followers && followers.map((follower) => <UserFollow key={follower[1]} pubkey={follower[1]} />)}
</div>
);
}

View File

@@ -1,23 +0,0 @@
import { RelayContext } from '@components/relaysProvider';
import { UserFollow } from '@components/user/follow';
import { READONLY_RELAYS } from '@stores/constants';
import { Author } from 'nostr-relaypool';
import { useContext, useEffect, useState } from 'react';
export default function ProfileFollows({ id }: { id: string }) {
const pool: any = useContext(RelayContext);
const [follows, setFollows] = useState(null);
useEffect(() => {
const user = new Author(pool, READONLY_RELAYS, id);
user.follows((res) => setFollows(res), 0);
}, [id, pool]);
return (
<div className="flex flex-col gap-3 px-3 py-5">
{follows && follows.map((follow) => <UserFollow key={follow.pubkey} pubkey={follow.pubkey} />)}
</div>
);
}

View File

@@ -1,47 +0,0 @@
import { RelayContext } from '@components/relaysProvider';
import { DEFAULT_AVATAR, READONLY_RELAYS } from '@stores/constants';
import { shortenKey } from '@utils/shortenKey';
import destr from 'destr';
import { Author } from 'nostr-relaypool';
import { useContext, useEffect, useState } from 'react';
const DEFAULT_BANNER = 'https://bafybeiacwit7hjmdefqggxqtgh6ht5dhth7ndptwn2msl5kpkodudsr7py.ipfs.w3s.link/banner-1.jpg';
export default function ProfileMetadata({ id }: { id: string }) {
const pool: any = useContext(RelayContext);
const [profile, setProfile] = useState(null);
useEffect(() => {
const user = new Author(pool, READONLY_RELAYS, id);
user.metaData((res) => setProfile(destr(res.content)), 0);
}, [id, pool]);
return (
<>
<div className="relative">
<div className="relative h-56 w-full rounded-t-lg bg-zinc-800">
<img src={profile?.banner || DEFAULT_BANNER} alt="user's banner" className="h-58 w-full object-cover" />
</div>
<div className="relative -top-8 z-10 px-4">
<div className="relative h-16 w-16 rounded-lg bg-zinc-900 ring-2 ring-zinc-900">
<img src={profile?.picture || DEFAULT_AVATAR} alt={id} className="h-16 w-16 rounded-lg object-cover" />
</div>
</div>
</div>
<div className="-mt-4 mb-8 px-4">
<div>
<div className="mb-3 flex flex-col">
<h3 className="text-lg font-semibold leading-tight text-zinc-100">
{profile?.display_name || profile?.name}
</h3>
<span className="text-sm leading-tight text-zinc-500">{profile?.username || (id && shortenKey(id))}</span>
</div>
<div className="prose-sm prose-zinc leading-tight dark:prose-invert">{profile?.about}</div>
</div>
</div>
</>
);
}

View File

@@ -1,27 +0,0 @@
import { NoteBase } from '@components/note/base';
import { RelayContext } from '@components/relaysProvider';
import { READONLY_RELAYS } from '@stores/constants';
import { Author } from 'nostr-relaypool';
import { useContext, useEffect, useState } from 'react';
export default function ProfileNotes({ id }: { id: string }) {
const pool: any = useContext(RelayContext);
const [data, setData] = useState([]);
useEffect(() => {
const user = new Author(pool, READONLY_RELAYS, id);
user.text((res) => setData((data) => [...data, res]), 100, 0);
}, [id, pool]);
return (
<div className="flex flex-col">
{data.map((item) => (
<div key={item.id}>
<NoteBase event={item} />
</div>
))}
</div>
);
}

View File

@@ -1,17 +0,0 @@
import { READONLY_RELAYS } from '@stores/constants';
import { RelayPool } from 'nostr-relaypool';
import { createContext, useMemo } from 'react';
export const RelayContext = createContext({});
export default function RelayProvider({ children }: { children: React.ReactNode }) {
const pool = useMemo(() => {
if (typeof window !== 'undefined') {
return new RelayPool(READONLY_RELAYS, { useEventCache: false, logSubscriptions: false });
} else {
return null;
}
}, []);
return <RelayContext.Provider value={pool}>{children}</RelayContext.Provider>;
}

View File

@@ -1,44 +0,0 @@
import { autoUpdate, offset, shift, useFloating, useFocus, useHover, useInteractions } from '@floating-ui/react';
import { useState } from 'react';
export default function Tooltip({ children, message }: { children: React.ReactNode; message: string }) {
const [isOpen, setIsOpen] = useState(false);
const { x, y, strategy, refs, context } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
placement: 'top',
middleware: [offset(8), shift()],
whileElementsMounted(...args) {
const cleanup = autoUpdate(...args, { animationFrame: true });
return cleanup;
},
});
const hover = useHover(context);
const focus = useFocus(context);
const { getReferenceProps, getFloatingProps } = useInteractions([hover, focus]);
return (
<>
<div ref={refs.setReference} {...getReferenceProps()}>
{children}
</div>
{isOpen && (
<div
ref={refs.setFloating}
className="w-max select-none rounded-md bg-zinc-800 px-4 py-2 text-xs font-medium leading-none text-zinc-100"
style={{
position: strategy,
top: y ?? 0,
left: x ?? 0,
}}
{...getFloatingProps()}
>
{message}
</div>
)}
</>
);
}

View File

@@ -1,30 +0,0 @@
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
import { memo } from 'react';
export const UserBase = memo(function UserBase({ pubkey }: { pubkey: string }) {
const profile = useProfile(pubkey);
return (
<div className="flex items-center gap-2">
<div className="relative h-11 w-11 shrink overflow-hidden rounded-full border border-white/10">
<img
src={profile?.picture || DEFAULT_AVATAR}
alt={pubkey}
className="h-11 w-11 rounded-full object-cover"
loading="lazy"
fetchpriority="high"
/>
</div>
<div className="flex w-full flex-1 flex-col items-start text-start">
<span className="truncate font-medium leading-tight text-zinc-200">
{profile?.display_name || profile?.name}
</span>
<span className="text-sm leading-tight text-zinc-400">{shortenKey(pubkey)}</span>
</div>
</div>
);
});

View File

@@ -1,40 +0,0 @@
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
export const UserExtend = ({ pubkey, time }: { pubkey: string; time: number }) => {
const profile = useProfile(pubkey);
return (
<div className="group flex h-11 items-center gap-2">
<div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-white">
<img
src={profile?.picture || DEFAULT_AVATAR}
alt={pubkey}
className="h-11 w-11 rounded-md object-cover"
loading="lazy"
fetchpriority="high"
/>
</div>
<div className="flex w-full flex-1 items-start justify-between">
<div className="flex flex-col gap-1">
<div className="flex items-baseline gap-2">
<h5 className="text-sm font-semibold leading-none group-hover:underline">
{profile?.display_name || profile?.name || shortenKey(pubkey)}
</h5>
<span className="text-sm leading-none text-zinc-700"></span>
</div>
<span className="text-sm leading-none text-zinc-500">
{profile?.nip05 || shortenKey(pubkey)} {dayjs().to(dayjs.unix(time))}
</span>
</div>
</div>
</div>
);
};

View File

@@ -1,28 +0,0 @@
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
export const UserFollow = ({ pubkey }: { pubkey: string }) => {
const profile = useProfile(pubkey);
return (
<div className="flex items-center gap-2">
<div className="relative h-11 w-11 shrink overflow-hidden rounded-full border border-white/10">
<img
src={profile?.picture || DEFAULT_AVATAR}
alt={pubkey}
className="h-11 w-11 rounded-full object-cover"
loading="lazy"
fetchpriority="high"
/>
</div>
<div className="flex w-full flex-1 flex-col items-start text-start">
<span className="truncate font-medium leading-tight text-zinc-200">
{profile?.display_name || profile?.name}
</span>
<span className="text-sm leading-tight text-zinc-400">{shortenKey(pubkey)}</span>
</div>
</div>
);
};

View File

@@ -1,45 +0,0 @@
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { MoreHoriz } from 'iconoir-react';
dayjs.extend(relativeTime);
export const UserLarge = ({ pubkey, time }: { pubkey: string; time: number }) => {
const profile = useProfile(pubkey);
return (
<div className="flex items-center gap-2">
<div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-white">
<img
src={profile?.picture || DEFAULT_AVATAR}
alt={pubkey}
className="h-11 w-11 rounded-md border border-white/10 object-cover"
loading="lazy"
fetchpriority="high"
/>
</div>
<div className="w-full flex-1">
<div className="flex w-full justify-between">
<div className="flex flex-col gap-1 text-sm">
<span className="font-bold leading-tight text-zinc-100">
{profile?.display_name || profile?.name || shortenKey(pubkey)}
</span>
<span className="leading-tight text-zinc-400">
{profile?.username || shortenKey(pubkey)} · {dayjs().to(dayjs.unix(time))}
</span>
</div>
<div>
<button className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-800">
<MoreHoriz width={12} height={12} className="text-zinc-500" />
</button>
</div>
</div>
</div>
</div>
);
};

View File

@@ -1,9 +0,0 @@
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
export const UserMention = ({ pubkey }: { pubkey: string }) => {
const profile = useProfile(pubkey);
return (
<span className="cursor-pointer text-fuchsia-500">@{profile?.name || profile?.username || shortenKey(pubkey)}</span>
);
};

View File

@@ -1,25 +0,0 @@
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
export const UserMini = ({ pubkey }: { pubkey: string }) => {
const profile = useProfile(pubkey);
return (
<div className="group flex items-start gap-1">
<div className="relative h-7 w-7 shrink overflow-hidden rounded border border-white/10">
<img
src={profile?.picture || DEFAULT_AVATAR}
alt={pubkey}
className="h-7 w-7 rounded object-cover"
loading="lazy"
fetchpriority="high"
/>
</div>
<span className="text-xs font-medium leading-none text-zinc-500">
Replying to {profile?.name || shortenKey(pubkey)}
</span>
</div>
);
};

View File

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

View File

@@ -1,37 +0,0 @@
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
export const UserQuoteRepost = ({ pubkey, time }: { pubkey: string; time: number }) => {
const profile = useProfile(pubkey);
return (
<div className="group flex items-center gap-2">
<div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-white">
<img
src={profile?.picture || DEFAULT_AVATAR}
alt={pubkey}
className="h-11 w-11 rounded-md object-cover"
loading="lazy"
fetchpriority="high"
/>
</div>
<div className="flex items-baseline gap-2 text-sm">
<h5 className="font-semibold leading-tight group-hover:underline">
{profile?.display_name || profile?.name || shortenKey(pubkey)}{' '}
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-transparent">
reposted
</span>
</h5>
<span className="leading-tight text-zinc-500">·</span>
<span className="text-zinc-500">{dayjs().to(dayjs.unix(time))}</span>
</div>
</div>
);
};