diff --git a/.changeset/silver-phones-develop.md b/.changeset/silver-phones-develop.md
new file mode 100644
index 000000000..5ffea34af
--- /dev/null
+++ b/.changeset/silver-phones-develop.md
@@ -0,0 +1,5 @@
+---
+"nostrudel": minor
+---
+
+Render multiple images as image gallery
diff --git a/src/components/embed-types/common.tsx b/src/components/embed-types/common.tsx
index 32804bc27..694f269dc 100644
--- a/src/components/embed-types/common.tsx
+++ b/src/components/embed-types/common.tsx
@@ -1,8 +1,10 @@
-import { Box, Image, ImageProps, Link, useDisclosure } from "@chakra-ui/react";
+import { Box, Image, ImageProps, Link, SimpleGrid, useDisclosure } from "@chakra-ui/react";
import appSettings from "../../services/settings/app-settings";
import { ImageGalleryLink } from "../image-gallery";
import { useTrusted } from "../../providers/trust";
import OpenGraphCard from "../open-graph-card";
+import { EmbedableContent, defaultGetLocation } from "../../helpers/embeds";
+import { matchLink } from "../../helpers/regexp";
const BlurredImage = (props: ImageProps) => {
const { isOpen, onOpen } = useDisclosure();
@@ -26,13 +28,21 @@ const BlurredImage = (props: ImageProps) => {
);
};
-const EmbeddedImage = ({ src }: { src: string }) => {
+const EmbeddedImage = ({ src, inGallery }: { src: string; inGallery?: boolean }) => {
const trusted = useTrusted();
const ImageComponent = trusted || !appSettings.value.blurImages ? Image : BlurredImage;
const thumbnail = appSettings.value.imageProxy
? new URL(`/256,fit/${src}`, appSettings.value.imageProxy).toString()
: src;
+ if (inGallery) {
+ return (
+
+
+
+ );
+ }
+
return (
@@ -40,8 +50,79 @@ const EmbeddedImage = ({ src }: { src: string }) => {
);
};
-// note1n06jceulg3gukw836ghd94p0ppwaz6u3mksnnz960d8vlcp2fnqsgx3fu9
+function ImageGallery({ images }: { images: string[] }) {
+ return (
+
+ {images.map((src) => (
+
+ ))}
+
+ );
+}
+
const imageExt = [".svg", ".gif", ".png", ".jpg", ".jpeg", ".webp", ".avif"];
+export function embedImageGallery(content: EmbedableContent): EmbedableContent {
+ return content
+ .map((subContent, i) => {
+ if (typeof subContent === "string") {
+ const matches = Array.from(subContent.matchAll(matchLink));
+
+ const newContent: EmbedableContent = [];
+ let lastBatchEnd = 0;
+ let batch: RegExpMatchArray[] = [];
+
+ const renderBatch = () => {
+ if (batch.length > 1) {
+ // render previous batch
+ const lastMatchPosition = defaultGetLocation(batch[batch.length - 1]);
+ const before = subContent.substring(lastBatchEnd, defaultGetLocation(batch[0]).start);
+ const render = m[0])} />;
+
+ newContent.push(before, render);
+ lastBatchEnd = lastMatchPosition.end;
+ }
+
+ batch = [];
+ };
+
+ for (const match of matches) {
+ try {
+ const url = new URL(match[0]);
+ if (!imageExt.some((ext) => url.pathname.endsWith(ext))) throw new Error("not an image");
+
+ // if this is the first image, add it to the batch
+ if (batch.length === 0) {
+ batch = [match];
+ continue;
+ }
+
+ const last = defaultGetLocation(batch[batch.length - 1]);
+ const position = defaultGetLocation(match);
+ const space = subContent.substring(last.end, position.start).trim();
+
+ // if there was a non-space between this and the last batch
+ if (space.length > 0) renderBatch();
+
+ batch.push(match);
+ } catch (e) {
+ // start a new batch without current match
+ batch = [];
+ }
+ }
+
+ renderBatch();
+
+ newContent.push(subContent.substring(lastBatchEnd));
+
+ return newContent;
+ }
+
+ return subContent;
+ })
+ .flat();
+}
+
+// note1n06jceulg3gukw836ghd94p0ppwaz6u3mksnnz960d8vlcp2fnqsgx3fu9
export function renderImageUrl(match: URL) {
if (!imageExt.some((ext) => match.pathname.endsWith(ext))) return null;
diff --git a/src/components/note/note-contents.tsx b/src/components/note/note-contents.tsx
index df0b2f51e..0354e298c 100644
--- a/src/components/note/note-contents.tsx
+++ b/src/components/note/note-contents.tsx
@@ -17,6 +17,7 @@ import {
renderVideoUrl,
embedEmoji,
renderOpenGraphUrl,
+ embedImageGallery,
} from "../embed-types";
import { ImageGalleryProvider } from "../image-gallery";
import { renderRedditUrl } from "../embed-types/reddit";
@@ -24,6 +25,9 @@ import { renderRedditUrl } from "../embed-types/reddit";
function buildContents(event: NostrEvent | DraftNostrEvent) {
let content: EmbedableContent = [event.content.trim()];
+ // image gallery
+ content = embedImageGallery(content);
+
// common
content = embedUrls(content, [
renderYoutubeUrl,
diff --git a/src/helpers/embeds.ts b/src/helpers/embeds.ts
index bb1178bc2..fa10b4fca 100644
--- a/src/helpers/embeds.ts
+++ b/src/helpers/embeds.ts
@@ -1,4 +1,5 @@
import { cloneElement } from "react";
+import { matchLink } from "./regexp";
export type EmbedableContent = (string | JSX.Element)[];
export type EmbedType = {
@@ -8,7 +9,7 @@ export type EmbedType = {
getLocation?: (match: RegExpMatchArray) => { start: number; end: number };
};
-function defaultGetLocation(match: RegExpMatchArray) {
+export function defaultGetLocation(match: RegExpMatchArray) {
if (match.index === undefined) throw new Error("match dose not have index");
return {
start: match.index,
@@ -72,7 +73,7 @@ export type LinkEmbedHandler = (link: URL) => JSX.Element | string | null;
export function embedUrls(content: EmbedableContent, handlers: LinkEmbedHandler[]) {
return embedJSX(content, {
name: "embedUrls",
- regexp: /https?:\/\/([a-zA-Z0-9\.\-]+\.[a-zA-Z]+)([\p{Letter}\p{Number}&\.-\/\?=#\-@%\+_,:]*)/giu,
+ regexp: matchLink,
render: (match) => {
try {
const url = new URL(match[0]);
diff --git a/src/helpers/regexp.ts b/src/helpers/regexp.ts
index a0c70eeb3..a2b4c5894 100644
--- a/src/helpers/regexp.ts
+++ b/src/helpers/regexp.ts
@@ -4,3 +4,4 @@ export const matchImageUrls =
export const matchNostrLink = /(nostr:|@)?((npub|note|nprofile|nevent)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,})/gi;
export const matchHashtag = /(^|[^\p{L}])#([\p{L}\p{N}]+)/giu;
+export const matchLink = /https?:\/\/([a-zA-Z0-9\.\-]+\.[a-zA-Z]+)([\p{Letter}\p{Number}&\.-\/\?=#\-@%\+_,:]*)/gu;