2026-05-25 05:25:57 +08:00
|
|
|
|
import {
|
|
|
|
|
|
createContext,
|
|
|
|
|
|
useCallback,
|
|
|
|
|
|
useContext,
|
|
|
|
|
|
useEffect,
|
|
|
|
|
|
useRef,
|
|
|
|
|
|
useState,
|
|
|
|
|
|
type PropsWithChildren,
|
|
|
|
|
|
} from "react";
|
|
|
|
|
|
import { createPortal } from "react-dom";
|
2026-05-30 02:25:01 +08:00
|
|
|
|
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
2026-05-25 05:25:57 +08:00
|
|
|
|
import type { Attachment } from "../../../types/post";
|
2026-05-30 18:44:15 +08:00
|
|
|
|
import { useI18n } from "../../../i18n";
|
2026-05-30 02:25:01 +08:00
|
|
|
|
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
2026-05-29 17:50:53 +08:00
|
|
|
|
import { BubbleImage } from "../BubbleImage";
|
2026-05-26 12:07:13 +08:00
|
|
|
|
import { autolink } from "../utils/autolink";
|
2026-05-25 05:25:57 +08:00
|
|
|
|
|
|
|
|
|
|
type LightboxState = {
|
|
|
|
|
|
images: Attachment[];
|
|
|
|
|
|
index: number;
|
2026-05-26 12:07:13 +08:00
|
|
|
|
caption?: string;
|
|
|
|
|
|
postId?: string;
|
2026-05-25 05:25:57 +08:00
|
|
|
|
} | null;
|
|
|
|
|
|
|
|
|
|
|
|
type Ctx = {
|
2026-05-26 12:07:13 +08:00
|
|
|
|
openLightbox: (
|
|
|
|
|
|
images: Attachment[],
|
|
|
|
|
|
startIndex?: number,
|
|
|
|
|
|
caption?: string,
|
|
|
|
|
|
postId?: string,
|
|
|
|
|
|
) => void;
|
2026-05-25 05:25:57 +08:00
|
|
|
|
closeLightbox: () => void;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const LightboxContext = createContext<Ctx | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
export function useLightbox(): Ctx {
|
|
|
|
|
|
const ctx = useContext(LightboxContext);
|
|
|
|
|
|
if (!ctx)
|
|
|
|
|
|
throw new Error("useLightbox must be used inside ImageLightboxProvider");
|
|
|
|
|
|
return ctx;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function ImageLightboxProvider({ children }: PropsWithChildren) {
|
|
|
|
|
|
const [state, setState] = useState<LightboxState>(null);
|
|
|
|
|
|
|
2026-05-26 12:07:13 +08:00
|
|
|
|
const openLightbox = useCallback(
|
|
|
|
|
|
(
|
|
|
|
|
|
images: Attachment[],
|
|
|
|
|
|
startIndex = 0,
|
|
|
|
|
|
caption?: string,
|
|
|
|
|
|
postId?: string,
|
|
|
|
|
|
) => {
|
|
|
|
|
|
if (!images.length) return;
|
|
|
|
|
|
const i = Math.min(Math.max(0, startIndex), images.length - 1);
|
|
|
|
|
|
setState({ images, index: i, caption, postId });
|
|
|
|
|
|
},
|
|
|
|
|
|
[],
|
|
|
|
|
|
);
|
2026-05-25 05:25:57 +08:00
|
|
|
|
|
|
|
|
|
|
const closeLightbox = useCallback(() => setState(null), []);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<LightboxContext.Provider value={{ openLightbox, closeLightbox }}>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
{state ? (
|
|
|
|
|
|
<LightboxView
|
|
|
|
|
|
images={state.images}
|
|
|
|
|
|
startIndex={state.index}
|
2026-05-26 12:07:13 +08:00
|
|
|
|
caption={state.caption}
|
2026-05-29 17:50:53 +08:00
|
|
|
|
postId={state.postId}
|
2026-05-25 05:25:57 +08:00
|
|
|
|
onClose={closeLightbox}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</LightboxContext.Provider>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-29 17:50:53 +08:00
|
|
|
|
function Filmstrip({
|
|
|
|
|
|
images,
|
|
|
|
|
|
index,
|
|
|
|
|
|
onSelect,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
images: Attachment[];
|
|
|
|
|
|
index: number;
|
|
|
|
|
|
onSelect: (i: number) => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const activeRef = useRef<HTMLButtonElement | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
activeRef.current?.scrollIntoView({
|
|
|
|
|
|
behavior: "smooth",
|
|
|
|
|
|
block: "nearest",
|
|
|
|
|
|
inline: "center",
|
|
|
|
|
|
});
|
|
|
|
|
|
}, [index]);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="shrink-0 bg-gradient-to-t from-black/90 to-transparent pb-[max(env(safe-area-inset-bottom),0.75rem)] pt-3"
|
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
|
>
|
2026-05-29 18:11:30 +08:00
|
|
|
|
<div className="overflow-x-auto px-4 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
|
|
|
|
|
<div className="mx-auto flex w-fit max-w-[920px] gap-2">
|
|
|
|
|
|
{images.map((image, i) => {
|
|
|
|
|
|
const active = i === index;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={image.id}
|
|
|
|
|
|
ref={active ? activeRef : null}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
onSelect(i);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className={`relative h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-black transition duration-200 md:h-16 md:w-16 ${
|
|
|
|
|
|
active
|
|
|
|
|
|
? "opacity-100 ring-2 ring-white"
|
|
|
|
|
|
: "opacity-45 hover:opacity-80"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
aria-label={`Image ${i + 1}`}
|
|
|
|
|
|
aria-current={active}
|
|
|
|
|
|
>
|
|
|
|
|
|
<BubbleImage
|
|
|
|
|
|
src={image.thumbnailUrl ?? image.thumbUrl ?? image.url}
|
|
|
|
|
|
fallbackSrc={[image.thumbUrl, image.url]}
|
|
|
|
|
|
className="h-full w-full object-cover"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
2026-05-29 17:50:53 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-25 05:25:57 +08:00
|
|
|
|
function LightboxView({
|
|
|
|
|
|
images,
|
|
|
|
|
|
startIndex,
|
2026-05-26 12:07:13 +08:00
|
|
|
|
caption: captionText,
|
2026-05-29 17:50:53 +08:00
|
|
|
|
postId,
|
2026-05-25 05:25:57 +08:00
|
|
|
|
onClose,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
images: Attachment[];
|
|
|
|
|
|
startIndex: number;
|
2026-05-26 12:07:13 +08:00
|
|
|
|
caption?: string;
|
2026-05-29 17:50:53 +08:00
|
|
|
|
postId?: string;
|
2026-05-25 05:25:57 +08:00
|
|
|
|
onClose: () => void;
|
|
|
|
|
|
}) {
|
2026-05-30 18:44:15 +08:00
|
|
|
|
const { t } = useI18n();
|
2026-05-25 05:25:57 +08:00
|
|
|
|
const [index, setIndex] = useState(startIndex);
|
2026-05-27 13:37:14 +08:00
|
|
|
|
const [isCaptionVisible, setIsCaptionVisible] = useState(true);
|
2026-05-30 18:44:15 +08:00
|
|
|
|
const [showSaveHint, setShowSaveHint] = useState(true);
|
2026-05-25 05:25:57 +08:00
|
|
|
|
const touchStartX = useRef<number | null>(null);
|
|
|
|
|
|
|
2026-05-30 02:27:20 +08:00
|
|
|
|
// Clamp at the ends instead of wrapping; the nav arrows / swipe / arrow
|
|
|
|
|
|
// keys should all behave like a linear gallery, not a carousel.
|
|
|
|
|
|
const goPrev = useCallback(() => setIndex((i) => Math.max(0, i - 1)), []);
|
2026-05-25 05:25:57 +08:00
|
|
|
|
const goNext = useCallback(
|
2026-05-30 02:27:20 +08:00
|
|
|
|
() => setIndex((i) => Math.min(images.length - 1, i + 1)),
|
2026-05-25 05:25:57 +08:00
|
|
|
|
[images.length],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const onKey = (e: KeyboardEvent) => {
|
|
|
|
|
|
if (e.key === "Escape") onClose();
|
|
|
|
|
|
if (e.key === "ArrowLeft") goPrev();
|
|
|
|
|
|
if (e.key === "ArrowRight") goNext();
|
|
|
|
|
|
};
|
|
|
|
|
|
window.addEventListener("keydown", onKey);
|
2026-05-29 18:11:30 +08:00
|
|
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
|
|
|
|
}, [goPrev, goNext, onClose]);
|
|
|
|
|
|
|
|
|
|
|
|
// Lock background scroll while the lightbox is open, then restore the exact
|
|
|
|
|
|
// scroll position on close — otherwise dismissing the overlay leaves the page
|
|
|
|
|
|
// scrolled back to the top instead of where the user was reading.
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const scrollY = window.scrollY;
|
2026-05-25 05:25:57 +08:00
|
|
|
|
const prevOverflow = document.body.style.overflow;
|
|
|
|
|
|
document.body.style.overflow = "hidden";
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
document.body.style.overflow = prevOverflow;
|
2026-05-29 18:11:30 +08:00
|
|
|
|
window.scrollTo(0, scrollY);
|
2026-05-25 05:25:57 +08:00
|
|
|
|
};
|
2026-05-29 18:11:30 +08:00
|
|
|
|
}, []);
|
2026-05-25 05:25:57 +08:00
|
|
|
|
|
2026-05-27 13:37:14 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setIsCaptionVisible(true);
|
|
|
|
|
|
}, [captionText, index]);
|
|
|
|
|
|
|
2026-05-25 05:25:57 +08:00
|
|
|
|
const current = images[index];
|
2026-05-30 18:44:15 +08:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const timer = window.setTimeout(() => setShowSaveHint(false), 2000);
|
|
|
|
|
|
return () => window.clearTimeout(timer);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-05-26 12:07:13 +08:00
|
|
|
|
const caption = captionText?.trim();
|
2026-05-27 13:37:14 +08:00
|
|
|
|
const showCaption = !!caption && isCaptionVisible;
|
2026-05-29 17:50:53 +08:00
|
|
|
|
const hasMany = images.length > 1;
|
2026-05-25 05:25:57 +08:00
|
|
|
|
if (!current) return null;
|
|
|
|
|
|
|
|
|
|
|
|
return createPortal(
|
|
|
|
|
|
<div
|
2026-05-26 18:37:17 +08:00
|
|
|
|
className="fixed inset-0 z-[100] flex flex-col bg-black/95 backdrop-blur-sm"
|
2026-05-25 05:25:57 +08:00
|
|
|
|
onClick={onClose}
|
|
|
|
|
|
role="dialog"
|
|
|
|
|
|
aria-modal="true"
|
|
|
|
|
|
>
|
2026-05-29 17:50:53 +08:00
|
|
|
|
{/* Top bar: counter + actions */}
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="relative z-20 flex shrink-0 items-center justify-between px-4 pb-2 pt-[max(env(safe-area-inset-top),1rem)]"
|
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
2026-05-25 05:25:57 +08:00
|
|
|
|
>
|
2026-05-29 17:50:53 +08:00
|
|
|
|
<div className="flex h-10 items-center">
|
|
|
|
|
|
{hasMany ? (
|
|
|
|
|
|
<span className="rounded-full bg-white/10 px-3 py-1 text-xs font-medium text-white">
|
|
|
|
|
|
{index + 1} / {images.length}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
{postId ? (
|
2026-05-30 02:25:01 +08:00
|
|
|
|
<AttachmentDownloadPill
|
|
|
|
|
|
postId={postId}
|
|
|
|
|
|
attachment={current}
|
|
|
|
|
|
size="lg"
|
|
|
|
|
|
className=""
|
|
|
|
|
|
/>
|
2026-05-29 17:50:53 +08:00
|
|
|
|
) : null}
|
2026-05-25 05:25:57 +08:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
2026-05-29 17:50:53 +08:00
|
|
|
|
onClick={onClose}
|
2026-05-30 02:25:01 +08:00
|
|
|
|
className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white shadow-lg ring-1 ring-white/15 backdrop-blur-md transition hover:bg-white/20"
|
2026-05-29 17:50:53 +08:00
|
|
|
|
aria-label="Close"
|
2026-05-25 05:25:57 +08:00
|
|
|
|
>
|
2026-05-30 02:25:01 +08:00
|
|
|
|
<X className="h-6 w-6" />
|
2026-05-25 05:25:57 +08:00
|
|
|
|
</button>
|
2026-05-29 17:50:53 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Image stage */}
|
|
|
|
|
|
<div className="relative flex min-h-0 w-full flex-1 items-center justify-center">
|
2026-05-30 02:27:20 +08:00
|
|
|
|
{hasMany && index > 0 ? (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
goPrev();
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="absolute left-2 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-black/60 text-white shadow-lg ring-1 ring-white/25 transition hover:bg-black/80 md:left-6"
|
|
|
|
|
|
aria-label="Previous"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ChevronLeft className="h-6 w-6" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{hasMany && index < images.length - 1 ? (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
goNext();
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="absolute right-2 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-black/60 text-white shadow-lg ring-1 ring-white/25 transition hover:bg-black/80 md:right-6"
|
|
|
|
|
|
aria-label="Next"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ChevronRight className="h-6 w-6" />
|
|
|
|
|
|
</button>
|
2026-05-29 17:50:53 +08:00
|
|
|
|
) : null}
|
2026-05-25 05:25:57 +08:00
|
|
|
|
|
2026-05-30 18:44:15 +08:00
|
|
|
|
{showSaveHint ? (
|
|
|
|
|
|
<div className="pointer-events-none fixed inset-x-0 top-[58%] z-30 flex -translate-y-1/2 justify-center px-4">
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="pointer-events-auto relative flex w-44 max-w-[calc(100vw-2rem)] flex-col items-center gap-1.5 rounded-xl bg-black/70 px-4 py-3 text-center text-white shadow-2xl ring-1 ring-white/20 backdrop-blur-md animate-scale-in"
|
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
|
>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setShowSaveHint(false)}
|
|
|
|
|
|
className="absolute right-1.5 top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-white/10 text-white/80 transition hover:bg-white/20 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70"
|
|
|
|
|
|
aria-label="Close hint"
|
|
|
|
|
|
>
|
|
|
|
|
|
<X className="h-3.5 w-3.5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-white/15 text-2xl animate-bounce">
|
|
|
|
|
|
☝️
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="max-w-full break-words text-xs font-medium leading-4">
|
|
|
|
|
|
{t("longPressImageSave")}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
2026-05-26 18:37:17 +08:00
|
|
|
|
<div
|
2026-05-29 17:50:53 +08:00
|
|
|
|
className="flex h-full w-full items-center justify-center px-4 py-2"
|
2026-05-27 13:37:14 +08:00
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
if (caption) setIsCaptionVisible((visible) => !visible);
|
|
|
|
|
|
}}
|
2026-05-26 18:37:17 +08:00
|
|
|
|
onTouchStart={(e) => {
|
|
|
|
|
|
touchStartX.current = e.touches[0].clientX;
|
|
|
|
|
|
}}
|
|
|
|
|
|
onTouchEnd={(e) => {
|
|
|
|
|
|
if (touchStartX.current == null) return;
|
|
|
|
|
|
const dx = e.changedTouches[0].clientX - touchStartX.current;
|
|
|
|
|
|
if (Math.abs(dx) > 40) {
|
|
|
|
|
|
if (dx > 0) goPrev();
|
|
|
|
|
|
else goNext();
|
|
|
|
|
|
}
|
|
|
|
|
|
touchStartX.current = null;
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-05-30 15:43:30 +08:00
|
|
|
|
{/* No select-none / touch-callout:none here so iOS Safari's native
|
|
|
|
|
|
long-press menu ("Save in Photos") works on the full-size image. */}
|
2026-05-26 18:37:17 +08:00
|
|
|
|
<img
|
|
|
|
|
|
src={current.url}
|
|
|
|
|
|
alt={current.filename}
|
2026-05-30 15:43:30 +08:00
|
|
|
|
className="max-h-full max-w-full object-contain [-webkit-touch-callout:default]"
|
2026-05-26 18:37:17 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-29 17:50:53 +08:00
|
|
|
|
{showCaption ? (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black via-black/90 to-black/70 px-4 py-4 text-sm leading-snug text-white sm:px-6"
|
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="message-stream-copyable-text mx-auto max-h-[32vh] max-w-[920px] overflow-y-auto whitespace-pre-wrap break-words [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
|
|
|
|
|
{autolink(caption)}
|
|
|
|
|
|
</div>
|
2026-05-26 18:37:17 +08:00
|
|
|
|
</div>
|
2026-05-29 17:50:53 +08:00
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Bottom thumbnail filmstrip */}
|
|
|
|
|
|
{hasMany ? (
|
|
|
|
|
|
<Filmstrip images={images} index={index} onSelect={setIndex} />
|
2026-05-26 18:37:17 +08:00
|
|
|
|
) : null}
|
2026-05-25 05:25:57 +08:00
|
|
|
|
</div>,
|
|
|
|
|
|
document.body,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|