diff --git a/package.json b/package.json index 3a5e687e..e45dbe30 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b7a42d6..8f5eaff2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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'} diff --git a/public/fallback-image.jpg b/public/fallback-image.jpg new file mode 100644 index 00000000..1490c0b7 Binary files /dev/null and b/public/fallback-image.jpg differ diff --git a/src/app.css b/src/app.css index a22f5396..a9a0e5fc 100644 --- a/src/app.css +++ b/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; + } +} diff --git a/src/shared/notes/child.tsx b/src/shared/notes/child.tsx index 7f5fffe5..d9d0a547 100644 --- a/src/shared/notes/child.tsx +++ b/src/shared/notes/child.tsx @@ -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 - + @@ -75,7 +75,7 @@ export function ChildNote({ id, root }: { id: string; root?: string }) {
-
+
{renderKind(data)}
diff --git a/src/shared/notes/kinds/file.tsx b/src/shared/notes/kinds/file.tsx index e18740db..380f2446 100644 --- a/src/shared/notes/kinds/file.tsx +++ b/src/shared/notes/kinds/file.tsx @@ -52,13 +52,7 @@ export function FileNote(props: { event?: NDKEvent }) { key={url} className="mt-2 aspect-video w-full overflow-hidden rounded-lg" > -
@@ -122,7 +122,7 @@ export function Repost({ event }: { event: NDKEvent }) {
-
+
{renderKind(data)}
diff --git a/src/shared/notes/kinds/text.tsx b/src/shared/notes/kinds/text.tsx index f8e8b211..9052999b 100644 --- a/src/shared/notes/kinds/text.tsx +++ b/src/shared/notes/kinds/text.tsx @@ -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 ( -
+
{props.content}
); } return ( -
- 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} - - {richContent.images.length ? : null} - {richContent.videos.length ? : null} - {richContent.links.length ? : null} - {richContent.notes.map((note: string) => ( - - ))} +
+
+ {parsedContent} +
+ {images.length ? : null} + {videos.length ? : null} + {linkPreview ? : null}
); } diff --git a/src/shared/notes/kinds/unknown.tsx b/src/shared/notes/kinds/unknown.tsx index eaa0e0e1..452058d5 100644 --- a/src/shared/notes/kinds/unknown.tsx +++ b/src/shared/notes/kinds/unknown.tsx @@ -2,8 +2,8 @@ import { NDKEvent } from '@nostr-dev-kit/ndk'; export function UnknownNote(props: { event?: NDKEvent }) { return ( -
-
+
+
Kind: {props.event.kind} @@ -12,7 +12,7 @@ export function UnknownNote(props: { event?: NDKEvent }) {

-

{props.event.content.toString()}

+ {props.event.content.toString()}
); diff --git a/src/shared/notes/mentions/note.tsx b/src/shared/notes/mentions/note.tsx index de2dadfc..24e1dc53 100644 --- a/src/shared/notes/mentions/note.tsx +++ b/src/shared/notes/mentions/note.tsx @@ -49,7 +49,7 @@ export const MentionNote = memo(function MentionNote({ id }: { id: string }) { if (status === 'pending') { return ( -
+
); @@ -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 ( -
+
-
- lume -
-
+
+
Lume (System)
- +
); @@ -79,7 +77,7 @@ export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
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" >
{renderKind(data)}
diff --git a/src/shared/notes/preview/image.tsx b/src/shared/notes/preview/image.tsx index cfc7f42c..5b2c33e3 100644 --- a/src/shared/notes/preview/image.tsx +++ b/src/shared/notes/preview/image.tsx @@ -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) => { + event.currentTarget.src = '/fallback-image.jpg'; + }; + return (
{urls.map((url) => ( -
- {url} - -
+ +
+ {url} + +
+
))}
); diff --git a/src/shared/notes/preview/link.tsx b/src/shared/notes/preview/link.tsx index 83fb4aff..a0fe3f4c 100644 --- a/src/shared/notes/preview/link.tsx +++ b/src/shared/notes/preview/link.tsx @@ -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 ( ) : null} diff --git a/src/shared/notes/preview/video.tsx b/src/shared/notes/preview/video.tsx index 4b5f61ac..1432f00d 100644 --- a/src/shared/notes/preview/video.tsx +++ b/src/shared/notes/preview/video.tsx @@ -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 (
{urls.map((url) => ( -
); -}); +} diff --git a/src/shared/notes/wrapper.tsx b/src/shared/notes/wrapper.tsx index 464e4eb3..6b3fc0af 100644 --- a/src/shared/notes/wrapper.tsx +++ b/src/shared/notes/wrapper.tsx @@ -29,7 +29,7 @@ export function NoteWrapper({
-
+
{cloneElement( children, event.kind === 1 ? { content: event.content } : { event: event } diff --git a/src/shared/user.tsx b/src/shared/user.tsx index 687e3a37..46dd6adb 100644 --- a/src/shared/user.tsx +++ b/src/shared/user.tsx @@ -54,7 +54,7 @@ export const User = memo(function User({ } return ( -
+
diff --git a/src/utils/hooks/useRichContent.tsx b/src/utils/hooks/useRichContent.tsx new file mode 100644 index 00000000..0c7e2e79 --- /dev/null +++ b/src/utils/hooks/useRichContent.tsx @@ -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) => ( + + )); + }); + } + + 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) => ( + + )); + } + + if (decoded.type === 'nevent') { + parsedContent = reactStringReplace(parsedContent, event, (match, i) => ( + + )); + } + }); + } + + 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) => ( + + )); + } + + if (decoded.type === 'nprofile' || decoded.type === 'naddr') { + parsedContent = reactStringReplace(parsedContent, mention, (match, i) => ( + + )); + } + }); + } + + 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 ( + + {url.toString()} + + ); + }); + + 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 }; + } +}