diff --git a/packages/app/src/Element/Event/getEventMedia.ts b/packages/app/src/Element/Event/getEventMedia.ts new file mode 100644 index 000000000..5b57c6f98 --- /dev/null +++ b/packages/app/src/Element/Event/getEventMedia.ts @@ -0,0 +1,9 @@ +import { transformTextCached } from "@/Hooks/useTextTransformCache"; +import { TaggedNostrEvent } from "@snort/system"; + +export default function getEventMedia(event: TaggedNostrEvent) { + const parsed = transformTextCached(event.id, event.content, event.tags); + return parsed.filter( + a => a.type === "media" && (a.mimeType?.startsWith("image/") || a.mimeType?.startsWith("video/")), + ); +} diff --git a/packages/app/src/Element/Feed/ImageGridItem.tsx b/packages/app/src/Element/Feed/ImageGridItem.tsx index 23c4b4161..66ff3f7d6 100644 --- a/packages/app/src/Element/Feed/ImageGridItem.tsx +++ b/packages/app/src/Element/Feed/ImageGridItem.tsx @@ -1,18 +1,15 @@ import { NostrLink, TaggedNostrEvent } from "@snort/system"; import { MouseEvent } from "react"; import useImgProxy from "@/Hooks/useImgProxy"; -import { transformTextCached } from "@/Hooks/useTextTransformCache"; import { Link } from "react-router-dom"; import Icon from "@/Icons/Icon"; +import getEventMedia from "@/Element/Event/getEventMedia"; const ImageGridItem = (props: { event: TaggedNostrEvent; onClick: (e: MouseEvent) => void }) => { const { event, onClick } = props; const { proxy } = useImgProxy(); - const parsed = transformTextCached(event.id, event.content, event.tags); - const media = parsed.filter( - a => a.type === "media" && (a.mimeType?.startsWith("image/") || a.mimeType?.startsWith("video/")), - ); + const media = getEventMedia(event); if (media.length === 0) return null; diff --git a/packages/app/src/Element/Feed/TimelineRenderer.tsx b/packages/app/src/Element/Feed/TimelineRenderer.tsx index 056f3249d..bb11cb73a 100644 --- a/packages/app/src/Element/Feed/TimelineRenderer.tsx +++ b/packages/app/src/Element/Feed/TimelineRenderer.tsx @@ -3,12 +3,13 @@ import ProfileImage from "@/Element/User/ProfileImage"; import { FormattedMessage } from "react-intl"; import Icon from "@/Icons/Icon"; import { NostrLink, TaggedNostrEvent } from "@snort/system"; -import { ReactNode, useEffect, useRef, useState } from "react"; +import { ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { TimelineFragment } from "@/Element/Feed/TimelineFragment"; import { DisplayAs } from "@/Element/Feed/DisplayAsSelector"; import { SpotlightThreadModal } from "@/Element/Spotlight/SpotlightThreadModal"; import ImageGridItem from "@/Element/Feed/ImageGridItem"; import ErrorBoundary from "@/Element/ErrorBoundary"; +import getEventMedia from "@/Element/Event/getEventMedia"; export interface TimelineRendererProps { frags: Array; @@ -24,11 +25,43 @@ export interface TimelineRendererProps { displayAs?: DisplayAs; } +// filter frags[0].events that have media +function Grid({ frags }: { frags: Array }) { + const [modalThreadIndex, setModalThreadIndex] = useState(undefined); + const allEvents = useMemo(() => { + return frags.flatMap(frag => frag.events); + }, [frags]); + const mediaEvents = useMemo(() => { + return allEvents.filter(event => getEventMedia(event).length > 0); + }, [allEvents]); + + const modalThread = modalThreadIndex !== undefined ? mediaEvents[modalThreadIndex] : undefined; + + return ( + <> +
+ {mediaEvents.map((event, index) => ( + setModalThreadIndex(index)} /> + ))} +
+ {modalThread && ( + setModalThreadIndex(undefined)} + onBack={() => setModalThreadIndex(undefined)} + onNext={() => setModalThreadIndex(Math.min(modalThreadIndex + 1, mediaEvents.length - 1))} + onPrev={() => setModalThreadIndex(Math.max(modalThreadIndex - 1, 0))} + /> + )} + + ); +} + export function TimelineRenderer(props: TimelineRendererProps) { const containerRef = useRef(null); const latestNotesFixedRef = useRef(null); const { ref, inView } = useInView(); - const [modalThread, setModalThread] = useState(undefined); const updateLatestNotesPosition = () => { if (containerRef.current && latestNotesFixedRef.current) { @@ -63,18 +96,6 @@ export function TimelineRenderer(props: TimelineRendererProps) { )); }; - const renderGrid = () => { - // TODO Hide images from notes with a content warning, unless otherwise configured - - return props.frags.map(frag => ( -
- {frag.events.map(event => ( - setModalThread(NostrLink.fromEvent(event))} /> - ))} -
- )); - }; - return (
{props.latest.length > 0 && ( @@ -116,14 +137,7 @@ export function TimelineRenderer(props: TimelineRendererProps) { )} )} - {props.displayAs === "grid" ? renderGrid() : renderNotes()} - {modalThread && ( - setModalThread(undefined)} - onBack={() => setModalThread(undefined)} - /> - )} + {props.displayAs === "grid" ? : renderNotes()}
); } diff --git a/packages/app/src/Element/Spotlight/SpotlightMedia.tsx b/packages/app/src/Element/Spotlight/SpotlightMedia.tsx index e6eef6bbf..d999e20ab 100644 --- a/packages/app/src/Element/Spotlight/SpotlightMedia.tsx +++ b/packages/app/src/Element/Spotlight/SpotlightMedia.tsx @@ -1,14 +1,16 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import Modal from "@/Element/Modal"; import Icon from "@/Icons/Icon"; import { ProxyImg } from "@/Element/ProxyImg"; import useImgProxy from "@/Hooks/useImgProxy"; interface SpotlightMediaProps { - images: Array; + media: Array; idx: number; className: string; onClose: () => void; + onNext?: () => void; + onPrev?: () => void; } const videoSuffixes = ["mp4", "webm", "ogg", "mov", "avi", "mkv"]; @@ -18,9 +20,25 @@ export function SpotlightMedia(props: SpotlightMediaProps) { const [idx, setIdx] = useState(props.idx); const image = useMemo(() => { - return props.images.at(idx % props.images.length); + return props.media.at(idx % props.media.length); }, [idx, props]); + const dec = useCallback(() => { + if (idx === 0 && props.onPrev) { + props.onPrev(); + } else { + setIdx(s => (s - 1 + props.media.length) % props.media.length); + } + }, [idx, props.onPrev, props.media.length]); // Add dependencies + + const inc = useCallback(() => { + if (idx === props.media.length - 1 && props.onNext) { + props.onNext(); + } else { + setIdx(s => (s + 1) % props.media.length); + } + }, [idx, props.onNext, props.media.length]); // Add dependencies + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { switch (e.key) { @@ -41,27 +59,7 @@ export function SpotlightMedia(props: SpotlightMediaProps) { document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, []); - - function dec() { - setIdx(s => { - if (s - 1 === -1) { - return props.images.length - 1; - } else { - return s - 1; - } - }); - } - - function inc() { - setIdx(s => { - if (s + 1 === props.images.length) { - return 0; - } else { - return s + 1; - } - }); - } + }, [dec, inc]); // Now dec and inc are stable const isVideo = useMemo(() => { return image && videoSuffixes.some(suffix => image.endsWith(suffix)); @@ -90,6 +88,10 @@ export function SpotlightMedia(props: SpotlightMediaProps) { } }; + const hasMultiple = props.media.length > 1; + const hasPrev = hasMultiple || props.onPrev; + const hasNext = hasMultiple || props.onNext; + return (
{mediaEl} @@ -101,27 +103,27 @@ export function SpotlightMedia(props: SpotlightMediaProps) {
- {props.images.length > 1 && `${idx + 1}/${props.images.length}`} + {props.media.length > 1 && `${idx + 1}/${props.media.length}`}
- {props.images.length > 1 && ( - <> - { - e.stopPropagation(); - dec(); - }}> - - - { - e.stopPropagation(); - inc(); - }}> - - - + {hasPrev && ( + { + e.stopPropagation(); + dec(); + }}> + + + )} + {hasNext && ( + { + e.stopPropagation(); + inc(); + }}> + + )} ); diff --git a/packages/app/src/Element/Spotlight/SpotlightThreadModal.tsx b/packages/app/src/Element/Spotlight/SpotlightThreadModal.tsx index 0a0288210..6c36695a2 100644 --- a/packages/app/src/Element/Spotlight/SpotlightThreadModal.tsx +++ b/packages/app/src/Element/Spotlight/SpotlightThreadModal.tsx @@ -2,11 +2,19 @@ import Modal from "@/Element/Modal"; import { ThreadContext, ThreadContextWrapper } from "@/Hooks/useThreadContext"; import { Thread } from "@/Element/Event/Thread"; import { useContext } from "react"; -import { transformTextCached } from "@/Hooks/useTextTransformCache"; import { SpotlightMedia } from "@/Element/Spotlight/SpotlightMedia"; import { NostrLink } from "@snort/system"; +import getEventMedia from "@/Element/Event/getEventMedia"; -export function SpotlightThreadModal(props: { thread: NostrLink; onClose?: () => void; onBack?: () => void }) { +interface SpotlightThreadModalProps { + thread: NostrLink; + onClose?: () => void; + onBack?: () => void; + onNext?: () => void; + onPrev?: () => void; +} + +export function SpotlightThreadModal(props: SpotlightThreadModalProps) { const onClose = () => props.onClose?.(); const onBack = () => props.onBack?.(); const onClickBg = (e: React.MouseEvent) => { @@ -20,7 +28,7 @@ export function SpotlightThreadModal(props: { thread: NostrLink; onClose?: () =>
- +
@@ -31,13 +39,26 @@ export function SpotlightThreadModal(props: { thread: NostrLink; onClose?: () => ); } -function SpotlightFromThread({ onClose }: { onClose: () => void }) { +interface SpotlightFromThreadProps { + onClose: () => void; + onNext?: () => void; + onPrev?: () => void; +} + +function SpotlightFromThread({ onClose, onNext, onPrev }: SpotlightFromThreadProps) { const thread = useContext(ThreadContext); - const parsed = thread.root ? transformTextCached(thread.root.id, thread.root.content, thread.root.tags) : []; - const images = parsed.filter( - a => a.type === "media" && (a.mimeType?.startsWith("image/") || a.mimeType?.startsWith("video/")), + if (!thread?.root) return null; + const media = getEventMedia(thread.root); + if (media.length === 0) return; + return ( + a.content)} + idx={0} + onClose={onClose} + onNext={onNext} + onPrev={onPrev} + /> ); - if (images.length === 0) return; - return a.content)} idx={0} onClose={onClose} />; } diff --git a/packages/app/src/Element/Text.tsx b/packages/app/src/Element/Text.tsx index d3e40f4aa..225a41aac 100644 --- a/packages/app/src/Element/Text.tsx +++ b/packages/app/src/Element/Text.tsx @@ -283,7 +283,7 @@ export default function Text({ return (
{renderContent()} - {showSpotlight && setShowSpotlight(false)} idx={imageIdx} />} + {showSpotlight && setShowSpotlight(false)} idx={imageIdx} />}
); } diff --git a/packages/app/src/Pages/Profile/ProfilePage.tsx b/packages/app/src/Pages/Profile/ProfilePage.tsx index 78f3ff24b..e1f2eb2e4 100644 --- a/packages/app/src/Pages/Profile/ProfilePage.tsx +++ b/packages/app/src/Pages/Profile/ProfilePage.tsx @@ -399,7 +399,7 @@ export default function ProfilePage({ id: propId, state }: ProfilePageProps) {
{tabContent()}
- {modalImage && setModalImage("")} images={[modalImage]} idx={0} />} + {modalImage && setModalImage("")} media={[modalImage]} idx={0} />} ); }