go to next grid item with arrows
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
parent
0f4352aa1b
commit
083f512bdf
9
packages/app/src/Element/Event/getEventMedia.ts
Normal file
9
packages/app/src/Element/Event/getEventMedia.ts
Normal 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/")),
|
||||||
|
);
|
||||||
|
}
|
@ -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;
|
||||||
|
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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} />;
|
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user