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 { 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;

View File

@ -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<TimelineFragment>;
@ -24,11 +25,43 @@ export interface TimelineRendererProps {
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) {
const containerRef = useRef<HTMLDivElement | null>(null);
const latestNotesFixedRef = useRef<HTMLDivElement | null>(null);
const { ref, inView } = useInView();
const [modalThread, setModalThread] = useState<NostrLink | undefined>(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 => (
<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 (
<div ref={containerRef}>
{props.latest.length > 0 && (
@ -116,14 +137,7 @@ export function TimelineRenderer(props: TimelineRendererProps) {
)}
</>
)}
{props.displayAs === "grid" ? renderGrid() : renderNotes()}
{modalThread && (
<SpotlightThreadModal
thread={modalThread}
onClose={() => setModalThread(undefined)}
onBack={() => setModalThread(undefined)}
/>
)}
{props.displayAs === "grid" ? <Grid frags={props.frags} /> : renderNotes()}
</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 Icon from "@/Icons/Icon";
import { ProxyImg } from "@/Element/ProxyImg";
import useImgProxy from "@/Hooks/useImgProxy";
interface SpotlightMediaProps {
images: Array<string>;
media: Array<string>;
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 (
<div className="select-none relative h-screen flex items-center flex-1 justify-center" onClick={onClickBg}>
{mediaEl}
@ -101,27 +103,27 @@ export function SpotlightMedia(props: SpotlightMediaProps) {
</span>
</div>
<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>
{props.images.length > 1 && (
<>
<span
className="absolute left-0 p-2 top-1/2 rotate-180 cursor-pointer opacity-80 hover:opacity-60"
onClick={e => {
e.stopPropagation();
dec();
}}>
<Icon name="arrowFront" size={24} />
</span>
<span
className="absolute right-0 p-2 top-1/2 cursor-pointer opacity-80 hover:opacity-60"
onClick={e => {
e.stopPropagation();
inc();
}}>
<Icon name="arrowFront" size={24} />
</span>
</>
{hasPrev && (
<span
className="absolute left-0 p-2 top-1/2 rotate-180 cursor-pointer opacity-80 hover:opacity-60"
onClick={e => {
e.stopPropagation();
dec();
}}>
<Icon name="arrowFront" size={24} />
</span>
)}
{hasNext && (
<span
className="absolute right-0 p-2 top-1/2 cursor-pointer opacity-80 hover:opacity-60"
onClick={e => {
e.stopPropagation();
inc();
}}>
<Icon name="arrowFront" size={24} />
</span>
)}
</div>
);

View File

@ -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?: () =>
<ThreadContextWrapper link={props.thread}>
<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}>
<SpotlightFromThread onClose={onClose} />
<SpotlightFromThread onClose={onClose} onNext={props.onNext} onPrev={props.onPrev} />
</div>
<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} />
@ -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 (
<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 (
<div dir="auto" className={classNames("text", className)} onClick={onClick}>
{renderContent()}
{showSpotlight && <SpotlightMediaModal images={images} onClose={() => setShowSpotlight(false)} idx={imageIdx} />}
{showSpotlight && <SpotlightMediaModal media={images} onClose={() => setShowSpotlight(false)} idx={imageIdx} />}
</div>
);
}

View File

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