diff --git a/.changeset/lovely-plants-leave.md b/.changeset/lovely-plants-leave.md new file mode 100644 index 000000000..0bd7707d6 --- /dev/null +++ b/.changeset/lovely-plants-leave.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Show all images in lightbox diff --git a/src/components/embed-types/common.tsx b/src/components/embed-types/common.tsx index 1f7d1373a..204e85baf 100644 --- a/src/components/embed-types/common.tsx +++ b/src/components/embed-types/common.tsx @@ -1,10 +1,33 @@ -import { Box, Image, ImageProps, Link, SimpleGrid, useDisclosure } from "@chakra-ui/react"; +import { useRef } from "react"; +import { Box, Image, ImageProps, Link, LinkProps, 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"; +import { useRegisterSlide } from "../lightbox-provider"; + +export const ImageGalleryLink = ({ children, href, ...props }: Omit) => { + const ref = useRef(null); + const { show } = useRegisterSlide(ref, href ? { type: "image", src: href } : undefined); + + return ( + { + if (href) { + e.preventDefault(); + show(); + } + }} + ref={ref} + > + {children} + + ); +}; const BlurredImage = (props: ImageProps) => { const { isOpen, onOpen } = useDisclosure(); diff --git a/src/components/image-gallery.tsx b/src/components/image-gallery.tsx deleted file mode 100644 index b80e32e98..000000000 --- a/src/components/image-gallery.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { PropsWithChildren, createContext, forwardRef, useCallback, useContext, useMemo, useState } from "react"; -import { LinkProps, Link, useDisclosure } from "@chakra-ui/react"; -import Lightbox, { SlideImage } from "yet-another-react-lightbox"; -import Zoom from "yet-another-react-lightbox/plugins/zoom"; -import Counter from "yet-another-react-lightbox/plugins/counter"; -import Download from "yet-another-react-lightbox/plugins/download"; - -import "yet-another-react-lightbox/styles.css"; -import "yet-another-react-lightbox/plugins/counter.css"; - -const GalleryContext = createContext({ - isOpen: false, - openImage(url: string) {}, -}); -export function useGalleryContext() { - return useContext(GalleryContext); -} - -export const ImageGalleryLink = forwardRef(({ children, href, ...props }: Omit, ref) => { - const { openImage } = useGalleryContext(); - - return ( - { - if (href) { - e.preventDefault(); - openImage(href); - } - }} - ref={ref} - > - {children} - - ); -}); - -export function ImageGalleryProvider({ children }: PropsWithChildren) { - const open = useDisclosure(); - const [slides, setSlides] = useState([]); - - const openImage = useCallback( - (url: string) => { - setSlides([{ src: url }]); - open.onOpen(); - }, - [setSlides, open.onOpen] - ); - - const context = useMemo(() => ({ isOpen: open.isOpen, openImage }), [open.isOpen, openImage]); - - return ( - - {children} - - - ); -} diff --git a/src/components/lightbox-provider.tsx b/src/components/lightbox-provider.tsx new file mode 100644 index 000000000..f63966779 --- /dev/null +++ b/src/components/lightbox-provider.tsx @@ -0,0 +1,162 @@ +import { + DependencyList, + MutableRefObject, + PropsWithChildren, + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useDisclosure } from "@chakra-ui/react"; +import { useUnmount } from "react-use"; + +import Lightbox, { Slide } from "yet-another-react-lightbox"; +import Zoom from "yet-another-react-lightbox/plugins/zoom"; +import Counter from "yet-another-react-lightbox/plugins/counter"; +import Download from "yet-another-react-lightbox/plugins/download"; + +import "yet-another-react-lightbox/styles.css"; +import "yet-another-react-lightbox/plugins/counter.css"; + +type RefType = MutableRefObject; + +function getElementPath(element: HTMLElement): HTMLElement[] { + if (!element.parentElement) return [element]; + return [...getElementPath(element.parentElement), element]; +} +function comparePaths(a: HTMLElement[] | null, b: HTMLElement[] | null) { + if (a && !b) return -1; + if (!b && a) return 1; + if (!a || !b) return 0; + + for (let i = 0; i < Math.min(a.length, b.length); i++) { + if (a[i] !== b[i] && a[i].parentElement === b[i].parentElement) { + const parent = a[i].parentElement; + if (!parent) return 0; + const children = Array.from(parent.children); + return Math.sign(children.indexOf(a[i]) - children.indexOf(b[i])); + } + } + return 0; +} + +const LightboxContext = createContext({ + isOpen: false, + removeSlide(ref: RefType) {}, + showSlide(ref: RefType) {}, + addSlide(ref: RefType, slide: Slide) {}, +}); +export function useLightbox() { + return useContext(LightboxContext); +} + +export function useRegisterSlide(ref?: RefType, slide?: Slide, watch: DependencyList[] = []) { + const { showSlide, addSlide, removeSlide } = useLightbox(); + const show = useCallback(() => { + if (ref) showSlide(ref); + }, [ref, showSlide]); + + useEffect(() => { + if (ref && slide) addSlide(ref, slide); + }, [ref, ...watch]); + + useUnmount(() => { + if (ref) removeSlide(ref); + }); + + return { show }; +} + +type DynamicSlide = { + ref: RefType; + slide: Slide; +}; + +const refPaths = new WeakMap(); +function getRefPath(ref: RefType) { + if (ref.current === null) return null; + const cache = refPaths.get(ref); + if (cache) return cache; + const path = getElementPath(ref.current); + refPaths.set(ref, path); + return path; +} + +export function LightboxProvider({ children }: PropsWithChildren) { + const lightbox = useDisclosure(); + const [index, setIndex] = useState(0); + const [slides, setSlides] = useState([]); + + const orderedSlides = useRef([]); + orderedSlides.current = Array.from(Object.values(slides)).sort((a, b) => + comparePaths(getRefPath(a.ref), getRefPath(b.ref)) + ); + + const addSlide = useCallback( + (ref: RefType, slide: Slide) => { + setSlides((arr) => { + if (arr.some((s) => s.ref === ref)) { + return arr.map((s) => (s.ref === ref ? { ref, slide } : s)); + } + return arr.concat({ ref, slide }); + }); + }, + [setSlides] + ); + const removeSlide = useCallback( + (ref: RefType) => { + setSlides((arr) => arr.filter((s) => s.ref !== ref)); + }, + [setSlides] + ); + const showSlide = useCallback( + (ref: RefType) => { + for (let i = 0; i < orderedSlides.current.length; i++) { + if (orderedSlides.current[i].ref === ref) { + // set slide index + setIndex(i); + lightbox.onOpen(); + return; + } + } + + // else select first image + setIndex(0); + lightbox.onOpen(); + }, + [lightbox.onOpen, setIndex] + ); + + const context = useMemo( + () => ({ isOpen: lightbox.isOpen, removeSlide, addSlide, showSlide }), + [lightbox.isOpen, removeSlide, addSlide, showSlide] + ); + + const lightboxSlides = useMemo(() => orderedSlides.current.map((s) => s.slide), [orderedSlides.current, slides]); + + const handleView = useCallback( + ({ index }: { index: number }) => { + setIndex(index); + }, + [setIndex] + ); + + return ( + + {children} + + + ); +} diff --git a/src/components/note/note-contents.tsx b/src/components/note/note-contents.tsx index 0354e298c..afa3cde28 100644 --- a/src/components/note/note-contents.tsx +++ b/src/components/note/note-contents.tsx @@ -19,7 +19,7 @@ import { renderOpenGraphUrl, embedImageGallery, } from "../embed-types"; -import { ImageGalleryProvider } from "../image-gallery"; +import { LightboxProvider } from "../lightbox-provider"; import { renderRedditUrl } from "../embed-types/reddit"; function buildContents(event: NostrEvent | DraftNostrEvent) { @@ -62,10 +62,10 @@ export const NoteContents = React.memo(({ event, ...props }: NoteContentsProps & const content = buildContents(event); return ( - + {content} - + ); }); diff --git a/src/components/timeline-page/index.tsx b/src/components/timeline-page/index.tsx index f40328a3c..7d955a29d 100644 --- a/src/components/timeline-page/index.tsx +++ b/src/components/timeline-page/index.tsx @@ -2,7 +2,7 @@ import { useCallback } from "react"; import { Flex, FlexProps, SimpleGrid } from "@chakra-ui/react"; import IntersectionObserverProvider from "../../providers/intersection-observer"; import GenericNoteTimeline from "./generic-note-timeline"; -import { ImageGalleryProvider } from "../image-gallery"; +import { LightboxProvider } from "../lightbox-provider"; import MediaTimeline from "./media-timeline"; import { TimelineLoader } from "../../classes/timeline-loader"; import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback"; @@ -44,11 +44,11 @@ export default function TimelinePage({ case "images": return ( - + - + ); case "health": diff --git a/src/components/timeline-page/media-timeline/index.tsx b/src/components/timeline-page/media-timeline/index.tsx index dae10efef..3f0155649 100644 --- a/src/components/timeline-page/media-timeline/index.tsx +++ b/src/components/timeline-page/media-timeline/index.tsx @@ -2,8 +2,8 @@ import React, { useMemo, useRef } from "react"; import { TimelineLoader } from "../../../classes/timeline-loader"; import useSubject from "../../../hooks/use-subject"; import { matchImageUrls } from "../../../helpers/regexp"; -import { ImageGalleryLink, ImageGalleryProvider } from "../../image-gallery"; -import { Box, IconButton } from "@chakra-ui/react"; +import { LightboxProvider, useRegisterSlide } from "../../lightbox-provider"; +import { Box, IconButton, Link } from "@chakra-ui/react"; import { useNavigate } from "react-router-dom"; import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer"; import { getSharableNoteId } from "../../../helpers/nip19"; @@ -19,9 +19,26 @@ const ImagePreview = React.memo(({ image }: { image: ImagePreview }) => { const ref = useRef(null); useRegisterIntersectionEntity(ref, image.eventId); + const { show } = useRegisterSlide(ref, { type: "image", src: image.src }); + return ( - - + { + if (image.src) { + e.preventDefault(); + show(); + } + }} + > + } aria-label="Open note" @@ -36,7 +53,7 @@ const ImagePreview = React.memo(({ image }: { image: ImagePreview }) => { navigate(`/n/${getSharableNoteId(image.eventId)}`); }} /> - + ); }); @@ -59,10 +76,10 @@ export default function MediaTimeline({ timeline }: { timeline: TimelineLoader } }, [events]); return ( - + {images.map((image) => ( ))} - + ); } diff --git a/src/views/streams/stream/stream-chat/index.tsx b/src/views/streams/stream/stream-chat/index.tsx index f02fdff9f..85062bc04 100644 --- a/src/views/streams/stream/stream-chat/index.tsx +++ b/src/views/streams/stream/stream-chat/index.tsx @@ -21,7 +21,7 @@ import ZapModal from "../../../../components/zap-modal"; import { LightningIcon } from "../../../../components/icons"; import ChatMessage from "./chat-message"; import ZapMessage from "./zap-message"; -import { ImageGalleryProvider } from "../../../../components/image-gallery"; +import { LightboxProvider } from "../../../../components/lightbox-provider"; import IntersectionObserverProvider from "../../../../providers/intersection-observer"; import useUserLNURLMetadata from "../../../../hooks/use-user-lnurl-metadata"; import { useInvoiceModalContext } from "../../../../providers/invoice-modal"; @@ -126,7 +126,7 @@ export default function StreamChat({ return ( <> - + {!isPopup && ( @@ -184,7 +184,7 @@ export default function StreamChat({ )} - + {zapModal.isOpen && (