wip: restructure
This commit is contained in:
14
src/shared/accountProvider.tsx
Normal file
14
src/shared/accountProvider.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export const AccountContext = createContext({});
|
||||
|
||||
let activeAccount: any = { id: '', pubkey: '', follows: null, metadata: {} };
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const { getActiveAccount } = await import('@lume/utils/storage');
|
||||
activeAccount = await getActiveAccount();
|
||||
}
|
||||
|
||||
export default function AccountProvider({ children }: { children: React.ReactNode }) {
|
||||
return <AccountContext.Provider value={activeAccount}>{children}</AccountContext.Provider>;
|
||||
}
|
||||
24
src/shared/activeLink.tsx
Normal file
24
src/shared/activeLink.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { usePageContext } from '@lume/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>
|
||||
);
|
||||
}
|
||||
54
src/shared/appHeader.tsx
Normal file
54
src/shared/appHeader.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import EventCollector from '@lume/shared/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>
|
||||
);
|
||||
}
|
||||
75
src/shared/avatarUploader.tsx
Normal file
75
src/shared/avatarUploader.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { createBlobFromFile } from '@lume/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>
|
||||
);
|
||||
};
|
||||
52
src/shared/channels/channelBlackList.tsx
Normal file
52
src/shared/channels/channelBlackList.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { UserMuted } from '@lume/shared/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>
|
||||
);
|
||||
};
|
||||
20
src/shared/channels/channelList.tsx
Normal file
20
src/shared/channels/channelList.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ChannelListItem } from '@lume/shared/channels/channelListItem';
|
||||
import { CreateChannelModal } from '@lume/shared/channels/createChannelModal';
|
||||
|
||||
let channels: any = [];
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const { getChannels } = await import('@lume/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>
|
||||
);
|
||||
}
|
||||
34
src/shared/channels/channelListItem.tsx
Normal file
34
src/shared/channels/channelListItem.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 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>
|
||||
);
|
||||
};
|
||||
40
src/shared/channels/channelProfile.tsx
Normal file
40
src/shared/channels/channelProfile.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 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>
|
||||
);
|
||||
};
|
||||
240
src/shared/channels/createChannelModal.tsx
Normal file
240
src/shared/channels/createChannelModal.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import { AccountContext } from '@lume/shared/accountProvider';
|
||||
import { AvatarUploader } from '@lume/shared/avatarUploader';
|
||||
import { RelayContext } from '@lume/shared/relaysProvider';
|
||||
import { DEFAULT_AVATAR, WRITEONLY_RELAYS } from '@lume/stores/constants';
|
||||
import { dateToUnix } from '@lume/utils/getDate';
|
||||
import { createChannel } from '@lume/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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
40
src/shared/channels/messages/hideMessageButton.tsx
Normal file
40
src/shared/channels/messages/hideMessageButton.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { AccountContext } from '@lume/shared/accountProvider';
|
||||
import { RelayContext } from '@lume/shared/relaysProvider';
|
||||
import Tooltip from '@lume/shared/tooltip';
|
||||
import { WRITEONLY_RELAYS } from '@lume/stores/constants';
|
||||
import { dateToUnix } from '@lume/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>
|
||||
);
|
||||
};
|
||||
48
src/shared/channels/messages/index.tsx
Normal file
48
src/shared/channels/messages/index.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { ChannelMessageItem } from '@lume/shared/channels/messages/item';
|
||||
import { Placeholder } from '@lume/shared/note/placeholder';
|
||||
import { sortedChannelMessagesAtom } from '@lume/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 />,
|
||||
};
|
||||
33
src/shared/channels/messages/item.tsx
Normal file
33
src/shared/channels/messages/item.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { HideMessageButton } from '@lume/shared/channels/messages/hideMessageButton';
|
||||
import { MuteButton } from '@lume/shared/channels/messages/muteButton';
|
||||
import { ReplyButton } from '@lume/shared/channels/messages/replyButton';
|
||||
import { MessageUser } from '@lume/shared/chats/messageUser';
|
||||
import { messageParser } from '@lume/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>
|
||||
);
|
||||
});
|
||||
40
src/shared/channels/messages/muteButton.tsx
Normal file
40
src/shared/channels/messages/muteButton.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { AccountContext } from '@lume/shared/accountProvider';
|
||||
import { RelayContext } from '@lume/shared/relaysProvider';
|
||||
import Tooltip from '@lume/shared/tooltip';
|
||||
import { WRITEONLY_RELAYS } from '@lume/stores/constants';
|
||||
import { dateToUnix } from '@lume/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>
|
||||
);
|
||||
};
|
||||
24
src/shared/channels/messages/replyButton.tsx
Normal file
24
src/shared/channels/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 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>
|
||||
);
|
||||
};
|
||||
239
src/shared/channels/updateChannelModal.tsx
Normal file
239
src/shared/channels/updateChannelModal.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import { AccountContext } from '@lume/shared/accountProvider';
|
||||
import { AvatarUploader } from '@lume/shared/avatarUploader';
|
||||
import { RelayContext } from '@lume/shared/relaysProvider';
|
||||
import { DEFAULT_AVATAR, WRITEONLY_RELAYS } from '@lume/stores/constants';
|
||||
import { dateToUnix } from '@lume/utils/getDate';
|
||||
import { getChannel, updateChannelMetadata } from '@lume/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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
46
src/shared/chats/chatList.tsx
Normal file
46
src/shared/chats/chatList.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { AccountContext } from '@lume/shared/accountProvider';
|
||||
import { ChatListItem } from '@lume/shared/chats/chatListItem';
|
||||
import { ChatModal } from '@lume/shared/chats/chatModal';
|
||||
import { DEFAULT_AVATAR } from '@lume/stores/constants';
|
||||
|
||||
import { useContext } from 'react';
|
||||
|
||||
let list: any = [];
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const { getChats, getActiveAccount } = await import('@lume/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>
|
||||
);
|
||||
}
|
||||
33
src/shared/chats/chatListItem.tsx
Normal file
33
src/shared/chats/chatListItem.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { DEFAULT_AVATAR } from '@lume/stores/constants';
|
||||
import { usePageContext } from '@lume/utils/hooks/usePageContext';
|
||||
import { useProfile } from '@lume/utils/hooks/useProfile';
|
||||
import { shortenKey } from '@lume/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>
|
||||
);
|
||||
};
|
||||
14
src/shared/chats/chatModal.tsx
Normal file
14
src/shared/chats/chatModal.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
40
src/shared/chats/chatModalUser.tsx
Normal file
40
src/shared/chats/chatModalUser.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { DEFAULT_AVATAR } from '@lume/stores/constants';
|
||||
import { shortenKey } from '@lume/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>
|
||||
);
|
||||
};
|
||||
53
src/shared/chats/messageList.tsx
Normal file
53
src/shared/chats/messageList.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { AccountContext } from '@lume/shared/accountProvider';
|
||||
import { MessageListItem } from '@lume/shared/chats/messageListItem';
|
||||
import { Placeholder } from '@lume/shared/note/placeholder';
|
||||
import { sortedChatMessagesAtom } from '@lume/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 />,
|
||||
};
|
||||
31
src/shared/chats/messageListItem.tsx
Normal file
31
src/shared/chats/messageListItem.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { MessageUser } from '@lume/shared/chats/messageUser';
|
||||
import { useDecryptMessage } from '@lume/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>
|
||||
);
|
||||
});
|
||||
35
src/shared/chats/messageUser.tsx
Normal file
35
src/shared/chats/messageUser.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
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 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>
|
||||
);
|
||||
};
|
||||
111
src/shared/eventCollector.tsx
Normal file
111
src/shared/eventCollector.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { AccountContext } from '@lume/shared/accountProvider';
|
||||
import { NetworkStatusIndicator } from '@lume/shared/networkStatusIndicator';
|
||||
import { RelayContext } from '@lume/shared/relaysProvider';
|
||||
import { READONLY_RELAYS } from '@lume/stores/constants';
|
||||
import { hasNewerNoteAtom } from '@lume/stores/note';
|
||||
import { dateToUnix } from '@lume/utils/getDate';
|
||||
import { createChat, createNote, updateAccount } from '@lume/utils/storage';
|
||||
import { getParentID, nip02ToArray } from '@lume/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 />;
|
||||
}
|
||||
71
src/shared/form/base.tsx
Normal file
71
src/shared/form/base.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { AccountContext } from '@lume/shared/accountProvider';
|
||||
import { ImagePicker } from '@lume/shared/form/imagePicker';
|
||||
import { RelayContext } from '@lume/shared/relaysProvider';
|
||||
import { WRITEONLY_RELAYS } from '@lume/stores/constants';
|
||||
import { noteContentAtom } from '@lume/stores/note';
|
||||
import { dateToUnix } from '@lume/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>
|
||||
);
|
||||
}
|
||||
130
src/shared/form/channel.tsx
Normal file
130
src/shared/form/channel.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { AccountContext } from '@lume/shared/accountProvider';
|
||||
import { ImagePicker } from '@lume/shared/form/imagePicker';
|
||||
import { RelayContext } from '@lume/shared/relaysProvider';
|
||||
import { UserMini } from '@lume/shared/user/mini';
|
||||
import { channelContentAtom, channelReplyAtom } from '@lume/stores/channel';
|
||||
import { FULL_RELAYS } from '@lume/stores/constants';
|
||||
import { dateToUnix } from '@lume/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>
|
||||
);
|
||||
};
|
||||
85
src/shared/form/chat.tsx
Normal file
85
src/shared/form/chat.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { AccountContext } from '@lume/shared/accountProvider';
|
||||
import { ImagePicker } from '@lume/shared/form/imagePicker';
|
||||
import { RelayContext } from '@lume/shared/relaysProvider';
|
||||
import { chatContentAtom } from '@lume/stores/chat';
|
||||
import { FULL_RELAYS } from '@lume/stores/constants';
|
||||
import { dateToUnix } from '@lume/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>
|
||||
);
|
||||
}
|
||||
70
src/shared/form/comment.tsx
Normal file
70
src/shared/form/comment.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { AccountContext } from '@lume/shared/accountProvider';
|
||||
import { RelayContext } from '@lume/shared/relaysProvider';
|
||||
import { WRITEONLY_RELAYS } from '@lume/stores/constants';
|
||||
import { dateToUnix } from '@lume/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>
|
||||
);
|
||||
}
|
||||
96
src/shared/form/imagePicker.tsx
Normal file
96
src/shared/form/imagePicker.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { channelContentAtom } from '@lume/stores/channel';
|
||||
import { chatContentAtom } from '@lume/stores/chat';
|
||||
import { noteContentAtom } from '@lume/stores/note';
|
||||
import { createBlobFromFile } from '@lume/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>
|
||||
);
|
||||
};
|
||||
10
src/shared/icons/lume.tsx
Normal file
10
src/shared/icons/lume.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
export default function LumeIcon({ className }: { className: string }) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" className={className}>
|
||||
<path
|
||||
d="M7.337 19.099a.32.32 0 0 1-.373.021 20.911 20.911 0 0 0-4.259-2.022c-.17-.063-.191-.297-.031-.383a13.876 13.876 0 0 0 4.886-4.639A13.715 13.715 0 0 0 9.69 5.14c0-.17.149-.309.32-.309h3.981c.17 0 .309.138.32.309.074 2.468.809 4.852 2.129 6.937a13.88 13.88 0 0 0 4.886 4.64c.16.095.139.33-.032.383-1.266.436-2.49.99-3.651 1.66-.203.116-.405.244-.607.361a.32.32 0 0 1-.373-.021 18.293 18.293 0 0 1-4.567-5.331l-.096-.16-.096.16a18.158 18.158 0 0 1-4.567 5.33Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
31
src/shared/layouts/channel.tsx
Normal file
31
src/shared/layouts/channel.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import AppHeader from '@lume/shared/appHeader';
|
||||
import MultiAccounts from '@lume/shared/multiAccounts';
|
||||
import Navigation from '@lume/shared/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>
|
||||
);
|
||||
}
|
||||
29
src/shared/layouts/newsfeed.tsx
Normal file
29
src/shared/layouts/newsfeed.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import AppHeader from '@lume/shared/appHeader';
|
||||
import MultiAccounts from '@lume/shared/multiAccounts';
|
||||
import Navigation from '@lume/shared/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>
|
||||
);
|
||||
}
|
||||
17
src/shared/layouts/onboarding.tsx
Normal file
17
src/shared/layouts/onboarding.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import AppHeader from '@lume/shared/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>
|
||||
);
|
||||
}
|
||||
11
src/shared/multiAccounts/activeAccount.tsx
Normal file
11
src/shared/multiAccounts/activeAccount.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { DEFAULT_AVATAR } from '@lume/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>
|
||||
);
|
||||
};
|
||||
17
src/shared/multiAccounts/inactiveAccount.tsx
Normal file
17
src/shared/multiAccounts/inactiveAccount.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { DEFAULT_AVATAR } from '@lume/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>
|
||||
);
|
||||
});
|
||||
49
src/shared/multiAccounts/index.tsx
Normal file
49
src/shared/multiAccounts/index.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { AccountContext } from '@lume/shared/accountProvider';
|
||||
import { ActiveAccount } from '@lume/shared/multiAccounts/activeAccount';
|
||||
import { InactiveAccount } from '@lume/shared/multiAccounts/inactiveAccount';
|
||||
import { APP_VERSION } from '@lume/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('@lume/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>
|
||||
);
|
||||
}
|
||||
91
src/shared/navigation.tsx
Normal file
91
src/shared/navigation.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import ActiveLink from '@lume/shared/activeLink';
|
||||
import ChannelList from '@lume/shared/channels/channelList';
|
||||
import ChatList from '@lume/shared/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>
|
||||
);
|
||||
}
|
||||
21
src/shared/networkStatusIndicator.tsx
Normal file
21
src/shared/networkStatusIndicator.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useNetworkStatus } from '@lume/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>
|
||||
);
|
||||
};
|
||||
59
src/shared/note/base.tsx
Normal file
59
src/shared/note/base.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { NoteMetadata } from '@lume/shared/note/metadata';
|
||||
import { NoteParent } from '@lume/shared/note/parent';
|
||||
import { UserExtend } from '@lume/shared/user/extend';
|
||||
import { contentParser } from '@lume/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>
|
||||
);
|
||||
});
|
||||
82
src/shared/note/comment.tsx
Normal file
82
src/shared/note/comment.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { NoteMetadata } from '@lume/shared/note/metadata';
|
||||
import { ImagePreview } from '@lume/shared/note/preview/image';
|
||||
import { VideoPreview } from '@lume/shared/note/preview/video';
|
||||
import { NoteQuote } from '@lume/shared/note/quote';
|
||||
import { UserExtend } from '@lume/shared/user/extend';
|
||||
import { UserMention } from '@lume/shared/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>
|
||||
);
|
||||
});
|
||||
82
src/shared/note/extend.tsx
Normal file
82
src/shared/note/extend.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { NoteMetadata } from '@lume/shared/note/metadata';
|
||||
import { ImagePreview } from '@lume/shared/note/preview/image';
|
||||
import { VideoPreview } from '@lume/shared/note/preview/video';
|
||||
import { NoteQuote } from '@lume/shared/note/quote';
|
||||
import { UserLarge } from '@lume/shared/user/large';
|
||||
import { UserMention } from '@lume/shared/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>
|
||||
);
|
||||
});
|
||||
158
src/shared/note/meta/comment.tsx
Normal file
158
src/shared/note/meta/comment.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { AccountContext } from '@lume/shared/accountProvider';
|
||||
import { RelayContext } from '@lume/shared/relaysProvider';
|
||||
import { UserExtend } from '@lume/shared/user/extend';
|
||||
import { WRITEONLY_RELAYS } from '@lume/stores/constants';
|
||||
import { dateToUnix } from '@lume/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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
69
src/shared/note/meta/reaction.tsx
Normal file
69
src/shared/note/meta/reaction.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { AccountContext } from '@lume/shared/accountProvider';
|
||||
import { RelayContext } from '@lume/shared/relaysProvider';
|
||||
import { WRITEONLY_RELAYS } from '@lume/stores/constants';
|
||||
import { dateToUnix } from '@lume/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>
|
||||
);
|
||||
};
|
||||
5
src/shared/note/metadata.tsx
Normal file
5
src/shared/note/metadata.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
export const NoteMetadata = memo(function NoteMetadata() {
|
||||
return <div className="relative z-10 -ml-1 flex items-center gap-8"></div>;
|
||||
});
|
||||
92
src/shared/note/parent.tsx
Normal file
92
src/shared/note/parent.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { NoteMetadata } from '@lume/shared/note/metadata';
|
||||
import { RelayContext } from '@lume/shared/relaysProvider';
|
||||
import { UserExtend } from '@lume/shared/user/extend';
|
||||
import { READONLY_RELAYS } from '@lume/stores/constants';
|
||||
import { contentParser } from '@lume/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>
|
||||
);
|
||||
});
|
||||
27
src/shared/note/placeholder.tsx
Normal file
27
src/shared/note/placeholder.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
14
src/shared/note/preview/image.tsx
Normal file
14
src/shared/note/preview/image.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
11
src/shared/note/preview/video.tsx
Normal file
11
src/shared/note/preview/video.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
16
src/shared/note/preview/youtube.tsx
Normal file
16
src/shared/note/preview/youtube.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
58
src/shared/note/quote.tsx
Normal file
58
src/shared/note/quote.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { RelayContext } from '@lume/shared/relaysProvider';
|
||||
import { UserExtend } from '@lume/shared/user/extend';
|
||||
import { READONLY_RELAYS } from '@lume/stores/constants';
|
||||
import { contentParser } from '@lume/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>
|
||||
);
|
||||
});
|
||||
19
src/shared/note/quoteRepost.tsx
Normal file
19
src/shared/note/quoteRepost.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { RootNote } from '@lume/shared/note/rootNote';
|
||||
import { UserQuoteRepost } from '@lume/shared/user/quoteRepost';
|
||||
import { getQuoteID } from '@lume/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>
|
||||
);
|
||||
});
|
||||
77
src/shared/note/rootNote.tsx
Normal file
77
src/shared/note/rootNote.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NoteMetadata } from '@lume/shared/note/metadata';
|
||||
import { RelayContext } from '@lume/shared/relaysProvider';
|
||||
import { UserExtend } from '@lume/shared/user/extend';
|
||||
import { READONLY_RELAYS } from '@lume/stores/constants';
|
||||
import { contentParser } from '@lume/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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
23
src/shared/profile/followers.tsx
Normal file
23
src/shared/profile/followers.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { RelayContext } from '@lume/shared/relaysProvider';
|
||||
import { UserFollow } from '@lume/shared/user/follow';
|
||||
import { READONLY_RELAYS } from '@lume/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>
|
||||
);
|
||||
}
|
||||
22
src/shared/profile/follows.tsx
Normal file
22
src/shared/profile/follows.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { RelayContext } from '@lume/shared/relaysProvider';
|
||||
import { UserFollow } from '@lume/shared/user/follow';
|
||||
import { READONLY_RELAYS } from '@lume/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>
|
||||
);
|
||||
}
|
||||
45
src/shared/profile/metadata.tsx
Normal file
45
src/shared/profile/metadata.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { RelayContext } from '@lume/shared/relaysProvider';
|
||||
import { DEFAULT_AVATAR, READONLY_RELAYS } from '@lume/stores/constants';
|
||||
import { shortenKey } from '@lume/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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
26
src/shared/profile/notes.tsx
Normal file
26
src/shared/profile/notes.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NoteBase } from '@lume/shared/note/base';
|
||||
import { RelayContext } from '@lume/shared/relaysProvider';
|
||||
import { READONLY_RELAYS } from '@lume/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>
|
||||
);
|
||||
}
|
||||
17
src/shared/relaysProvider.tsx
Normal file
17
src/shared/relaysProvider.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { READONLY_RELAYS } from '@lume/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>;
|
||||
}
|
||||
44
src/shared/tooltip.tsx
Normal file
44
src/shared/tooltip.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
29
src/shared/user/base.tsx
Normal file
29
src/shared/user/base.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { DEFAULT_AVATAR } from '@lume/stores/constants';
|
||||
import { useProfile } from '@lume/utils/hooks/useProfile';
|
||||
import { shortenKey } from '@lume/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>
|
||||
);
|
||||
});
|
||||
39
src/shared/user/extend.tsx
Normal file
39
src/shared/user/extend.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
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 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>
|
||||
);
|
||||
};
|
||||
27
src/shared/user/follow.tsx
Normal file
27
src/shared/user/follow.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { DEFAULT_AVATAR } from '@lume/stores/constants';
|
||||
import { useProfile } from '@lume/utils/hooks/useProfile';
|
||||
import { shortenKey } from '@lume/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>
|
||||
);
|
||||
};
|
||||
44
src/shared/user/large.tsx
Normal file
44
src/shared/user/large.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
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';
|
||||
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>
|
||||
);
|
||||
};
|
||||
9
src/shared/user/mention.tsx
Normal file
9
src/shared/user/mention.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { useProfile } from '@lume/utils/hooks/useProfile';
|
||||
import { shortenKey } from '@lume/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>
|
||||
);
|
||||
};
|
||||
24
src/shared/user/mini.tsx
Normal file
24
src/shared/user/mini.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { DEFAULT_AVATAR } from '@lume/stores/constants';
|
||||
import { useProfile } from '@lume/utils/hooks/useProfile';
|
||||
import { shortenKey } from '@lume/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>
|
||||
);
|
||||
};
|
||||
64
src/shared/user/muted.tsx
Normal file
64
src/shared/user/muted.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
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 const UserMuted = ({ data }: { data: any }) => {
|
||||
const profile = 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">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
36
src/shared/user/quoteRepost.tsx
Normal file
36
src/shared/user/quoteRepost.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
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 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user