feat(media): rework the viewer — smaller rounded chat previews, a draggable desktop window, a fullscreen mobile sheet, and swipe animation

This commit is contained in:
heaven 2026-06-08 16:17:59 +03:00
parent 901bec2e9a
commit b6ffa03891
17 changed files with 952 additions and 696 deletions

View file

@ -1,26 +1,28 @@
import { style } from '@vanilla-extract/css';
import { recipe } from '@vanilla-extract/recipes';
import { color, config, toRem } from 'folds';
const StreamMediaCornerNotch = toRem(4);
// Small uniform rounding on ALL four corners (R400 = 8px) — the old
// asymmetric "notch toward sender" corner is gone; previews now read as
// plain rounded thumbnails on both own and peer sides.
const StreamMediaCornerSmall = config.radii.R400;
const StreamMediaCornerRound = config.radii.R500;
export const StreamMediaBubble = recipe({
base: {
export const StreamMediaBubble = style({
position: 'relative',
overflow: 'hidden',
backgroundColor: color.SurfaceVariant.Container,
maxWidth: '100%',
boxSizing: 'border-box',
flexShrink: 0,
borderRadius: StreamMediaCornerSmall,
// 1px frame drawn ABOVE the image via a pseudo-element. `inset
// box-shadow` would sit on the bg layer and get hidden by the
// `<img>` filling 100% × 100%; a real `border` would push the
// image's coordinate space and re-introduce the 1px chip-alignment
// offset we just removed. The pseudo sits at z-index 1 (above
// image's auto-stacked 0, below the username overlay at z 2),
// inherits the bubble's asymmetric border-radius, and is
// pointer-events:none so clicks pass through to the image.
// inherits the bubble's border-radius, and is pointer-events:none so
// clicks pass through to the image.
selectors: {
'&::after': {
content: '""',
@ -32,22 +34,10 @@ export const StreamMediaBubble = recipe({
zIndex: 1,
},
},
},
variants: {
own: {
true: {
borderRadius: `${StreamMediaCornerNotch} ${StreamMediaCornerRound} ${StreamMediaCornerRound} ${StreamMediaCornerRound}`,
},
false: {
borderRadius: `${StreamMediaCornerRound} ${StreamMediaCornerRound} ${StreamMediaCornerRound} ${StreamMediaCornerNotch}`,
},
},
},
});
// Caption mini-bubble under the image. Uniform R500 corners — the asymmetric
// notch lives on the image-bubble itself; stacking two notch'd rectangles
// reads worse than one notched + one rounded.
// Caption mini-bubble under the image. Keeps a slightly larger R500 radius,
// intentionally distinct from the preview bubble's uniform R400.
export const StreamMediaCaption = style({
marginTop: toRem(4),
backgroundColor: color.SurfaceVariant.Container,

View file

@ -3,7 +3,10 @@ import { toRem } from 'folds';
import { StreamMediaBubble } from './StreamMedia.css';
import { logMedia, useMediaMeasureDebug } from './streamMediaDebug';
const STREAM_MEDIA_MAX_DIM = 320;
// Timeline media preview box — shrunk ~1.9× from the original 320 per
// design so photos/videos read as compact inline previews; the full
// image opens in the media viewer on tap.
const STREAM_MEDIA_MAX_DIM = 168;
const STREAM_MEDIA_MIN_ASPECT = 3 / 4;
const STREAM_MEDIA_MAX_ASPECT = 4 / 3;
@ -40,10 +43,13 @@ function computeBoxStyle(naturalW?: number, naturalH?: number): React.CSSPropert
return { width: toRem(h * naturalAspect), height: toRem(h) };
}
// Shared chrome for image / video timeline bubbles: square-ish bubble with
// asymmetric notch + 1px pseudo-frame. Caller plugs in the actual media
// renderer as `children`. The sender nick is rendered ABOVE the media by the
// Stream layout's name header (like text messages), not overlaid on the image.
// Shared chrome for image / video timeline bubbles: an aspect-preserving
// (up to 168px) preview box with uniformly-rounded R400 corners and a 1px
// pseudo-frame. Caller plugs in the actual media renderer as `children`.
// The sender nick is rendered ABOVE the media by the Stream layout's name
// header (like text messages), not overlaid on the image. `own` no longer
// affects rendering after the uniform-corner change — it survives only as
// a debug-log field (sender vs peer) for the media-measure diagnostics.
export function StreamMediaShell({ naturalW, naturalH, own, children }: StreamMediaShellProps) {
const bubbleRef = useRef<HTMLDivElement>(null);
const computedStyle = computeBoxStyle(naturalW, naturalH);
@ -58,7 +64,7 @@ export function StreamMediaShell({ naturalW, naturalH, own, children }: StreamMe
useMediaMeasureDebug(bubbleRef, [computedStyle.width, computedStyle.height]);
return (
<div ref={bubbleRef} className={StreamMediaBubble({ own })} style={computedStyle}>
<div ref={bubbleRef} className={StreamMediaBubble} style={computedStyle}>
{children}
</div>
);

View file

@ -68,8 +68,8 @@ export type ImageContentProps = {
markedAsSpoiler?: boolean;
spoilerReason?: string;
// When provided AND the `MediaViewerHostContext` is non-null,
// clicking the thumbnail opens the atom-driven horseshoe viewer
// (mobile bottom-up sheet / desktop right pane) instead of the
// clicking the thumbnail opens the atom-driven viewer (mobile
// bottom-up sheet / desktop+tablet floating window) instead of the
// legacy full-screen `<Overlay>` viewer. Non-Room surfaces
// (pin-menu, message search) leave the host context as `null` and
// therefore keep the legacy modal even if they pass `eventId`.

View file

@ -71,6 +71,20 @@ export const image = style({
imageRendering: 'auto',
});
// Swipe-filmstrip neighbour layer — fills the stage, centres its image
// like the current one, and ignores pointers (the current image owns the
// gesture surface). Translated by the parent so prev/next slide with the
// drag.
export const previewLayer = style({
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
pointerEvents: 'none',
});
// Side chevrons — visibility logic:
// • Coarse pointer (touch devices, mobile): always visible at
// 0.7 opacity so users have a tappable nav affordance without

View file

@ -1,6 +1,6 @@
// Body for the room media viewer — mounted inside both the mobile
// `MobileMediaViewerHorseshoe` panel-body and the desktop
// `RoomViewMediaSidePanel`. Owns:
// `MobileMediaViewerHorseshoe` panel-body and the desktop floating
// `RoomViewMediaWindow`. Owns:
// • Resolving + decrypting the media URL for the current entry,
// including revoke-on-unmount for blob URLs we create.
// • Zoom for images (button row + `+`/`-`/`0` keys).
@ -46,6 +46,13 @@ const SWIPE_COMMIT_PX = 80;
// the gesture is interpreted as a swipe — keeps accidental diagonal
// drags from committing.
const SWIPE_DOMINANCE = 1.5;
// Duration of the slide animation when a prev/next navigation commits
// (swipe release past threshold, chevron tap, arrow key).
const SWIPE_ANIM_MS = 280;
// Gutter between adjacent images in the swipe filmstrip, so consecutive
// pictures don't read as glued edge-to-edge while sliding. The panel
// «pitch» (centre-to-centre travel) is therefore stageWidth + this.
const SWIPE_GAP_PX = 24;
// Walk the room's loaded timeline events and build the ordered set
// of viewable image + video events. Out-of-window media (older than
@ -94,12 +101,63 @@ function eventToEntry(roomId: string, ev: MatrixEvent): MediaViewerEntry | null
};
}
// A non-interactive neighbour image for the swipe «filmstrip». It resolves
// (and shares, via the parent's `resolveSrc` cache) its own decrypted src so
// the prev / next image is already on-screen as the user drags, then slides
// to centre on commit. Image-only — videos keep instant navigation. Sits
// BEHIND the interactive current image (`pointer-events: none`).
function SwipePreview({
entry,
resolveSrc,
offsetPx,
transition,
}: {
entry: MediaViewerEntry;
resolveSrc: (e: MediaViewerEntry) => Promise<string>;
offsetPx: number;
transition: string;
}) {
const [src, setSrc] = useState<string | undefined>(undefined);
useEffect(() => {
let alive = true;
resolveSrc(entry)
.then((s) => {
if (alive) setSrc(s);
})
.catch(() => {
/* neighbour preview is best-effort */
});
return () => {
alive = false;
};
}, [entry, resolveSrc]);
return (
<div className={css.previewLayer} aria-hidden="true">
{src && (
<img
src={src}
alt=""
className={css.image}
draggable={false}
style={{ transform: `translate3d(${offsetPx}px, 0, 0)`, transition }}
/>
)}
</div>
);
}
type MediaViewerBodyProps = {
entry: MediaViewerEntry;
requestClose: () => void;
// Drop the title/zoom/download/close toolbar entirely. The desktop
// floating window owns its own minimal chrome (drag bar + close), so
// it passes this; zoom is wheel-only and nav is keyboard/chevron
// there. The mobile horseshoe + (legacy) side pane leave it false to
// keep the full toolbar.
hideHeader?: boolean;
};
export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewerBodyProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
@ -127,6 +185,24 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
const [pan, setPan] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
const [panCursor, setPanCursor] = useState<'grab' | 'grabbing' | 'initial'>('initial');
// Horizontal swipe / slide state (declared here, before the
// navigation + keyboard handlers that read it). `swipeOffset` is the
// live drag translate; `swipeAnimating` is true while a commit slide
// is settling (so the transition is on for the ±stageWidth glide);
// `stageWidth` feeds the neighbour-panel positions and the commit
// target; `commitTimerRef` defers the entry swap to the end of the
// slide. Gesture detail is in the big stage-listener effect below.
const [swipeOffset, setSwipeOffset] = useState(0);
const [swipeAnimating, setSwipeAnimating] = useState(false);
const [stageWidth, setStageWidth] = useState(0);
const commitTimerRef = useRef<number | null>(null);
const prefersReducedMotion = useMemo(
() =>
typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches,
[]
);
// Mirror `zoom` and `pan` into refs so callbacks installed in
// long-lived listeners can read the latest values without
// re-binding, and so the inlined anchor-aware zoom math in
@ -203,12 +279,31 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
// `onImageLoad` set a small ceiling) to a high-res one would
// briefly use the stale ceiling until the new image's `onLoad`
// fires. Gesture state is reset in a separate effect below.
// Also cancel any in-flight commit slide (e.g. an external open
// mid-animation) so its deferred entry swap can't fire stale.
useEffect(() => {
setZoom(1);
setPan({ x: 0, y: 0 });
setMaxZoom(ZOOM_CEILING_FALLBACK);
if (commitTimerRef.current !== null) {
window.clearTimeout(commitTimerRef.current);
commitTimerRef.current = null;
}
setSwipeAnimating(false);
}, [entry.eventId, setZoom]);
// Track the stage width so the swipe filmstrip can position the
// prev/next panels exactly one stage-width to either side and the
// commit slide knows how far to glide.
useEffect(() => {
const el = stageRef.current;
if (!el) return undefined;
setStageWidth(el.clientWidth);
const ro = new ResizeObserver(() => setStageWidth(el.clientWidth));
ro.observe(el);
return () => ro.disconnect();
}, []);
// Re-clamp the existing pan against the new bounds when zoom
// changes (e.g. zooming out from 3× to 1× should snap a panned
// image back to centre since there's nothing to pan into).
@ -254,55 +349,62 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
[clampPan]
);
// Resolve + decrypt the media URL. Mirrors `ImageContent`'s
// pattern. Blob-URL lifecycle is managed via `lastBlobUrlRef`:
// every encrypted entry allocates a fresh blob INSIDE the async
// callback, and we revoke the previous one before storing the
// new one. This pattern handles two leak paths that a naive
// `useEffect(srcState)` cleanup misses:
// 1. `useAsyncCallback`'s "Request replaced!" reject path —
// a rapidly-replaced loadSrc may have created a blob via
// `createObjectURL` before the throw, which never reaches
// `srcState` and so is never cleaned up. Here we revoke
// whatever was last stamped into the ref before stamping
// the new one.
// 2. Unmount mid-load — `useEffect` cleanup with a stale
// `srcState.data` could revoke a blob the new mount is
// still using. With a ref-tracked invariant of "we own
// `lastBlobUrlRef.current`", the unmount cleanup is
// unambiguous.
const lastBlobUrlRef = useRef<string | null>(null);
const { url: entryUrl, encInfo: entryEncInfo, mimeType: entryMimeType } = entry;
const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, entryUrl, useAuthentication);
// Resolve + decrypt the media URL into a displayable src, CACHED by
// eventId so the current image and its swipe neighbours share one
// decrypted blob — that's what lets a swipe slide between two
// already-decrypted images with no reload gap. Blob URLs (encrypted
// media) are tracked in `blobUrlsRef` so we revoke exactly those on
// eviction / unmount; plain http(s) srcs need no revoke. The
// concurrent-resolve re-check below means a rapidly-replaced load (or
// a preview + main racing on the same event) never leaks a duplicate
// blob.
const srcCacheRef = useRef<Map<string, string>>(new Map());
const blobUrlsRef = useRef<Set<string>>(new Set());
const resolveMediaSrc = useCallback(
async (e: MediaViewerEntry): Promise<string> => {
const cached = srcCacheRef.current.get(e.eventId);
if (cached) return cached;
const mediaUrl = mxcUrlToHttp(mx, e.url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
if (entryEncInfo) {
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, entryMimeType ?? FALLBACK_MIMETYPE, entryEncInfo)
);
const blob = URL.createObjectURL(fileContent);
if (lastBlobUrlRef.current && lastBlobUrlRef.current !== blob) {
URL.revokeObjectURL(lastBlobUrlRef.current);
}
lastBlobUrlRef.current = blob;
return blob;
}
const enc = e.encInfo;
if (!enc) {
srcCacheRef.current.set(e.eventId, mediaUrl);
return mediaUrl;
}, [mx, entryUrl, entryEncInfo, entryMimeType, useAuthentication])
}
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, e.mimeType ?? FALLBACK_MIMETYPE, enc)
);
// A concurrent resolve for the same event may have won the cache
// while we decrypted — drop our duplicate blob and use the winner.
const winner = srcCacheRef.current.get(e.eventId);
if (winner) return winner;
const blob = URL.createObjectURL(fileContent);
blobUrlsRef.current.add(blob);
srcCacheRef.current.set(e.eventId, blob);
return blob;
},
[mx, useAuthentication]
);
const [srcState, loadSrc] = useAsyncCallback(
useCallback(() => resolveMediaSrc(entry), [resolveMediaSrc, entry])
);
useEffect(() => {
loadSrc();
}, [loadSrc]);
// Revoke any blob URL we still own when the viewer body unmounts
// (sheet closed entirely, room change, etc).
// Revoke every blob URL we created (and cancel a pending commit
// slide) when the viewer body unmounts — sheet closed, room change.
useEffect(
() => () => {
if (lastBlobUrlRef.current) {
URL.revokeObjectURL(lastBlobUrlRef.current);
lastBlobUrlRef.current = null;
blobUrlsRef.current.forEach((u) => URL.revokeObjectURL(u));
blobUrlsRef.current.clear();
srcCacheRef.current.clear();
if (commitTimerRef.current !== null) {
window.clearTimeout(commitTimerRef.current);
commitTimerRef.current = null;
}
},
[]
@ -377,24 +479,84 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
[mediaSet, entry.eventId]
);
const step = useCallback(
// Navigate prev/next with a slide. For image→image (and motion
// allowed, stage measured) we glide the current image out and the
// neighbour panel in over `SWIPE_ANIM_MS`, THEN swap the atom entry —
// because the neighbour was prefetched into the shared src cache the
// new current paints instantly at centre, so the swap is seamless.
// Anything else (video involved, reduced motion, stage not measured)
// falls back to an instant switch. Guarded against re-entry while a
// commit slide is already in flight.
const animatedStep = useCallback(
(delta: -1 | 1) => {
if (commitTimerRef.current !== null) return;
if (currentIndex < 0) return;
const target = currentIndex + delta;
if (target < 0 || target >= mediaSet.length) return;
const next = mediaSet[target];
if (!next) return;
openMediaViewer(next);
const targetIndex = currentIndex + delta;
if (targetIndex < 0 || targetIndex >= mediaSet.length) return;
const target = mediaSet[targetIndex];
if (!target) return;
const stageW = stageRef.current?.clientWidth ?? 0;
// Slide only at rest zoom (a zoomed image has no neighbour panels
// and shouldn't glide while scaled — chevron/arrow nav while zoomed
// switches instantly). `zoomRef` keeps this out of the deps.
const canSlide =
!prefersReducedMotion &&
stageW > 0 &&
zoomRef.current === 1 &&
entry.kind === 'image' &&
target.kind === 'image';
if (!canSlide) {
setSwipeOffset(0);
openMediaViewer(target);
return;
}
setSwipeAnimating(true);
// delta +1 (next) slides content LEFT by one panel pitch (stage +
// gutter) → current glides off-screen while the prefetched next
// panel (sitting one pitch to the right) glides to centre.
const pitch = stageW + SWIPE_GAP_PX;
setSwipeOffset(delta === 1 ? -pitch : pitch);
commitTimerRef.current = window.setTimeout(() => {
commitTimerRef.current = null;
setSwipeAnimating(false);
setSwipeOffset(0);
openMediaViewer(target);
}, SWIPE_ANIM_MS);
},
[currentIndex, mediaSet, openMediaViewer]
[currentIndex, mediaSet, entry.kind, openMediaViewer, prefersReducedMotion]
);
const canPrev = currentIndex > 0;
const canNext = currentIndex >= 0 && currentIndex < mediaSet.length - 1;
// Prefetch the immediate image neighbours so a swipe slides into an
// already-decrypted picture, and prune cached srcs outside a small
// window around the current index (revoking their blobs) to bound
// memory. The current entry and its ±1 neighbours are always kept, so
// we never revoke a src a live <img> is using.
useEffect(() => {
if (currentIndex < 0) return;
[mediaSet[currentIndex - 1], mediaSet[currentIndex + 1]].forEach((e) => {
if (e && e.kind === 'image') resolveMediaSrc(e).catch(() => undefined);
});
const keep = new Set<string>([entry.eventId]);
for (let d = -2; d <= 2; d += 1) {
const e = mediaSet[currentIndex + d];
if (e) keep.add(e.eventId);
}
srcCacheRef.current.forEach((src, eid) => {
if (keep.has(eid)) return;
if (blobUrlsRef.current.has(src)) {
URL.revokeObjectURL(src);
blobUrlsRef.current.delete(src);
}
srcCacheRef.current.delete(eid);
});
}, [currentIndex, mediaSet, entry.eventId, resolveMediaSrc]);
// Keyboard — ArrowLeft / Right step, +/- / 0 manage zoom. Esc is
// handled by the parent shell (focus-trap on desktop, explicit
// keydown listener on mobile).
// handled by the parent shell via an explicit window keydown listener
// (both the mobile horseshoe and the desktop floating window).
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.defaultPrevented) return;
@ -407,10 +569,10 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
}
if (e.key === 'ArrowLeft') {
e.preventDefault();
step(-1);
animatedStep(-1);
} else if (e.key === 'ArrowRight') {
e.preventDefault();
step(1);
animatedStep(1);
} else if (e.key === '+' || e.key === '=') {
e.preventDefault();
zoomIn();
@ -424,7 +586,7 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [step, zoomIn, zoomOut, setZoom]);
}, [animatedStep, zoomIn, zoomOut, setZoom]);
// Multi-touch gesture state on the image stage. Three modes:
// • zoom=1, 1 finger → horizontal-swipe to prev/next image.
@ -438,7 +600,8 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
// Chromium both fire for touch, which double-fires every handler.
// The MDN reference pattern that drove this implementation:
// https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events/Pinch_zoom_gestures
const [swipeOffset, setSwipeOffset] = useState(0);
// (`swipeOffset` / `swipeAnimating` are declared up top with the rest
// of the slide state.)
const swipeStateRef = useRef({
active: false,
@ -487,8 +650,19 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
canPrevRef.current = canPrev;
const canNextRef = useRef(canNext);
canNextRef.current = canNext;
const stepRef = useRef(step);
stepRef.current = step;
// Whether the prev/next neighbour is an IMAGE (i.e. slides). A video
// neighbour (or a true edge) instead rubber-bands the drag — there's no
// slide panel to follow the finger into, it switches instantly on
// release. Mirrored for the once-installed gesture listeners.
const prevIsImageRef = useRef(false);
prevIsImageRef.current = currentIndex > 0 && mediaSet[currentIndex - 1]?.kind === 'image';
const nextIsImageRef = useRef(false);
nextIsImageRef.current =
currentIndex >= 0 &&
currentIndex < mediaSet.length - 1 &&
mediaSet[currentIndex + 1]?.kind === 'image';
const animatedStepRef = useRef(animatedStep);
animatedStepRef.current = animatedStep;
const entryKindRef = useRef(entry.kind);
entryKindRef.current = entry.kind;
// `zoomRef` + `panRef` are already mirrored in the pan section
@ -531,6 +705,12 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
// mouse here for everything.
if (e.pointerType === 'mouse') return;
if (entryKindRef.current !== 'image') return;
// Ignore new touches while a commit slide is gliding (the entry is
// about to swap). Without this, a fresh pointerdown mid-glide would
// run `setSwipeOffset(0)` while `swipeAnimating` is still on and
// animate the outgoing image BACK toward centre — a reverse/stutter
// on fast double-swipes. Mirrors the same guard in `animatedStep`.
if (commitTimerRef.current !== null) return;
// Capture the pointer so subsequent `pointermove`/`pointerup`
// events are dispatched to the stage even when the finger
@ -651,8 +831,12 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
}
if (s.claimed !== 'swipe') return;
if (e.cancelable) e.preventDefault();
const blocked = (dx > 0 && !canPrevRef.current) || (dx < 0 && !canNextRef.current);
setSwipeOffset(blocked ? dx / 3 : dx);
// Rubber-band (1/3 travel) when the swipe direction has nothing to
// slide into — a true list edge OR a video neighbour (videos switch
// instantly on release rather than gliding). A real image neighbour
// follows the finger 1:1.
const resisted = (dx > 0 && !prevIsImageRef.current) || (dx < 0 && !nextIsImageRef.current);
setSwipeOffset(resisted ? dx / 3 : dx);
return;
}
@ -699,11 +883,19 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
const s = swipeStateRef.current;
if (s.active) {
s.active = false;
let committed = false;
if (s.claimed === 'swipe') {
if (s.lastDx > SWIPE_COMMIT_PX && canPrevRef.current) stepRef.current(-1);
else if (s.lastDx < -SWIPE_COMMIT_PX && canNextRef.current) stepRef.current(1);
if (s.lastDx > SWIPE_COMMIT_PX && canPrevRef.current) {
animatedStepRef.current(-1);
committed = true;
} else if (s.lastDx < -SWIPE_COMMIT_PX && canNextRef.current) {
animatedStepRef.current(1);
committed = true;
}
setSwipeOffset(0);
}
// A commit lets `animatedStep` own the offset (glide to
// ±stageWidth); a non-committing release snaps back to centre.
if (!committed) setSwipeOffset(0);
}
touchPanStateRef.current = null;
}
@ -766,7 +958,12 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
};
}, []);
const effectiveSrc = srcState.status === AsyncStatus.Success ? srcState.data : undefined;
// Prefer the shared cache so a just-committed (prefetched) entry paints
// instantly with no `useAsyncCallback` Loading flash; fall back to the
// loader's result for the first, uncached open.
const cachedSrc = srcCacheRef.current.get(entry.eventId);
const effectiveSrc =
cachedSrc ?? (srcState.status === AsyncStatus.Success ? srcState.data : undefined);
const isLoading =
!effectiveSrc &&
(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Idle);
@ -795,11 +992,30 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
const transform = isReady
? `translate3d(${swipeOffset + pan.x}px, ${pan.y}px, 0) scale(${zoom})`
: undefined;
const imageTransition =
swipeOffset === 0 ? 'transform 200ms cubic-bezier(0.32, 0.72, 0, 1)' : 'none';
// Transition ON while a commit slide settles (swipeAnimating) and at
// rest (offset 0 — smooth zoom + snap-back); OFF during an active drag
// so the image tracks the finger 1:1.
let imageTransition = 'none';
if (swipeAnimating) {
imageTransition = `transform ${SWIPE_ANIM_MS}ms cubic-bezier(0.32, 0.72, 0, 1)`;
} else if (swipeOffset === 0) {
imageTransition = 'transform 200ms cubic-bezier(0.32, 0.72, 0, 1)';
}
// Swipe filmstrip neighbours — only at rest zoom and for images (swipe
// is image-only). They slide WITH the current image (same offset, one
// stage-width to either side) so the prev/next picture is visible as
// the user drags and glides to centre on commit.
const prevEntry = currentIndex > 0 ? mediaSet[currentIndex - 1] : undefined;
const nextEntry =
currentIndex >= 0 && currentIndex < mediaSet.length - 1
? mediaSet[currentIndex + 1]
: undefined;
const showSwipePreviews = entry.kind === 'image' && zoom === 1 && stageWidth > 0;
return (
<div className={css.root}>
{!hideHeader && (
<PageHeader
// `PageHeader` (= folds `Header size="600"`) so the header's
// bottom rule aligns to the pixel with the chat header to the
@ -870,14 +1086,36 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
</IconButton>
</Box>
</PageHeader>
)}
<div className={css.stage} ref={stageRef}>
{showSwipePreviews && prevEntry?.kind === 'image' && (
<SwipePreview
key={prevEntry.eventId}
entry={prevEntry}
resolveSrc={resolveMediaSrc}
offsetPx={swipeOffset - stageWidth - SWIPE_GAP_PX}
transition={imageTransition}
/>
)}
{showSwipePreviews && nextEntry?.kind === 'image' && (
<SwipePreview
key={nextEntry.eventId}
entry={nextEntry}
resolveSrc={resolveMediaSrc}
offsetPx={swipeOffset + stageWidth + SWIPE_GAP_PX}
transition={imageTransition}
/>
)}
{isReady && effectiveSrc && entry.kind === 'image' && (
// `onMouseDown` drives drag-pan when the image is zoomed in.
// Keyboard pan is wired separately at the stage level.
// Keyboard pan is wired separately at the stage level. Keyed by
// eventId (like the video) so a commit mounts a fresh element
// centred at offset 0 — no slide-back from the committed edge.
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<img
ref={imgRef}
key={entry.eventId}
src={effectiveSrc}
alt={entry.body}
className={css.image}
@ -942,9 +1180,9 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
aria-label={t('MediaViewer.previous', 'Previous')}
// `aria-disabled` (not `disabled`) so the CSS dimmed-
// state selector still matches; the onClick already
// bails internally via `step`'s bounds check.
// bails internally via `animatedStep`'s bounds check.
aria-disabled={!canPrev}
onClick={() => step(-1)}
onClick={() => animatedStep(-1)}
>
<Icon size="200" src={Icons.ChevronLeft} />
</button>
@ -953,7 +1191,7 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
className={classNames(css.chevron, css.chevronRight)}
aria-label={t('MediaViewer.next', 'Next')}
aria-disabled={!canNext}
onClick={() => step(1)}
onClick={() => animatedStep(1)}
>
<Icon size="200" src={Icons.ChevronRight} />
</button>

View file

@ -1,11 +1,6 @@
import { style } from '@vanilla-extract/css';
import { color, toRem } from 'folds';
import { VOJO_HORSESHOE_GAP_PX, VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
// Re-exported so the TSX can pick up the constants without crossing
// the vanilla-extract / runtime boundary twice.
export const HORSESHOE_RADIUS_PX = VOJO_HORSESHOE_RADIUS_PX;
export const HORSESHOE_GAP_PX = VOJO_HORSESHOE_GAP_PX;
import { VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
// Outer container — anchor for the two absolutely-positioned panes
// (`appBody` and `silhouette`). Same shape as the settings-sheet
@ -41,7 +36,11 @@ export const appBody = style({
// The viewer sheet's surface. `Background.Container` (deepest Dawn
// tone) so the image floats on a near-black backdrop — closest to
// the legacy `<Overlay>` viewer's full-screen dark scrim.
// the legacy `<Overlay>` viewer's full-screen dark scrim. Rounded
// top corners (same radius as the tab curtains) with `overflow:
// hidden` clip the header / image to the curve; the carved corners
// expose the app's dark background behind, so the rounding reads
// without re-introducing a black void gap.
export const silhouette = style({
position: 'absolute',
bottom: 0,
@ -51,7 +50,9 @@ export const silhouette = style({
flexDirection: 'column',
overflow: 'hidden',
backgroundColor: color.Background.Container,
willChange: 'height, border-top-left-radius, border-top-right-radius',
borderTopLeftRadius: toRem(VOJO_HORSESHOE_RADIUS_PX),
borderTopRightRadius: toRem(VOJO_HORSESHOE_RADIUS_PX),
willChange: 'height',
});
// Top-anchored — handle + viewer body reveal top-down as silhouette

View file

@ -1,8 +1,9 @@
// Bottom-up «horseshoe» sheet that wraps the mobile chat column for
// the media viewer. Sister of `MobileSettingsHorseshoe` (which wraps
// the DM list with the same idiom for the Settings tree) — same
// clip-path carve, same VAUL easing, same Strict-Mode-safe entry
// animation, same `keepMounted` delayed unmount.
// Bottom-up sheet that wraps the mobile chat column for the media
// viewer. Built on the same scaffolding as `MobileSettingsHorseshoe`
// (clip-path bottom-carve, VAUL easing, Strict-Mode-safe entry
// animation, `keepMounted` delayed unmount) but — being a FULLSCREEN
// viewer — it drops the «horseshoe» look: no rounded top corners and
// no black void gap above the sheet (see the geometry block below).
//
// Differences from the settings sheet:
// • Tap-to-open only. No drag-up origin — opening is driven from
@ -10,7 +11,9 @@
// 20px handle band is the ONLY drag-sensitive area; touches on
// the viewer body below run zoom / pan / horizontal swipe
// instead.
// • `RAIL_FRACTION = 3 / 4` (75% of viewport) vs settings's 2/3.
// • Fullscreen, flush top: the sheet covers the chat (incl. its
// header) and its flat top lands flush under the system status
// bar — no rounded carve, no void seam — vs settings's rounded 2/3.
// • Silhouette bg = `Background.Container` (deepest Dawn tone) for
// a dark image backdrop, vs settings's `SurfaceVariant.Container`.
// • Wrapped content is the chat column, not the DM list.
@ -25,7 +28,6 @@ import { useTranslation } from 'react-i18next';
import { mediaViewerAtom } from '../../state/mediaViewer';
import { useCloseMediaViewer } from '../../state/hooks/mediaViewer';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { VOJO_HORSESHOE_VOID_COLOR } from '../../styles/horseshoe';
import { MediaViewerBody } from './MediaViewerBody';
import * as css from './MobileMediaViewerHorseshoe.css';
@ -34,39 +36,19 @@ const ANIMATION_MS = 250;
// Drag distance past which release commits the close. Matches the
// settings sheet's 80px (the only commit gesture there too).
const COMMIT_THRESHOLD_PX = 80;
// Sheet height as a fraction of the viewport — product spec.
// Capped at runtime by the wrapper container's actual height
// (see `railHeightPx` calc below) so it can't spill past the
// chat header when chrome outside the chat column (e.g.
// `HorseshoeContainer.bottomRail` during a call) shrinks the
// wrapper.
const RAIL_VIEWPORT_FRACTION = 0.8;
// Sheet height as a fraction of the viewport — near-fullscreen by
// design: the viewer should cover the chat (incl. its header) and stop
// just under the system status bar / tray. Capped at runtime by the
// wrapper container's actual height minus the status-bar inset (see
// `railHeightPx` calc below) so the sheet's rounded top edge lands at
// the bottom of the status bar, never over the tray icons.
const RAIL_VIEWPORT_FRACTION = 1;
// Absolute floor for tiny / split-screen viewports — the sheet
// becomes useless below this. NB: floor cannot exceed the
// container's ceiling (`Math.min` is applied last), so in extreme
// squeeze cases (split-screen, oversized call rail) the sheet
// silently shrinks below the floor rather than clipping the chat
// header.
// silently shrinks below the floor rather than clipping the status bar.
const RAIL_MIN_PX = 200;
// Headroom reserved at the TOP of the container for the chat
// header (`PageHeader` = folds `Header size="600"`, ~64px) plus
// the 12px horseshoe void gap above the silhouette's rounded
// TL/TR carves. With this in place the sheet's top edge stops
// just below the chat header even when the wrapper container
// shrinks (e.g. `HorseshoeContainer.bottomRail` is on during a
// call). Previously the headroom was 8px which let the sheet
// climb over the header in tight viewports.
const CHAT_HEADER_RESERVED_PX = 76;
// Same emerge window as the settings sheet so the silhouette's
// rounding + void gap finish exactly when the gesture qualifies to
// commit.
const HORSESHOE_EMERGE_PX = 80;
// Symmetric cubic in-out — slow start, fast middle, slow finish.
// Same curve the settings / profile horseshoes use for their emerge
// (rationale at the top of `MobileSettingsHorseshoe.tsx`).
const easeInOutCubic = (t: number): number =>
t < 0.5 ? 4 * t * t * t : 1 - ((-2 * t + 2) ** 3) / 2;
type DragState = {
inputType: 'touch' | 'pointer';
@ -97,13 +79,12 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
// Measure the wrapper container directly via `ResizeObserver` so
// the rail height tracks whatever vertical space is actually
// available to the chat column. The viewport-based fraction is
// the *intent* (90% of the screen — product spec), but
// the *intent* (full screen — product spec), but
// `HorseshoeContainer.bottomRail` (the call rail) sits OUTSIDE
// the chat column inside the same viewport and shrinks our
// wrapper when active. Without the container clamp, 90% of the
// viewport could exceed the wrapper's height, and the sheet
// would render up to / past the chat header which sits inside
// the wrapper's appBody.
// wrapper when active. Without the container clamp, a full-viewport
// sheet could exceed the wrapper's height and spill past the bottom
// chrome; the clamp keeps the top edge at the status bar instead.
const containerRef = useRef<HTMLDivElement>(null);
const [containerHeight, setContainerHeight] = useState(() =>
typeof window === 'undefined' ? 800 : window.innerHeight
@ -121,15 +102,32 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
return () => ro.disconnect();
}, []);
// Final rail = viewport × fraction, then floor-then-ceiling
// clamped. Container (minus chat-header reserve) is the HARD
// ceiling: when the wrapper shrinks (call rail / split-screen)
// the sheet shrinks below `RAIL_MIN_PX` rather than spilling
// over the chat header. Order matters — `Math.min` last makes
// container win against floor.
// Live status-bar inset. `env(safe-area-inset-top)` can't be read
// reliably off a custom property in JS, so we measure a zero-width
// probe whose height is `var(--vojo-safe-top)` (= the env value).
// This is the only thing the near-fullscreen sheet must stay below
// — it covers the chat header but must not climb over the tray.
const safeTopRef = useRef<HTMLDivElement>(null);
const [safeTopPx, setSafeTopPx] = useState(0);
useLayoutEffect(() => {
const el = safeTopRef.current;
if (!el) return undefined;
setSafeTopPx(el.offsetHeight);
const ro = new ResizeObserver(() => setSafeTopPx(el.offsetHeight));
ro.observe(el);
return () => ro.disconnect();
}, []);
// Final rail = viewport × fraction (full screen), then floor-then-
// ceiling clamped. Container minus the status-bar inset is the HARD
// ceiling: the sheet's flat top lands flush at the bottom of the
// status bar (no void gap / breathing), covering the whole chat column
// down to just under the tray; when the wrapper shrinks (call rail /
// split-screen) it shrinks below `RAIL_MIN_PX` rather than spilling
// over the tray. Order matters — `Math.min` last makes container win.
const idealRailPx = Math.round(viewportHeight * RAIL_VIEWPORT_FRACTION);
const railHeightPx = Math.min(
Math.max(0, containerHeight - CHAT_HEADER_RESERVED_PX),
Math.max(0, containerHeight - safeTopPx),
Math.max(RAIL_MIN_PX, idealRailPx)
);
const open = !!entry;
@ -172,9 +170,7 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
const expandedPx = drag
? Math.max(0, Math.min(railHeightPx, baseExpanded - drag.deltaY))
: baseExpanded;
const expandedFraction = railHeightPx > 0 ? expandedPx / railHeightPx : 0;
const isDragging = drag !== null;
const horseshoeActive = expandedPx > 0;
const handleRef = useRef<HTMLDivElement>(null);
@ -208,9 +204,7 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
const target = e.target as HTMLElement | null;
if (
target &&
(target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable)
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
) {
return;
}
@ -307,30 +301,19 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
};
}, []);
// Geometry — same emerge ramp as the settings / profile horseshoes
// (rationale at the top of `MobileSettingsHorseshoe`).
let horseshoeRamp: number;
if (isDragging) {
horseshoeRamp = easeInOutCubic(Math.min(1, expandedPx / HORSESHOE_EMERGE_PX));
} else {
horseshoeRamp = expandedFraction > 0 ? 1 : 0;
}
const silhouetteRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX;
const appBodyRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX;
const appBodyGapPx = horseshoeRamp * css.HORSESHOE_GAP_PX;
const appBodyMaskBottomPx = expandedPx + appBodyGapPx;
// Geometry — the media viewer is fullscreen, so (unlike the settings /
// profile horseshoes) it carves NO rounded top corners and NO void gap
// above the sheet: the sheet's flat top butts directly against the chat
// it clips (which now fills only the status-bar strip above it). Chat bg
// and sheet bg are the same Dawn tone, so there's no black void seam at
// the top — the viewer reads as a flush fullscreen panel under the tray.
const appBodyMaskBottomPx = expandedPx;
const appBodyClipPath = `inset(0px 0px ${appBodyMaskBottomPx}px 0px round 0px 0px ${appBodyRadiusPx}px ${appBodyRadiusPx}px)`;
const appBodyClipPath = `inset(0px 0px ${appBodyMaskBottomPx}px 0px)`;
const silhouetteTransition = isDragging
? 'none'
: `height ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-left-radius ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-right-radius ${ANIMATION_MS}ms ${VAUL_EASING}`;
const silhouetteTransition = isDragging ? 'none' : `height ${ANIMATION_MS}ms ${VAUL_EASING}`;
const appBodyTransition = isDragging ? 'none' : `clip-path ${ANIMATION_MS}ms ${VAUL_EASING}`;
const containerStyle: React.CSSProperties = {
backgroundColor: horseshoeActive ? VOJO_HORSESHOE_VOID_COLOR : undefined,
};
const renderEntry = entry ?? lastEntryRef.current;
const renderBody = !!renderEntry && (keepMounted || isDragging);
@ -344,7 +327,22 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
: null;
return (
<div ref={containerRef} className={css.container} style={containerStyle}>
<div ref={containerRef} className={css.container}>
{/* Zero-width probe its height resolves `env(safe-area-inset-top)`
so the JS rail-height calc can keep the sheet below the tray. */}
<div
ref={safeTopRef}
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: 0,
width: 0,
height: 'var(--vojo-safe-top, 0px)',
pointerEvents: 'none',
visibility: 'hidden',
}}
/>
{open && portalTarget
? createPortal(
<div
@ -370,8 +368,6 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
className={css.silhouette}
style={{
height: `${expandedPx}px`,
borderTopLeftRadius: `${silhouetteRadiusPx}px`,
borderTopRightRadius: `${silhouetteRadiusPx}px`,
transition: silhouetteTransition,
visibility: expandedPx > 0 ? 'visible' : 'hidden',
// Reset `--vojo-safe-top` so any descendant that paints a

View file

@ -25,9 +25,8 @@ import { RoomViewProfileSidePanel } from './RoomViewProfileSidePanel';
import { RoomViewMembersPanel } from './RoomViewMembersPanel';
import { RoomViewMembersSidePanel } from './RoomViewMembersSidePanel';
import { MobileMediaViewerHorseshoe } from './MobileMediaViewerHorseshoe';
import { RoomViewMediaSidePanel } from './RoomViewMediaSidePanel';
import { RoomViewMediaWindow } from './RoomViewMediaWindow';
import { MediaViewerHostContext } from './mediaViewerHostContext';
import { mediaViewerAtom } from '../../state/mediaViewer';
import { roomMembersSheetAtom } from '../../state/roomMembersSheet';
import { useChannelsMode, ThreadDrawerOpenProvider } from '../../hooks/useChannelsMode';
import { CHANNELS_THREAD_PATH } from '../../pages/paths';
@ -133,16 +132,12 @@ export function Room({ renderRoomView }: RoomProps) {
// PageRoot. The chat column applies an explicit Background bg so the
// parent void can't bleed through any transparent slivers.
const profileOpen = !!useAtomValue(userRoomProfileAtom);
const mediaOpen = !!useAtomValue(mediaViewerAtom);
const membersSheetOpen = !!useAtomValue(roomMembersSheetAtom);
const callView = room.isCallRoom();
const showProfileHorseshoe = profileOpen && !isMobile && !showThreadDrawer;
// Media viewer side pane on desktop — same horseshoe seam idiom
// as the profile pane. The two are mutually exclusive in practice
// (the open hooks clear the other atom), so at most one shows at
// a time; both feed into `paintParentVoid` for the chat-column bg
// and locally gate the shared `<VoidGap />` seam.
const showMediaHorseshoe = mediaOpen && !isMobile && !showThreadDrawer;
// The desktop/tablet media viewer is no longer a right-side pane — it's
// a free-floating draggable window (`RoomViewMediaWindow`) portaled over
// the app, so it doesn't carve a horseshoe seam or paint the parent void.
// Thread drawer side pane on desktop. Mobile hides the chat column
// entirely (`drawerHidesChat`) so the seam doesn't apply there.
const showThreadHorseshoe = showThreadDrawer && !isMobile;
@ -168,8 +163,7 @@ export function Room({ renderRoomView }: RoomProps) {
// remain local to each render site (a single `paintParentVoid` gate
// would over-render when only members is open — there is no
// profile/media slot to anchor it to).
const paintParentVoid =
showProfileHorseshoe || showMediaHorseshoe || showThreadHorseshoe || showMembersHorseshoe;
const paintParentVoid = showProfileHorseshoe || showThreadHorseshoe || showMembersHorseshoe;
useKeyDown(
window,
@ -193,12 +187,13 @@ export function Room({ renderRoomView }: RoomProps) {
);
// Disable the atom-driven media viewer when the desktop thread
// drawer is open — the side-pane mount block below is gated on
// `!showThreadDrawer`, so the new viewer's right pane wouldn't
// render, and an atom-only open path would silently no-op on the
// user's tap. With the host context cleared, `ImageContent` /
// `VideoContent` fall back to the legacy full-screen `<Overlay>`
// modal, which sits on top of every pane and still works.
// drawer is open — with the host context cleared, a timeline
// image/video tap falls back to the legacy full-screen `<Overlay>`
// modal instead of opening a floating window on top of the drawer
// (the `<Overlay>` sits above every pane and still works). NB: an
// already-open floating window is NOT torn down by this — the window
// mounts for all non-mobile screens and stays visible over the
// drawer; only NEW taps are routed to the legacy modal.
// Mobile is unaffected: `drawerHidesChat` hides the chat column
// entirely on mobile + thread, so there's no media to tap anyway.
//
@ -283,11 +278,18 @@ export function Room({ renderRoomView }: RoomProps) {
exclusive via the open hooks. */}
{!isMobile && !showThreadDrawer && (
<>
{(showProfileHorseshoe || showMediaHorseshoe) && <VoidGap />}
{showProfileHorseshoe && <VoidGap />}
<RoomViewProfileSidePanel />
<RoomViewMediaSidePanel />
</>
)}
{/* Desktop / tablet media viewer a free-floating draggable
window portaled over the app (replaces the old right-side
pane). It self-gates on `mediaViewerAtom`, so mount it for
every non-mobile screen unconditionally on the thread
drawer too, so a window opened before the drawer stays
visible over it (opening NEW media during the drawer is
still routed to the legacy modal see `mediaHostValue`). */}
{!isMobile && <RoomViewMediaWindow />}
{callView && chat && (
<>

View file

@ -1,107 +0,0 @@
import { style } from '@vanilla-extract/css';
import { color, toRem } from 'folds';
import { VOJO_HORSESHOE_GAP_PX, VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
// Right-side media pane. Width is owned by the component (resizable,
// localStorage-backed via `mediaSidePanelWidthAtom`); no `width` here
// so the inline `style={{ width }}` wins without specificity fights.
// `overflow: visible` so the absolutely-positioned resize handle in
// the void gap to the left isn't clipped — symmetric with
// `ThreadDrawerResizable`.
//
// Left edge rounded (TL + BL) to carve across the 12px horseshoe
// void gap rendered by `Room.tsx` — same design language as the
// profile pane and the page-nav <-> chat split.
//
// `Background.Container` (#0d0e11) chosen for the dark image
// backdrop — same logic as the mobile silhouette bg.
export const panel = style({
position: 'relative',
flexShrink: 0,
flexGrow: 0,
display: 'flex',
flexDirection: 'column',
minHeight: 0,
});
// Inner content surface that actually paints the panel bg + rounded
// carves. Separate from the outer `panel` so the resize handle (which
// sits OUTSIDE the panel's left edge, inside the horseshoe void) isn't
// clipped by `overflow: hidden`.
export const inner = style({
flex: 1,
display: 'flex',
flexDirection: 'column',
minHeight: 0,
minWidth: 0,
backgroundColor: color.Background.Container,
overflow: 'hidden',
borderTopLeftRadius: toRem(VOJO_HORSESHOE_RADIUS_PX),
borderBottomLeftRadius: toRem(VOJO_HORSESHOE_RADIUS_PX),
});
// Body fills the remaining vertical space below `MediaViewerBody`'s
// own header. The pane has no separate `PageHeader` strip — the
// viewer body owns its chrome (close button, title, action row) so
// the header sits flush with the rounded TL corner.
export const body = style({
flex: 1,
display: 'flex',
flexDirection: 'column',
minHeight: 0,
minWidth: 0,
});
// Resize handle mirror of `ThreadDrawerResizeHandle` — sits in the
// 12px horseshoe void to the LEFT of the panel. Same indicator-bar
// cosmetics so the two resizable right panes feel identical.
export const resizeHandle = style({
position: 'absolute',
top: 0,
bottom: 0,
left: `-${toRem(VOJO_HORSESHOE_GAP_PX)}`,
width: toRem(VOJO_HORSESHOE_GAP_PX),
cursor: 'col-resize',
zIndex: 1,
background: 'transparent',
touchAction: 'none',
outline: 'none',
selectors: {
'&::before': {
content: '""',
position: 'absolute',
top: '50%',
left: '50%',
width: 2,
height: toRem(36),
transform: 'translate(-50%, -50%)',
borderRadius: 1,
backgroundColor: color.Surface.OnContainer,
opacity: 0,
transition:
'opacity 140ms ease, height 140ms ease, width 140ms ease, background-color 140ms ease',
},
'&:hover::before, &:focus-visible::before': {
opacity: 0.25,
},
'&:focus-visible::before': {
backgroundColor: color.Primary.Main,
opacity: 0.45,
},
'&[data-dragging="true"]::before': {
opacity: 0.55,
height: toRem(48),
backgroundColor: color.Primary.Main,
},
'&[data-dragging="true"][data-at-min="true"]::before': {
height: toRem(28),
width: 3,
opacity: 0.85,
},
'&[data-dragging="true"][data-at-max="true"]::before': {
height: toRem(76),
width: 2,
opacity: 0.9,
},
},
});

View file

@ -1,222 +0,0 @@
// Desktop / tablet right-side media pane. Renders the same
// `MediaViewerBody` the mobile bottom-up horseshoe shows, but as a
// flex sibling next to the chat column instead of a slide-up rail.
//
// Width is resizable on the left edge — mirror of `ThreadDrawer`'s
// desktop variant. Saved width lives in `mediaSidePanelWidthAtom`
// (localStorage); the max is computed «smart»: viewport minus the
// left rail + chat-list column (its own saved width) + two horseshoe
// void gaps + a chat-column reservation. So dragging the media pane
// wider only eats slack, never squeezes the chat below readability.
//
// Mounted in `Room.tsx` only on non-mobile screens; mobile uses
// `MobileMediaViewerHorseshoe` instead.
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useAtom, useAtomValue } from 'jotai';
import { useTranslation } from 'react-i18next';
import FocusTrap from 'focus-trap-react';
import { mediaViewerAtom } from '../../state/mediaViewer';
import { useCloseMediaViewer } from '../../state/hooks/mediaViewer';
import { stopPropagation } from '../../utils/keyboard';
import { MediaViewerBody } from './MediaViewerBody';
import {
MEDIA_SIDE_PANEL_WIDTH_MIN,
clampMediaSidePanelWidth,
computeMediaSidePanelMax,
mediaSidePanelWidthAtom,
sidebarWidthAtom,
} from '../../state/mediaSidePanelWidth';
import * as css from './RoomViewMediaSidePanel.css';
export function RoomViewMediaSidePanel() {
const { t } = useTranslation();
const entry = useAtomValue(mediaViewerAtom);
const close = useCloseMediaViewer();
const open = !!entry;
const entryRef = useRef(entry);
entryRef.current = entry;
const [savedWidth, setSavedWidth] = useAtom(mediaSidePanelWidthAtom);
const pageNavWidth = useAtomValue(sidebarWidthAtom);
const [vw, setVw] = useState<number>(typeof window !== 'undefined' ? window.innerWidth : 1280);
const [dragging, setDragging] = useState(false);
const [liveWidth, setLiveWidth] = useState<number | null>(null);
const resizableRef = useRef<HTMLDivElement>(null);
const resizeHandleRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const onResize = () => setVw(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
const maxW = computeMediaSidePanelMax(vw, pageNavWidth);
const baseWidth = dragging && liveWidth !== null ? liveWidth : savedWidth;
const panelWidth = clampMediaSidePanelWidth(baseWidth, vw, pageNavWidth);
const canResize = maxW > MEDIA_SIDE_PANEL_WIDTH_MIN;
const atMin = dragging && liveWidth !== null && liveWidth <= MEDIA_SIDE_PANEL_WIDTH_MIN;
const atMax = dragging && liveWidth !== null && liveWidth >= maxW;
// Body-style cleanup if the drag terminates without `pointerup`
// (unmount mid-drag — entry cleared, route change, mobile flip).
// Without this the page can get stuck with col-resize cursor.
useEffect(() => {
if (!dragging) return undefined;
return () => {
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
}, [dragging]);
const beginDrag = useCallback((pointerId: number) => {
setDragging(true);
setLiveWidth(null);
try {
resizeHandleRef.current?.setPointerCapture(pointerId);
} catch {
/* setPointerCapture is best-effort */
}
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}, []);
const endDrag = useCallback(() => {
setDragging(false);
document.body.style.cursor = '';
document.body.style.userSelect = '';
setLiveWidth((current) => {
if (current !== null) {
setSavedWidth(clampMediaSidePanelWidth(current, window.innerWidth, pageNavWidth));
}
return null;
});
}, [setSavedWidth, pageNavWidth]);
const onResizePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0 || !canResize) return;
e.preventDefault();
beginDrag(e.pointerId);
},
[beginDrag, canResize]
);
const onResizePointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!dragging || !resizableRef.current) return;
// Right edge of the wrapper is anchored at the row's right edge
// (panel is the last item in Room.tsx's flex row). Width grows
// as the pointer moves LEFT, shrinks as it moves right — inverse
// of the page-nav handle.
const rect = resizableRef.current.getBoundingClientRect();
setLiveWidth(
clampMediaSidePanelWidth(rect.right - e.clientX, window.innerWidth, pageNavWidth)
);
},
[dragging, pageNavWidth]
);
const onResizeStopPointer = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
try {
resizeHandleRef.current?.releasePointerCapture(e.pointerId);
} catch {
/* releasePointerCapture is best-effort */
}
endDrag();
},
[endDrag]
);
const onResizeKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (!canResize) return;
const step = e.shiftKey ? 64 : 16;
let next: number | null = null;
// ArrowLeft grows the panel (its left edge moves left → width
// increases), ArrowRight shrinks. Home → max, End → min.
if (e.key === 'ArrowLeft') next = savedWidth + step;
else if (e.key === 'ArrowRight') next = savedWidth - step;
else if (e.key === 'Home') next = maxW;
else if (e.key === 'End') next = MEDIA_SIDE_PANEL_WIDTH_MIN;
if (next === null) return;
e.preventDefault();
setSavedWidth(clampMediaSidePanelWidth(next, vw, pageNavWidth));
},
[savedWidth, vw, pageNavWidth, maxW, canResize, setSavedWidth]
);
if (!open || !entry) return null;
return (
<FocusTrap
focusTrapOptions={{
// Move focus into the trap on open so screen readers
// announce the dialog and keyboard arrow keys / Esc reach
// the viewer body. Falls back to first focusable element
// (the close button in the body header) without us pinning
// a specific ref — simpler than threading one down.
initialFocus: undefined,
// Outside clicks pass through to chat (`allowOutsideClick`)
// but don't close — clicking around in chat to scroll /
// compose shouldn't dismiss the viewer. Explicit close via
// the body's × button or `Esc`. Mirror of the profile side
// pane's stance.
clickOutsideDeactivates: false,
allowOutsideClick: () => true,
escapeDeactivates: stopPropagation,
onDeactivate: () => {
if (entryRef.current) close();
},
checkCanFocusTrap: () => Promise.resolve(),
}}
active={open}
>
<div
ref={resizableRef}
className={css.panel}
style={{ width: panelWidth }}
role="dialog"
aria-modal="true"
aria-label={entry.body || t('MediaViewer.title', 'Media viewer')}
>
<div className={css.inner}>
<div className={css.body}>
<MediaViewerBody entry={entry} requestClose={close} />
</div>
</div>
{canResize && (
// Canonical WAI-ARIA window-splitter pattern (focusable
// separator with current/min/max). See ThreadDrawer for the
// same justification.
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/role-supports-aria-props, jsx-a11y/no-noninteractive-tabindex
<div
ref={resizeHandleRef}
role="separator"
aria-orientation="vertical"
aria-valuenow={panelWidth}
aria-valuemin={MEDIA_SIDE_PANEL_WIDTH_MIN}
aria-valuemax={maxW}
aria-label={t('MediaViewer.resize', 'Resize media viewer')}
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0}
className={css.resizeHandle}
data-dragging={dragging || undefined}
data-at-min={atMin || undefined}
data-at-max={atMax || undefined}
onPointerDown={onResizePointerDown}
onPointerMove={onResizePointerMove}
onPointerUp={onResizeStopPointer}
onPointerCancel={onResizeStopPointer}
onLostPointerCapture={onResizeStopPointer}
onKeyDown={onResizeKeyDown}
/>
)}
</div>
</FocusTrap>
);
}

View file

@ -0,0 +1,94 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
// Desktop / tablet floating media window — a draggable overlay that
// sits ON TOP of the app (portaled to #portalContainer) instead of the
// old right-side flex pane. Position + size are owned by the component
// (inline `style`); chrome is deliberately minimal: a thin drag bar +
// close, plus a subtle resize grip. Zoom is wheel-only, navigation is
// keyboard / hover-chevrons (both live in `MediaViewerBody`).
export const floating = style({
position: 'fixed',
// Above normal app chrome (call rail, headers); folds modals/overlays
// still portal above this (zIndex.Max), which is correct for dialogs
// over the window.
zIndex: config.zIndex.Z400,
display: 'flex',
flexDirection: 'column',
minHeight: 0,
minWidth: 0,
backgroundColor: color.Background.Container,
color: color.Background.OnContainer,
borderRadius: toRem(12),
overflow: 'hidden',
boxShadow: config.shadow.E400,
border: `1px solid ${color.SurfaceVariant.Container}`,
});
// The only drag-to-move origin. `touch-action: none` so a touch-drag on
// a tablet moves the window instead of scrolling the page behind.
export const dragBar = style({
flexShrink: 0,
height: toRem(32),
display: 'flex',
alignItems: 'center',
gap: config.space.S200,
paddingLeft: config.space.S300,
paddingRight: config.space.S100,
cursor: 'move',
userSelect: 'none',
touchAction: 'none',
backgroundColor: color.Surface.Container,
borderBottom: `1px solid ${color.SurfaceVariant.Container}`,
});
// Faint filename — present but unobtrusive; doubles as part of the
// grab surface.
export const title = style({
flex: '1 1 auto',
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: toRem(12),
color: color.Surface.OnContainer,
opacity: 0.7,
});
export const body = style({
position: 'relative',
flex: 1,
display: 'flex',
flexDirection: 'column',
minHeight: 0,
minWidth: 0,
});
// Subtle bottom-right corner resize affordance — no button, just a
// draggable corner that brightens on hover.
export const resizeGrip = style({
position: 'absolute',
right: 0,
bottom: 0,
width: toRem(20),
height: toRem(20),
cursor: 'nwse-resize',
touchAction: 'none',
zIndex: 3,
selectors: {
'&::after': {
content: '""',
position: 'absolute',
right: toRem(3),
bottom: toRem(3),
width: toRem(9),
height: toRem(9),
borderRight: `2px solid ${color.Surface.OnContainer}`,
borderBottom: `2px solid ${color.Surface.OnContainer}`,
borderBottomRightRadius: toRem(3),
opacity: 0.25,
transition: 'opacity 140ms ease',
},
'&:hover::after': { opacity: 0.6 },
},
});

View file

@ -0,0 +1,243 @@
// Desktop / tablet media viewer — a free-floating, draggable window that
// renders ON TOP of the app instead of the old right-side flex pane. It
// reuses `MediaViewerBody` (resolve/decrypt, zoom, pan, swipe, prev/next)
// with `hideHeader` so the only chrome is a thin drag bar + close button,
// plus a subtle corner resize grip. Portaled to #portalContainer so no
// ancestor transform/overflow can clip it.
//
// Mounted in `Room.tsx` for non-mobile screens; mobile uses
// `MobileMediaViewerHorseshoe`.
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useAtom, useAtomValue } from 'jotai';
import { useTranslation } from 'react-i18next';
import { Icon, IconButton, Icons } from 'folds';
import { mediaViewerAtom } from '../../state/mediaViewer';
import { useCloseMediaViewer } from '../../state/hooks/mediaViewer';
import { useRoom } from '../../hooks/useRoom';
import {
MediaWindowRect,
clampMediaWindowRect,
defaultMediaWindowRect,
mediaWindowRectAtom,
} from '../../state/mediaWindowRect';
import { MediaViewerBody } from './MediaViewerBody';
import * as css from './RoomViewMediaWindow.css';
type GestureMode = 'move' | 'resize';
export function RoomViewMediaWindow() {
const { t } = useTranslation();
const entry = useAtomValue(mediaViewerAtom);
const close = useCloseMediaViewer();
const room = useRoom();
const [savedRect, setSavedRect] = useAtom(mediaWindowRectAtom);
const open = !!entry;
const [viewport, setViewport] = useState(() => ({
w: typeof window === 'undefined' ? 1280 : window.innerWidth,
h: typeof window === 'undefined' ? 800 : window.innerHeight,
}));
useEffect(() => {
const onResize = () => setViewport({ w: window.innerWidth, h: window.innerHeight });
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
// Live window geometry. Seeded from the saved rect (clamped) or a
// centred default the first time the window opens, then mutated by
// drag / resize and re-clamped on viewport resize.
const [rect, setRect] = useState<MediaWindowRect | null>(null);
const wasOpenRef = useRef(false);
useLayoutEffect(() => {
if (open && !wasOpenRef.current) {
const vw = window.innerWidth;
const vh = window.innerHeight;
// Route BOTH branches through the clamp so the centred default is
// floored to the min size too (defaultMediaWindowRect alone can dip
// below MEDIA_WINDOW_MIN_H on a very short viewport).
setRect(clampMediaWindowRect(savedRect ?? defaultMediaWindowRect(vw, vh), vw, vh));
}
wasOpenRef.current = open;
}, [open, savedRect]);
// Re-clamp into the viewport when it shrinks while the window is open.
useEffect(() => {
if (!open) return;
setRect((prev) => (prev ? clampMediaWindowRect(prev, viewport.w, viewport.h) : prev));
}, [open, viewport.w, viewport.h]);
// Drag (move) + resize share one gesture, distinguished by `mode`.
// We listen on `document` (added on pointerdown, removed on pointerup)
// so the gesture keeps tracking even when the cursor leaves the small
// drag bar / grip. `startRect` is snapshotted so each move computes an
// absolute delta from the gesture origin (no drift accumulation).
const gestureRef = useRef<{
mode: GestureMode;
startX: number;
startY: number;
startRect: MediaWindowRect;
} | null>(null);
const persist = useCallback(
(r: MediaWindowRect) => {
setSavedRect(clampMediaWindowRect(r, window.innerWidth, window.innerHeight));
},
[setSavedRect]
);
const onGesturePointerMove = useCallback((e: PointerEvent) => {
const g = gestureRef.current;
if (!g) return;
e.preventDefault();
const dx = e.clientX - g.startX;
const dy = e.clientY - g.startY;
const vw = window.innerWidth;
const vh = window.innerHeight;
if (g.mode === 'move') {
setRect(
clampMediaWindowRect(
{ ...g.startRect, x: g.startRect.x + dx, y: g.startRect.y + dy },
vw,
vh
)
);
} else {
// Resize from the bottom-right grip (x/y fixed): cap growth to the
// space available to the right / below the window's origin so the
// grip can't be pushed off-screen and become unreachable.
setRect(
clampMediaWindowRect(
{
...g.startRect,
w: Math.min(g.startRect.w + dx, vw - g.startRect.x),
h: Math.min(g.startRect.h + dy, vh - g.startRect.y),
},
vw,
vh
)
);
}
}, []);
const onGesturePointerUp = useCallback(() => {
gestureRef.current = null;
document.removeEventListener('pointermove', onGesturePointerMove);
document.removeEventListener('pointerup', onGesturePointerUp);
document.removeEventListener('pointercancel', onGesturePointerUp);
setRect((prev) => {
if (prev) persist(prev);
return prev;
});
}, [onGesturePointerMove, persist]);
const beginGesture = useCallback(
(mode: GestureMode, e: React.PointerEvent<HTMLDivElement>) => {
if (!rect || e.button !== 0) return;
e.preventDefault();
gestureRef.current = { mode, startX: e.clientX, startY: e.clientY, startRect: rect };
document.addEventListener('pointermove', onGesturePointerMove);
document.addEventListener('pointerup', onGesturePointerUp);
// A cancelled pointer (tablet edge-gesture, palm rejection, OS
// scroll takeover) fires pointercancel instead of pointerup — end
// the gesture the same way so it can't strand + leak listeners.
document.addEventListener('pointercancel', onGesturePointerUp);
},
[rect, onGesturePointerMove, onGesturePointerUp]
);
// Belt-and-suspenders: drop any in-flight document listeners if the
// window unmounts mid-drag (route change, atom cleared).
useEffect(
() => () => {
document.removeEventListener('pointermove', onGesturePointerMove);
document.removeEventListener('pointerup', onGesturePointerUp);
document.removeEventListener('pointercancel', onGesturePointerUp);
},
[onGesturePointerMove, onGesturePointerUp]
);
// Esc closes (the body owns arrows / +/- / 0; it deliberately leaves
// Esc to the host so each shell can decide its own dismiss policy).
const closeRef = useRef(close);
closeRef.current = close;
const entryRef = useRef(entry);
entryRef.current = entry;
// Clear the global media atom when the room changes (this window lives
// under a `key={room.roomId}` RoomProvider, so a room switch unmounts
// it). Without this the next room would re-seed the window and float
// the previous room's media over it with dead prev/next (currentIndex
// = -1). Mirrors RoomViewProfileSidePanel + the mobile horseshoe. Refs
// so the cleanup doesn't re-fire on every prev/next entry step.
useEffect(
() => () => {
if (entryRef.current) closeRef.current();
},
[room.roomId]
);
useEffect(() => {
if (!open) return undefined;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
const target = e.target as HTMLElement | null;
if (
target &&
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
) {
return;
}
closeRef.current();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [open]);
if (!open || !entry || !rect) return null;
const portalTarget =
typeof document !== 'undefined'
? document.getElementById('portalContainer') ?? document.body
: null;
if (!portalTarget) return null;
return createPortal(
<div
className={css.floating}
style={{ left: rect.x, top: rect.y, width: rect.w, height: rect.h }}
role="dialog"
aria-label={entry.body || t('MediaViewer.title', 'Media viewer')}
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div className={css.dragBar} onPointerDown={(e) => beginGesture('move', e)}>
<span className={css.title}>{entry.body}</span>
<IconButton
variant="Background"
fill="None"
radii="300"
size="300"
// Don't let a click on the close button start a window drag.
onPointerDown={(e: React.PointerEvent) => e.stopPropagation()}
onClick={close}
aria-label={t('Common.close', 'Close')}
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
</div>
<div className={css.body}>
<MediaViewerBody entry={entry} requestClose={close} hideHeader />
</div>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className={css.resizeGrip}
aria-hidden="true"
onPointerDown={(e) => beginGesture('resize', e)}
/>
</div>,
portalTarget
);
}

View file

@ -1,7 +1,8 @@
import { createContext, useContext } from 'react';
// Non-null only when an atom-driven media-viewer shell (the mobile
// bottom-up horseshoe or the desktop right side pane) is mounted in
// bottom-up horseshoe or the desktop/tablet floating window
// `RoomViewMediaWindow`) is mounted in
// the React tree above this consumer. `ImageContent` reads this to
// decide between the new atom-open path (Room timeline) and the
// legacy local-state `<Overlay>` viewer (pin-menu, message search
@ -16,5 +17,4 @@ export type MediaViewerHostValue = { roomId: string } | null;
export const MediaViewerHostContext = createContext<MediaViewerHostValue>(null);
export const useMediaViewerHost = (): MediaViewerHostValue =>
useContext(MediaViewerHostContext);
export const useMediaViewerHost = (): MediaViewerHostValue => useContext(MediaViewerHostContext);

View file

@ -32,11 +32,11 @@ type OpenCallback = (
export const useOpenUserRoomProfile = (): OpenCallback => {
const setUserRoomProfile = useSetAtom(userRoomProfileAtom);
// Bidirectional mutual exclusion with the media viewer — opening
// profile closes any open media viewer pane / sheet. Mirrors the
// clear-the-other-atom behaviour in `useOpenMediaViewer`. Without
// this, on desktop both `RoomViewProfileSidePanel` and
// `RoomViewMediaSidePanel` would mount as siblings and fight for
// the right-pane slot.
// profile closes any open media viewer window / sheet. Mirrors the
// clear-the-other-atom behaviour in `useOpenMediaViewer`. The media
// viewer is a floating window (`RoomViewMediaWindow`) on desktop now,
// but keeping them mutually exclusive avoids a profile pane fighting
// the window for attention.
const setMediaViewer = useSetAtom(mediaViewerAtom);
// Symmetric mutual exclusion with the group-room members sheet — when
// the user taps a row inside the members list, the per-user profile

View file

@ -1,68 +0,0 @@
import {
atomWithLocalStorage,
getLocalStorageItem,
setLocalStorageItem,
} from './utils/atomWithLocalStorage';
import { sidebarWidthAtom } from './sidebarWidth';
export const MEDIA_SIDE_PANEL_WIDTH_KEY = 'vojo_media_side_panel_width';
// Lowest readable width for `MediaViewerBody`'s media card + action row.
// Below ~360 the file-name truncates aggressively and the action row
// starts wrapping.
export const MEDIA_SIDE_PANEL_WIDTH_MIN = 360;
// Pleasant starting width on a typical 1440-wide desktop after the
// chat-list column at its default 416 and the chat column reserve.
export const MEDIA_SIDE_PANEL_WIDTH_DEFAULT = 520;
// Hard ceiling: even on ultra-wide displays the media pane shouldn't
// dwarf the chat — matches the prior CSS `clamp(..., ..., 880px)` cap.
export const MEDIA_SIDE_PANEL_WIDTH_HARD_MAX = 880;
// Layout constants the smart-max math depends on. Kept here (not
// imported from horseshoe.ts / Sidebar.css.ts) so the clamp helper
// stays a pure function safe to call from anywhere — including a
// re-mount race where the CSS-module hasn't bound yet.
const SIDEBAR_RAIL_PX = 66; // global icon rail, fixed
const VOID_GAP_PX = 12; // VOJO_HORSESHOE_GAP_PX (page-nav<->chat and chat<->media)
// Minimum width to reserve for the chat column when media is open.
// Below ~360 the timeline text gets line-broken to single words and
// reading falls apart — the whole point of this clamp is to protect
// that reading area, not the media pane.
const CHAT_COLUMN_RESERVED_PX = 360;
const readMediaSidePanelWidth = (key: string): number => {
const raw = getLocalStorageItem<unknown>(key, MEDIA_SIDE_PANEL_WIDTH_DEFAULT);
const value =
typeof raw === 'number' && Number.isFinite(raw) ? raw : MEDIA_SIDE_PANEL_WIDTH_DEFAULT;
return Math.max(MEDIA_SIDE_PANEL_WIDTH_MIN, Math.round(value));
};
export const mediaSidePanelWidthAtom = atomWithLocalStorage<number>(
MEDIA_SIDE_PANEL_WIDTH_KEY,
readMediaSidePanelWidth,
setLocalStorageItem
);
// Smart maximum: viewport minus (left rail + chat-list column + two
// horseshoe void gaps + chat-column reservation). Both the chat-list
// and the chat-column are protected — dragging media wider can only
// eat the slack between them and the hard cap. If even the reservation
// can't fit (tiny laptop / virtual viewport), the max collapses to
// MIN so the panel still mounts at a sane size.
export const computeMediaSidePanelMax = (viewportWidth: number, pageNavWidth: number): number => {
const reserved = SIDEBAR_RAIL_PX + pageNavWidth + VOID_GAP_PX * 2 + CHAT_COLUMN_RESERVED_PX;
const available = viewportWidth - reserved;
return Math.max(MEDIA_SIDE_PANEL_WIDTH_MIN, Math.min(MEDIA_SIDE_PANEL_WIDTH_HARD_MAX, available));
};
export const clampMediaSidePanelWidth = (
px: number,
viewportWidth: number,
pageNavWidth: number
): number => {
const max = computeMediaSidePanelMax(viewportWidth, pageNavWidth);
return Math.max(MEDIA_SIDE_PANEL_WIDTH_MIN, Math.min(max, Math.round(px)));
};
// Re-export so callers don't have to import `sidebarWidthAtom` from a
// separate state file just to feed the clamp helper.
export { sidebarWidthAtom };

View file

@ -3,7 +3,8 @@ import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { IImageInfo, IVideoInfo } from '../../types/matrix/common';
// Open state for the room media viewer — bottom-up sheet on mobile,
// right-side pane on desktop. Mirror of `settingsSheetAtom`. The
// floating draggable window on desktop/tablet (RoomViewMediaWindow).
// Mirror of `settingsSheetAtom`. The
// atom-driven shell is mounted in `Room.tsx`; `ImageContent` /
// `VideoContent` open the atom when the host context from
// `mediaViewerHostContext` is non-null (Room timeline path);

View file

@ -0,0 +1,68 @@
import {
atomWithLocalStorage,
getLocalStorageItem,
setLocalStorageItem,
} from './utils/atomWithLocalStorage';
// Persisted geometry of the desktop/tablet floating media window
// (`RoomViewMediaWindow`). Position + size survive reloads; both are
// re-clamped into the live viewport on open so a window saved on a big
// monitor can't open off-screen on a small one. `null` until the user
// has opened the viewer once — the component then centres a default.
export const MEDIA_WINDOW_RECT_KEY = 'vojo_media_window_rect';
export type MediaWindowRect = { x: number; y: number; w: number; h: number };
// Floors so the window never collapses below a usable media card.
export const MEDIA_WINDOW_MIN_W = 320;
export const MEDIA_WINDOW_MIN_H = 240;
// Always keep at least this much of the window on-screen so the drag
// bar stays grabbable no matter where it was dropped.
const ON_SCREEN_MARGIN_PX = 80;
const isFiniteNumber = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
const readMediaWindowRect = (key: string): MediaWindowRect | null => {
const raw = getLocalStorageItem<unknown>(key, null);
if (!raw || typeof raw !== 'object') return null;
const r = raw as Partial<MediaWindowRect>;
if (
!isFiniteNumber(r.x) ||
!isFiniteNumber(r.y) ||
!isFiniteNumber(r.w) ||
!isFiniteNumber(r.h)
) {
return null;
}
return { x: r.x, y: r.y, w: r.w, h: r.h };
};
export const mediaWindowRectAtom = atomWithLocalStorage<MediaWindowRect | null>(
MEDIA_WINDOW_RECT_KEY,
readMediaWindowRect,
setLocalStorageItem
);
// Pleasant centred default — roughly half the width, ~70% of the height
// of a typical desktop, clamped to the floors.
export const defaultMediaWindowRect = (vw: number, vh: number): MediaWindowRect => {
const w = Math.round(Math.min(640, Math.max(MEDIA_WINDOW_MIN_W, vw * 0.45)));
const h = Math.round(Math.min(Math.round(vh * 0.85), Math.max(MEDIA_WINDOW_MIN_H, vh * 0.7)));
return { x: Math.round((vw - w) / 2), y: Math.round((vh - h) / 2), w, h };
};
// Clamp a rect into the viewport: size capped to the viewport and floored,
// position kept so a margin always stays on-screen (drag bar grabbable).
export const clampMediaWindowRect = (
rect: MediaWindowRect,
vw: number,
vh: number
): MediaWindowRect => {
const w = Math.round(Math.max(MEDIA_WINDOW_MIN_W, Math.min(rect.w, vw)));
const h = Math.round(Math.max(MEDIA_WINDOW_MIN_H, Math.min(rect.h, vh)));
const x = Math.round(
Math.max(ON_SCREEN_MARGIN_PX - w, Math.min(rect.x, vw - ON_SCREEN_MARGIN_PX))
);
const y = Math.round(Math.max(0, Math.min(rect.y, Math.max(0, vh - 40))));
return { x, y, w, h };
};