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;