diff --git a/components/chat/media-grid.tsx b/components/chat/media-grid.tsx index 359e5848..755d36f9 100644 --- a/components/chat/media-grid.tsx +++ b/components/chat/media-grid.tsx @@ -37,7 +37,10 @@ export function MediaGrid({ onClick={() => onOpen(0)} className="block overflow-hidden rounded-lg" > - + ); } @@ -46,7 +49,7 @@ export function MediaGrid({ // Two columns throughout: two items make a row, three or four make the // 2×2. A three-item grid deliberately leaves the fourth cell empty rather // than stretching one tile — an uneven crop reads worse than a gap. -
+
{visible.map((item, i) => { const isLast = i === visible.length - 1; return ( diff --git a/components/chat/media-lightbox.tsx b/components/chat/media-lightbox.tsx index 1d8af9ed..9de4c14f 100644 --- a/components/chat/media-lightbox.tsx +++ b/components/chat/media-lightbox.tsx @@ -7,9 +7,16 @@ * comes free on touch, the scrollbar reflects position, and the browser handles * momentum. Arrow keys and the on-screen buttons scroll to a slide rather than * setting state, so every input path drives the same mechanism. + * + * Portalled to , and that is load-bearing rather than tidiness: every + * bubble sits inside MessageGestures, which sets a transform for swipe-to-reply + * — and a transformed ancestor becomes the containing block for `position: + * fixed`. Rendered in place, `inset-0` would resolve against the bubble, and + * the message list's overflow would then clip it. */ import { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { ChevronLeft, ChevronRight, Download, Loader2, X } from "lucide-react"; import { toast } from "sonner"; import { attachmentUrl, type Attachment } from "@/lib/chat"; @@ -53,6 +60,16 @@ export function MediaLightbox({ return () => window.removeEventListener("keydown", onKey); }, [index, items.length, onClose, scrollTo]); + // A full-screen viewer over a scrollable thread: without this the thread + // scrolls behind the image once the carousel reaches its own edge. + useEffect(() => { + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = prev; + }; + }, []); + const current = items[index]; /** @@ -85,8 +102,10 @@ export function MediaLightbox({ } }; - return ( -
+ if (typeof document === "undefined") return null; + + return createPortal( +
-
+
, + document.body, ); } diff --git a/components/chat/message-group.tsx b/components/chat/message-group.tsx index 25501fbc..a44803d9 100644 --- a/components/chat/message-group.tsx +++ b/components/chat/message-group.tsx @@ -54,6 +54,22 @@ function isStickerOnly(m: ChatMessage): boolean { return !m.body && atts.length > 0 && atts.every((a) => a.kind === "sticker"); } +/** True when a message carries photos or video. */ +function carriesMedia(m: ChatMessage): boolean { + return (m.attachments ?? []).some( + (a) => a.kind === "image" || a.kind === "video", + ); +} + +/** + * True when a message's content brings its own frame — a shared post card, + * stickers, or media. All three are images with their own edges, and a + * coloured bubble around them reads as a second frame. + */ +function rendersBare(m: ChatMessage): boolean { + return sharesAnEntity(m) || isStickerOnly(m) || carriesMedia(m); +} + /** Distinct emoji shown before the rest collapse into "+N". */ const MAX_TRAIL_EMOJI = 4; @@ -229,7 +245,7 @@ export function MessageGroup({ // own border, rounding and background, so the bubble drops // its chrome rather than drawing a second frame around it. // Any caption gets the padding back on its own line below. - sharesAnEntity(m) || isStickerOnly(m) + rendersBare(m) ? "overflow-hidden bg-transparent" : cn( "px-3 py-2", @@ -264,11 +280,16 @@ export function MessageGroup({