This commit is contained in:
reya 2023-11-07 16:23:01 +07:00
parent ee3e8eb105
commit ce864c8990
11 changed files with 133 additions and 108 deletions

View File

@ -2,13 +2,13 @@ import { memo } from 'react';
import { useRichContent } from '@utils/hooks/useRichContent'; import { useRichContent } from '@utils/hooks/useRichContent';
export function TextKind({ content, truncate }: { content: string; truncate?: boolean }) { export function TextKind({ content, textmode }: { content: string; textmode?: boolean }) {
const { parsedContent } = useRichContent(content); const { parsedContent } = useRichContent(content, textmode);
if (truncate) { if (textmode) {
return ( return (
<div className="break-p select-text whitespace-pre-line leading-normal text-neutral-900 dark:text-neutral-100"> <div className="break-p line-clamp-3 select-text leading-normal text-neutral-900 dark:text-neutral-100">
{content} {parsedContent}
</div> </div>
); );
} }

View File

@ -1,7 +1,6 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk'; import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { memo } from 'react'; import { memo } from 'react';
import { PlusIcon } from '@shared/icons';
import { import {
MemoizedArticleKind, MemoizedArticleKind,
MemoizedFileKind, MemoizedFileKind,
@ -22,7 +21,7 @@ export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
const renderKind = (event: NDKEvent) => { const renderKind = (event: NDKEvent) => {
switch (event.kind) { switch (event.kind) {
case NDKKind.Text: case NDKKind.Text:
return <MemoizedTextKind content={event.content} />; return <MemoizedTextKind content={event.content} textmode />;
case NDKKind.Article: case NDKKind.Article:
return <MemoizedArticleKind id={event.id} tags={event.tags} />; return <MemoizedArticleKind id={event.id} tags={event.tags} />;
case 1063: case 1063:
@ -42,10 +41,25 @@ export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
return ( return (
<div className="my-2 flex w-full cursor-default flex-col gap-1 rounded-lg bg-neutral-100 dark:bg-neutral-900"> <div className="my-2 flex w-full cursor-default flex-col gap-1 rounded-lg bg-neutral-100 dark:bg-neutral-900">
<div className="px-3 pt-3"> <div className="mt-3 px-3">
<User pubkey={data.pubkey} time={data.created_at} variant="mention" /> <User pubkey={data.pubkey} time={data.created_at} variant="mention" />
</div> </div>
<div>{renderKind(data)}</div> <div className="mt-1 px-3 pb-3">
{renderKind(data)}
<button
type="button"
onClick={() =>
addWidget.mutate({
kind: WidgetKinds.local.thread,
title: 'Thread',
content: data.id,
})
}
className="mt-2 text-blue-500 hover:text-blue-600"
>
Show more
</button>
</div>
</div> </div>
); );
}); });

View File

@ -2,7 +2,12 @@ import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { memo } from 'react'; import { memo } from 'react';
import { ShareIcon } from '@shared/icons'; import { ShareIcon } from '@shared/icons';
import { MemoizedArticleKind, MemoizedFileKind, NoteSkeleton } from '@shared/notes'; import {
MemoizedArticleKind,
MemoizedFileKind,
MemoizedTextKind,
NoteSkeleton,
} from '@shared/notes';
import { User } from '@shared/user'; import { User } from '@shared/user';
import { WidgetKinds } from '@stores/constants'; import { WidgetKinds } from '@stores/constants';
@ -21,11 +26,7 @@ export function NotifyNote({ event }: { event: NDKEvent }) {
const renderKind = (event: NDKEvent) => { const renderKind = (event: NDKEvent) => {
switch (event.kind) { switch (event.kind) {
case NDKKind.Text: case NDKKind.Text:
return ( return <MemoizedTextKind key={event.id} content={event.content} textmode />;
<div className="break-p line-clamp-3 select-text leading-normal text-neutral-900 dark:text-neutral-100">
{event.content}
</div>
);
case NDKKind.Article: case NDKKind.Article:
return <MemoizedArticleKind key={event.id} id={event.id} tags={event.tags} />; return <MemoizedArticleKind key={event.id} id={event.id} tags={event.tags} />;
case 1063: case 1063:

View File

@ -3,19 +3,15 @@ import { useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useNDK } from '@libs/ndk/provider'; import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { ReplyMediaUploader } from '@shared/notes'; import { ReplyMediaUploader } from '@shared/notes';
import { User } from '@shared/user';
export function NoteReplyForm({ id }: { id: string }) { export function NoteReplyForm({ eventId }: { eventId: string }) {
const { db } = useStorage();
const { ndk, relayUrls } = useNDK(); const { ndk, relayUrls } = useNDK();
const [value, setValue] = useState(''); const [value, setValue] = useState('');
const submit = async () => { const submit = async () => {
const tags = [['e', id, relayUrls[0], 'root']]; const tags = [['e', eventId, relayUrls[0], 'root']];
// publish event // publish event
const event = new NDKEvent(ndk); const event = new NDKEvent(ndk);
@ -31,26 +27,23 @@ export function NoteReplyForm({ id }: { id: string }) {
}; };
return ( return (
<div className="mt-3 flex gap-3"> <div className="mt-3 flex flex-col rounded-xl bg-neutral-50 dark:bg-neutral-950">
<User pubkey={db.account.pubkey} variant="miniavatar" /> <textarea
<div className="relative flex flex-1 flex-col rounded-xl bg-neutral-100 dark:bg-neutral-900"> value={value}
<textarea onChange={(e) => setValue(e.target.value)}
value={value} placeholder="Reply to this post..."
onChange={(e) => setValue(e.target.value)} className="h-28 w-full resize-none rounded-t-xl bg-neutral-100 px-5 py-4 text-neutral-900 !outline-none placeholder:text-neutral-600 dark:bg-neutral-900 dark:text-neutral-100 dark:placeholder:text-neutral-400"
placeholder="Reply to this thread..." spellCheck={false}
className="relative h-36 w-full resize-none bg-transparent px-5 py-4 text-neutral-900 !outline-none placeholder:text-neutral-600 dark:text-neutral-100 dark:placeholder:text-neutral-400" />
spellCheck={false} <div className="inline-flex items-center justify-end gap-2 rounded-b-xl p-2">
/> <ReplyMediaUploader setValue={setValue} />
<div className="inline-flex items-center justify-end gap-2 rounded-b-xl border-t border-neutral-200 p-2 dark:border-neutral-800"> <button
<ReplyMediaUploader setValue={setValue} /> onClick={() => submit()}
<button disabled={value.length === 0 ? true : false}
onClick={() => submit()} className="h-9 w-20 rounded-lg bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-50"
disabled={value.length === 0 ? true : false} >
className="h-9 w-20 rounded-lg bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-50" Reply
> </button>
Reply
</button>
</div>
</div> </div>
</div> </div>
); );

View File

@ -3,7 +3,7 @@ import { useState } from 'react';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { NavArrowDownIcon } from '@shared/icons'; import { NavArrowDownIcon } from '@shared/icons';
import { MemoizedTextNote, NoteActions, SubReply } from '@shared/notes'; import { MemoizedTextKind, NoteActions, SubReply } from '@shared/notes';
import { User } from '@shared/user'; import { User } from '@shared/user';
import { NDKEventWithReplies } from '@utils/types'; import { NDKEventWithReplies } from '@utils/types';
@ -12,23 +12,20 @@ export function Reply({ event, root }: { event: NDKEventWithReplies; root?: stri
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
return ( return (
<div className="relative"> <div>
<div className="relative flex flex-col"> <div className="flex flex-col gap-2">
<User pubkey={event.pubkey} time={event.created_at} eventId={event.id} /> <User pubkey={event.pubkey} time={event.created_at} eventId={event.id} />
<div className="-mt-4 flex items-start gap-3"> <MemoizedTextKind content={event.content} />
<div className="w-10 shrink-0" /> <div className="-ml-1">
<div className="relative z-10 flex-1"> <NoteActions
<MemoizedTextNote content={event.content} /> id={event.id}
<NoteActions pubkey={event.pubkey}
id={event.id} root={root}
pubkey={event.pubkey} extraButtons={false}
root={root} />
extraButtons={false}
/>
</div>
</div> </div>
</div> </div>
<div className="pl-[48px]"> <div className="pl-4">
<Collapsible.Root open={open} onOpenChange={setOpen}> <Collapsible.Root open={open} onOpenChange={setOpen}>
{event.replies?.length > 0 ? ( {event.replies?.length > 0 ? (
<div> <div>

View File

@ -6,7 +6,7 @@ import { Reply } from '@shared/notes';
import { useNostr } from '@utils/hooks/useNostr'; import { useNostr } from '@utils/hooks/useNostr';
import { NDKEventWithReplies } from '@utils/types'; import { NDKEventWithReplies } from '@utils/types';
export function ReplyList({ id }: { id: string }) { export function ReplyList({ eventId }: { eventId: string }) {
const { fetchAllReplies, sub } = useNostr(); const { fetchAllReplies, sub } = useNostr();
const [data, setData] = useState<null | NDKEventWithReplies[]>(null); const [data, setData] = useState<null | NDKEventWithReplies[]>(null);
@ -14,31 +14,32 @@ export function ReplyList({ id }: { id: string }) {
let isCancelled = false; let isCancelled = false;
async function fetchRepliesAndSub() { async function fetchRepliesAndSub() {
const events = await fetchAllReplies(id); const events = await fetchAllReplies(eventId);
if (!isCancelled) { if (!isCancelled) {
setData(events); setData(events);
} }
// subscribe for new replies // subscribe for new replies
sub( sub(
{ {
'#e': [id], '#e': [eventId],
since: Math.floor(Date.now() / 1000), since: Math.floor(Date.now() / 1000),
}, },
(event: NDKEventWithReplies) => setData((prev) => [event, ...prev]), (event: NDKEventWithReplies) => setData((prev) => [event, ...prev]),
false false
); );
} }
fetchRepliesAndSub(); fetchRepliesAndSub();
return () => { return () => {
isCancelled = true; isCancelled = true;
}; };
}, [id]); }, [eventId]);
if (!data) { if (!data) {
return ( return (
<div className="mt-3"> <div className="mt-3">
<div className="flex h-16 items-center justify-center rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900"> <div className="flex h-16 items-center justify-center rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950">
<LoaderIcon className="h-5 w-5 animate-spin" /> <LoaderIcon className="h-5 w-5 animate-spin" />
</div> </div>
</div> </div>
@ -52,12 +53,12 @@ export function ReplyList({ id }: { id: string }) {
<div className="flex flex-col items-center justify-center gap-2 py-6"> <div className="flex flex-col items-center justify-center gap-2 py-6">
<h3 className="text-3xl">👋</h3> <h3 className="text-3xl">👋</h3>
<p className="leading-none text-neutral-600 dark:text-neutral-400"> <p className="leading-none text-neutral-600 dark:text-neutral-400">
Share your thought on it... Be the first to Reply!
</p> </p>
</div> </div>
</div> </div>
) : ( ) : (
data.map((event) => <Reply key={event.id} event={event} root={id} />) data.map((event) => <Reply key={event.id} event={event} root={eventId} />)
)} )}
</div> </div>
); );

View File

@ -1,18 +1,15 @@
import { NDKEvent } from '@nostr-dev-kit/ndk'; import { NDKEvent } from '@nostr-dev-kit/ndk';
import { MemoizedTextNote, NoteActions } from '@shared/notes'; import { MemoizedTextKind, NoteActions } from '@shared/notes';
import { User } from '@shared/user'; import { User } from '@shared/user';
export function SubReply({ event }: { event: NDKEvent }) { export function SubReply({ event }: { event: NDKEvent }) {
return ( return (
<div className="mb-3 flex flex-col"> <div className="flex flex-col gap-2">
<User pubkey={event.pubkey} time={event.created_at} eventId={event.id} /> <User pubkey={event.pubkey} time={event.created_at} eventId={event.id} />
<div className="-mt-4 flex items-start gap-3"> <MemoizedTextKind content={event.content} />
<div className="w-10 shrink-0" /> <div className="-ml-1">
<div className="flex-1"> <NoteActions id={event.id} pubkey={event.pubkey} extraButtons={false} />
<MemoizedTextNote content={event.content} />
<NoteActions id={event.id} pubkey={event.pubkey} extraButtons={false} />
</div>
</div> </div>
</div> </div>
); );

View File

@ -4,11 +4,15 @@ import { memo } from 'react';
import { ChildNote, NoteActions } from '@shared/notes'; import { ChildNote, NoteActions } from '@shared/notes';
import { User } from '@shared/user'; import { User } from '@shared/user';
import { WidgetKinds } from '@stores/constants';
import { useNostr } from '@utils/hooks/useNostr'; import { useNostr } from '@utils/hooks/useNostr';
import { useRichContent } from '@utils/hooks/useRichContent'; import { useRichContent } from '@utils/hooks/useRichContent';
import { useWidget } from '@utils/hooks/useWidget';
export function TextNote({ event }: { event: NDKEvent }) { export function TextNote({ event }: { event: NDKEvent }) {
const { parsedContent } = useRichContent(event.content); const { parsedContent } = useRichContent(event.content);
const { addWidget } = useWidget();
const { getEventThread } = useNostr(); const { getEventThread } = useNostr();
const thread = getEventThread(event); const thread = getEventThread(event);
@ -22,6 +26,19 @@ export function TextNote({ event }: { event: NDKEvent }) {
<div className="flex h-min w-full flex-col gap-3 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900"> <div className="flex h-min w-full flex-col gap-3 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
{thread.rootEventId ? <ChildNote id={thread.rootEventId} isRoot /> : null} {thread.rootEventId ? <ChildNote id={thread.rootEventId} isRoot /> : null}
{thread.replyEventId ? <ChildNote id={thread.replyEventId} /> : null} {thread.replyEventId ? <ChildNote id={thread.replyEventId} /> : null}
<button
type="button"
onClick={() =>
addWidget.mutate({
kind: WidgetKinds.local.thread,
title: 'Thread',
content: thread.rootEventId,
})
}
className="self-start text-blue-500 hover:text-blue-600"
>
Show full thread
</button>
</div> </div>
</div> </div>
) : null} ) : null}

View File

@ -102,7 +102,7 @@ export const User = memo(function User({
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Avatar.Root className="shrink-0"> <Avatar.Root className="h-8 w-8 shrink-0">
<Avatar.Image <Avatar.Image
src={user?.picture || user?.image} src={user?.picture || user?.image}
alt={pubkey} alt={pubkey}
@ -308,14 +308,16 @@ export const User = memo(function User({
/> />
</Avatar.Fallback> </Avatar.Fallback>
</Avatar.Root> </Avatar.Root>
<div className="absolute left-2 top-2 font-semibold leading-tight"> <div className="absolute left-2 top-2 inline-flex items-center gap-1.5 font-semibold leading-tight">
{user?.display_name || <div className="w-full max-w-[10rem] truncate">
user?.name || {user?.display_name ||
user?.displayName || user?.name ||
displayNpub(pubkey, 16)}{' '} user?.displayName ||
<span className="font-normal text-neutral-700 dark:text-neutral-300"> displayNpub(pubkey, 16)}{' '}
</div>
<div className="font-normal text-neutral-700 dark:text-neutral-300">
{subtext}: {subtext}:
</span> </div>
</div> </div>
</> </>
); );
@ -440,20 +442,20 @@ export const User = memo(function User({
} }
return ( return (
<div className="flex items-center gap-3"> <div className="flex h-16 items-center gap-3 px-3">
<Avatar.Root className="h-10 w-10 shrink-0"> <Avatar.Root className="h-10 w-10 shrink-0">
<Avatar.Image <Avatar.Image
src={user?.picture || user?.image} src={user?.picture || user?.image}
alt={pubkey} alt={pubkey}
loading="lazy" loading="lazy"
decoding="async" decoding="async"
className="h-10 w-10 rounded-lg" className="h-10 w-10 rounded-lg ring-1 ring-neutral-200/50 dark:ring-neutral-800/50"
/> />
<Avatar.Fallback delayMs={300}> <Avatar.Fallback delayMs={300}>
<img <img
src={svgURI} src={svgURI}
alt={pubkey} alt={pubkey}
className="h-10 w-10 rounded-lg bg-black dark:bg-white" className="h-10 w-10 rounded-lg bg-black ring-1 ring-neutral-200/50 dark:bg-white dark:ring-neutral-800/50"
/> />
</Avatar.Fallback> </Avatar.Fallback>
</Avatar.Root> </Avatar.Root>

View File

@ -4,13 +4,11 @@ import { WVList } from 'virtua';
import { LoaderIcon } from '@shared/icons'; import { LoaderIcon } from '@shared/icons';
import { import {
MemoizedArticleNote, MemoizedArticleKind,
MemoizedFileNote, MemoizedFileKind,
MemoizedTextNote, MemoizedTextKind,
NoteActions, NoteActions,
NoteReplyForm, NoteReplyForm,
NoteStats,
UnknownNote,
} from '@shared/notes'; } from '@shared/notes';
import { ReplyList } from '@shared/notes/replies/list'; import { ReplyList } from '@shared/notes/replies/list';
import { TitleBar } from '@shared/titleBar'; import { TitleBar } from '@shared/titleBar';
@ -27,13 +25,13 @@ export function LocalThreadWidget({ params }: { params: Widget }) {
(event: NDKEvent) => { (event: NDKEvent) => {
switch (event.kind) { switch (event.kind) {
case NDKKind.Text: case NDKKind.Text:
return <MemoizedTextNote content={event.content} />; return <MemoizedTextKind content={event.content} />;
case NDKKind.Article: case NDKKind.Article:
return <MemoizedArticleNote event={event} />; return <MemoizedArticleKind id={event.id} tags={event.tags} />;
case 1063: case 1063:
return <MemoizedFileNote event={event} />; return <MemoizedFileKind tags={event.tags} />;
default: default:
return <UnknownNote event={event} />; return null;
} }
}, },
[data] [data]
@ -42,23 +40,22 @@ export function LocalThreadWidget({ params }: { params: Widget }) {
return ( return (
<WidgetWrapper> <WidgetWrapper>
<TitleBar id={params.id} title={params.title} /> <TitleBar id={params.id} title={params.title} />
<WVList className="flex-1 overflow-y-auto px-3"> <WVList className="flex-1 overflow-y-auto px-3 pb-5">
{status === 'pending' ? ( {status === 'pending' ? (
<div className="flex h-16 items-center justify-center rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900"> <div className="flex h-16 items-center justify-center rounded-xl bg-neutral-50 px-3 py-3 dark:bg-neutral-950">
<LoaderIcon className="h-5 w-5 animate-spin" /> <LoaderIcon className="h-5 w-5 animate-spin" />
</div> </div>
) : ( ) : (
<div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900"> <>
<User pubkey={data.pubkey} time={data.created_at} variant="thread" /> <div className="flex flex-col rounded-xl bg-neutral-50 dark:bg-neutral-950">
<div className="mt-2">{renderKind(data)}</div> <User pubkey={data.pubkey} time={data.created_at} variant="thread" />
<NoteActions id={params.content} pubkey={data.pubkey} extraButtons={false} /> {renderKind(data)}
</div> <NoteActions id={data.id} pubkey={data.pubkey} />
</div>
<NoteReplyForm eventId={params.content} />
<ReplyList eventId={data.id} />
</>
)} )}
<NoteStats id={params.content} />
<hr className="my-4 h-px w-full border-none bg-neutral-100" />
<NoteReplyForm id={params.content} />
<ReplyList id={params.content} />
<div className="h-10" />
</WVList> </WVList>
</WidgetWrapper> </WidgetWrapper>
); );

View File

@ -42,17 +42,23 @@ const VIDEOS = [
'.m3u8', '.m3u8',
]; ];
export function useRichContent(content: string) { export function useRichContent(content: string, textmode: boolean = false) {
let parsedContent: string | ReactNode[] = content; let parsedContent: string | ReactNode[] = content;
let linkPreview: string; let linkPreview: string;
let images: string[] = [];
let videos: string[] = [];
let events: string[] = [];
const text = content; const text = content;
const words = text.split(/(\s+)/); const words = text.split(/(\s+)/);
const images = words.filter((word) => IMAGES.some((el) => word.endsWith(el))); if (!textmode) {
const videos = words.filter((word) => VIDEOS.some((el) => word.endsWith(el))); images = words.filter((word) => IMAGES.some((el) => word.endsWith(el)));
videos = words.filter((word) => VIDEOS.some((el) => word.endsWith(el)));
events = words.filter((word) => NOSTR_EVENTS.some((el) => word.startsWith(el)));
}
const hashtags = words.filter((word) => word.startsWith('#')); const hashtags = words.filter((word) => word.startsWith('#'));
const events = words.filter((word) => NOSTR_EVENTS.some((el) => word.startsWith(el)));
const mentions = words.filter((word) => const mentions = words.filter((word) =>
NOSTR_MENTIONS.some((el) => word.startsWith(el)) NOSTR_MENTIONS.some((el) => word.startsWith(el))
); );
@ -127,7 +133,7 @@ export function useRichContent(content: string) {
const url = new URL(match); const url = new URL(match);
url.search = ''; url.search = '';
if (!linkPreview) { if (!linkPreview && !textmode) {
linkPreview = match; linkPreview = match;
return <LinkPreview key={match + i} url={url.toString()} />; return <LinkPreview key={match + i} url={url.toString()} />;
} }