new parser

This commit is contained in:
reya 2023-11-04 14:18:29 +07:00
parent 912c740c51
commit dad388c6ab
17 changed files with 366 additions and 107 deletions

View File

@ -59,6 +59,7 @@
"@tiptap/react": "^2.1.12",
"@tiptap/starter-kit": "^2.1.12",
"@tiptap/suggestion": "^2.1.12",
"cobe": "^0.6.3",
"dayjs": "^1.11.10",
"destr": "^2.0.2",
"framer-motion": "^10.16.4",
@ -79,7 +80,9 @@
"react-dom": "^18.2.0",
"react-hook-form": "^7.47.0",
"react-hotkeys-hook": "^4.4.1",
"react-medium-image-zoom": "^5.1.8",
"react-router-dom": "^6.18.0",
"react-string-replace": "^1.1.1",
"reactflow": "^11.9.4",
"sonner": "^1.1.0",
"tailwind-scrollbar": "^3.0.5",

34
pnpm-lock.yaml generated
View File

@ -128,6 +128,9 @@ dependencies:
'@tiptap/suggestion':
specifier: ^2.1.12
version: 2.1.12(@tiptap/core@2.1.12)(@tiptap/pm@2.1.12)
cobe:
specifier: ^0.6.3
version: 0.6.3
dayjs:
specifier: ^1.11.10
version: 1.11.10
@ -188,9 +191,15 @@ dependencies:
react-hotkeys-hook:
specifier: ^4.4.1
version: 4.4.1(react-dom@18.2.0)(react@18.2.0)
react-medium-image-zoom:
specifier: ^5.1.8
version: 5.1.8(react-dom@18.2.0)(react@18.2.0)
react-router-dom:
specifier: ^6.18.0
version: 6.18.0(react-dom@18.2.0)(react@18.2.0)
react-string-replace:
specifier: ^1.1.1
version: 1.1.1
reactflow:
specifier: ^11.9.4
version: 11.9.4(@types/react@18.2.34)(immer@10.0.3)(react-dom@18.2.0)(react@18.2.0)
@ -3381,6 +3390,12 @@ packages:
resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==}
engines: {node: '>=6'}
/cobe@0.6.3:
resolution: {integrity: sha512-WHr7X4o1ym94GZ96h7b1pNemZJacbOzd02dZtnVwuC4oWBaLg96PBmp2rIS1SAhUDhhC/QyS9WEqkpZIs/ZBTg==}
dependencies:
phenomenon: 1.6.0
dev: false
/color-convert@1.9.3:
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
dependencies:
@ -5153,6 +5168,10 @@ packages:
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
dev: false
/phenomenon@1.6.0:
resolution: {integrity: sha512-7h9/fjPD3qNlgggzm88cY58l9sudZ6Ey+UmZsizfhtawO6E3srZQXywaNm2lBwT72TbpHYRPy7ytIHeBUD/G0A==}
dev: false
/picocolors@1.0.0:
resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==}
@ -5530,6 +5549,16 @@ packages:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
dev: true
/react-medium-image-zoom@5.1.8(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-2X4oLlEopIWg7qalR1Qpy4gPrU9CTF0DvJ7HNu5u/NwdyQWupEsje2vuMbjBz7+np8MmQ4DKJ6zGr1ofCuzB3g==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/react-remove-scroll-bar@2.3.4(@types/react@18.2.34)(react@18.2.0):
resolution: {integrity: sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==}
engines: {node: '>=10'}
@ -5588,6 +5617,11 @@ packages:
react: 18.2.0
dev: false
/react-string-replace@1.1.1:
resolution: {integrity: sha512-26TUbLzLfHQ5jO5N7y3Mx88eeKo0Ml0UjCQuX4BMfOd/JX+enQqlKpL1CZnmjeBRvQE8TR+ds9j1rqx9CxhKHQ==}
engines: {node: '>=0.12.0'}
dev: false
/react-style-singleton@2.2.1(@types/react@18.2.34)(react@18.2.0):
resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==}
engines: {node: '>=10'}

