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';
export function TextKind({ content, truncate }: { content: string; truncate?: boolean }) {
const { parsedContent } = useRichContent(content);
export function TextKind({ content, textmode }: { content: string; textmode?: boolean }) {
const { parsedContent } = useRichContent(content, textmode);
if (truncate) {
if (textmode) {
return (
<div className="break-p select-text whitespace-pre-line leading-normal text-neutral-900 dark:text-neutral-100">
{content}
<div className="break-p line-clamp-3 select-text leading-normal text-neutral-900 dark:text-neutral-100">
{parsedContent}
</div>
);
}

View File

@ -1,7 +1,6 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { memo } from 'react';
import { PlusIcon } from '@shared/icons';
import {
MemoizedArticleKind,
MemoizedFileKind,
@ -22,7 +21,7 @@ export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
const renderKind = (event: NDKEvent) => {
switch (event.kind) {
case NDKKind.Text:
return <MemoizedTextKind content={event.content} />;
return <MemoizedTextKind content={event.content} textmode />;
case NDKKind.Article:
return <MemoizedArticleKind id={event.id} tags={event.tags} />;
case 1063:
@ -42,10 +41,25 @@ export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
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="px-3 pt-3">
<div className="mt-3 px-3">
<User pubkey={data.pubkey} time={data.created_at} variant="mention" />
</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>
);
});

View File

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

View File

@ -3,19 +3,15 @@ import { useState } from 'react';
import { toast } from 'sonner';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { ReplyMediaUploader } from '@shared/notes';
import { User } from '@shared/user';
export function NoteReplyForm({ id }: { id: string }) {
const { db } = useStorage();
export function NoteReplyForm({ eventId }: { eventId: string }) {
const { ndk, relayUrls } = useNDK();
const [value, setValue] = useState('');
const submit = async () => {
const tags = [['e', id, relayUrls[0], 'root']];
const tags = [['e', eventId, relayUrls[0], 'root']];
// publish event
const event = new NDKEvent(ndk);
@ -31,26 +27,23 @@ export function NoteReplyForm({ id }: { id: string }) {
};
return (
<div className="mt-3 flex gap-3">
<User pubkey={db.account.pubkey} variant="miniavatar" />
<div className="relative flex flex-1 flex-col rounded-xl bg-neutral-100 dark:bg-neutral-900">
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Reply to this thread..."
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 border-t border-neutral-200 p-2 dark:border-neutral-800">
<ReplyMediaUploader setValue={setValue} />
<button
onClick={() => submit()}
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>
</div>
<div className="mt-3 flex flex-col rounded-xl bg-neutral-50 dark:bg-neutral-950">
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Reply to this post..."
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"
spellCheck={false}
/>
<div className="inline-flex items-center justify-end gap-2 rounded-b-xl p-2">
<ReplyMediaUploader setValue={setValue} />
<button
onClick={() => submit()}
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>
</div>
</div>
);

View File

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

View File

@ -6,7 +6,7 @@ import { Reply } from '@shared/notes';
import { useNostr } from '@utils/hooks/useNostr';
import { NDKEventWithReplies } from '@utils/types';
export function ReplyList({ id }: { id: string }) {
export function ReplyList({ eventId }: { eventId: string }) {
const { fetchAllReplies, sub } = useNostr();
const [data, setData] = useState<null | NDKEventWithReplies[]>(null);
@ -14,31 +14,32 @@ export function ReplyList({ id }: { id: string }) {
let isCancelled = false;
async function fetchRepliesAndSub() {
const events = await fetchAllReplies(id);
const events = await fetchAllReplies(eventId);
if (!isCancelled) {
setData(events);
}
// subscribe for new replies
sub(
{
'#e': [id],
'#e': [eventId],
since: Math.floor(Date.now() / 1000),
},
(event: NDKEventWithReplies) => setData((prev) => [event, ...prev]),
false
);
}
fetchRepliesAndSub();
return () => {
isCancelled = true;
};
}, [id]);
}, [eventId]);
if (!data) {
return (
<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" />
</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">
<h3 className="text-3xl">👋</h3>
<p className="leading-none text-neutral-600 dark:text-neutral-400">
Share your thought on it...
Be the first to Reply!
</p>
</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>
);

View File

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

View File

@ -4,11 +4,15 @@ import { memo } from 'react';
import { ChildNote, NoteActions } from '@shared/notes';
import { User } from '@shared/user';
import { WidgetKinds } from '@stores/constants';
import { useNostr } from '@utils/hooks/useNostr';
import { useRichContent } from '@utils/hooks/useRichContent';
import { useWidget } from '@utils/hooks/useWidget';
export function TextNote({ event }: { event: NDKEvent }) {
const { parsedContent } = useRichContent(event.content);
const { addWidget } = useWidget();
const { getEventThread } = useNostr();
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">
{thread.rootEventId ? <ChildNote id={thread.rootEventId} isRoot /> : 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>
) : null}

View File

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

View File

@ -4,13 +4,11 @@ import { WVList } from 'virtua';
import { LoaderIcon } from '@shared/icons';
import {
MemoizedArticleNote,
MemoizedFileNote,
MemoizedTextNote,
MemoizedArticleKind,
MemoizedFileKind,
MemoizedTextKind,
NoteActions,
NoteReplyForm,
NoteStats,
UnknownNote,
} from '@shared/notes';
import { ReplyList } from '@shared/notes/replies/list';
import { TitleBar } from '@shared/titleBar';
@ -27,13 +25,13 @@ export function LocalThreadWidget({ params }: { params: Widget }) {
(event: NDKEvent) => {
switch (event.kind) {
case NDKKind.Text:
return <MemoizedTextNote content={event.content} />;
return <MemoizedTextKind content={event.content} />;
case NDKKind.Article:
return <MemoizedArticleNote event={event} />;
return <MemoizedArticleKind id={event.id} tags={event.tags} />;
case 1063:
return <MemoizedFileNote event={event} />;
return <MemoizedFileKind tags={event.tags} />;
default:
return <UnknownNote event={event} />;
return null;
}
},
[data]
@ -42,23 +40,22 @@ export function LocalThreadWidget({ params }: { params: Widget }) {
return (
<WidgetWrapper>
<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' ? (
<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" />
</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="mt-2">{renderKind(data)}</div>
<NoteActions id={params.content} pubkey={data.pubkey} extraButtons={false} />
</div>
<>
<div className="flex flex-col rounded-xl bg-neutral-50 dark:bg-neutral-950">
<User pubkey={data.pubkey} time={data.created_at} variant="thread" />
{renderKind(data)}
<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>
</WidgetWrapper>
);

View File

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