+ {showSwipePreviews && prevEntry?.kind === 'image' && (
+
+ )}
+ {showSwipePreviews && nextEntry?.kind === 'image' && (
+
+ )}
{isReady && effectiveSrc && entry.kind === 'image' && (
// `onMouseDown` drives drag-pan when the image is zoomed in.
- // Keyboard pan is wired separately at the stage level.
+ // Keyboard pan is wired separately at the stage level. Keyed by
+ // eventId (like the video) so a commit mounts a fresh element
+ // centred at offset 0 — no slide-back from the committed edge.
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions

step(-1)}
+ onClick={() => animatedStep(-1)}
>
@@ -953,7 +1191,7 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
className={classNames(css.chevron, css.chevronRight)}
aria-label={t('MediaViewer.next', 'Next')}
aria-disabled={!canNext}
- onClick={() => step(1)}
+ onClick={() => animatedStep(1)}
>
diff --git a/src/app/features/room/MobileMediaViewerHorseshoe.css.ts b/src/app/features/room/MobileMediaViewerHorseshoe.css.ts
index cc554a48..ae18a7ed 100644
--- a/src/app/features/room/MobileMediaViewerHorseshoe.css.ts
+++ b/src/app/features/room/MobileMediaViewerHorseshoe.css.ts
@@ -1,11 +1,6 @@
import { style } from '@vanilla-extract/css';
import { color, toRem } from 'folds';
-import { VOJO_HORSESHOE_GAP_PX, VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
-
-// Re-exported so the TSX can pick up the constants without crossing
-// the vanilla-extract / runtime boundary twice.
-export const HORSESHOE_RADIUS_PX = VOJO_HORSESHOE_RADIUS_PX;
-export const HORSESHOE_GAP_PX = VOJO_HORSESHOE_GAP_PX;
+import { VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
// Outer container — anchor for the two absolutely-positioned panes
// (`appBody` and `silhouette`). Same shape as the settings-sheet
@@ -41,7 +36,11 @@ export const appBody = style({
// The viewer sheet's surface. `Background.Container` (deepest Dawn
// tone) so the image floats on a near-black backdrop — closest to
-// the legacy `
` viewer's full-screen dark scrim.
+// the legacy `` viewer's full-screen dark scrim. Rounded
+// top corners (same radius as the tab curtains) with `overflow:
+// hidden` clip the header / image to the curve; the carved corners
+// expose the app's dark background behind, so the rounding reads
+// without re-introducing a black void gap.
export const silhouette = style({
position: 'absolute',
bottom: 0,
@@ -51,7 +50,9 @@ export const silhouette = style({
flexDirection: 'column',
overflow: 'hidden',
backgroundColor: color.Background.Container,
- willChange: 'height, border-top-left-radius, border-top-right-radius',
+ borderTopLeftRadius: toRem(VOJO_HORSESHOE_RADIUS_PX),
+ borderTopRightRadius: toRem(VOJO_HORSESHOE_RADIUS_PX),
+ willChange: 'height',
});
// Top-anchored — handle + viewer body reveal top-down as silhouette
diff --git a/src/app/features/room/MobileMediaViewerHorseshoe.tsx b/src/app/features/room/MobileMediaViewerHorseshoe.tsx
index a54fe5b9..5a08d700 100644
--- a/src/app/features/room/MobileMediaViewerHorseshoe.tsx
+++ b/src/app/features/room/MobileMediaViewerHorseshoe.tsx
@@ -1,8 +1,9 @@
-// Bottom-up «horseshoe» sheet that wraps the mobile chat column for
-// the media viewer. Sister of `MobileSettingsHorseshoe` (which wraps
-// the DM list with the same idiom for the Settings tree) — same
-// clip-path carve, same VAUL easing, same Strict-Mode-safe entry
-// animation, same `keepMounted` delayed unmount.
+// Bottom-up sheet that wraps the mobile chat column for the media
+// viewer. Built on the same scaffolding as `MobileSettingsHorseshoe`
+// (clip-path bottom-carve, VAUL easing, Strict-Mode-safe entry
+// animation, `keepMounted` delayed unmount) but — being a FULLSCREEN
+// viewer — it drops the «horseshoe» look: no rounded top corners and
+// no black void gap above the sheet (see the geometry block below).
//
// Differences from the settings sheet:
// • Tap-to-open only. No drag-up origin — opening is driven from
@@ -10,7 +11,9 @@
// 20px handle band is the ONLY drag-sensitive area; touches on
// the viewer body below run zoom / pan / horizontal swipe
// instead.
-// • `RAIL_FRACTION = 3 / 4` (75% of viewport) vs settings's 2/3.
+// • Fullscreen, flush top: the sheet covers the chat (incl. its
+// header) and its flat top lands flush under the system status
+// bar — no rounded carve, no void seam — vs settings's rounded 2/3.
// • Silhouette bg = `Background.Container` (deepest Dawn tone) for
// a dark image backdrop, vs settings's `SurfaceVariant.Container`.
// • Wrapped content is the chat column, not the DM list.
@@ -25,7 +28,6 @@ import { useTranslation } from 'react-i18next';
import { mediaViewerAtom } from '../../state/mediaViewer';
import { useCloseMediaViewer } from '../../state/hooks/mediaViewer';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
-import { VOJO_HORSESHOE_VOID_COLOR } from '../../styles/horseshoe';
import { MediaViewerBody } from './MediaViewerBody';
import * as css from './MobileMediaViewerHorseshoe.css';
@@ -34,39 +36,19 @@ const ANIMATION_MS = 250;
// Drag distance past which release commits the close. Matches the
// settings sheet's 80px (the only commit gesture there too).
const COMMIT_THRESHOLD_PX = 80;
-// Sheet height as a fraction of the viewport — product spec.
-// Capped at runtime by the wrapper container's actual height
-// (see `railHeightPx` calc below) so it can't spill past the
-// chat header when chrome outside the chat column (e.g.
-// `HorseshoeContainer.bottomRail` during a call) shrinks the
-// wrapper.
-const RAIL_VIEWPORT_FRACTION = 0.8;
+// Sheet height as a fraction of the viewport — near-fullscreen by
+// design: the viewer should cover the chat (incl. its header) and stop
+// just under the system status bar / tray. Capped at runtime by the
+// wrapper container's actual height minus the status-bar inset (see
+// `railHeightPx` calc below) so the sheet's rounded top edge lands at
+// the bottom of the status bar, never over the tray icons.
+const RAIL_VIEWPORT_FRACTION = 1;
// Absolute floor for tiny / split-screen viewports — the sheet
// becomes useless below this. NB: floor cannot exceed the
// container's ceiling (`Math.min` is applied last), so in extreme
// squeeze cases (split-screen, oversized call rail) the sheet
-// silently shrinks below the floor rather than clipping the chat
-// header.
+// silently shrinks below the floor rather than clipping the status bar.
const RAIL_MIN_PX = 200;
-// Headroom reserved at the TOP of the container for the chat
-// header (`PageHeader` = folds `Header size="600"`, ~64px) plus
-// the 12px horseshoe void gap above the silhouette's rounded
-// TL/TR carves. With this in place the sheet's top edge stops
-// just below the chat header even when the wrapper container
-// shrinks (e.g. `HorseshoeContainer.bottomRail` is on during a
-// call). Previously the headroom was 8px which let the sheet
-// climb over the header in tight viewports.
-const CHAT_HEADER_RESERVED_PX = 76;
-// Same emerge window as the settings sheet so the silhouette's
-// rounding + void gap finish exactly when the gesture qualifies to
-// commit.
-const HORSESHOE_EMERGE_PX = 80;
-
-// Symmetric cubic in-out — slow start, fast middle, slow finish.
-// Same curve the settings / profile horseshoes use for their emerge
-// (rationale at the top of `MobileSettingsHorseshoe.tsx`).
-const easeInOutCubic = (t: number): number =>
- t < 0.5 ? 4 * t * t * t : 1 - ((-2 * t + 2) ** 3) / 2;
type DragState = {
inputType: 'touch' | 'pointer';
@@ -97,13 +79,12 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
// Measure the wrapper container directly via `ResizeObserver` so
// the rail height tracks whatever vertical space is actually
// available to the chat column. The viewport-based fraction is
- // the *intent* (90% of the screen — product spec), but
+ // the *intent* (full screen — product spec), but
// `HorseshoeContainer.bottomRail` (the call rail) sits OUTSIDE
// the chat column inside the same viewport and shrinks our
- // wrapper when active. Without the container clamp, 90% of the
- // viewport could exceed the wrapper's height, and the sheet
- // would render up to / past the chat header which sits inside
- // the wrapper's appBody.
+ // wrapper when active. Without the container clamp, a full-viewport
+ // sheet could exceed the wrapper's height and spill past the bottom
+ // chrome; the clamp keeps the top edge at the status bar instead.
const containerRef = useRef(null);
const [containerHeight, setContainerHeight] = useState(() =>
typeof window === 'undefined' ? 800 : window.innerHeight
@@ -121,15 +102,32 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
return () => ro.disconnect();
}, []);
- // Final rail = viewport × fraction, then floor-then-ceiling
- // clamped. Container (minus chat-header reserve) is the HARD
- // ceiling: when the wrapper shrinks (call rail / split-screen)
- // the sheet shrinks below `RAIL_MIN_PX` rather than spilling
- // over the chat header. Order matters — `Math.min` last makes
- // container win against floor.
+ // Live status-bar inset. `env(safe-area-inset-top)` can't be read
+ // reliably off a custom property in JS, so we measure a zero-width
+ // probe whose height is `var(--vojo-safe-top)` (= the env value).
+ // This is the only thing the near-fullscreen sheet must stay below
+ // — it covers the chat header but must not climb over the tray.
+ const safeTopRef = useRef(null);
+ const [safeTopPx, setSafeTopPx] = useState(0);
+ useLayoutEffect(() => {
+ const el = safeTopRef.current;
+ if (!el) return undefined;
+ setSafeTopPx(el.offsetHeight);
+ const ro = new ResizeObserver(() => setSafeTopPx(el.offsetHeight));
+ ro.observe(el);
+ return () => ro.disconnect();
+ }, []);
+
+ // Final rail = viewport × fraction (full screen), then floor-then-
+ // ceiling clamped. Container minus the status-bar inset is the HARD
+ // ceiling: the sheet's flat top lands flush at the bottom of the
+ // status bar (no void gap / breathing), covering the whole chat column
+ // down to just under the tray; when the wrapper shrinks (call rail /
+ // split-screen) it shrinks below `RAIL_MIN_PX` rather than spilling
+ // over the tray. Order matters — `Math.min` last makes container win.
const idealRailPx = Math.round(viewportHeight * RAIL_VIEWPORT_FRACTION);
const railHeightPx = Math.min(
- Math.max(0, containerHeight - CHAT_HEADER_RESERVED_PX),
+ Math.max(0, containerHeight - safeTopPx),
Math.max(RAIL_MIN_PX, idealRailPx)
);
const open = !!entry;
@@ -172,9 +170,7 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
const expandedPx = drag
? Math.max(0, Math.min(railHeightPx, baseExpanded - drag.deltaY))
: baseExpanded;
- const expandedFraction = railHeightPx > 0 ? expandedPx / railHeightPx : 0;
const isDragging = drag !== null;
- const horseshoeActive = expandedPx > 0;
const handleRef = useRef(null);
@@ -208,9 +204,7 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
const target = e.target as HTMLElement | null;
if (
target &&
- (target.tagName === 'INPUT' ||
- target.tagName === 'TEXTAREA' ||
- target.isContentEditable)
+ (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
) {
return;
}
@@ -307,30 +301,19 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
};
}, []);
- // Geometry — same emerge ramp as the settings / profile horseshoes
- // (rationale at the top of `MobileSettingsHorseshoe`).
- let horseshoeRamp: number;
- if (isDragging) {
- horseshoeRamp = easeInOutCubic(Math.min(1, expandedPx / HORSESHOE_EMERGE_PX));
- } else {
- horseshoeRamp = expandedFraction > 0 ? 1 : 0;
- }
- const silhouetteRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX;
- const appBodyRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX;
- const appBodyGapPx = horseshoeRamp * css.HORSESHOE_GAP_PX;
- const appBodyMaskBottomPx = expandedPx + appBodyGapPx;
+ // Geometry — the media viewer is fullscreen, so (unlike the settings /
+ // profile horseshoes) it carves NO rounded top corners and NO void gap
+ // above the sheet: the sheet's flat top butts directly against the chat
+ // it clips (which now fills only the status-bar strip above it). Chat bg
+ // and sheet bg are the same Dawn tone, so there's no black void seam at
+ // the top — the viewer reads as a flush fullscreen panel under the tray.
+ const appBodyMaskBottomPx = expandedPx;
- const appBodyClipPath = `inset(0px 0px ${appBodyMaskBottomPx}px 0px round 0px 0px ${appBodyRadiusPx}px ${appBodyRadiusPx}px)`;
+ const appBodyClipPath = `inset(0px 0px ${appBodyMaskBottomPx}px 0px)`;
- const silhouetteTransition = isDragging
- ? 'none'
- : `height ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-left-radius ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-right-radius ${ANIMATION_MS}ms ${VAUL_EASING}`;
+ const silhouetteTransition = isDragging ? 'none' : `height ${ANIMATION_MS}ms ${VAUL_EASING}`;
const appBodyTransition = isDragging ? 'none' : `clip-path ${ANIMATION_MS}ms ${VAUL_EASING}`;
- const containerStyle: React.CSSProperties = {
- backgroundColor: horseshoeActive ? VOJO_HORSESHOE_VOID_COLOR : undefined,
- };
-
const renderEntry = entry ?? lastEntryRef.current;
const renderBody = !!renderEntry && (keepMounted || isDragging);
@@ -344,7 +327,22 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
: null;
return (
-
+
+ {/* Zero-width probe — its height resolves `env(safe-area-inset-top)`
+ so the JS rail-height calc can keep the sheet below the tray. */}
+
{open && portalTarget
? createPortal(
0 ? 'visible' : 'hidden',
// Reset `--vojo-safe-top` so any descendant that paints a
diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx
index d4cd38e9..67fe586a 100644
--- a/src/app/features/room/Room.tsx
+++ b/src/app/features/room/Room.tsx
@@ -25,9 +25,8 @@ import { RoomViewProfileSidePanel } from './RoomViewProfileSidePanel';
import { RoomViewMembersPanel } from './RoomViewMembersPanel';
import { RoomViewMembersSidePanel } from './RoomViewMembersSidePanel';
import { MobileMediaViewerHorseshoe } from './MobileMediaViewerHorseshoe';
-import { RoomViewMediaSidePanel } from './RoomViewMediaSidePanel';
+import { RoomViewMediaWindow } from './RoomViewMediaWindow';
import { MediaViewerHostContext } from './mediaViewerHostContext';
-import { mediaViewerAtom } from '../../state/mediaViewer';
import { roomMembersSheetAtom } from '../../state/roomMembersSheet';
import { useChannelsMode, ThreadDrawerOpenProvider } from '../../hooks/useChannelsMode';
import { CHANNELS_THREAD_PATH } from '../../pages/paths';
@@ -133,16 +132,12 @@ export function Room({ renderRoomView }: RoomProps) {
// PageRoot. The chat column applies an explicit Background bg so the
// parent void can't bleed through any transparent slivers.
const profileOpen = !!useAtomValue(userRoomProfileAtom);
- const mediaOpen = !!useAtomValue(mediaViewerAtom);
const membersSheetOpen = !!useAtomValue(roomMembersSheetAtom);
const callView = room.isCallRoom();
const showProfileHorseshoe = profileOpen && !isMobile && !showThreadDrawer;
- // Media viewer side pane on desktop — same horseshoe seam idiom
- // as the profile pane. The two are mutually exclusive in practice
- // (the open hooks clear the other atom), so at most one shows at
- // a time; both feed into `paintParentVoid` for the chat-column bg
- // and locally gate the shared `
` seam.
- const showMediaHorseshoe = mediaOpen && !isMobile && !showThreadDrawer;
+ // The desktop/tablet media viewer is no longer a right-side pane — it's
+ // a free-floating draggable window (`RoomViewMediaWindow`) portaled over
+ // the app, so it doesn't carve a horseshoe seam or paint the parent void.
// Thread drawer side pane on desktop. Mobile hides the chat column
// entirely (`drawerHidesChat`) so the seam doesn't apply there.
const showThreadHorseshoe = showThreadDrawer && !isMobile;
@@ -168,8 +163,7 @@ export function Room({ renderRoomView }: RoomProps) {
// remain local to each render site (a single `paintParentVoid` gate
// would over-render when only members is open — there is no
// profile/media slot to anchor it to).
- const paintParentVoid =
- showProfileHorseshoe || showMediaHorseshoe || showThreadHorseshoe || showMembersHorseshoe;
+ const paintParentVoid = showProfileHorseshoe || showThreadHorseshoe || showMembersHorseshoe;
useKeyDown(
window,
@@ -193,12 +187,13 @@ export function Room({ renderRoomView }: RoomProps) {
);
// Disable the atom-driven media viewer when the desktop thread
- // drawer is open — the side-pane mount block below is gated on
- // `!showThreadDrawer`, so the new viewer's right pane wouldn't
- // render, and an atom-only open path would silently no-op on the
- // user's tap. With the host context cleared, `ImageContent` /
- // `VideoContent` fall back to the legacy full-screen `
`
- // modal, which sits on top of every pane and still works.
+ // drawer is open — with the host context cleared, a timeline
+ // image/video tap falls back to the legacy full-screen ``
+ // modal instead of opening a floating window on top of the drawer
+ // (the `` sits above every pane and still works). NB: an
+ // already-open floating window is NOT torn down by this — the window
+ // mounts for all non-mobile screens and stays visible over the
+ // drawer; only NEW taps are routed to the legacy modal.
// Mobile is unaffected: `drawerHidesChat` hides the chat column
// entirely on mobile + thread, so there's no media to tap anyway.
//
@@ -283,11 +278,18 @@ export function Room({ renderRoomView }: RoomProps) {
exclusive via the open hooks. */}
{!isMobile && !showThreadDrawer && (
<>
- {(showProfileHorseshoe || showMediaHorseshoe) && }
+ {showProfileHorseshoe && }
-
>
)}
+ {/* 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 && }
{callView && chat && (
<>
diff --git a/src/app/features/room/RoomViewMediaSidePanel.css.ts b/src/app/features/room/RoomViewMediaSidePanel.css.ts
deleted file mode 100644
index f559e7a1..00000000
--- a/src/app/features/room/RoomViewMediaSidePanel.css.ts
+++ /dev/null
@@ -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,
- },
- },
-});
diff --git a/src/app/features/room/RoomViewMediaSidePanel.tsx b/src/app/features/room/RoomViewMediaSidePanel.tsx
deleted file mode 100644
index 61161dd9..00000000
--- a/src/app/features/room/RoomViewMediaSidePanel.tsx
+++ /dev/null
@@ -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(typeof window !== 'undefined' ? window.innerWidth : 1280);
- const [dragging, setDragging] = useState(false);
- const [liveWidth, setLiveWidth] = useState(null);
-
- const resizableRef = useRef(null);
- const resizeHandleRef = useRef(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) => {
- if (e.button !== 0 || !canResize) return;
- e.preventDefault();
- beginDrag(e.pointerId);
- },
- [beginDrag, canResize]
- );
-
- const onResizePointerMove = useCallback(
- (e: React.PointerEvent) => {
- 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) => {
- try {
- resizeHandleRef.current?.releasePointerCapture(e.pointerId);
- } catch {
- /* releasePointerCapture is best-effort */
- }
- endDrag();
- },
- [endDrag]
- );
-
- const onResizeKeyDown = useCallback(
- (e: React.KeyboardEvent) => {
- 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 (
- true,
- escapeDeactivates: stopPropagation,
- onDeactivate: () => {
- if (entryRef.current) close();
- },
- checkCanFocusTrap: () => Promise.resolve(),
- }}
- active={open}
- >
-
-
- {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
-
- )}
-
-
- );
-}
diff --git a/src/app/features/room/RoomViewMediaWindow.css.ts b/src/app/features/room/RoomViewMediaWindow.css.ts
new file mode 100644
index 00000000..2702d720
--- /dev/null
+++ b/src/app/features/room/RoomViewMediaWindow.css.ts
@@ -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 },
+ },
+});
diff --git a/src/app/features/room/RoomViewMediaWindow.tsx b/src/app/features/room/RoomViewMediaWindow.tsx
new file mode 100644
index 00000000..2c00935a
--- /dev/null
+++ b/src/app/features/room/RoomViewMediaWindow.tsx
@@ -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(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) => {
+ 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(
+
+ {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
+
beginGesture('move', e)}>
+ {entry.body}
+ e.stopPropagation()}
+ onClick={close}
+ aria-label={t('Common.close', 'Close')}
+ >
+
+
+
+
+
+
+ {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
+
beginGesture('resize', e)}
+ />
+
,
+ portalTarget
+ );
+}
diff --git a/src/app/features/room/mediaViewerHostContext.ts b/src/app/features/room/mediaViewerHostContext.ts
index 234d49c3..108f0611 100644
--- a/src/app/features/room/mediaViewerHostContext.ts
+++ b/src/app/features/room/mediaViewerHostContext.ts
@@ -1,7 +1,8 @@
import { createContext, useContext } from 'react';
// Non-null only when an atom-driven media-viewer shell (the mobile
-// bottom-up horseshoe or the desktop right side pane) is mounted in
+// bottom-up horseshoe or the desktop/tablet floating window
+// `RoomViewMediaWindow`) is mounted in
// the React tree above this consumer. `ImageContent` reads this to
// decide between the new atom-open path (Room timeline) and the
// legacy local-state `
` viewer (pin-menu, message search
@@ -16,5 +17,4 @@ export type MediaViewerHostValue = { roomId: string } | null;
export const MediaViewerHostContext = createContext(null);
-export const useMediaViewerHost = (): MediaViewerHostValue =>
- useContext(MediaViewerHostContext);
+export const useMediaViewerHost = (): MediaViewerHostValue => useContext(MediaViewerHostContext);
diff --git a/src/app/state/hooks/userRoomProfile.ts b/src/app/state/hooks/userRoomProfile.ts
index d67270c6..427aa5c4 100644
--- a/src/app/state/hooks/userRoomProfile.ts
+++ b/src/app/state/hooks/userRoomProfile.ts
@@ -32,11 +32,11 @@ type OpenCallback = (
export const useOpenUserRoomProfile = (): OpenCallback => {
const setUserRoomProfile = useSetAtom(userRoomProfileAtom);
// Bidirectional mutual exclusion with the media viewer — opening
- // profile closes any open media viewer pane / sheet. Mirrors the
- // clear-the-other-atom behaviour in `useOpenMediaViewer`. Without
- // this, on desktop both `RoomViewProfileSidePanel` and
- // `RoomViewMediaSidePanel` would mount as siblings and fight for
- // the right-pane slot.
+ // profile closes any open media viewer window / sheet. Mirrors the
+ // clear-the-other-atom behaviour in `useOpenMediaViewer`. The media
+ // viewer is a floating window (`RoomViewMediaWindow`) on desktop now,
+ // but keeping them mutually exclusive avoids a profile pane fighting
+ // the window for attention.
const setMediaViewer = useSetAtom(mediaViewerAtom);
// Symmetric mutual exclusion with the group-room members sheet — when
// the user taps a row inside the members list, the per-user profile
diff --git a/src/app/state/mediaSidePanelWidth.ts b/src/app/state/mediaSidePanelWidth.ts
deleted file mode 100644
index 6324827a..00000000
--- a/src/app/state/mediaSidePanelWidth.ts
+++ /dev/null
@@ -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(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(
- 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 };
diff --git a/src/app/state/mediaViewer.ts b/src/app/state/mediaViewer.ts
index d979d171..6cd70f35 100644
--- a/src/app/state/mediaViewer.ts
+++ b/src/app/state/mediaViewer.ts
@@ -3,7 +3,8 @@ import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { IImageInfo, IVideoInfo } from '../../types/matrix/common';
// Open state for the room media viewer — bottom-up sheet on mobile,
-// right-side pane on desktop. Mirror of `settingsSheetAtom`. The
+// floating draggable window on desktop/tablet (RoomViewMediaWindow).
+// Mirror of `settingsSheetAtom`. The
// atom-driven shell is mounted in `Room.tsx`; `ImageContent` /
// `VideoContent` open the atom when the host context from
// `mediaViewerHostContext` is non-null (Room timeline path);
diff --git a/src/app/state/mediaWindowRect.ts b/src/app/state/mediaWindowRect.ts
new file mode 100644
index 00000000..f464f250
--- /dev/null
+++ b/src/app/state/mediaWindowRect.ts
@@ -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(key, null);
+ if (!raw || typeof raw !== 'object') return null;
+ const r = raw as Partial;
+ 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(
+ 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 };
+};