BIN
public/fallback-image.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

View File

@ -64,3 +64,115 @@ input::-ms-clear {
.ProseMirror img.ProseMirror-selectednode {
@apply outline-blue-500;
}
[data-rmiz] {
position: relative;
}
[data-rmiz-ghost] {
position: absolute;
pointer-events: none;
}
[data-rmiz-btn-zoom],
[data-rmiz-btn-unzoom] {
background-color: rgba(0, 0, 0, 0.7);
border-radius: 50%;
border: none;
box-shadow: 0 0 1px rgba(255, 255, 255, 0.5);
color: #fff;
height: 40px;
margin: 0;
outline-offset: 2px;
padding: 9px;
touch-action: manipulation;
width: 40px;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
[data-rmiz-btn-zoom]:not(:focus):not(:active) {
position: absolute;
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
overflow: hidden;
pointer-events: none;
white-space: nowrap;
width: 1px;
}
[data-rmiz-btn-zoom] {
position: absolute;
inset: 10px 10px auto auto;
cursor: zoom-in;
}
[data-rmiz-btn-unzoom] {
position: absolute;
inset: 20px 20px auto auto;
cursor: zoom-out;
z-index: 1;
}
[data-rmiz-content="found"] img,
[data-rmiz-content="found"] svg,
[data-rmiz-content="found"] [role="img"],
[data-rmiz-content="found"] [data-zoom] {
cursor: zoom-in;
}
[data-rmiz-modal]::backdrop {
display: none;
}
[data-rmiz-modal][open] {
position: fixed;
width: 100vw;
width: 100dvw;
height: 100vh;
height: 100dvh;
max-width: none;
max-height: none;
margin: 0;
padding: 0;
border: 0;
background: transparent;
overflow: hidden;
}
[data-rmiz-modal-overlay] {
position: absolute;
inset: 0;
transition: background-color 0.3s;
}
[data-rmiz-modal-overlay="hidden"] {
background-color: rgba(255, 255, 255, 0);
}
[data-rmiz-modal-overlay="visible"] {
background-color: rgba(255, 255, 255, 1);
}
[data-rmiz-modal-content] {
position: relative;
width: 100%;
height: 100%;
}
[data-rmiz-modal-img] {
position: absolute;
cursor: zoom-out;
image-rendering: high-quality;
transform-origin: top left;
transition: transform 0.3s;
}
@media (prefers-reduced-motion: reduce) {
[data-rmiz-modal-overlay],
[data-rmiz-modal-img] {
transition-duration: 0.01ms !important;
}
}

View File

@ -60,7 +60,7 @@ export function ChildNote({ id, root }: { id: string; root?: string }) {
Lume cannot find this post with your current relay set, but you can view
it via njump.me
</div>
<LinkPreview urls={[noteLink]} />
<LinkPreview url={noteLink} />
</div>
</div>
</div>
@ -75,7 +75,7 @@ export function ChildNote({ id, root }: { id: string; root?: string }) {
<User pubkey={data.pubkey} time={data.created_at} eventId={data.id} />
<div className="-mt-4 flex items-start gap-3">
<div className="w-10 shrink-0" />
<div className="relative z-20 flex-1">
<div className="relative z-20 min-w-0 flex-1">
{renderKind(data)}
<NoteActions id={data.id} pubkey={data.pubkey} root={root} />
</div>

View File

@ -52,13 +52,7 @@ export function FileNote(props: { event?: NDKEvent }) {
key={url}
className="mt-2 aspect-video w-full overflow-hidden rounded-lg"
>
<video
slot="media"
src={url}
poster={`https://thumbnail.video/api/get?url=${url}&seconds=1`}
preload="none"
muted
/>
<video slot="media" src={url} preload="metadata" muted />
<MediaControlBar>
<MediaPlayButton></MediaPlayButton>
<MediaTimeRange></MediaTimeRange>
@ -72,7 +66,7 @@ export function FileNote(props: { event?: NDKEvent }) {
return (
<div className="mt-2">
<LinkPreview urls={[url]} />
<LinkPreview url={url} />
</div>
);
}

View File

@ -75,7 +75,7 @@ export function Repost({ event }: { event: NDKEvent }) {
if (status === 'pending') {
return (
<div className="h-min w-full px-3 pb-3">
<div className="w-full px-3 pb-3">
<div className="relative overflow-hidden rounded-xl border border-neutral-300 bg-neutral-200 p-3 dark:border-neutral-700 dark:bg-neutral-800">
<NoteSkeleton />
</div>
@ -105,7 +105,7 @@ export function Repost({ event }: { event: NDKEvent }) {
Lume cannot find this post with your current relay set, but you can view
it via njump.me
</div>
<LinkPreview urls={[noteLink]} />
<LinkPreview url={noteLink} />
</div>
</div>
</div>
@ -122,7 +122,7 @@ export function Repost({ event }: { event: NDKEvent }) {
<User pubkey={data.pubkey} time={data.created_at} eventId={data.id} />
<div className="-mt-4 flex items-start gap-3">
<div className="w-10 shrink-0" />
<div className="relative z-20 flex-1">
<div className="relative z-20 min-w-0 flex-1">
{renderKind(data)}
<NoteActions id={data.id} pubkey={data.pubkey} />
</div>

View File

@ -1,67 +1,28 @@
import Markdown from 'markdown-to-jsx';
import { memo } from 'react';
import {
Boost,
Hashtag,
ImagePreview,
Invoice,
LinkPreview,
MentionNote,
MentionUser,
VideoPreview,
} from '@shared/notes';
import { ImagePreview, LinkPreview, VideoPreview } from '@shared/notes';
import { parser } from '@utils/parser';
import { useRichContent } from '@utils/hooks/useRichContent';
export function TextNote(props: { content?: string; truncate?: boolean }) {
const richContent = parser(props.content);
const { parsedContent, images, videos, linkPreview } = useRichContent(props.content);
if (props.truncate) {
return (
<div className="break-p prose prose-neutral line-clamp-4 max-w-none select-text whitespace-pre-line leading-normal dark:prose-invert prose-headings:mb-1 prose-headings:mt-3 prose-p:mb-0 prose-p:mt-0 prose-p:last:mb-1 prose-a:font-normal prose-a:text-blue-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-blue-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:bg-white/10 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2 hover:prose-a:text-blue-600 prose-a:hover:underline">
<div className="break-p prose prose-neutral max-w-none select-text whitespace-pre-line leading-normal dark:prose-invert">
{props.content}
</div>
);
}
return (
<div className="flex w-full flex-col items-start gap-2">
<Markdown
options={{
overrides: {
Hashtag: {
component: Hashtag,
},
Boost: {
component: Boost,
},
MentionUser: {
component: MentionUser,
},
Invoice: {
component: Invoice,
},
a: {
props: {
target: '_blank',
},
},
},
slugify: (str) => str,
forceBlock: true,
enforceAtxHeadings: true,
}}
className="break-p prose prose-neutral max-w-none select-text whitespace-pre-line leading-normal dark:prose-invert prose-headings:mb-1 prose-headings:mt-3 prose-p:mb-0 prose-p:mt-0 prose-p:last:mb-1 prose-a:font-normal prose-a:text-blue-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-blue-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:bg-white/10 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2 hover:prose-a:text-blue-600 prose-a:hover:underline"
>
{richContent.parsed}
</Markdown>
{richContent.images.length ? <ImagePreview urls={richContent.images} /> : null}
{richContent.videos.length ? <VideoPreview urls={richContent.videos} /> : null}
{richContent.links.length ? <LinkPreview urls={richContent.links} /> : null}
{richContent.notes.map((note: string) => (
<MentionNote key={note} id={note} />
))}
<div className="flex flex-col gap-3">
<div className="break-p prose prose-neutral max-w-none select-text whitespace-pre-line leading-normal dark:prose-invert prose-img:mb-0 prose-img:mt-0">
{parsedContent}
</div>
{images.length ? <ImagePreview urls={images} /> : null}
{videos.length ? <VideoPreview urls={videos} /> : null}
{linkPreview ? <LinkPreview url={linkPreview} /> : null}
</div>
);
}

View File

@ -2,8 +2,8 @@ import { NDKEvent } from '@nostr-dev-kit/ndk';
export function UnknownNote(props: { event?: NDKEvent }) {
return (
<div className="flex w-full flex-col gap-2">
<div className="inline-flex flex-col rounded-md bg-neutral-200 px-2 py-2 dark:bg-neutral-800">
<div className="mt-2 flex w-full flex-col gap-2">
<div className="inline-flex flex-col rounded-md border border-neutral-300 bg-neutral-200 px-2 py-2 dark:border-neutral-700 dark:bg-neutral-800">
<span className="text-sm font-medium text-neutral-500 dark:text-neutral-400">
Kind: {props.event.kind}
</span>
@ -12,7 +12,7 @@ export function UnknownNote(props: { event?: NDKEvent }) {
</p>
</div>
<div className="select-text whitespace-pre-line break-words text-neutral-800 dark:text-neutral-200">
<p>{props.event.content.toString()}</p>
{props.event.content.toString()}
</div>
</div>
);

View File

@ -49,7 +49,7 @@ export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
if (status === 'pending') {
return (
<div className="w-full cursor-default rounded-lg border border-neutral-300 bg-neutral-200 p-3 dark:border-neutral-700 dark:bg-neutral-800">
<div className="my-2 w-full cursor-default rounded-lg border border-neutral-300 bg-neutral-200 p-3 dark:border-neutral-700 dark:bg-neutral-800">
<NoteSkeleton />
</div>
);
@ -58,17 +58,15 @@ export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
if (status === 'error') {
const noteLink = `https://njump.me/${nip19.noteEncode(id)}`;
return (
<div className="w-full rounded-lg bg-neutral-200 px-3 py-3 dark:bg-neutral-800">
<div className="my-2 w-full rounded-lg bg-neutral-200 px-3 py-3 dark:bg-neutral-800">
<div className="flex items-center gap-2">
<div className="inline-flex h-6 w-6 items-end justify-center rounded bg-black pb-1">
<img src="/lume.png" alt="lume" className="h-auto w-1/3" />
</div>
<h5 className="truncate font-semibold leading-none text-white">
<div className="inline-flex h-6 w-6 items-end justify-center rounded-md bg-black pb-1"></div>
<h5 className="truncate font-semibold">
Lume <span className="text-green-500">(System)</span>
</h5>
</div>
<div className="mt-1.5">
<LinkPreview urls={[noteLink]} />
<LinkPreview url={noteLink} />
</div>
</div>
);
@ -79,7 +77,7 @@ export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
<div
role="button"
onClick={(e) => openThread(e, id)}
className="w-full cursor-default rounded-lg border border-neutral-300 bg-neutral-200 p-3 dark:border-neutral-700 dark:bg-neutral-800"
className="my-2 w-full cursor-default rounded-lg border border-neutral-300 bg-neutral-200 p-3 dark:border-neutral-700 dark:bg-neutral-800"
>
<User pubkey={data.pubkey} time={data.created_at} variant="mention" />
<div className="mt-1 text-left">{renderKind(data)}</div>

View File

@ -1,5 +1,7 @@
import { downloadDir } from '@tauri-apps/api/path';
import { download } from '@tauri-apps/plugin-upload';
import { SyntheticEvent } from 'react';
import Zoom from 'react-medium-image-zoom';
import { DownloadIcon } from '@shared/icons';
@ -10,23 +12,33 @@ export function ImagePreview({ urls }: { urls: string[] }) {
return await download(url, downloadDirPath + `/${filename}`);
};
const fallback = (event: SyntheticEvent<HTMLImageElement, Event>) => {
event.currentTarget.src = '/fallback-image.jpg';
};
return (
<div className="flex w-full flex-col gap-2">
{urls.map((url) => (
<div key={url} className="group relative">
<img
src={url}
alt={url}
className="h-auto w-full rounded-lg border border-neutral-300 object-cover dark:border-neutral-700"
/>
<button
type="button"
onClick={() => downloadImage(url)}
className="absolute right-2 top-2 hidden h-10 w-10 items-center justify-center rounded-lg bg-black/50 backdrop-blur-xl group-hover:inline-flex hover:bg-blue-500"
>
<DownloadIcon className="h-5 w-5 text-white" />
</button>
</div>
<Zoom key={url} zoomMargin={50}>
<div className="group relative">
<img
src={url}
alt={url}
loading="lazy"
decoding="async"
style={{ contentVisibility: 'auto' }}
onError={fallback}
className="h-auto w-full rounded-lg border border-neutral-300 object-cover dark:border-neutral-700"
/>
<button
type="button"
onClick={() => downloadImage(url)}
className="absolute right-2 top-2 hidden h-10 w-10 items-center justify-center rounded-xl bg-black/50 backdrop-blur-xl group-hover:inline-flex hover:bg-blue-500"
>
<DownloadIcon className="h-4 w-4 text-white" />
</button>
</div>
</Zoom>
))}
</div>
);

View File

@ -6,9 +6,9 @@ function isImage(url: string) {
return /^https?:\/\/.+\.(jpg|jpeg|png|webp|avif)$/.test(url);
}
export function LinkPreview({ urls }: { urls: string[] }) {
const { status, data } = useOpenGraph(urls[0]);
const domain = new URL(urls[0]);
export function LinkPreview({ url }: { url: string }) {
const domain = new URL(url);
const { status, data } = useOpenGraph(url);
if (status === 'pending') {
return (
@ -27,7 +27,7 @@ export function LinkPreview({ urls }: { urls: string[] }) {
return (
<Link
to={urls[0]}
to={url}
target="_blank"
rel="noreferrer"
className="flex w-full flex-col rounded-lg border border-neutral-300 bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800"
@ -35,7 +35,7 @@ export function LinkPreview({ urls }: { urls: string[] }) {
{isImage(data.image) ? (
<img
src={data.image}
alt={urls[0]}
alt={url}
className="h-44 w-full rounded-t-lg bg-white object-cover"
/>
) : null}

View File

@ -7,20 +7,13 @@ import {
MediaTimeRange,
MediaVolumeRange,
} from 'media-chrome/dist/react';
import { memo } from 'react';
export const VideoPreview = memo(function VideoPreview({ urls }: { urls: string[] }) {
export function VideoPreview({ urls }: { urls: string[] }) {
return (
<div className="flex w-full flex-col gap-2">
{urls.map((url) => (
<MediaController key={url} className="aspect-video overflow-hidden rounded-lg">
<video
slot="media"
src={url}
poster={`https://thumbnail.video/api/get?url=${url}&seconds=1`}
preload="none"
muted
/>
<video slot="media" src={url} preload="metadata" muted />
<MediaControlBar>
<MediaPlayButton></MediaPlayButton>
<MediaTimeRange></MediaTimeRange>
@ -32,4 +25,4 @@ export const VideoPreview = memo(function VideoPreview({ urls }: { urls: string[
))}
</div>
);
});
}

View File

@ -29,7 +29,7 @@ export function NoteWrapper({
<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-20 flex-1">
<div className="relative z-20 min-w-0 flex-1">
{cloneElement(
children,
event.kind === 1 ? { content: event.content } : { event: event }

View File

@ -54,7 +54,7 @@ export const User = memo(function User({
}
return (
<div className="flex items-center gap-2">
<div className="flex h-6 items-center gap-2">
<Avatar.Root className="shrink-0">
<Avatar.Image
src={user?.picture || user?.image}

View File

@ -19,8 +19,6 @@ import {
import { TitleBar } from '@shared/titleBar';
import { WidgetWrapper } from '@shared/widgets';
import { useNostr } from '@utils/hooks/useNostr';
export function NewsfeedWidget() {
const queryClient = useQueryClient();
@ -139,6 +137,8 @@ export function NewsfeedWidget() {
};
}, [status]);
console.log('RERENDER');
return (
<WidgetWrapper>
<TitleBar id="9999" isLive />

View File

@ -0,0 +1,152 @@
import { nip19 } from 'nostr-tools';
import { ReactNode } from 'react';
import { Link } from 'react-router-dom';
import reactStringReplace from 'react-string-replace';
import { Hashtag, MentionNote, MentionUser } from '@shared/notes';
const NOSTR_MENTIONS = [
'@npub1',
'nostr:npub1',
'nostr:nprofile1',
'nostr:naddr1',
'npub1',
'nprofile1',
'naddr1',
];
const NOSTR_EVENTS = ['nostr:note1', 'note1', 'nostr:nevent1', 'nevent1'];
// const BITCOINS = ['lnbc', 'bc1p', 'bc1q'];
const IMAGES = ['.jpg', '.jpeg', '.gif', '.png', '.webp', '.avif', '.tiff'];
const VIDEOS = [
'.mp4',
'.mov',
'.webm',
'.wmv',
'.flv',
'.mts',
'.avi',
'.ogv',
'.mkv',
'.mp3',
'.m3u8',
];
export function useRichContent(content: string) {
let parsedContent: string | ReactNode[] = content;
let linkPreview: string;
const text = content;
const words = text.split(/(\s+)/);
const images = words
.filter((word) => IMAGES.some((el) => word.endsWith(el)))
.map((item: string) => item);
const videos = words
.filter((word) => VIDEOS.some((el) => word.endsWith(el)))
.map((item: string) => item);
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))
);
try {
if (images.length) {
images.forEach((image) => {
// @ts-expect-error, it is string at this time
parsedContent = parsedContent.replace(image, '');
});
}
if (videos.length) {
videos.forEach((video) => {
// @ts-expect-error, it is string at this time
parsedContent = parsedContent.replace(video, '');
});
}
if (hashtags.length) {
hashtags.forEach((hashtag) => {
parsedContent = reactStringReplace(parsedContent, hashtag, (match, i) => (
<Hashtag key={match + i} tag={hashtag} />
));
});
}
if (events.length) {
events.forEach((event) => {
const address = event.replace('nostr:', '');
const decoded = nip19.decode(address);
if (decoded.type === 'note') {
parsedContent = reactStringReplace(parsedContent, event, (match, i) => (
<MentionNote key={match + i} id={decoded.data} />
));
}
if (decoded.type === 'nevent') {
parsedContent = reactStringReplace(parsedContent, event, (match, i) => (
<MentionNote key={match + i} id={decoded.data.id} />
));
}
});
}
if (mentions.length) {
mentions.forEach((mention) => {
const address = mention.replace('nostr:', '').replace('@', '');
const decoded = nip19.decode(address);
if (decoded.type === 'npub') {
parsedContent = reactStringReplace(parsedContent, mention, (match, i) => (
<MentionUser key={match + i} pubkey={decoded.data} />
));
}
if (decoded.type === 'nprofile' || decoded.type === 'naddr') {
parsedContent = reactStringReplace(parsedContent, mention, (match, i) => (
<MentionUser key={match + i} pubkey={decoded.data.pubkey} />
));
}
});
}
parsedContent = reactStringReplace(parsedContent, /(https?:\/\/\S+)/g, (match, i) => {
if (!linkPreview) {
linkPreview = match;
if (content.length < 500) return null;
}
const url = new URL(match);
url.search = '';
return (
<Link
key={match + i}
to={url}
target="_blank"
rel="noreferrer"
className="break-all font-normal text-blue-500 hover:text-blue-600"
>
{url.toString()}
</Link>
);
});
if (typeof parsedContent[0] === 'string') {
parsedContent[0] = parsedContent[0].trim();
}
return { parsedContent, images, videos, linkPreview };
} catch (e) {
console.warn('[parser] parse failed: ', e);
return { parsedContent, images, videos, linkPreview };
}
}