mirror of
https://github.com/lumehq/lume.git
synced 2025-03-17 21:32:32 +01:00
new parser
This commit is contained in:
parent
912c740c51
commit
dad388c6ab
@ -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
34
pnpm-lock.yaml
generated
@ -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
BIN
public/fallback-image.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 88 KiB |
112
src/app.css
112
src/app.css
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -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 }
|
||||
|
@ -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}
|
||||
|
@ -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 />
|
||||
|
152
src/utils/hooks/useRichContent.tsx
Normal file
152
src/utils/hooks/useRichContent.tsx
Normal 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 };
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user