update channel
This commit is contained in:
52
src/app/channel/components/blacklist.tsx
Normal file
52
src/app/channel/components/blacklist.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import MutedItem from '@lume/app/channel/components/mutedItem';
|
||||
|
||||
import { Popover, Transition } from '@headlessui/react';
|
||||
import { MicMute } from 'iconoir-react';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
export default function ChannelBlackList({ blacklist }: { blacklist: any }) {
|
||||
return (
|
||||
<Popover className="relative">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`group inline-flex h-8 w-8 items-center justify-center rounded-md ${
|
||||
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) => (
|
||||
<MutedItem key={item.id} data={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
244
src/app/channel/components/createModal.tsx
Normal file
244
src/app/channel/components/createModal.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import { AvatarUploader } from '@lume/shared/avatarUploader';
|
||||
import { DEFAULT_AVATAR, WRITEONLY_RELAYS } from '@lume/stores/constants';
|
||||
import { dateToUnix } from '@lume/utils/getDate';
|
||||
import { useActiveAccount } from '@lume/utils/hooks/useActiveAccount';
|
||||
import { createChannel } from '@lume/utils/storage';
|
||||
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { Cancel, Plus } from 'iconoir-react';
|
||||
import { RelayPool } from 'nostr-relaypool';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { Fragment, useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { navigate } from 'vite-plugin-ssr/client/router';
|
||||
|
||||
export default function ChannelCreateModal() {
|
||||
const { account, isError, isLoading } = useActiveAccount();
|
||||
|
||||
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);
|
||||
|
||||
if (!isError && !isLoading && account) {
|
||||
const pool = new RelayPool(WRITEONLY_RELAYS);
|
||||
const event: any = {
|
||||
content: JSON.stringify(data),
|
||||
created_at: dateToUnix(),
|
||||
kind: 40,
|
||||
pubkey: account.pubkey,
|
||||
tags: [],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
|
||||
// publish channel
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// insert to database
|
||||
createChannel(event.id, event.pubkey, event.content, event.created_at);
|
||||
// reset form
|
||||
reset();
|
||||
setTimeout(() => {
|
||||
// close modal
|
||||
setIsOpen(false);
|
||||
// redirect to channel page
|
||||
navigate(`/channel?id=${event.id}`);
|
||||
}, 2000);
|
||||
} else {
|
||||
console.log('error');
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
34
src/app/channel/components/item.tsx
Normal file
34
src/app/channel/components/item.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { DEFAULT_AVATAR } from '@lume/stores/constants';
|
||||
import { useChannelMetadata } from '@lume/utils/hooks/useChannelMetadata';
|
||||
import { usePageContext } from '@lume/utils/hooks/usePageContext';
|
||||
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export default function ChannelsListItem({ 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={`/app/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>
|
||||
);
|
||||
}
|
||||
34
src/app/channel/components/list.tsx
Normal file
34
src/app/channel/components/list.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import ChannelCreateModal from '@lume/app/channel/components/createModal';
|
||||
import ChannelsListItem from '@lume/app/channel/components/item';
|
||||
import { getChannels } from '@lume/utils/storage';
|
||||
|
||||
import useSWR from 'swr';
|
||||
|
||||
const fetcher = () => getChannels(10, 0);
|
||||
|
||||
export default function ChannelsList() {
|
||||
const { data, error }: any = useSWR('channels', fetcher);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-px">
|
||||
<>
|
||||
{error && <div>failed to fetch</div>}
|
||||
{!data ? (
|
||||
<>
|
||||
<div className="inline-flex items-center gap-2 rounded-md px-2.5 py-1.5">
|
||||
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800"></div>
|
||||
<div className="h-3 w-full animate-pulse bg-zinc-800"></div>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2 rounded-md px-2.5 py-1.5">
|
||||
<div className="relative h-5 w-5 shrink-0 animate-pulse rounded bg-zinc-800"></div>
|
||||
<div className="h-3 w-full animate-pulse bg-zinc-800"></div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
data.map((item: { event_id: string }) => <ChannelsListItem key={item.event_id} data={item} />)
|
||||
)}
|
||||
</>
|
||||
<ChannelCreateModal />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
src/app/channel/components/messageList.tsx
Normal file
42
src/app/channel/components/messageList.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import ChannelMessageItem from '@lume/app/channel/components/messages/item';
|
||||
import { sortedChannelMessagesAtom } from '@lume/stores/channel';
|
||||
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
|
||||
export default function ChannelMessageList() {
|
||||
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}
|
||||
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>
|
||||
);
|
||||
}
|
||||
123
src/app/channel/components/messages/form.tsx
Normal file
123
src/app/channel/components/messages/form.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import UserReply from '@lume/app/channel/components/messages/userReply';
|
||||
import { ImagePicker } from '@lume/shared/form/imagePicker';
|
||||
import { channelContentAtom, channelReplyAtom } from '@lume/stores/channel';
|
||||
import { FULL_RELAYS, WRITEONLY_RELAYS } from '@lume/stores/constants';
|
||||
import { dateToUnix } from '@lume/utils/getDate';
|
||||
import { useActiveAccount } from '@lume/utils/hooks/useActiveAccount';
|
||||
|
||||
import { Cancel } from 'iconoir-react';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { useResetAtom } from 'jotai/utils';
|
||||
import { RelayPool } from 'nostr-relaypool';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
|
||||
export default function ChannelMessageForm({ channelID }: { channelID: string | string[] }) {
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
|
||||
const [value, setValue] = useAtom(channelContentAtom);
|
||||
const resetValue = useResetAtom(channelContentAtom);
|
||||
|
||||
const channelReply = useAtomValue(channelReplyAtom);
|
||||
const resetChannelReply = useResetAtom(channelReplyAtom);
|
||||
|
||||
const submitEvent = () => {
|
||||
let tags: any[][];
|
||||
|
||||
if (channelReply.id !== null) {
|
||||
tags = [
|
||||
['e', channelID, '', 'root'],
|
||||
['e', channelReply.id, '', 'reply'],
|
||||
['p', channelReply.pubkey, ''],
|
||||
];
|
||||
} else {
|
||||
tags = [['e', channelID, '', 'root']];
|
||||
}
|
||||
|
||||
if (!isError && !isLoading && account) {
|
||||
const pool = new RelayPool(WRITEONLY_RELAYS);
|
||||
const event: any = {
|
||||
content: value,
|
||||
created_at: dateToUnix(),
|
||||
kind: 42,
|
||||
pubkey: account.pubkey,
|
||||
tags: tags,
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
|
||||
// publish note
|
||||
pool.publish(event, FULL_RELAYS);
|
||||
// reset state
|
||||
resetValue();
|
||||
// reset channel reply
|
||||
resetChannelReply();
|
||||
} else {
|
||||
console.log('error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnterPress = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submitEvent();
|
||||
}
|
||||
};
|
||||
|
||||
const stopReply = () => {
|
||||
resetChannelReply();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative ${
|
||||
channelReply.id ? 'h-36' : 'h-24'
|
||||
} w-full overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20`}
|
||||
>
|
||||
{channelReply.id && (
|
||||
<div className="absolute left-0 top-0 z-10 h-14 w-full p-[2px]">
|
||||
<div className="flex h-full w-full items-center justify-between rounded-t-md border-b border-zinc-700/70 bg-zinc-900 px-3">
|
||||
<div className="flex w-full flex-col">
|
||||
<UserReply pubkey={channelReply.pubkey} />
|
||||
<div className="-mt-3.5 pl-[32px]">
|
||||
<div className="text-xs text-zinc-200">{channelReply.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => stopReply()}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-800"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
43
src/app/channel/components/messages/hideButton.tsx
Normal file
43
src/app/channel/components/messages/hideButton.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import Tooltip from '@lume/shared/tooltip';
|
||||
import { WRITEONLY_RELAYS } from '@lume/stores/constants';
|
||||
import { dateToUnix } from '@lume/utils/getDate';
|
||||
import { useActiveAccount } from '@lume/utils/hooks/useActiveAccount';
|
||||
|
||||
import { EyeClose } from 'iconoir-react';
|
||||
import { RelayPool } from 'nostr-relaypool';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
|
||||
export default function MessageHideButton({ id }: { id: string }) {
|
||||
const { account, isError, isLoading } = useActiveAccount();
|
||||
|
||||
const hideMessage = () => {
|
||||
if (!isError && !isLoading && account) {
|
||||
const pool = new RelayPool(WRITEONLY_RELAYS);
|
||||
const event: any = {
|
||||
content: '',
|
||||
created_at: dateToUnix(),
|
||||
kind: 43,
|
||||
pubkey: account.pubkey,
|
||||
tags: [['e', id]],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
|
||||
// publish note
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
} else {
|
||||
console.log('error');
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
31
src/app/channel/components/messages/item.tsx
Normal file
31
src/app/channel/components/messages/item.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import MessageHideButton from '@lume/app/channel/components/messages/hideButton';
|
||||
import MessageMuteButton from '@lume/app/channel/components/messages/muteButton';
|
||||
import MessageReplyButton from '@lume/app/channel/components/messages/replyButton';
|
||||
import ChannelMessageUser from '@lume/app/channel/components/messages/user';
|
||||
import { messageParser } from '@lume/utils/parser';
|
||||
|
||||
export default 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">
|
||||
<ChannelMessageUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="-mt-[17px] pl-[48px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="whitespace-pre-line break-words break-words text-sm leading-tight">
|
||||
{data.hide ? <span>[hided message]</span> : 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">
|
||||
<MessageReplyButton id={data.id} pubkey={data.pubkey} content={data.content} />
|
||||
<MessageHideButton id={data.id} />
|
||||
<MessageMuteButton pubkey={data.pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
src/app/channel/components/messages/muteButton.tsx
Normal file
43
src/app/channel/components/messages/muteButton.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import Tooltip from '@lume/shared/tooltip';
|
||||
import { WRITEONLY_RELAYS } from '@lume/stores/constants';
|
||||
import { dateToUnix } from '@lume/utils/getDate';
|
||||
import { useActiveAccount } from '@lume/utils/hooks/useActiveAccount';
|
||||
|
||||
import { MicMute } from 'iconoir-react';
|
||||
import { RelayPool } from 'nostr-relaypool';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
|
||||
export default function MessageMuteButton({ pubkey }: { pubkey: string }) {
|
||||
const { account, isError, isLoading } = useActiveAccount();
|
||||
|
||||
const muteUser = () => {
|
||||
if (!isError && !isLoading && account) {
|
||||
const pool = new RelayPool(WRITEONLY_RELAYS);
|
||||
const event: any = {
|
||||
content: '',
|
||||
created_at: dateToUnix(),
|
||||
kind: 44,
|
||||
pubkey: account.pubkey,
|
||||
tags: [['p', pubkey]],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
|
||||
// publish note
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
} else {
|
||||
console.log('error');
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
24
src/app/channel/components/messages/replyButton.tsx
Normal file
24
src/app/channel/components/messages/replyButton.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import Tooltip from '@lume/shared/tooltip';
|
||||
import { channelReplyAtom } from '@lume/stores/channel';
|
||||
|
||||
import { Reply } from 'iconoir-react';
|
||||
import { useSetAtom } from 'jotai';
|
||||
|
||||
export default function MessageReplyButton({ id, pubkey, content }: { id: string; pubkey: string; content: string }) {
|
||||
const setChannelReplyAtom = useSetAtom(channelReplyAtom);
|
||||
|
||||
const createReply = () => {
|
||||
setChannelReplyAtom({ id: id, pubkey: pubkey, content: content });
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
48
src/app/channel/components/messages/user.tsx
Normal file
48
src/app/channel/components/messages/user.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { DEFAULT_AVATAR } from '@lume/stores/constants';
|
||||
import { useProfile } from '@lume/utils/hooks/useProfile';
|
||||
import { shortenKey } from '@lume/utils/shortenKey';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export default function ChannelMessageUser({ pubkey, time }: { pubkey: string; time: number }) {
|
||||
const { user, isError, isLoading } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<div className="group flex items-start gap-3">
|
||||
{isError || isLoading ? (
|
||||
<>
|
||||
<div className="relative h-9 w-9 shrink animate-pulse rounded-md bg-zinc-800"></div>
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex items-baseline gap-2 text-sm">
|
||||
<div className="h-4 w-20 animate-pulse rounded bg-zinc-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative h-9 w-9 shrink rounded-md">
|
||||
<img
|
||||
src={user?.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">
|
||||
{user?.display_name || user?.name || shortenKey(pubkey)}
|
||||
</span>
|
||||
<span className="leading-none text-zinc-500">·</span>
|
||||
<span className="leading-none text-zinc-500">{dayjs().to(dayjs.unix(time))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
src/app/channel/components/messages/userReply.tsx
Normal file
33
src/app/channel/components/messages/userReply.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { DEFAULT_AVATAR } from '@lume/stores/constants';
|
||||
import { useProfile } from '@lume/utils/hooks/useProfile';
|
||||
import { shortenKey } from '@lume/utils/shortenKey';
|
||||
|
||||
export default function UserReply({ pubkey }: { pubkey: string }) {
|
||||
const { user, isError, isLoading } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<div className="group flex items-start gap-1">
|
||||
{isError || isLoading ? (
|
||||
<>
|
||||
<div className="relative h-7 w-7 shrink animate-pulse overflow-hidden rounded bg-zinc-800"></div>
|
||||
<span className="h-2 w-10 animate-pulse rounded bg-zinc-800 text-xs font-medium leading-none text-zinc-500"></span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative h-7 w-7 shrink overflow-hidden rounded">
|
||||
<img
|
||||
src={user?.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 {user?.name || shortenKey(pubkey)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
src/app/channel/components/metadata.tsx
Normal file
40
src/app/channel/components/metadata.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { DEFAULT_AVATAR } from '@lume/stores/constants';
|
||||
import { useChannelProfile } from '@lume/utils/hooks/useChannelProfile';
|
||||
|
||||
import { Copy } from 'iconoir-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
export default function ChannelMetadata({ id, pubkey }: { id: string; pubkey: string }) {
|
||||
const metadata = useChannelProfile(id, pubkey);
|
||||
const noteID = id ? nip19.noteEncode(id) : null;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
78
src/app/channel/components/mutedItem.tsx
Normal file
78
src/app/channel/components/mutedItem.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { DEFAULT_AVATAR } from '@lume/stores/constants';
|
||||
import { useProfile } from '@lume/utils/hooks/useProfile';
|
||||
import { shortenKey } from '@lume/utils/shortenKey';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function MutedItem({ data }: { data: any }) {
|
||||
const { user, isError, isLoading } = useProfile(data.content);
|
||||
const [status, setStatus] = useState(data.status);
|
||||
|
||||
const unmute = async () => {
|
||||
const { updateItemInBlacklist } = await import('@lume/utils/storage');
|
||||
const res = await updateItemInBlacklist(data.content, 0);
|
||||
if (res) {
|
||||
setStatus(0);
|
||||
}
|
||||
};
|
||||
|
||||
const mute = async () => {
|
||||
const { updateItemInBlacklist } = await import('@lume/utils/storage');
|
||||
const res = await updateItemInBlacklist(data.content, 1);
|
||||
if (res) {
|
||||
setStatus(1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
{isError || isLoading ? (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="relative h-9 w-9 shrink animate-pulse rounded-md bg-zinc-800"></div>
|
||||
<div className="flex w-full flex-1 flex-col items-start gap-0.5 text-start">
|
||||
<div className="h-3 w-16 animate-pulse bg-zinc-800"></div>
|
||||
<div className="h-2 w-10 animate-pulse bg-zinc-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="relative h-9 w-9 shrink rounded-md">
|
||||
<img
|
||||
src={user?.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">
|
||||
{user?.display_name || user?.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>
|
||||
);
|
||||
}
|
||||
247
src/app/channel/components/updateModal.tsx
Normal file
247
src/app/channel/components/updateModal.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import { AvatarUploader } from '@lume/shared/avatarUploader';
|
||||
import { DEFAULT_AVATAR, WRITEONLY_RELAYS } from '@lume/stores/constants';
|
||||
import { dateToUnix } from '@lume/utils/getDate';
|
||||
import { useActiveAccount } from '@lume/utils/hooks/useActiveAccount';
|
||||
import { getChannel, updateChannelMetadata } from '@lume/utils/storage';
|
||||
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { Cancel, EditPencil } from 'iconoir-react';
|
||||
import { RelayPool } from 'nostr-relaypool';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { Fragment, useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
export default function ChannelUpdateModal({ id }: { id: string }) {
|
||||
const { account, isError, isLoading } = useActiveAccount();
|
||||
|
||||
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);
|
||||
if (channel) {
|
||||
const metadata = JSON.parse(channel.metadata);
|
||||
// update image state
|
||||
setImage(metadata.picture);
|
||||
// set default values
|
||||
return metadata;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: any) => {
|
||||
setLoading(true);
|
||||
|
||||
if (!isError && !isLoading && account) {
|
||||
const pool = new RelayPool(WRITEONLY_RELAYS);
|
||||
const event: any = {
|
||||
content: JSON.stringify(data),
|
||||
created_at: dateToUnix(),
|
||||
kind: 41,
|
||||
pubkey: account.pubkey,
|
||||
tags: [],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.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);
|
||||
} else {
|
||||
console.log('error');
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +1,34 @@
|
||||
import { ChannelBlackList } from '@lume/shared/channels/channelBlackList';
|
||||
import { ChannelProfile } from '@lume/shared/channels/channelProfile';
|
||||
import { UpdateChannelModal } from '@lume/shared/channels/updateChannelModal';
|
||||
import { FormChannel } from '@lume/shared/form/channel';
|
||||
import ChannelBlackList from '@lume/app/channel/components/blacklist';
|
||||
import ChannelMessageForm from '@lume/app/channel/components/messages/form';
|
||||
import ChannelMetadata from '@lume/app/channel/components/metadata';
|
||||
import ChannelUpdateModal from '@lume/app/channel/components/updateModal';
|
||||
import { channelMessagesAtom, channelReplyAtom } from '@lume/stores/channel';
|
||||
import { FULL_RELAYS } from '@lume/stores/constants';
|
||||
import { dateToUnix, hoursAgo } from '@lume/utils/getDate';
|
||||
import { useActiveAccount } from '@lume/utils/hooks/useActiveAccount';
|
||||
import { usePageContext } from '@lume/utils/hooks/usePageContext';
|
||||
import { arrayObjToPureArr } from '@lume/utils/transform';
|
||||
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useResetAtom } from 'jotai/utils';
|
||||
import { RelayPool } from 'nostr-relaypool';
|
||||
import { Suspense, lazy, useRef } from 'react';
|
||||
import { Suspense, lazy, useEffect, useRef } from 'react';
|
||||
import useSWRSubscription from 'swr/subscription';
|
||||
|
||||
const ChannelMessages = lazy(() => import('@lume/shared/channels/messages'));
|
||||
|
||||
let mutedList: any = [];
|
||||
let activeAccount: any = {};
|
||||
let activeMutedList: any = [];
|
||||
let activeHidedList: any = [];
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const { getBlacklist, getActiveBlacklist, getActiveAccount } = await import('@lume/utils/storage');
|
||||
activeAccount = await getActiveAccount();
|
||||
const activeAccount = await getActiveAccount();
|
||||
activeHidedList = await getActiveBlacklist(activeAccount.id, 43);
|
||||
activeMutedList = await getActiveBlacklist(activeAccount.id, 44);
|
||||
mutedList = await getBlacklist(activeAccount.id, 44);
|
||||
}
|
||||
|
||||
const ChannelMessageList = lazy(() => import('@lume/app/channel/components/messageList'));
|
||||
|
||||
export function Page() {
|
||||
const pageContext = usePageContext();
|
||||
const searchParams: any = pageContext.urlParsed.search;
|
||||
@@ -36,6 +36,8 @@ export function Page() {
|
||||
const channelID = searchParams.id;
|
||||
const channelPubkey = searchParams.pubkey;
|
||||
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
|
||||
const setChannelMessages = useSetAtom(channelMessagesAtom);
|
||||
const resetChannelMessages = useResetAtom(channelMessagesAtom);
|
||||
const resetChannelReply = useResetAtom(channelReplyAtom);
|
||||
@@ -44,29 +46,26 @@ export function Page() {
|
||||
const hided = arrayObjToPureArr(activeHidedList);
|
||||
const muted = arrayObjToPureArr(activeMutedList);
|
||||
|
||||
useSWRSubscription(channelID, () => {
|
||||
// reset channel reply
|
||||
resetChannelReply();
|
||||
// reset channel messages
|
||||
resetChannelMessages();
|
||||
// subscribe for new messages
|
||||
useSWRSubscription(channelID ? channelID : null, (key: string, {}: any) => {
|
||||
// subscribe to channel
|
||||
const pool = new RelayPool(FULL_RELAYS);
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
'#e': [channelID],
|
||||
'#e': [key],
|
||||
kinds: [42],
|
||||
since: dateToUnix(hoursAgo(48, now.current)),
|
||||
since: dateToUnix(hoursAgo(72, now.current)),
|
||||
limit: 20,
|
||||
},
|
||||
],
|
||||
FULL_RELAYS,
|
||||
(event: { kind: number; tags: string[][]; pubkey: string; id: string }) => {
|
||||
if (muted.includes(event.pubkey)) {
|
||||
console.log('muted');
|
||||
} else if (hided.includes(event.id)) {
|
||||
console.log('hided');
|
||||
} else {
|
||||
setChannelMessages((prev) => [...prev, event]);
|
||||
(event) => {
|
||||
const message: any = event;
|
||||
if (hided.includes(event.id)) {
|
||||
message.push({ hide: true });
|
||||
}
|
||||
if (!muted.includes(event.pubkey)) {
|
||||
setChannelMessages((prev) => [...prev, message]);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -76,23 +75,34 @@ export function Page() {
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// reset channel reply
|
||||
resetChannelReply();
|
||||
// reset channel messages
|
||||
resetChannelMessages();
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col justify-between gap-2">
|
||||
<div className="flex h-11 w-full shrink-0 items-center justify-between">
|
||||
<div>
|
||||
<ChannelProfile id={channelID} pubkey={channelPubkey} />
|
||||
<ChannelMetadata id={channelID} pubkey={channelPubkey} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ChannelBlackList blacklist={mutedList} />
|
||||
{activeAccount.pubkey === channelPubkey && <UpdateChannelModal id={activeAccount} />}
|
||||
{!isLoading && !isError && account ? (
|
||||
account.pubkey === channelPubkey && <ChannelUpdateModal id={account.id} />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex w-full flex-1 flex-col justify-between rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
|
||||
<Suspense fallback={<p>Loading...</p>}>
|
||||
<ChannelMessages />
|
||||
<ChannelMessageList />
|
||||
</Suspense>
|
||||
<div className="shrink-0 p-3">
|
||||
<FormChannel eventId={channelID} />
|
||||
<div className="inline-flex shrink-0 p-3">
|
||||
<ChannelMessageForm channelID={channelID} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ImagePreview } from '@lume/app/newsfeed/components/note/preview/image';
|
||||
import { VideoPreview } from '@lume/app/newsfeed/components/note/preview/video';
|
||||
import { YoutubePreview } from '@lume/app/newsfeed/components/note/preview/youtube';
|
||||
import { NoteQuote } from '@lume/app/newsfeed/components/note/quote';
|
||||
import { NoteMentionUser } from '@lume/app/newsfeed/components/user/mention';
|
||||
import ImagePreview from '@lume/shared/preview/image';
|
||||
import VideoPreview from '@lume/shared/preview/video';
|
||||
import YoutubePreview from '@lume/shared/preview/youtube';
|
||||
|
||||
import destr from 'destr';
|
||||
import reactStringReplace from 'react-string-replace';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -8,32 +8,47 @@ import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export const NoteDefaultUser = ({ pubkey, time }: { pubkey: string; time: number }) => {
|
||||
const profile = useProfile(pubkey);
|
||||
const { user, isError, isLoading } = 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>
|
||||
{isError || isLoading ? (
|
||||
<>
|
||||
<div className="relative h-11 w-11 shrink animate-pulse overflow-hidden rounded-md bg-white bg-zinc-800"></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">
|
||||
<div className="h-4 w-20 animate-pulse rounded bg-zinc-800"></div>
|
||||
</div>
|
||||
<div className="h-2.5 w-14 animate-pulse rounded bg-zinc-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm leading-none text-zinc-500">
|
||||
{profile?.nip05 || shortenKey(pubkey)} • {dayjs().to(dayjs.unix(time))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-white">
|
||||
<img
|
||||
src={user?.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">
|
||||
{user?.display_name || user?.name || shortenKey(pubkey)}
|
||||
</h5>
|
||||
</div>
|
||||
<span className="text-sm leading-none text-zinc-500">
|
||||
{user?.nip05 || shortenKey(pubkey)} • {dayjs().to(dayjs.unix(time))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,8 +2,15 @@ import { useProfile } from '@lume/utils/hooks/useProfile';
|
||||
import { shortenKey } from '@lume/utils/shortenKey';
|
||||
|
||||
export const NoteMentionUser = ({ pubkey }: { pubkey: string }) => {
|
||||
const profile = useProfile(pubkey);
|
||||
const { user, isError, isLoading } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<span className="cursor-pointer text-fuchsia-500">@{profile?.name || profile?.username || shortenKey(pubkey)}</span>
|
||||
<>
|
||||
{isError || isLoading ? (
|
||||
<span className="inline-flex h-4 w-10 animate-pulse rounded bg-zinc-800"></span>
|
||||
) : (
|
||||
<span className="cursor-pointer text-fuchsia-500">@{user?.username || user?.name || shortenKey(pubkey)}</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,29 +8,44 @@ import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export const NoteRepostUser = ({ pubkey, time }: { pubkey: string; time: number }) => {
|
||||
const profile = useProfile(pubkey);
|
||||
const { user, isError, isLoading } = 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>
|
||||
{isError || isLoading ? (
|
||||
<>
|
||||
<div className="relative h-11 w-11 shrink animate-pulse overflow-hidden rounded-md bg-white bg-zinc-800"></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">
|
||||
<div className="h-4 w-20 animate-pulse rounded bg-zinc-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-white">
|
||||
<img
|
||||
src={user?.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">
|
||||
{user?.display_name || user?.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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user