refactor note component
This commit is contained in:
47
src/app/note/components/base.tsx
Normal file
47
src/app/note/components/base.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import NoteMetadata from '@lume/app/note/components/metadata';
|
||||
import { NoteParent } from '@lume/app/note/components/parent';
|
||||
import { noteParser } from '@lume/app/note/components/parser';
|
||||
import ImagePreview from '@lume/app/note/components/preview/image';
|
||||
import VideoPreview from '@lume/app/note/components/preview/video';
|
||||
import { NoteDefaultUser } from '@lume/app/note/components/user/default';
|
||||
|
||||
import { navigate } from 'vite-plugin-ssr/client/router';
|
||||
|
||||
export default function NoteBase({ event }: { event: any }) {
|
||||
const content = noteParser(event);
|
||||
|
||||
const openNote = (e) => {
|
||||
const selection = window.getSelection();
|
||||
if (selection.toString().length === 0) {
|
||||
navigate(`/app/note?id=${event.parent_id}`);
|
||||
} else {
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => openNote(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"
|
||||
>
|
||||
{event.parent_id && event.parent_id !== event.event_id ? (
|
||||
<NoteParent key={event.parent_id} id={event.parent_id} />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<div className="relative z-10 flex flex-col">
|
||||
<NoteDefaultUser pubkey={event.pubkey} time={event.created_at} />
|
||||
<div className="mt-1 pl-[52px]">
|
||||
<div className="whitespace-pre-line break-words text-[15px] leading-tight text-zinc-100">
|
||||
{content.parsed}
|
||||
</div>
|
||||
{Array.isArray(content.images) && content.images.length ? <ImagePreview urls={content.images} /> : <></>}
|
||||
{Array.isArray(content.videos) && content.videos.length ? <VideoPreview urls={content.videos} /> : <></>}
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()} className="mt-5 pl-[52px]">
|
||||
<NoteMetadata id={event.event_id} eventPubkey={event.pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
src/app/note/components/comment.tsx
Normal file
21
src/app/note/components/comment.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NoteDefaultUser } from '@lume/app/note/components/user/default';
|
||||
|
||||
import { memo } from 'react';
|
||||
|
||||
export const NoteComment = memo(function NoteComment({ event }: { event: any }) {
|
||||
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">
|
||||
<NoteDefaultUser 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">
|
||||
{event.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()} className="mt-5 pl-[52px]"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
64
src/app/note/components/metadata.tsx
Normal file
64
src/app/note/components/metadata.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import NoteLike from '@lume/app/note/components/metadata/like';
|
||||
import NoteReply from '@lume/app/note/components/metadata/reply';
|
||||
import NoteRepost from '@lume/app/note/components/metadata/repost';
|
||||
import ZapIcon from '@lume/shared/icons/zap';
|
||||
import { RelayContext } from '@lume/shared/relayProvider';
|
||||
import { READONLY_RELAYS } from '@lume/stores/constants';
|
||||
|
||||
import { useContext, useState } from 'react';
|
||||
import useSWRSubscription from 'swr/subscription';
|
||||
|
||||
export default function NoteMetadata({ id, eventPubkey }: { id: string; eventPubkey: string }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const [replies, setReplies] = useState(0);
|
||||
const [reposts, setReposts] = useState(0);
|
||||
const [likes, setLikes] = useState(0);
|
||||
|
||||
useSWRSubscription(id ? ['note-metadata', id] : null, ([, key], {}) => {
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
'#e': [key],
|
||||
since: 0,
|
||||
kinds: [1, 6, 7],
|
||||
limit: 20,
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
switch (event.kind) {
|
||||
case 1:
|
||||
setReplies((replies) => replies + 1);
|
||||
break;
|
||||
case 6:
|
||||
setReposts((reposts) => reposts + 1);
|
||||
break;
|
||||
case 7:
|
||||
if (event.content === '🤙' || event.content === '+') {
|
||||
setLikes((likes) => likes + 1);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-16">
|
||||
<NoteReply id={id} replies={replies} />
|
||||
<NoteLike id={id} pubkey={eventPubkey} likes={likes} />
|
||||
<NoteRepost id={id} pubkey={eventPubkey} reposts={reposts} />
|
||||
<button className="group inline-flex w-min items-center gap-1.5">
|
||||
<ZapIcon width={20} height={20} className="text-zinc-400 group-hover:text-orange-400" />
|
||||
<span className="text-sm leading-none text-zinc-400 group-hover:text-zinc-200">{0}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
src/app/note/components/metadata/like.tsx
Normal file
51
src/app/note/components/metadata/like.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import LikeIcon from '@lume/shared/icons/like';
|
||||
import { RelayContext } from '@lume/shared/relayProvider';
|
||||
import { WRITEONLY_RELAYS } from '@lume/stores/constants';
|
||||
import { dateToUnix } from '@lume/utils/getDate';
|
||||
import { useActiveAccount } from '@lume/utils/hooks/useActiveAccount';
|
||||
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
|
||||
export default function NoteLike({ id, pubkey, likes }: { id: string; pubkey: string; likes: number }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
const submitEvent = (e: any) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!isLoading && !isError && account) {
|
||||
const event: any = {
|
||||
content: '+',
|
||||
kind: 7,
|
||||
tags: [
|
||||
['e', id],
|
||||
['p', pubkey],
|
||||
],
|
||||
created_at: dateToUnix(),
|
||||
pubkey: account.pubkey,
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
// publish event to all relays
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// update state
|
||||
setCount(count + 1);
|
||||
} else {
|
||||
console.log('error');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setCount(likes);
|
||||
}, [likes]);
|
||||
|
||||
return (
|
||||
<button type="button" onClick={(e) => submitEvent(e)} className="group inline-flex w-min items-center gap-1.5">
|
||||
<LikeIcon width={20} height={20} className="text-zinc-400 group-hover:text-rose-400" />
|
||||
<span className="text-sm leading-none text-zinc-400 group-hover:text-zinc-200">{count}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
128
src/app/note/components/metadata/reply.tsx
Normal file
128
src/app/note/components/metadata/reply.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import ReplyIcon from '@lume/shared/icons/reply';
|
||||
import { RelayContext } from '@lume/shared/relayProvider';
|
||||
import { WRITEONLY_RELAYS } from '@lume/stores/constants';
|
||||
import { dateToUnix } from '@lume/utils/getDate';
|
||||
import { useActiveAccount } from '@lume/utils/hooks/useActiveAccount';
|
||||
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { Fragment, useContext, useEffect, useState } from 'react';
|
||||
|
||||
export default function NoteReply({ id, replies }: { id: string; replies: number }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const [count, setCount] = useState(0);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
const profile = account ? JSON.parse(account.metadata) : null;
|
||||
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const openModal = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const submitEvent = () => {
|
||||
if (!isLoading && !isError && account) {
|
||||
const event: any = {
|
||||
content: value,
|
||||
created_at: dateToUnix(),
|
||||
kind: 1,
|
||||
pubkey: account.pubkey,
|
||||
tags: [['e', id]],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
|
||||
// publish event
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// close modal
|
||||
setIsOpen(false);
|
||||
setCount(count + 1);
|
||||
} else {
|
||||
console.log('error');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setCount(replies);
|
||||
}, [replies]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => openModal()} className="group inline-flex w-min items-center gap-1.5">
|
||||
<ReplyIcon width={20} height={20} className="text-zinc-400 group-hover:text-green-400" />
|
||||
<span className="text-sm leading-none text-zinc-400 group-hover:text-zinc-200">{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 p-3">
|
||||
{/* root note */}
|
||||
{/* 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-24 w-full flex-1 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
|
||||
<div>
|
||||
<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 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
51
src/app/note/components/metadata/repost.tsx
Normal file
51
src/app/note/components/metadata/repost.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import RepostIcon from '@lume/shared/icons/repost';
|
||||
import { RelayContext } from '@lume/shared/relayProvider';
|
||||
import { WRITEONLY_RELAYS } from '@lume/stores/constants';
|
||||
import { dateToUnix } from '@lume/utils/getDate';
|
||||
import { useActiveAccount } from '@lume/utils/hooks/useActiveAccount';
|
||||
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
|
||||
export default function NoteRepost({ id, pubkey, reposts }: { id: string; pubkey: string; reposts: number }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
const submitEvent = (e: any) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!isLoading && !isError && account) {
|
||||
const event: any = {
|
||||
content: '',
|
||||
kind: 6,
|
||||
tags: [
|
||||
['e', id],
|
||||
['p', pubkey],
|
||||
],
|
||||
created_at: dateToUnix(),
|
||||
pubkey: account.pubkey,
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
// publish event to all relays
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// update state
|
||||
setCount(count + 1);
|
||||
} else {
|
||||
console.log('error');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setCount(reposts);
|
||||
}, [reposts]);
|
||||
|
||||
return (
|
||||
<button type="button" onClick={(e) => submitEvent(e)} className="group inline-flex w-min items-center gap-1.5">
|
||||
<RepostIcon width={20} height={20} className="text-zinc-400 group-hover:text-blue-400" />
|
||||
<span className="text-sm leading-none text-zinc-400 group-hover:text-zinc-200">{count}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
88
src/app/note/components/parent.tsx
Normal file
88
src/app/note/components/parent.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import NoteMetadata from '@lume/app/note/components/metadata';
|
||||
import { noteParser } from '@lume/app/note/components/parser';
|
||||
import ImagePreview from '@lume/app/note/components/preview/image';
|
||||
import VideoPreview from '@lume/app/note/components/preview/video';
|
||||
import { NoteDefaultUser } from '@lume/app/note/components/user/default';
|
||||
import { RelayContext } from '@lume/shared/relayProvider';
|
||||
import { READONLY_RELAYS } from '@lume/stores/constants';
|
||||
|
||||
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 ? id : null, (key, { next }) => {
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
ids: [key],
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
next(null, event);
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
unsubscribeOnEose: true,
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
|
||||
const content = !error && data ? noteParser(data) : null;
|
||||
|
||||
return (
|
||||
<div className="relative pb-5">
|
||||
{error && <div>failed to load</div>}
|
||||
{!data ? (
|
||||
<>
|
||||
<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="animated-pulse relative z-10">
|
||||
<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" />
|
||||
</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">
|
||||
<NoteDefaultUser 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">
|
||||
{content ? content.parsed : ''}
|
||||
</div>
|
||||
{Array.isArray(content.images) && content.images.length ? <ImagePreview urls={content.images} /> : <></>}
|
||||
{Array.isArray(content.videos) && content.videos.length ? <VideoPreview urls={content.videos} /> : <></>}
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()} className="mt-5 pl-[52px]">
|
||||
<NoteMetadata id={data.id} eventPubkey={data.pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,55 +1,66 @@
|
||||
import { NoteQuote } from '@lume/app/note/components/quote';
|
||||
import { NoteMentionUser } from '@lume/app/note/components/user/mention';
|
||||
|
||||
import { Event, parseReferences } from 'nostr-tools';
|
||||
import reactStringReplace from 'react-string-replace';
|
||||
|
||||
const getURLs = new RegExp(
|
||||
'(^|[ \t\r\n])((ftp|http|https|gopher|mailto|news|nntp|telnet|wais|file|prospero|aim|webcal):(([A-Za-z0-9$_.+!*(),;/?:@&~=-])|%[A-Fa-f0-9]{2}){2,}(#([a-zA-Z0-9][a-zA-Z0-9$_.+!*(),;/?:@&~=%-]*))?([A-Za-z0-9$_+!*();/?:~-]))',
|
||||
'g'
|
||||
'(^|[ \t\r\n])((ftp|http|https|gopher|mailto|news|nntp|telnet|wais|file|prospero|aim|webcal|wss|ws):(([A-Za-z0-9$_.+!*(),;/?:@&~=-])|%[A-Fa-f0-9]{2}){2,}(#([a-zA-Z0-9][a-zA-Z0-9$_.+!*(),;/?:@&~=%-]*))?([A-Za-z0-9$_+!*();/?:~-]))',
|
||||
'gmi'
|
||||
);
|
||||
|
||||
export const noteParser = (event: Event) => {
|
||||
const references = parseReferences(event);
|
||||
const content = { original: event.content, parsed: event.content, images: [], videos: [], others: [] };
|
||||
|
||||
// remove extra whitespace
|
||||
content.parsed = content.parsed.replace(/\s+/g, ' ').trim();
|
||||
const content: { original: string; parsed: any; images: string[]; videos: string[] } = {
|
||||
original: event.content,
|
||||
parsed: event.content,
|
||||
images: [],
|
||||
videos: [],
|
||||
};
|
||||
|
||||
// handle media
|
||||
content.original.match(getURLs)?.forEach((url) => {
|
||||
if (url.match(/\.(jpg|jpeg|gif|png|webp|avif)$/im)) {
|
||||
content.original.match(getURLs)?.forEach((item) => {
|
||||
// make sure url is trimmed
|
||||
const url = item.trim();
|
||||
|
||||
if (url.match(/\.(jpg|jpeg|gif|png|webp|avif)$/gim)) {
|
||||
// image url
|
||||
content.images.push(url.trim());
|
||||
content.images.push(url);
|
||||
// remove url from original content
|
||||
content.parsed = content.parsed.replace(url, '');
|
||||
} else if (url.match(/(http:|https:)?(\/\/)?(www\.)?(youtube.com|youtu.be)\/(watch|embed)?(\?v=|\/)?(\S+)?/)) {
|
||||
// youtube
|
||||
content.videos.push(url.trim());
|
||||
// remove url from original content
|
||||
content.parsed = content.parsed.replace(url, '');
|
||||
} else if (url.match(/\.(mp4|webm|mov)$/im)) {
|
||||
} else if (url.match(/\.(mp4|webm|mov)$/i)) {
|
||||
// video
|
||||
content.videos.push(url.trim());
|
||||
content.videos.push(url);
|
||||
// remove url from original content
|
||||
content.parsed = content.parsed.replace(url, '');
|
||||
} else {
|
||||
content.others.push(url.trim());
|
||||
}
|
||||
});
|
||||
|
||||
// handle hashtag
|
||||
content.parsed = reactStringReplace(content.parsed, /#(\w+)/g, (match, i) => (
|
||||
<span key={match + i} className="cursor-pointer text-fuchsia-500 hover:text-fuchsia-600">
|
||||
#{match}
|
||||
</span>
|
||||
));
|
||||
|
||||
// handle note references
|
||||
references?.forEach((reference) => {
|
||||
if (reference?.profile) {
|
||||
content.parsed = content.parsed.replace(
|
||||
reference.text,
|
||||
`<NoteMentionUser pubkey="${reference.profile.pubkey}" />`
|
||||
);
|
||||
content.parsed = reactStringReplace(content.parsed, reference.text, () => {
|
||||
return <NoteMentionUser key={reference.profile.pubkey} pubkey={reference.profile.pubkey} />;
|
||||
});
|
||||
}
|
||||
if (reference?.event) {
|
||||
content.parsed = content.parsed.replace(reference.text, `<NoteQuote id="${reference.event.id}" />;`);
|
||||
content.parsed = reactStringReplace(content.parsed, reference.text, () => {
|
||||
return <NoteQuote key={reference.event.id} id={reference.event.id} />;
|
||||
});
|
||||
}
|
||||
if (reference?.address) {
|
||||
content.parsed = content.parsed.replace(
|
||||
reference.text,
|
||||
`<a href="${reference.address}" target="_blank">${reference.address}</>`
|
||||
);
|
||||
});
|
||||
|
||||
// make sure no unnessary spaces are left
|
||||
content.parsed.forEach((item: string, index: string) => {
|
||||
if (typeof item === 'string') {
|
||||
content.parsed[index] = item.replace(/^\x20+|\x20+$/gm, '');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
25
src/app/note/components/placeholder.tsx
Normal file
25
src/app/note/components/placeholder.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
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" />
|
||||
</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>
|
||||
);
|
||||
};
|
||||
71
src/app/note/components/quote.tsx
Normal file
71
src/app/note/components/quote.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { noteParser } from '@lume/app/note/components/parser';
|
||||
import ImagePreview from '@lume/app/note/components/preview/image';
|
||||
import VideoPreview from '@lume/app/note/components/preview/video';
|
||||
import { NoteDefaultUser } from '@lume/app/note/components/user/default';
|
||||
import { RelayContext } from '@lume/shared/relayProvider';
|
||||
import { READONLY_RELAYS } from '@lume/stores/constants';
|
||||
|
||||
import { memo, useContext } from 'react';
|
||||
import useSWRSubscription from 'swr/subscription';
|
||||
import { navigate } from 'vite-plugin-ssr/client/router';
|
||||
|
||||
export const NoteQuote = memo(function NoteQuote({ id }: { id: string }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
|
||||
const { data, error } = useSWRSubscription(id ? id : null, (key, { next }) => {
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
ids: [key],
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
next(null, event);
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
unsubscribeOnEose: true,
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
|
||||
const openNote = (e) => {
|
||||
const selection = window.getSelection();
|
||||
if (selection.toString().length === 0) {
|
||||
navigate(`/app/note?id=${id}`);
|
||||
} else {
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const content = !error && data ? noteParser(data) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => openNote(e)}
|
||||
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">
|
||||
<NoteDefaultUser 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">
|
||||
{content ? content.parsed : ''}
|
||||
</div>
|
||||
{Array.isArray(content.images) && content.images.length ? <ImagePreview urls={content.images} /> : <></>}
|
||||
{Array.isArray(content.videos) && content.videos.length ? <VideoPreview urls={content.videos} /> : <></>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
19
src/app/note/components/quoteRepost.tsx
Normal file
19
src/app/note/components/quoteRepost.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { RootNote } from '@lume/app/note/components/rootNote';
|
||||
import { NoteRepostUser } from '@lume/app/note/components/user/repost';
|
||||
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>
|
||||
<NoteRepostUser pubkey={event.pubkey} time={event.created_at} />
|
||||
</div>
|
||||
<RootNote id={rootID} fallback={event.content} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import NoteReplyForm from '@lume/app/note/components/form';
|
||||
import NoteReplyForm from '@lume/app/note/components/formReply';
|
||||
import NoteReply from '@lume/app/note/components/reply';
|
||||
import { RelayContext } from '@lume/shared/relayProvider';
|
||||
import { READONLY_RELAYS } from '@lume/stores/constants';
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { contentParser } from '@lume/app/newsfeed/components/contentParser';
|
||||
|
||||
import NoteReplyUser from './user';
|
||||
import { noteParser } from '@lume/app/note/components//parser';
|
||||
import ImagePreview from '@lume/app/note/components/preview/image';
|
||||
import VideoPreview from '@lume/app/note/components/preview/video';
|
||||
import NoteReplyUser from '@lume/app/note/components/user/reply';
|
||||
|
||||
export default function NoteReply({ data }: { data: any }) {
|
||||
const content = contentParser(data.content, data.tags);
|
||||
const content = noteParser(data);
|
||||
|
||||
return (
|
||||
<div className="flex h-min min-h-min w-full select-text flex-col px-5 py-3.5 hover:bg-black/20">
|
||||
<div className="flex flex-col">
|
||||
<NoteReplyUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="-mt-[17px] pl-[48px]">
|
||||
<div className="whitespace-pre-line break-words break-words text-sm leading-tight">{content}</div>
|
||||
<div className="whitespace-pre-line break-words text-sm leading-tight">{content.parsed}</div>
|
||||
{Array.isArray(content.images) && content.images.length ? <ImagePreview urls={content.images} /> : <></>}
|
||||
{Array.isArray(content.videos) && content.videos.length ? <VideoPreview urls={content.videos} /> : <></>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
121
src/app/note/components/rootNote.tsx
Normal file
121
src/app/note/components/rootNote.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import NoteMetadata from '@lume/app/note/components/metadata';
|
||||
import { noteParser } from '@lume/app/note/components/parser';
|
||||
import ImagePreview from '@lume/app/note/components/preview/image';
|
||||
import VideoPreview from '@lume/app/note/components/preview/video';
|
||||
import { NoteDefaultUser } from '@lume/app/note/components/user/default';
|
||||
import { RelayContext } from '@lume/shared/relayProvider';
|
||||
import { READONLY_RELAYS } from '@lume/stores/constants';
|
||||
|
||||
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, fallback }: { id: string; fallback?: any }) {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const parseFallback = fallback.length > 1 ? JSON.parse(fallback) : null;
|
||||
|
||||
const { data, error } = useSWRSubscription(parseFallback ? null : id, (key, { next }) => {
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
ids: [key],
|
||||
},
|
||||
],
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
next(null, event);
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
unsubscribeOnEose: true,
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
|
||||
const openNote = (e) => {
|
||||
const selection = window.getSelection();
|
||||
if (selection.toString().length === 0) {
|
||||
navigate(`/app/note?id=${id}`);
|
||||
} else {
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const content = !error && data ? noteParser(data) : null;
|
||||
|
||||
if (parseFallback) {
|
||||
const contentFallback = noteParser(parseFallback);
|
||||
|
||||
return (
|
||||
<div onClick={(e) => openNote(e)} className="relative z-10 flex flex-col">
|
||||
<NoteDefaultUser pubkey={parseFallback.pubkey} time={parseFallback.created_at} />
|
||||
<div className="mt-1 pl-[52px]">
|
||||
<div className="whitespace-pre-line break-words text-[15px] leading-tight text-zinc-100">
|
||||
{contentFallback.parsed}
|
||||
</div>
|
||||
{Array.isArray(contentFallback.images) && contentFallback.images.length ? (
|
||||
<ImagePreview urls={contentFallback.images} />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{Array.isArray(contentFallback.videos) && contentFallback.videos.length ? (
|
||||
<VideoPreview urls={contentFallback.videos} />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()} className="mt-5 pl-[52px]">
|
||||
<NoteMetadata id={parseFallback.id} eventPubkey={parseFallback.pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div>failed to load</div>}
|
||||
{!data ? (
|
||||
<div className="relative z-10 flex h-min animate-pulse select-text flex-col">
|
||||
<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" />
|
||||
</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 onClick={(e) => openNote(e)} className="relative z-10 flex flex-col">
|
||||
<NoteDefaultUser 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">
|
||||
{content ? content.parsed : ''}
|
||||
</div>
|
||||
{Array.isArray(content.images) && content.images.length ? <ImagePreview urls={content.images} /> : <></>}
|
||||
{Array.isArray(content.videos) && content.videos.length ? <VideoPreview urls={content.videos} /> : <></>}
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()} className="mt-5 pl-[52px]">
|
||||
<NoteMetadata id={data.id} eventPubkey={data.pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
54
src/app/note/components/user/default.tsx
Normal file
54
src/app/note/components/user/default.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { DEFAULT_AVATAR, IMGPROXY_URL } 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 NoteDefaultUser = ({ pubkey, time }: { pubkey: string; time: number }) => {
|
||||
const { user, isError, isLoading } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<div className="group relative z-10 flex h-11 items-center gap-2">
|
||||
{isError || isLoading ? (
|
||||
<>
|
||||
<div className="h-11 w-11 shrink animate-pulse overflow-hidden rounded-md bg-white"></div>
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<div className="h-4 w-20 animate-pulse rounded bg-zinc-800"></div>
|
||||
</div>
|
||||
<div className="h-2.5 w-14 animate-pulse rounded bg-zinc-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="h-11 w-11 shrink overflow-hidden rounded-md bg-white">
|
||||
<img
|
||||
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${user?.picture ? user.picture : DEFAULT_AVATAR}`}
|
||||
alt={pubkey}
|
||||
className="h-11 w-11 rounded-md object-cover"
|
||||
loading="lazy"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h5 className="text-sm font-semibold leading-none group-hover:underline">
|
||||
{user?.display_name || user?.name || shortenKey(pubkey)}
|
||||
</h5>
|
||||
</div>
|
||||
<span className="text-sm leading-none text-zinc-500">
|
||||
{user?.nip05 || shortenKey(pubkey)} • {dayjs().to(dayjs.unix(time))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
16
src/app/note/components/user/mention.tsx
Normal file
16
src/app/note/components/user/mention.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useProfile } from '@lume/utils/hooks/useProfile';
|
||||
import { shortenKey } from '@lume/utils/shortenKey';
|
||||
|
||||
export const NoteMentionUser = ({ pubkey }: { pubkey: string }) => {
|
||||
const { user, isError, isLoading } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isError || isLoading ? (
|
||||
<span className="inline-flex h-4 w-10 animate-pulse rounded bg-zinc-800"></span>
|
||||
) : (
|
||||
<span className="cursor-pointer text-fuchsia-500">@{user?.username || user?.name || shortenKey(pubkey)}</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
51
src/app/note/components/user/repost.tsx
Normal file
51
src/app/note/components/user/repost.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { DEFAULT_AVATAR, IMGPROXY_URL } 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 NoteRepostUser = ({ pubkey, time }: { pubkey: string; time: number }) => {
|
||||
const { user, isError, isLoading } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<div className="group flex items-center gap-2">
|
||||
{isError || isLoading ? (
|
||||
<>
|
||||
<div className="relative h-11 w-11 shrink animate-pulse overflow-hidden rounded-md bg-white"></div>
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<div className="h-4 w-20 animate-pulse rounded bg-zinc-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-white">
|
||||
<img
|
||||
src={`${IMGPROXY_URL}/rs:fit:100:100/plain/${user?.picture ? user.picture : DEFAULT_AVATAR}`}
|
||||
alt={pubkey}
|
||||
className="h-11 w-11 rounded-md object-cover"
|
||||
loading="lazy"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2 text-sm">
|
||||
<h5 className="font-semibold leading-tight group-hover:underline">
|
||||
{user?.display_name || user?.name || shortenKey(pubkey)}{' '}
|
||||
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-transparent">
|
||||
reposted
|
||||
</span>
|
||||
</h5>
|
||||
<span className="leading-tight text-zinc-500">·</span>
|
||||
<span className="text-zinc-500">{dayjs().to(dayjs.unix(time))}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user