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:
parent
901bec2e9a
commit
b6ffa03891
17 changed files with 952 additions and 696 deletions
|
|
@ -1,53 +1,43 @@
|
||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
import { recipe } from '@vanilla-extract/recipes';
|
|
||||||
import { color, config, toRem } from 'folds';
|
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;
|
const StreamMediaCornerRound = config.radii.R500;
|
||||||
|
|
||||||
export const StreamMediaBubble = recipe({
|
export const StreamMediaBubble = style({
|
||||||
base: {
|
position: 'relative',
|
||||||
position: 'relative',
|
overflow: 'hidden',
|
||||||
overflow: 'hidden',
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
backgroundColor: color.SurfaceVariant.Container,
|
maxWidth: '100%',
|
||||||
maxWidth: '100%',
|
boxSizing: 'border-box',
|
||||||
boxSizing: 'border-box',
|
flexShrink: 0,
|
||||||
flexShrink: 0,
|
borderRadius: StreamMediaCornerSmall,
|
||||||
// 1px frame drawn ABOVE the image via a pseudo-element. `inset
|
// 1px frame drawn ABOVE the image via a pseudo-element. `inset
|
||||||
// box-shadow` would sit on the bg layer and get hidden by the
|
// box-shadow` would sit on the bg layer and get hidden by the
|
||||||
// `<img>` filling 100% × 100%; a real `border` would push the
|
// `<img>` filling 100% × 100%; a real `border` would push the
|
||||||
// image's coordinate space and re-introduce the 1px chip-alignment
|
// image's coordinate space and re-introduce the 1px chip-alignment
|
||||||
// offset we just removed. The pseudo sits at z-index 1 (above
|
// offset we just removed. The pseudo sits at z-index 1 (above
|
||||||
// image's auto-stacked 0, below the username overlay at z 2),
|
// image's auto-stacked 0, below the username overlay at z 2),
|
||||||
// inherits the bubble's asymmetric border-radius, and is
|
// inherits the bubble's border-radius, and is pointer-events:none so
|
||||||
// pointer-events:none so clicks pass through to the image.
|
// clicks pass through to the image.
|
||||||
selectors: {
|
selectors: {
|
||||||
'&::after': {
|
'&::after': {
|
||||||
content: '""',
|
content: '""',
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
border: `1.2px solid ${color.Surface.Container}`,
|
border: `1.2px solid ${color.Surface.Container}`,
|
||||||
borderRadius: 'inherit',
|
borderRadius: 'inherit',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
zIndex: 1,
|
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
|
// Caption mini-bubble under the image. Keeps a slightly larger R500 radius,
|
||||||
// notch lives on the image-bubble itself; stacking two notch'd rectangles
|
// intentionally distinct from the preview bubble's uniform R400.
|
||||||
// reads worse than one notched + one rounded.
|
|
||||||
export const StreamMediaCaption = style({
|
export const StreamMediaCaption = style({
|
||||||
marginTop: toRem(4),
|
marginTop: toRem(4),
|
||||||
backgroundColor: color.SurfaceVariant.Container,
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,10 @@ import { toRem } from 'folds';
|
||||||
import { StreamMediaBubble } from './StreamMedia.css';
|
import { StreamMediaBubble } from './StreamMedia.css';
|
||||||
import { logMedia, useMediaMeasureDebug } from './streamMediaDebug';
|
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_MIN_ASPECT = 3 / 4;
|
||||||
const STREAM_MEDIA_MAX_ASPECT = 4 / 3;
|
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) };
|
return { width: toRem(h * naturalAspect), height: toRem(h) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared chrome for image / video timeline bubbles: square-ish bubble with
|
// Shared chrome for image / video timeline bubbles: an aspect-preserving
|
||||||
// asymmetric notch + 1px pseudo-frame. Caller plugs in the actual media
|
// (up to 168px) preview box with uniformly-rounded R400 corners and a 1px
|
||||||
// renderer as `children`. The sender nick is rendered ABOVE the media by the
|
// pseudo-frame. Caller plugs in the actual media renderer as `children`.
|
||||||
// Stream layout's name header (like text messages), not overlaid on the image.
|
// 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) {
|
export function StreamMediaShell({ naturalW, naturalH, own, children }: StreamMediaShellProps) {
|
||||||
const bubbleRef = useRef<HTMLDivElement>(null);
|
const bubbleRef = useRef<HTMLDivElement>(null);
|
||||||
const computedStyle = computeBoxStyle(naturalW, naturalH);
|
const computedStyle = computeBoxStyle(naturalW, naturalH);
|
||||||
|
|
@ -58,7 +64,7 @@ export function StreamMediaShell({ naturalW, naturalH, own, children }: StreamMe
|
||||||
useMediaMeasureDebug(bubbleRef, [computedStyle.width, computedStyle.height]);
|
useMediaMeasureDebug(bubbleRef, [computedStyle.width, computedStyle.height]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={bubbleRef} className={StreamMediaBubble({ own })} style={computedStyle}>
|
<div ref={bubbleRef} className={StreamMediaBubble} style={computedStyle}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -68,8 +68,8 @@ export type ImageContentProps = {
|
||||||
markedAsSpoiler?: boolean;
|
markedAsSpoiler?: boolean;
|
||||||
spoilerReason?: string;
|
spoilerReason?: string;
|
||||||
// When provided AND the `MediaViewerHostContext` is non-null,
|
// When provided AND the `MediaViewerHostContext` is non-null,
|
||||||
// clicking the thumbnail opens the atom-driven horseshoe viewer
|
// clicking the thumbnail opens the atom-driven viewer (mobile
|
||||||
// (mobile bottom-up sheet / desktop right pane) instead of the
|
// bottom-up sheet / desktop+tablet floating window) instead of the
|
||||||
// legacy full-screen `<Overlay>` viewer. Non-Room surfaces
|
// legacy full-screen `<Overlay>` viewer. Non-Room surfaces
|
||||||
// (pin-menu, message search) leave the host context as `null` and
|
// (pin-menu, message search) leave the host context as `null` and
|
||||||
// therefore keep the legacy modal even if they pass `eventId`.
|
// therefore keep the legacy modal even if they pass `eventId`.
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,20 @@ export const image = style({
|
||||||
imageRendering: 'auto',
|
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:
|
// Side chevrons — visibility logic:
|
||||||
// • Coarse pointer (touch devices, mobile): always visible at
|
// • Coarse pointer (touch devices, mobile): always visible at
|
||||||
// 0.7 opacity so users have a tappable nav affordance without
|
// 0.7 opacity so users have a tappable nav affordance without
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Body for the room media viewer — mounted inside both the mobile
|
// Body for the room media viewer — mounted inside both the mobile
|
||||||
// `MobileMediaViewerHorseshoe` panel-body and the desktop
|
// `MobileMediaViewerHorseshoe` panel-body and the desktop floating
|
||||||
// `RoomViewMediaSidePanel`. Owns:
|
// `RoomViewMediaWindow`. Owns:
|
||||||
// • Resolving + decrypting the media URL for the current entry,
|
// • Resolving + decrypting the media URL for the current entry,
|
||||||
// including revoke-on-unmount for blob URLs we create.
|
// including revoke-on-unmount for blob URLs we create.
|
||||||
// • Zoom for images (button row + `+`/`-`/`0` keys).
|
// • 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
|
// the gesture is interpreted as a swipe — keeps accidental diagonal
|
||||||
// drags from committing.
|
// drags from committing.
|
||||||
const SWIPE_DOMINANCE = 1.5;
|
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
|
// Walk the room's loaded timeline events and build the ordered set
|
||||||
// of viewable image + video events. Out-of-window media (older than
|
// 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 = {
|
type MediaViewerBodyProps = {
|
||||||
entry: MediaViewerEntry;
|
entry: MediaViewerEntry;
|
||||||
requestClose: () => void;
|
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 { t } = useTranslation();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
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 [pan, setPan] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||||
const [panCursor, setPanCursor] = useState<'grab' | 'grabbing' | 'initial'>('initial');
|
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
|
// Mirror `zoom` and `pan` into refs so callbacks installed in
|
||||||
// long-lived listeners can read the latest values without
|
// long-lived listeners can read the latest values without
|
||||||
// re-binding, and so the inlined anchor-aware zoom math in
|
// 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
|
// `onImageLoad` set a small ceiling) to a high-res one would
|
||||||
// briefly use the stale ceiling until the new image's `onLoad`
|
// briefly use the stale ceiling until the new image's `onLoad`
|
||||||
// fires. Gesture state is reset in a separate effect below.
|
// 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(() => {
|
useEffect(() => {
|
||||||
setZoom(1);
|
setZoom(1);
|
||||||
setPan({ x: 0, y: 0 });
|
setPan({ x: 0, y: 0 });
|
||||||
setMaxZoom(ZOOM_CEILING_FALLBACK);
|
setMaxZoom(ZOOM_CEILING_FALLBACK);
|
||||||
|
if (commitTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(commitTimerRef.current);
|
||||||
|
commitTimerRef.current = null;
|
||||||
|
}
|
||||||
|
setSwipeAnimating(false);
|
||||||
}, [entry.eventId, setZoom]);
|
}, [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
|
// Re-clamp the existing pan against the new bounds when zoom
|
||||||
// changes (e.g. zooming out from 3× to 1× should snap a panned
|
// changes (e.g. zooming out from 3× to 1× should snap a panned
|
||||||
// image back to centre since there's nothing to pan into).
|
// image back to centre since there's nothing to pan into).
|
||||||
|
|
@ -254,55 +349,62 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
|
||||||
[clampPan]
|
[clampPan]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Resolve + decrypt the media URL. Mirrors `ImageContent`'s
|
// Resolve + decrypt the media URL into a displayable src, CACHED by
|
||||||
// pattern. Blob-URL lifecycle is managed via `lastBlobUrlRef`:
|
// eventId so the current image and its swipe neighbours share one
|
||||||
// every encrypted entry allocates a fresh blob INSIDE the async
|
// decrypted blob — that's what lets a swipe slide between two
|
||||||
// callback, and we revoke the previous one before storing the
|
// already-decrypted images with no reload gap. Blob URLs (encrypted
|
||||||
// new one. This pattern handles two leak paths that a naive
|
// media) are tracked in `blobUrlsRef` so we revoke exactly those on
|
||||||
// `useEffect(srcState)` cleanup misses:
|
// eviction / unmount; plain http(s) srcs need no revoke. The
|
||||||
// 1. `useAsyncCallback`'s "Request replaced!" reject path —
|
// concurrent-resolve re-check below means a rapidly-replaced load (or
|
||||||
// a rapidly-replaced loadSrc may have created a blob via
|
// a preview + main racing on the same event) never leaks a duplicate
|
||||||
// `createObjectURL` before the throw, which never reaches
|
// blob.
|
||||||
// `srcState` and so is never cleaned up. Here we revoke
|
const srcCacheRef = useRef<Map<string, string>>(new Map());
|
||||||
// whatever was last stamped into the ref before stamping
|
const blobUrlsRef = useRef<Set<string>>(new Set());
|
||||||
// the new one.
|
|
||||||
// 2. Unmount mid-load — `useEffect` cleanup with a stale
|
const resolveMediaSrc = useCallback(
|
||||||
// `srcState.data` could revoke a blob the new mount is
|
async (e: MediaViewerEntry): Promise<string> => {
|
||||||
// still using. With a ref-tracked invariant of "we own
|
const cached = srcCacheRef.current.get(e.eventId);
|
||||||
// `lastBlobUrlRef.current`", the unmount cleanup is
|
if (cached) return cached;
|
||||||
// unambiguous.
|
const mediaUrl = mxcUrlToHttp(mx, e.url, useAuthentication);
|
||||||
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);
|
|
||||||
if (!mediaUrl) throw new Error('Invalid media URL');
|
if (!mediaUrl) throw new Error('Invalid media URL');
|
||||||
if (entryEncInfo) {
|
const enc = e.encInfo;
|
||||||
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
|
if (!enc) {
|
||||||
decryptFile(encBuf, entryMimeType ?? FALLBACK_MIMETYPE, entryEncInfo)
|
srcCacheRef.current.set(e.eventId, mediaUrl);
|
||||||
);
|
return mediaUrl;
|
||||||
const blob = URL.createObjectURL(fileContent);
|
|
||||||
if (lastBlobUrlRef.current && lastBlobUrlRef.current !== blob) {
|
|
||||||
URL.revokeObjectURL(lastBlobUrlRef.current);
|
|
||||||
}
|
|
||||||
lastBlobUrlRef.current = blob;
|
|
||||||
return blob;
|
|
||||||
}
|
}
|
||||||
return mediaUrl;
|
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
|
||||||
}, [mx, entryUrl, entryEncInfo, entryMimeType, useAuthentication])
|
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(() => {
|
useEffect(() => {
|
||||||
loadSrc();
|
loadSrc();
|
||||||
}, [loadSrc]);
|
}, [loadSrc]);
|
||||||
|
|
||||||
// Revoke any blob URL we still own when the viewer body unmounts
|
// Revoke every blob URL we created (and cancel a pending commit
|
||||||
// (sheet closed entirely, room change, etc).
|
// slide) when the viewer body unmounts — sheet closed, room change.
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
if (lastBlobUrlRef.current) {
|
blobUrlsRef.current.forEach((u) => URL.revokeObjectURL(u));
|
||||||
URL.revokeObjectURL(lastBlobUrlRef.current);
|
blobUrlsRef.current.clear();
|
||||||
lastBlobUrlRef.current = null;
|
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]
|
[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) => {
|
(delta: -1 | 1) => {
|
||||||
|
if (commitTimerRef.current !== null) return;
|
||||||
if (currentIndex < 0) return;
|
if (currentIndex < 0) return;
|
||||||
const target = currentIndex + delta;
|
const targetIndex = currentIndex + delta;
|
||||||
if (target < 0 || target >= mediaSet.length) return;
|
if (targetIndex < 0 || targetIndex >= mediaSet.length) return;
|
||||||
const next = mediaSet[target];
|
const target = mediaSet[targetIndex];
|
||||||
if (!next) return;
|
if (!target) return;
|
||||||
openMediaViewer(next);
|
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 canPrev = currentIndex > 0;
|
||||||
const canNext = currentIndex >= 0 && currentIndex < mediaSet.length - 1;
|
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
|
// Keyboard — ArrowLeft / Right step, +/- / 0 manage zoom. Esc is
|
||||||
// handled by the parent shell (focus-trap on desktop, explicit
|
// handled by the parent shell via an explicit window keydown listener
|
||||||
// keydown listener on mobile).
|
// (both the mobile horseshoe and the desktop floating window).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.defaultPrevented) return;
|
if (e.defaultPrevented) return;
|
||||||
|
|
@ -407,10 +569,10 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
|
||||||
}
|
}
|
||||||
if (e.key === 'ArrowLeft') {
|
if (e.key === 'ArrowLeft') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
step(-1);
|
animatedStep(-1);
|
||||||
} else if (e.key === 'ArrowRight') {
|
} else if (e.key === 'ArrowRight') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
step(1);
|
animatedStep(1);
|
||||||
} else if (e.key === '+' || e.key === '=') {
|
} else if (e.key === '+' || e.key === '=') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
zoomIn();
|
zoomIn();
|
||||||
|
|
@ -424,7 +586,7 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', onKeyDown);
|
window.addEventListener('keydown', onKeyDown);
|
||||||
return () => window.removeEventListener('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:
|
// Multi-touch gesture state on the image stage. Three modes:
|
||||||
// • zoom=1, 1 finger → horizontal-swipe to prev/next image.
|
// • 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.
|
// Chromium both fire for touch, which double-fires every handler.
|
||||||
// The MDN reference pattern that drove this implementation:
|
// The MDN reference pattern that drove this implementation:
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events/Pinch_zoom_gestures
|
// 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({
|
const swipeStateRef = useRef({
|
||||||
active: false,
|
active: false,
|
||||||
|
|
@ -487,8 +650,19 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
|
||||||
canPrevRef.current = canPrev;
|
canPrevRef.current = canPrev;
|
||||||
const canNextRef = useRef(canNext);
|
const canNextRef = useRef(canNext);
|
||||||
canNextRef.current = canNext;
|
canNextRef.current = canNext;
|
||||||
const stepRef = useRef(step);
|
// Whether the prev/next neighbour is an IMAGE (i.e. slides). A video
|
||||||
stepRef.current = step;
|
// 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);
|
const entryKindRef = useRef(entry.kind);
|
||||||
entryKindRef.current = entry.kind;
|
entryKindRef.current = entry.kind;
|
||||||
// `zoomRef` + `panRef` are already mirrored in the pan section
|
// `zoomRef` + `panRef` are already mirrored in the pan section
|
||||||
|
|
@ -531,6 +705,12 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
|
||||||
// mouse here for everything.
|
// mouse here for everything.
|
||||||
if (e.pointerType === 'mouse') return;
|
if (e.pointerType === 'mouse') return;
|
||||||
if (entryKindRef.current !== 'image') 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`
|
// Capture the pointer so subsequent `pointermove`/`pointerup`
|
||||||
// events are dispatched to the stage even when the finger
|
// 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 (s.claimed !== 'swipe') return;
|
||||||
if (e.cancelable) e.preventDefault();
|
if (e.cancelable) e.preventDefault();
|
||||||
const blocked = (dx > 0 && !canPrevRef.current) || (dx < 0 && !canNextRef.current);
|
// Rubber-band (1/3 travel) when the swipe direction has nothing to
|
||||||
setSwipeOffset(blocked ? dx / 3 : dx);
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -699,11 +883,19 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
|
||||||
const s = swipeStateRef.current;
|
const s = swipeStateRef.current;
|
||||||
if (s.active) {
|
if (s.active) {
|
||||||
s.active = false;
|
s.active = false;
|
||||||
|
let committed = false;
|
||||||
if (s.claimed === 'swipe') {
|
if (s.claimed === 'swipe') {
|
||||||
if (s.lastDx > SWIPE_COMMIT_PX && canPrevRef.current) stepRef.current(-1);
|
if (s.lastDx > SWIPE_COMMIT_PX && canPrevRef.current) {
|
||||||
else if (s.lastDx < -SWIPE_COMMIT_PX && canNextRef.current) stepRef.current(1);
|
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;
|
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 =
|
const isLoading =
|
||||||
!effectiveSrc &&
|
!effectiveSrc &&
|
||||||
(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Idle);
|
(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Idle);
|
||||||
|
|
@ -795,89 +992,130 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
|
||||||
const transform = isReady
|
const transform = isReady
|
||||||
? `translate3d(${swipeOffset + pan.x}px, ${pan.y}px, 0) scale(${zoom})`
|
? `translate3d(${swipeOffset + pan.x}px, ${pan.y}px, 0) scale(${zoom})`
|
||||||
: undefined;
|
: undefined;
|
||||||
const imageTransition =
|
// Transition ON while a commit slide settles (swipeAnimating) and at
|
||||||
swipeOffset === 0 ? 'transform 200ms cubic-bezier(0.32, 0.72, 0, 1)' : 'none';
|
// 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 (
|
return (
|
||||||
<div className={css.root}>
|
<div className={css.root}>
|
||||||
<PageHeader
|
{!hideHeader && (
|
||||||
// `PageHeader` (= folds `Header size="600"`) so the header's
|
<PageHeader
|
||||||
// bottom rule aligns to the pixel with the chat header to the
|
// `PageHeader` (= folds `Header size="600"`) so the header's
|
||||||
// left across the 12px void gap — same trick the profile
|
// bottom rule aligns to the pixel with the chat header to the
|
||||||
// side pane uses. A plain `<div height: 48px>` was visibly
|
// left across the 12px void gap — same trick the profile
|
||||||
// shorter and broke the cross-pane seam.
|
// side pane uses. A plain `<div height: 48px>` was visibly
|
||||||
className={`${ContainerColor({ variant: 'Surface' })} ${css.header}`}
|
// shorter and broke the cross-pane seam.
|
||||||
>
|
className={`${ContainerColor({ variant: 'Surface' })} ${css.header}`}
|
||||||
<Box grow="Yes" alignItems="Center" gap="200">
|
>
|
||||||
<Text className={css.title} size="H4" truncate>
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
{entry.body}
|
<Text className={css.title} size="H4" truncate>
|
||||||
</Text>
|
{entry.body}
|
||||||
{mediaSet.length > 1 && (
|
</Text>
|
||||||
<span className={css.indexPill} aria-hidden="true">
|
{mediaSet.length > 1 && (
|
||||||
{currentIndex >= 0 ? currentIndex + 1 : '?'} / {mediaSet.length}
|
<span className={css.indexPill} aria-hidden="true">
|
||||||
</span>
|
{currentIndex >= 0 ? currentIndex + 1 : '?'} / {mediaSet.length}
|
||||||
)}
|
</span>
|
||||||
</Box>
|
)}
|
||||||
<Box shrink="No" alignItems="Center" gap="100">
|
</Box>
|
||||||
{entry.kind === 'image' && (
|
<Box shrink="No" alignItems="Center" gap="100">
|
||||||
<>
|
{entry.kind === 'image' && (
|
||||||
<IconButton
|
<>
|
||||||
variant="Background"
|
<IconButton
|
||||||
fill="None"
|
variant="Background"
|
||||||
radii="300"
|
fill="None"
|
||||||
size="300"
|
radii="300"
|
||||||
onClick={zoomOut}
|
size="300"
|
||||||
aria-label={t('MediaViewer.zoom_out', 'Zoom out')}
|
onClick={zoomOut}
|
||||||
>
|
aria-label={t('MediaViewer.zoom_out', 'Zoom out')}
|
||||||
<Icon size="100" src={Icons.Minus} />
|
>
|
||||||
</IconButton>
|
<Icon size="100" src={Icons.Minus} />
|
||||||
<Text size="B300" as="span" style={{ minWidth: '3em', textAlign: 'center' }}>
|
</IconButton>
|
||||||
{Math.round(zoom * 100)}%
|
<Text size="B300" as="span" style={{ minWidth: '3em', textAlign: 'center' }}>
|
||||||
</Text>
|
{Math.round(zoom * 100)}%
|
||||||
<IconButton
|
</Text>
|
||||||
variant="Background"
|
<IconButton
|
||||||
fill="None"
|
variant="Background"
|
||||||
radii="300"
|
fill="None"
|
||||||
size="300"
|
radii="300"
|
||||||
onClick={zoomIn}
|
size="300"
|
||||||
aria-label={t('MediaViewer.zoom_in', 'Zoom in')}
|
onClick={zoomIn}
|
||||||
>
|
aria-label={t('MediaViewer.zoom_in', 'Zoom in')}
|
||||||
<Icon size="100" src={Icons.Plus} />
|
>
|
||||||
</IconButton>
|
<Icon size="100" src={Icons.Plus} />
|
||||||
</>
|
</IconButton>
|
||||||
)}
|
</>
|
||||||
<IconButton
|
)}
|
||||||
variant="Background"
|
<IconButton
|
||||||
fill="None"
|
variant="Background"
|
||||||
radii="300"
|
fill="None"
|
||||||
size="300"
|
radii="300"
|
||||||
onClick={handleDownload}
|
size="300"
|
||||||
disabled={!isReady}
|
onClick={handleDownload}
|
||||||
aria-label={t('MediaViewer.download', 'Download')}
|
disabled={!isReady}
|
||||||
>
|
aria-label={t('MediaViewer.download', 'Download')}
|
||||||
<Icon size="100" src={Icons.Download} />
|
>
|
||||||
</IconButton>
|
<Icon size="100" src={Icons.Download} />
|
||||||
{entry.kind === 'image' && <div className={css.headerSeparator} aria-hidden="true" />}
|
</IconButton>
|
||||||
<IconButton
|
{entry.kind === 'image' && <div className={css.headerSeparator} aria-hidden="true" />}
|
||||||
variant="Background"
|
<IconButton
|
||||||
fill="None"
|
variant="Background"
|
||||||
radii="300"
|
fill="None"
|
||||||
size="300"
|
radii="300"
|
||||||
onClick={requestClose}
|
size="300"
|
||||||
aria-label={t('Common.close', 'Close')}
|
onClick={requestClose}
|
||||||
>
|
aria-label={t('Common.close', 'Close')}
|
||||||
<Icon size="100" src={Icons.Cross} />
|
>
|
||||||
</IconButton>
|
<Icon size="100" src={Icons.Cross} />
|
||||||
</Box>
|
</IconButton>
|
||||||
</PageHeader>
|
</Box>
|
||||||
|
</PageHeader>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={css.stage} ref={stageRef}>
|
<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' && (
|
{isReady && effectiveSrc && entry.kind === 'image' && (
|
||||||
// `onMouseDown` drives drag-pan when the image is zoomed in.
|
// `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
|
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
|
||||||
<img
|
<img
|
||||||
ref={imgRef}
|
ref={imgRef}
|
||||||
|
key={entry.eventId}
|
||||||
src={effectiveSrc}
|
src={effectiveSrc}
|
||||||
alt={entry.body}
|
alt={entry.body}
|
||||||
className={css.image}
|
className={css.image}
|
||||||
|
|
@ -942,9 +1180,9 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
|
||||||
aria-label={t('MediaViewer.previous', 'Previous')}
|
aria-label={t('MediaViewer.previous', 'Previous')}
|
||||||
// `aria-disabled` (not `disabled`) so the CSS dimmed-
|
// `aria-disabled` (not `disabled`) so the CSS dimmed-
|
||||||
// state selector still matches; the onClick already
|
// state selector still matches; the onClick already
|
||||||
// bails internally via `step`'s bounds check.
|
// bails internally via `animatedStep`'s bounds check.
|
||||||
aria-disabled={!canPrev}
|
aria-disabled={!canPrev}
|
||||||
onClick={() => step(-1)}
|
onClick={() => animatedStep(-1)}
|
||||||
>
|
>
|
||||||
<Icon size="200" src={Icons.ChevronLeft} />
|
<Icon size="200" src={Icons.ChevronLeft} />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -953,7 +1191,7 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
|
||||||
className={classNames(css.chevron, css.chevronRight)}
|
className={classNames(css.chevron, css.chevronRight)}
|
||||||
aria-label={t('MediaViewer.next', 'Next')}
|
aria-label={t('MediaViewer.next', 'Next')}
|
||||||
aria-disabled={!canNext}
|
aria-disabled={!canNext}
|
||||||
onClick={() => step(1)}
|
onClick={() => animatedStep(1)}
|
||||||
>
|
>
|
||||||
<Icon size="200" src={Icons.ChevronRight} />
|
<Icon size="200" src={Icons.ChevronRight} />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,6 @@
|
||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
import { color, toRem } from 'folds';
|
import { color, toRem } from 'folds';
|
||||||
import { VOJO_HORSESHOE_GAP_PX, VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
|
import { 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;
|
|
||||||
|
|
||||||
// Outer container — anchor for the two absolutely-positioned panes
|
// Outer container — anchor for the two absolutely-positioned panes
|
||||||
// (`appBody` and `silhouette`). Same shape as the settings-sheet
|
// (`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
|
// The viewer sheet's surface. `Background.Container` (deepest Dawn
|
||||||
// tone) so the image floats on a near-black backdrop — closest to
|
// 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({
|
export const silhouette = style({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
|
|
@ -51,7 +50,9 @@ export const silhouette = style({
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
backgroundColor: color.Background.Container,
|
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
|
// Top-anchored — handle + viewer body reveal top-down as silhouette
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
// Bottom-up «horseshoe» sheet that wraps the mobile chat column for
|
// Bottom-up sheet that wraps the mobile chat column for the media
|
||||||
// the media viewer. Sister of `MobileSettingsHorseshoe` (which wraps
|
// viewer. Built on the same scaffolding as `MobileSettingsHorseshoe`
|
||||||
// the DM list with the same idiom for the Settings tree) — same
|
// (clip-path bottom-carve, VAUL easing, Strict-Mode-safe entry
|
||||||
// clip-path carve, same VAUL easing, same Strict-Mode-safe entry
|
// animation, `keepMounted` delayed unmount) but — being a FULLSCREEN
|
||||||
// animation, same `keepMounted` delayed unmount.
|
// 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:
|
// Differences from the settings sheet:
|
||||||
// • Tap-to-open only. No drag-up origin — opening is driven from
|
// • 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
|
// 20px handle band is the ONLY drag-sensitive area; touches on
|
||||||
// the viewer body below run zoom / pan / horizontal swipe
|
// the viewer body below run zoom / pan / horizontal swipe
|
||||||
// instead.
|
// 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
|
// • Silhouette bg = `Background.Container` (deepest Dawn tone) for
|
||||||
// a dark image backdrop, vs settings's `SurfaceVariant.Container`.
|
// a dark image backdrop, vs settings's `SurfaceVariant.Container`.
|
||||||
// • Wrapped content is the chat column, not the DM list.
|
// • 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 { mediaViewerAtom } from '../../state/mediaViewer';
|
||||||
import { useCloseMediaViewer } from '../../state/hooks/mediaViewer';
|
import { useCloseMediaViewer } from '../../state/hooks/mediaViewer';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
import { VOJO_HORSESHOE_VOID_COLOR } from '../../styles/horseshoe';
|
|
||||||
import { MediaViewerBody } from './MediaViewerBody';
|
import { MediaViewerBody } from './MediaViewerBody';
|
||||||
import * as css from './MobileMediaViewerHorseshoe.css';
|
import * as css from './MobileMediaViewerHorseshoe.css';
|
||||||
|
|
||||||
|
|
@ -34,39 +36,19 @@ const ANIMATION_MS = 250;
|
||||||
// Drag distance past which release commits the close. Matches the
|
// Drag distance past which release commits the close. Matches the
|
||||||
// settings sheet's 80px (the only commit gesture there too).
|
// settings sheet's 80px (the only commit gesture there too).
|
||||||
const COMMIT_THRESHOLD_PX = 80;
|
const COMMIT_THRESHOLD_PX = 80;
|
||||||
// Sheet height as a fraction of the viewport — product spec.
|
// Sheet height as a fraction of the viewport — near-fullscreen by
|
||||||
// Capped at runtime by the wrapper container's actual height
|
// design: the viewer should cover the chat (incl. its header) and stop
|
||||||
// (see `railHeightPx` calc below) so it can't spill past the
|
// just under the system status bar / tray. Capped at runtime by the
|
||||||
// chat header when chrome outside the chat column (e.g.
|
// wrapper container's actual height minus the status-bar inset (see
|
||||||
// `HorseshoeContainer.bottomRail` during a call) shrinks the
|
// `railHeightPx` calc below) so the sheet's rounded top edge lands at
|
||||||
// wrapper.
|
// the bottom of the status bar, never over the tray icons.
|
||||||
const RAIL_VIEWPORT_FRACTION = 0.8;
|
const RAIL_VIEWPORT_FRACTION = 1;
|
||||||
// Absolute floor for tiny / split-screen viewports — the sheet
|
// Absolute floor for tiny / split-screen viewports — the sheet
|
||||||
// becomes useless below this. NB: floor cannot exceed the
|
// becomes useless below this. NB: floor cannot exceed the
|
||||||
// container's ceiling (`Math.min` is applied last), so in extreme
|
// container's ceiling (`Math.min` is applied last), so in extreme
|
||||||
// squeeze cases (split-screen, oversized call rail) the sheet
|
// squeeze cases (split-screen, oversized call rail) the sheet
|
||||||
// silently shrinks below the floor rather than clipping the chat
|
// silently shrinks below the floor rather than clipping the status bar.
|
||||||
// header.
|
|
||||||
const RAIL_MIN_PX = 200;
|
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 = {
|
type DragState = {
|
||||||
inputType: 'touch' | 'pointer';
|
inputType: 'touch' | 'pointer';
|
||||||
|
|
@ -97,13 +79,12 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
|
||||||
// Measure the wrapper container directly via `ResizeObserver` so
|
// Measure the wrapper container directly via `ResizeObserver` so
|
||||||
// the rail height tracks whatever vertical space is actually
|
// the rail height tracks whatever vertical space is actually
|
||||||
// available to the chat column. The viewport-based fraction is
|
// 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
|
// `HorseshoeContainer.bottomRail` (the call rail) sits OUTSIDE
|
||||||
// the chat column inside the same viewport and shrinks our
|
// the chat column inside the same viewport and shrinks our
|
||||||
// wrapper when active. Without the container clamp, 90% of the
|
// wrapper when active. Without the container clamp, a full-viewport
|
||||||
// viewport could exceed the wrapper's height, and the sheet
|
// sheet could exceed the wrapper's height and spill past the bottom
|
||||||
// would render up to / past the chat header which sits inside
|
// chrome; the clamp keeps the top edge at the status bar instead.
|
||||||
// the wrapper's appBody.
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [containerHeight, setContainerHeight] = useState(() =>
|
const [containerHeight, setContainerHeight] = useState(() =>
|
||||||
typeof window === 'undefined' ? 800 : window.innerHeight
|
typeof window === 'undefined' ? 800 : window.innerHeight
|
||||||
|
|
@ -121,15 +102,32 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
|
||||||
return () => ro.disconnect();
|
return () => ro.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Final rail = viewport × fraction, then floor-then-ceiling
|
// Live status-bar inset. `env(safe-area-inset-top)` can't be read
|
||||||
// clamped. Container (minus chat-header reserve) is the HARD
|
// reliably off a custom property in JS, so we measure a zero-width
|
||||||
// ceiling: when the wrapper shrinks (call rail / split-screen)
|
// probe whose height is `var(--vojo-safe-top)` (= the env value).
|
||||||
// the sheet shrinks below `RAIL_MIN_PX` rather than spilling
|
// This is the only thing the near-fullscreen sheet must stay below
|
||||||
// over the chat header. Order matters — `Math.min` last makes
|
// — it covers the chat header but must not climb over the tray.
|
||||||
// container win against floor.
|
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 idealRailPx = Math.round(viewportHeight * RAIL_VIEWPORT_FRACTION);
|
||||||
const railHeightPx = Math.min(
|
const railHeightPx = Math.min(
|
||||||
Math.max(0, containerHeight - CHAT_HEADER_RESERVED_PX),
|
Math.max(0, containerHeight - safeTopPx),
|
||||||
Math.max(RAIL_MIN_PX, idealRailPx)
|
Math.max(RAIL_MIN_PX, idealRailPx)
|
||||||
);
|
);
|
||||||
const open = !!entry;
|
const open = !!entry;
|
||||||
|
|
@ -172,9 +170,7 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
|
||||||
const expandedPx = drag
|
const expandedPx = drag
|
||||||
? Math.max(0, Math.min(railHeightPx, baseExpanded - drag.deltaY))
|
? Math.max(0, Math.min(railHeightPx, baseExpanded - drag.deltaY))
|
||||||
: baseExpanded;
|
: baseExpanded;
|
||||||
const expandedFraction = railHeightPx > 0 ? expandedPx / railHeightPx : 0;
|
|
||||||
const isDragging = drag !== null;
|
const isDragging = drag !== null;
|
||||||
const horseshoeActive = expandedPx > 0;
|
|
||||||
|
|
||||||
const handleRef = useRef<HTMLDivElement>(null);
|
const handleRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
|
@ -208,9 +204,7 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
|
||||||
const target = e.target as HTMLElement | null;
|
const target = e.target as HTMLElement | null;
|
||||||
if (
|
if (
|
||||||
target &&
|
target &&
|
||||||
(target.tagName === 'INPUT' ||
|
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
||||||
target.tagName === 'TEXTAREA' ||
|
|
||||||
target.isContentEditable)
|
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -307,30 +301,19 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Geometry — same emerge ramp as the settings / profile horseshoes
|
// Geometry — the media viewer is fullscreen, so (unlike the settings /
|
||||||
// (rationale at the top of `MobileSettingsHorseshoe`).
|
// profile horseshoes) it carves NO rounded top corners and NO void gap
|
||||||
let horseshoeRamp: number;
|
// above the sheet: the sheet's flat top butts directly against the chat
|
||||||
if (isDragging) {
|
// it clips (which now fills only the status-bar strip above it). Chat bg
|
||||||
horseshoeRamp = easeInOutCubic(Math.min(1, expandedPx / HORSESHOE_EMERGE_PX));
|
// and sheet bg are the same Dawn tone, so there's no black void seam at
|
||||||
} else {
|
// the top — the viewer reads as a flush fullscreen panel under the tray.
|
||||||
horseshoeRamp = expandedFraction > 0 ? 1 : 0;
|
const appBodyMaskBottomPx = expandedPx;
|
||||||
}
|
|
||||||
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;
|
|
||||||
|
|
||||||
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
|
const silhouetteTransition = isDragging ? 'none' : `height ${ANIMATION_MS}ms ${VAUL_EASING}`;
|
||||||
? '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 appBodyTransition = isDragging ? 'none' : `clip-path ${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 renderEntry = entry ?? lastEntryRef.current;
|
||||||
const renderBody = !!renderEntry && (keepMounted || isDragging);
|
const renderBody = !!renderEntry && (keepMounted || isDragging);
|
||||||
|
|
||||||
|
|
@ -344,7 +327,22 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
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
|
{open && portalTarget
|
||||||
? createPortal(
|
? createPortal(
|
||||||
<div
|
<div
|
||||||
|
|
@ -370,8 +368,6 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
|
||||||
className={css.silhouette}
|
className={css.silhouette}
|
||||||
style={{
|
style={{
|
||||||
height: `${expandedPx}px`,
|
height: `${expandedPx}px`,
|
||||||
borderTopLeftRadius: `${silhouetteRadiusPx}px`,
|
|
||||||
borderTopRightRadius: `${silhouetteRadiusPx}px`,
|
|
||||||
transition: silhouetteTransition,
|
transition: silhouetteTransition,
|
||||||
visibility: expandedPx > 0 ? 'visible' : 'hidden',
|
visibility: expandedPx > 0 ? 'visible' : 'hidden',
|
||||||
// Reset `--vojo-safe-top` so any descendant that paints a
|
// Reset `--vojo-safe-top` so any descendant that paints a
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,8 @@ import { RoomViewProfileSidePanel } from './RoomViewProfileSidePanel';
|
||||||
import { RoomViewMembersPanel } from './RoomViewMembersPanel';
|
import { RoomViewMembersPanel } from './RoomViewMembersPanel';
|
||||||
import { RoomViewMembersSidePanel } from './RoomViewMembersSidePanel';
|
import { RoomViewMembersSidePanel } from './RoomViewMembersSidePanel';
|
||||||
import { MobileMediaViewerHorseshoe } from './MobileMediaViewerHorseshoe';
|
import { MobileMediaViewerHorseshoe } from './MobileMediaViewerHorseshoe';
|
||||||
import { RoomViewMediaSidePanel } from './RoomViewMediaSidePanel';
|
import { RoomViewMediaWindow } from './RoomViewMediaWindow';
|
||||||
import { MediaViewerHostContext } from './mediaViewerHostContext';
|
import { MediaViewerHostContext } from './mediaViewerHostContext';
|
||||||
import { mediaViewerAtom } from '../../state/mediaViewer';
|
|
||||||
import { roomMembersSheetAtom } from '../../state/roomMembersSheet';
|
import { roomMembersSheetAtom } from '../../state/roomMembersSheet';
|
||||||
import { useChannelsMode, ThreadDrawerOpenProvider } from '../../hooks/useChannelsMode';
|
import { useChannelsMode, ThreadDrawerOpenProvider } from '../../hooks/useChannelsMode';
|
||||||
import { CHANNELS_THREAD_PATH } from '../../pages/paths';
|
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
|
// PageRoot. The chat column applies an explicit Background bg so the
|
||||||
// parent void can't bleed through any transparent slivers.
|
// parent void can't bleed through any transparent slivers.
|
||||||
const profileOpen = !!useAtomValue(userRoomProfileAtom);
|
const profileOpen = !!useAtomValue(userRoomProfileAtom);
|
||||||
const mediaOpen = !!useAtomValue(mediaViewerAtom);
|
|
||||||
const membersSheetOpen = !!useAtomValue(roomMembersSheetAtom);
|
const membersSheetOpen = !!useAtomValue(roomMembersSheetAtom);
|
||||||
const callView = room.isCallRoom();
|
const callView = room.isCallRoom();
|
||||||
const showProfileHorseshoe = profileOpen && !isMobile && !showThreadDrawer;
|
const showProfileHorseshoe = profileOpen && !isMobile && !showThreadDrawer;
|
||||||
// Media viewer side pane on desktop — same horseshoe seam idiom
|
// The desktop/tablet media viewer is no longer a right-side pane — it's
|
||||||
// as the profile pane. The two are mutually exclusive in practice
|
// a free-floating draggable window (`RoomViewMediaWindow`) portaled over
|
||||||
// (the open hooks clear the other atom), so at most one shows at
|
// the app, so it doesn't carve a horseshoe seam or paint the parent void.
|
||||||
// a time; both feed into `paintParentVoid` for the chat-column bg
|
|
||||||
// and locally gate the shared `<VoidGap />` seam.
|
|
||||||
const showMediaHorseshoe = mediaOpen && !isMobile && !showThreadDrawer;
|
|
||||||
// Thread drawer side pane on desktop. Mobile hides the chat column
|
// Thread drawer side pane on desktop. Mobile hides the chat column
|
||||||
// entirely (`drawerHidesChat`) so the seam doesn't apply there.
|
// entirely (`drawerHidesChat`) so the seam doesn't apply there.
|
||||||
const showThreadHorseshoe = showThreadDrawer && !isMobile;
|
const showThreadHorseshoe = showThreadDrawer && !isMobile;
|
||||||
|
|
@ -168,8 +163,7 @@ export function Room({ renderRoomView }: RoomProps) {
|
||||||
// remain local to each render site (a single `paintParentVoid` gate
|
// remain local to each render site (a single `paintParentVoid` gate
|
||||||
// would over-render when only members is open — there is no
|
// would over-render when only members is open — there is no
|
||||||
// profile/media slot to anchor it to).
|
// profile/media slot to anchor it to).
|
||||||
const paintParentVoid =
|
const paintParentVoid = showProfileHorseshoe || showThreadHorseshoe || showMembersHorseshoe;
|
||||||
showProfileHorseshoe || showMediaHorseshoe || showThreadHorseshoe || showMembersHorseshoe;
|
|
||||||
|
|
||||||
useKeyDown(
|
useKeyDown(
|
||||||
window,
|
window,
|
||||||
|
|
@ -193,12 +187,13 @@ export function Room({ renderRoomView }: RoomProps) {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Disable the atom-driven media viewer when the desktop thread
|
// Disable the atom-driven media viewer when the desktop thread
|
||||||
// drawer is open — the side-pane mount block below is gated on
|
// drawer is open — with the host context cleared, a timeline
|
||||||
// `!showThreadDrawer`, so the new viewer's right pane wouldn't
|
// image/video tap falls back to the legacy full-screen `<Overlay>`
|
||||||
// render, and an atom-only open path would silently no-op on the
|
// modal instead of opening a floating window on top of the drawer
|
||||||
// user's tap. With the host context cleared, `ImageContent` /
|
// (the `<Overlay>` sits above every pane and still works). NB: an
|
||||||
// `VideoContent` fall back to the legacy full-screen `<Overlay>`
|
// already-open floating window is NOT torn down by this — the window
|
||||||
// modal, which sits on top of every pane and still works.
|
// 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
|
// Mobile is unaffected: `drawerHidesChat` hides the chat column
|
||||||
// entirely on mobile + thread, so there's no media to tap anyway.
|
// 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. */}
|
exclusive via the open hooks. */}
|
||||||
{!isMobile && !showThreadDrawer && (
|
{!isMobile && !showThreadDrawer && (
|
||||||
<>
|
<>
|
||||||
{(showProfileHorseshoe || showMediaHorseshoe) && <VoidGap />}
|
{showProfileHorseshoe && <VoidGap />}
|
||||||
<RoomViewProfileSidePanel />
|
<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 && (
|
{callView && chat && (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
94
src/app/features/room/RoomViewMediaWindow.css.ts
Normal file
94
src/app/features/room/RoomViewMediaWindow.css.ts
Normal 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 },
|
||||||
|
},
|
||||||
|
});
|
||||||
243
src/app/features/room/RoomViewMediaWindow.tsx
Normal file
243
src/app/features/room/RoomViewMediaWindow.tsx
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { createContext, useContext } from 'react';
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
// Non-null only when an atom-driven media-viewer shell (the mobile
|
// 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
|
// the React tree above this consumer. `ImageContent` reads this to
|
||||||
// decide between the new atom-open path (Room timeline) and the
|
// decide between the new atom-open path (Room timeline) and the
|
||||||
// legacy local-state `<Overlay>` viewer (pin-menu, message search
|
// 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 MediaViewerHostContext = createContext<MediaViewerHostValue>(null);
|
||||||
|
|
||||||
export const useMediaViewerHost = (): MediaViewerHostValue =>
|
export const useMediaViewerHost = (): MediaViewerHostValue => useContext(MediaViewerHostContext);
|
||||||
useContext(MediaViewerHostContext);
|
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,11 @@ type OpenCallback = (
|
||||||
export const useOpenUserRoomProfile = (): OpenCallback => {
|
export const useOpenUserRoomProfile = (): OpenCallback => {
|
||||||
const setUserRoomProfile = useSetAtom(userRoomProfileAtom);
|
const setUserRoomProfile = useSetAtom(userRoomProfileAtom);
|
||||||
// Bidirectional mutual exclusion with the media viewer — opening
|
// Bidirectional mutual exclusion with the media viewer — opening
|
||||||
// profile closes any open media viewer pane / sheet. Mirrors the
|
// profile closes any open media viewer window / sheet. Mirrors the
|
||||||
// clear-the-other-atom behaviour in `useOpenMediaViewer`. Without
|
// clear-the-other-atom behaviour in `useOpenMediaViewer`. The media
|
||||||
// this, on desktop both `RoomViewProfileSidePanel` and
|
// viewer is a floating window (`RoomViewMediaWindow`) on desktop now,
|
||||||
// `RoomViewMediaSidePanel` would mount as siblings and fight for
|
// but keeping them mutually exclusive avoids a profile pane fighting
|
||||||
// the right-pane slot.
|
// the window for attention.
|
||||||
const setMediaViewer = useSetAtom(mediaViewerAtom);
|
const setMediaViewer = useSetAtom(mediaViewerAtom);
|
||||||
// Symmetric mutual exclusion with the group-room members sheet — when
|
// Symmetric mutual exclusion with the group-room members sheet — when
|
||||||
// the user taps a row inside the members list, the per-user profile
|
// the user taps a row inside the members list, the per-user profile
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
@ -3,7 +3,8 @@ import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||||
import { IImageInfo, IVideoInfo } from '../../types/matrix/common';
|
import { IImageInfo, IVideoInfo } from '../../types/matrix/common';
|
||||||
|
|
||||||
// Open state for the room media viewer — bottom-up sheet on mobile,
|
// 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` /
|
// atom-driven shell is mounted in `Room.tsx`; `ImageContent` /
|
||||||
// `VideoContent` open the atom when the host context from
|
// `VideoContent` open the atom when the host context from
|
||||||
// `mediaViewerHostContext` is non-null (Room timeline path);
|
// `mediaViewerHostContext` is non-null (Room timeline path);
|
||||||
|
|
|
||||||
68
src/app/state/mediaWindowRect.ts
Normal file
68
src/app/state/mediaWindowRect.ts
Normal 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 };
|
||||||
|
};
|
||||||
Loading…
Add table
Reference in a new issue