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

34
pnpm-lock.yaml generated
View File

@ -128,6 +128,9 @@ dependencies:
'@tiptap/suggestion': '@tiptap/suggestion':
specifier: ^2.1.12 specifier: ^2.1.12
version: 2.1.12(@tiptap/core@2.1.12)(@tiptap/pm@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: dayjs:
specifier: ^1.11.10 specifier: ^1.11.10
version: 1.11.10 version: 1.11.10
@ -188,9 +191,15 @@ dependencies:
react-hotkeys-hook: react-hotkeys-hook:
specifier: ^4.4.1 specifier: ^4.4.1
version: 4.4.1(react-dom@18.2.0)(react@18.2.0) 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: react-router-dom:
specifier: ^6.18.0 specifier: ^6.18.0
version: 6.18.0(react-dom@18.2.0)(react@18.2.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: reactflow:
specifier: ^11.9.4 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) 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==} resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==}
engines: {node: '>=6'} 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: /color-convert@1.9.3:
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
dependencies: dependencies:
@ -5153,6 +5168,10 @@ packages:
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
dev: false dev: false
/phenomenon@1.6.0:
resolution: {integrity: sha512-7h9/fjPD3qNlgggzm88cY58l9sudZ6Ey+UmZsizfhtawO6E3srZQXywaNm2lBwT72TbpHYRPy7ytIHeBUD/G0A==}
dev: false
/picocolors@1.0.0: /picocolors@1.0.0:
resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==}
@ -5530,6 +5549,16 @@ packages:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
dev: true 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): /react-remove-scroll-bar@2.3.4(@types/react@18.2.34)(react@18.2.0):
resolution: {integrity: sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==} resolution: {integrity: sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -5588,6 +5617,11 @@ packages:
react: 18.2.0 react: 18.2.0
dev: false 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): /react-style-singleton@2.2.1(@types/react@18.2.34)(react@18.2.0):
resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==} resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==}
engines: {node: '>=10'} 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 { .ProseMirror img.ProseMirror-selectednode {
@apply outline-blue-500; @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 Lume cannot find this post with your current relay set, but you can view
it via njump.me it via njump.me
</div> </div>
<LinkPreview urls={[noteLink]} /> <LinkPreview url={noteLink} />
</div> </div>
</div> </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} /> <User pubkey={data.pubkey} time={data.created_at} eventId={data.id} />
<div className="-mt-4 flex items-start gap-3"> <div className="-mt-4 flex items-start gap-3">
<div className="w-10 shrink-0" /> <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)} {renderKind(data)}
<NoteActions id={data.id} pubkey={data.pubkey} root={root} /> <NoteActions id={data.id} pubkey={data.pubkey} root={root} />
</div> </div>

View File

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

View File

@ -75,7 +75,7 @@ export function Repost({ event }: { event: NDKEvent }) {
if (status === 'pending') { if (status === 'pending') {
return ( 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"> <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 /> <NoteSkeleton />
</div> </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 Lume cannot find this post with your current relay set, but you can view
it via njump.me it via njump.me
</div> </div>
<LinkPreview urls={[noteLink]} /> <LinkPreview url={noteLink} />
</div> </div>
</div> </div>
</div> </div>
@ -122,7 +122,7 @@ export function Repost({ event }: { event: NDKEvent }) {
<User pubkey={data.pubkey} time={data.created_at} eventId={data.id} /> <User pubkey={data.pubkey} time={data.created_at} eventId={data.id} />
<div className="-mt-4 flex items-start gap-3"> <div className="-mt-4 flex items-start gap-3">
<div className="w-10 shrink-0" /> <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)} {renderKind(data)}
<NoteActions id={data.id} pubkey={data.pubkey} /> <NoteActions id={data.id} pubkey={data.pubkey} />
</div> </div>

View File

@ -1,67 +1,28 @@
import Markdown from 'markdown-to-jsx';
import { memo } from 'react'; import { memo } from 'react';
import { import { ImagePreview, LinkPreview, VideoPreview } from '@shared/notes';
Boost,
Hashtag,
ImagePreview,
Invoice,
LinkPreview,
MentionNote,
MentionUser,
VideoPreview,
} from '@shared/notes';
import { parser } from '@utils/parser'; import { useRichContent } from '@utils/hooks/useRichContent';
export function TextNote(props: { content?: string; truncate?: boolean }) { export function TextNote(props: { content?: string; truncate?: boolean }) {
const richContent = parser(props.content); const { parsedContent, images, videos, linkPreview } = useRichContent(props.content);
if (props.truncate) { if (props.truncate) {
return ( 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} {props.content}
</div> </div>
); );
} }
return ( return (
<div className="flex w-full flex-col items-start gap-2"> <div className="flex flex-col gap-3">
<Markdown <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">
options={{ {parsedContent}
overrides: { </div>
Hashtag: { {images.length ? <ImagePreview urls={images} /> : null}
component: Hashtag, {videos.length ? <VideoPreview urls={videos} /> : null}
}, {linkPreview ? <LinkPreview url={linkPreview} /> : null}
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> </div>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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