go to next grid item with arrows

This commit is contained in:
Martti Malmi 2023-12-19 10:50:25 +02:00
parent 0f4352aa1b
commit 083f512bdf
7 changed files with 125 additions and 82 deletions

View File

@ -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/")),
);
}

View File

@ -1,18 +1,15 @@
import { NostrLink, TaggedNostrEvent } from "@snort/system"; import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { MouseEvent } from "react"; import { MouseEvent } from "react";
import useImgProxy from "@/Hooks/useImgProxy"; import useImgProxy from "@/Hooks/useImgProxy";
import { transformTextCached } from "@/Hooks/useTextTransformCache";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import Icon from "@/Icons/Icon"; import Icon from "@/Icons/Icon";
import getEventMedia from "@/Element/Event/getEventMedia";
const ImageGridItem = (props: { event: TaggedNostrEvent; onClick: (e: MouseEvent) => void }) => { const ImageGridItem = (props: { event: TaggedNostrEvent; onClick: (e: MouseEvent) => void }) => {
const { event, onClick } = props; const { event, onClick } = props;
const { proxy } = useImgProxy(); const { proxy } = useImgProxy();
const parsed = transformTextCached(event.id, event.content, event.tags); const media = getEventMedia(event);
const media = parsed.filter(
a => a.type === "media" && (a.mimeType?.startsWith("image/") || a.mimeType?.startsWith("video/")),
);
if (media.length === 0) return null; if (media.length === 0) return null;

View File

@ -3,12 +3,13 @@ import ProfileImage from "@/Element/User/ProfileImage";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import Icon from "@/Icons/Icon"; import Icon from "@/Icons/Icon";
import { NostrLink, TaggedNostrEvent } from "@snort/system"; 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 { TimelineFragment } from "@/Element/Feed/TimelineFragment";
import { DisplayAs } from "@/Element/Feed/DisplayAsSelector"; import { DisplayAs } from "@/Element/Feed/DisplayAsSelector";
import { SpotlightThreadModal } from "@/Element/Spotlight/SpotlightThreadModal"; import { SpotlightThreadModal } from "@/Element/Spotlight/SpotlightThreadModal";
import ImageGridItem from "@/Element/Feed/ImageGridItem"; import ImageGridItem from "@/Element/Feed/ImageGridItem";
import ErrorBoundary from "@/Element/ErrorBoundary"; import ErrorBoundary from "@/Element/ErrorBoundary";
import getEventMedia from "@/Element/Event/getEventMedia";
export interface TimelineRendererProps { export interface TimelineRendererProps {
frags: Array<TimelineFragment>; frags: Array<TimelineFragment>;
@ -24,11 +25,43 @@ export interface TimelineRendererProps {
displayAs?: DisplayAs; displayAs?: DisplayAs;
} }
// filter frags[0].events that have media
function Grid({ frags }: { frags: Array<TimelineFragment> }) {
const [modalThreadIndex, setModalThreadIndex] = useState<number | undefined>(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 (
<>
<div className="grid grid-cols-3 gap-px md:gap-1">
{mediaEvents.map((event, index) => (
<ImageGridItem key={event.id} event={event} onClick={() => setModalThreadIndex(index)} />
))}
</div>
{modalThread && (
<SpotlightThreadModal
key={modalThreadIndex}
thread={NostrLink.fromEvent(modalThread)}
onClose={() => 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) { export function TimelineRenderer(props: TimelineRendererProps) {
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const latestNotesFixedRef = useRef<HTMLDivElement | null>(null); const latestNotesFixedRef = useRef<HTMLDivElement | null>(null);
const { ref, inView } = useInView(); const { ref, inView } = useInView();
const [modalThread, setModalThread] = useState<NostrLink | undefined>(undefined);
const updateLatestNotesPosition = () => { const updateLatestNotesPosition = () => {
if (containerRef.current && latestNotesFixedRef.current) { 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 => (
<div className="grid grid-cols-3 gap-px md:gap-1">
{frag.events.map(event => (
<ImageGridItem event={event} onClick={() => setModalThread(NostrLink.fromEvent(event))} />
))}
</div>
));
};
return ( return (
<div ref={containerRef}> <div ref={containerRef}>
{props.latest.length > 0 && ( {props.latest.length > 0 && (
@ -116,14 +137,7 @@ export function TimelineRenderer(props: TimelineRendererProps) {
)} )}
</> </>
)} )}
{props.displayAs === "grid" ? renderGrid() : renderNotes()} {props.displayAs === "grid" ? <Grid frags={props.frags} /> : renderNotes()}
{modalThread && (
<SpotlightThreadModal
thread={modalThread}
onClose={() => setModalThread(undefined)}
onBack={() => setModalThread(undefined)}
/>
)}
</div> </div>
); );
} }

View File

@ -1,14 +1,16 @@
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import Modal from "@/Element/Modal"; import Modal from "@/Element/Modal";
import Icon from "@/Icons/Icon"; import Icon from "@/Icons/Icon";
import { ProxyImg } from "@/Element/ProxyImg"; import { ProxyImg } from "@/Element/ProxyImg";
import useImgProxy from "@/Hooks/useImgProxy"; import useImgProxy from "@/Hooks/useImgProxy";
interface SpotlightMediaProps { interface SpotlightMediaProps {
images: Array<string>; media: Array<string>;
idx: number; idx: number;
className: string; className: string;
onClose: () => void; onClose: () => void;
onNext?: () => void;
onPrev?: () => void;
} }
const videoSuffixes = ["mp4", "webm", "ogg", "mov", "avi", "mkv"]; const videoSuffixes = ["mp4", "webm", "ogg", "mov", "avi", "mkv"];
@ -18,9 +20,25 @@ export function SpotlightMedia(props: SpotlightMediaProps) {
const [idx, setIdx] = useState(props.idx); const [idx, setIdx] = useState(props.idx);
const image = useMemo(() => { const image = useMemo(() => {
return props.images.at(idx % props.images.length); return props.media.at(idx % props.media.length);
}, [idx, props]); }, [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(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) { switch (e.key) {
@ -41,27 +59,7 @@ export function SpotlightMedia(props: SpotlightMediaProps) {
document.addEventListener("keydown", handleKeyDown); document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown);
}, []); }, [dec, inc]); // Now dec and inc are stable
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;
}
});
}
const isVideo = useMemo(() => { const isVideo = useMemo(() => {
return image && videoSuffixes.some(suffix => image.endsWith(suffix)); 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 ( return (
<div className="select-none relative h-screen flex items-center flex-1 justify-center" onClick={onClickBg}> <div className="select-none relative h-screen flex items-center flex-1 justify-center" onClick={onClickBg}>
{mediaEl} {mediaEl}
@ -101,27 +103,27 @@ export function SpotlightMedia(props: SpotlightMediaProps) {
</span> </span>
</div> </div>
<div className="absolute flex flex-row items-center gap-4 right-0 top-0 p-4"> <div className="absolute flex flex-row items-center gap-4 right-0 top-0 p-4">
{props.images.length > 1 && `${idx + 1}/${props.images.length}`} {props.media.length > 1 && `${idx + 1}/${props.media.length}`}
</div> </div>
{props.images.length > 1 && ( {hasPrev && (
<> <span
<span className="absolute left-0 p-2 top-1/2 rotate-180 cursor-pointer opacity-80 hover:opacity-60"
className="absolute left-0 p-2 top-1/2 rotate-180 cursor-pointer opacity-80 hover:opacity-60" onClick={e => {
onClick={e => { e.stopPropagation();
e.stopPropagation(); dec();
dec(); }}>
}}> <Icon name="arrowFront" size={24} />
<Icon name="arrowFront" size={24} /> </span>
</span> )}
<span {hasNext && (
className="absolute right-0 p-2 top-1/2 cursor-pointer opacity-80 hover:opacity-60" <span
onClick={e => { className="absolute right-0 p-2 top-1/2 cursor-pointer opacity-80 hover:opacity-60"
e.stopPropagation(); onClick={e => {
inc(); e.stopPropagation();
}}> inc();
<Icon name="arrowFront" size={24} /> }}>
</span> <Icon name="arrowFront" size={24} />
</> </span>
)} )}
</div> </div>
); );

View File

@ -2,11 +2,19 @@ import Modal from "@/Element/Modal";
import { ThreadContext, ThreadContextWrapper } from "@/Hooks/useThreadContext"; import { ThreadContext, ThreadContextWrapper } from "@/Hooks/useThreadContext";
import { Thread } from "@/Element/Event/Thread"; import { Thread } from "@/Element/Event/Thread";
import { useContext } from "react"; import { useContext } from "react";
import { transformTextCached } from "@/Hooks/useTextTransformCache";
import { SpotlightMedia } from "@/Element/Spotlight/SpotlightMedia"; import { SpotlightMedia } from "@/Element/Spotlight/SpotlightMedia";
import { NostrLink } from "@snort/system"; 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 onClose = () => props.onClose?.();
const onBack = () => props.onBack?.(); const onBack = () => props.onBack?.();
const onClickBg = (e: React.MouseEvent) => { const onClickBg = (e: React.MouseEvent) => {
@ -20,7 +28,7 @@ export function SpotlightThreadModal(props: { thread: NostrLink; onClose?: () =>
<ThreadContextWrapper link={props.thread}> <ThreadContextWrapper link={props.thread}>
<div className="flex flex-row h-screen w-screen"> <div className="flex flex-row h-screen w-screen">
<div className="flex w-full md:w-2/3 items-center justify-center overflow-hidden" onClick={onClickBg}> <div className="flex w-full md:w-2/3 items-center justify-center overflow-hidden" onClick={onClickBg}>
<SpotlightFromThread onClose={onClose} /> <SpotlightFromThread onClose={onClose} onNext={props.onNext} onPrev={props.onPrev} />
</div> </div>
<div className="hidden md:flex w-1/3 min-w-[400px] flex-shrink-0 overflow-y-auto bg-bg-color"> <div className="hidden md:flex w-1/3 min-w-[400px] flex-shrink-0 overflow-y-auto bg-bg-color">
<Thread onBack={onBack} disableSpotlight={true} /> <Thread onBack={onBack} disableSpotlight={true} />
@ -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 thread = useContext(ThreadContext);
const parsed = thread.root ? transformTextCached(thread.root.id, thread.root.content, thread.root.tags) : []; if (!thread?.root) return null;
const images = parsed.filter( const media = getEventMedia(thread.root);
a => a.type === "media" && (a.mimeType?.startsWith("image/") || a.mimeType?.startsWith("video/")), if (media.length === 0) return;
return (
<SpotlightMedia
className="w-full"
media={media.map(a => a.content)}
idx={0}
onClose={onClose}
onNext={onNext}
onPrev={onPrev}
/>
); );
if (images.length === 0) return;
return <SpotlightMedia className="w-full" images={images.map(a => a.content)} idx={0} onClose={onClose} />;
} }

View File

@ -283,7 +283,7 @@ export default function Text({
return ( return (
<div dir="auto" className={classNames("text", className)} onClick={onClick}> <div dir="auto" className={classNames("text", className)} onClick={onClick}>
{renderContent()} {renderContent()}
{showSpotlight && <SpotlightMediaModal images={images} onClose={() => setShowSpotlight(false)} idx={imageIdx} />} {showSpotlight && <SpotlightMediaModal media={images} onClose={() => setShowSpotlight(false)} idx={imageIdx} />}
</div> </div>
); );
} }

View File

@ -399,7 +399,7 @@ export default function ProfilePage({ id: propId, state }: ProfilePageProps) {
</div> </div>
</div> </div>
<div className="main-content">{tabContent()}</div> <div className="main-content">{tabContent()}</div>
{modalImage && <SpotlightMediaModal onClose={() => setModalImage("")} images={[modalImage]} idx={0} />} {modalImage && <SpotlightMediaModal onClose={() => setModalImage("")} media={[modalImage]} idx={0} />}
</> </>
); );
} }