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"
>
-
+
@@ -72,7 +66,7 @@ export function FileNote(props: { event?: NDKEvent }) {
return (
-
+
);
}
diff --git a/src/shared/notes/kinds/repost.tsx b/src/shared/notes/kinds/repost.tsx
index 45645ef7..83bee3ed 100644
--- a/src/shared/notes/kinds/repost.tsx
+++ b/src/shared/notes/kinds/repost.tsx
@@ -75,7 +75,7 @@ export function Repost({ event }: { event: NDKEvent }) {
if (status === 'pending') {
return (
-
+
@@ -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
-
+
@@ -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 (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) => (
-
-
-
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"
- >
-
-
-
+
+
+
+
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"
+ >
+
+
+
+
))}
);
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) => (
-
+
@@ -32,4 +25,4 @@ export const VideoPreview = memo(function VideoPreview({ urls }: { urls: string[
))}
);
-});
+}
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 };
+ }
+}