feat(media): show the full caption under the media in the viewer (#164)

A captioned image/video (MSC2530: `filename` set and `body` differs) put the
caption in the header, truncated to one line, so a long caption was
unreadable. The header now shows the file name; the caption is shown in full
under the media (wrapped, scrolls past 25vh). The image alt text uses the
caption, and Download now saves under the real file name instead of the
caption.

Verified in Chromium: header "lake-sunset.png", the 150-character caption
fully visible below the image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
Lotus CI
2026-09-24 21:51:33 -04:00
co-authored by Claude Opus 5.5
parent ea07fb5087
commit c088b4c0e6
+40 -3
View File
@@ -106,6 +106,10 @@ export type LightboxItem = {
mimeType?: string;
msgtype: MsgType.Image | MsgType.Video;
body: string;
/** The file's name when the message also carries a caption (MSC2530). */
filename?: string;
/** Caption text: `body` when it differs from `filename`. */
caption?: string;
sender: string;
ts: number;
eventId: string;
@@ -127,12 +131,19 @@ export function toLightboxItems(room: Room, events: MatrixEvent[]): LightboxItem
.map((ev) => {
const c = ev.getContent();
const isEnc = !!c.file;
const filename = typeof c.filename === 'string' && c.filename ? c.filename : undefined;
const caption =
filename && typeof c.body === 'string' && c.body && c.body !== filename
? c.body
: undefined;
return {
mxcUrl: c.file?.url ?? c.url ?? '',
encInfo: isEnc ? c.file : undefined,
mimeType: c.info?.mimetype,
msgtype: c.msgtype as MsgType.Image | MsgType.Video,
body: c.body ?? '',
filename,
caption,
sender: getSenderName(room, ev.getSender() ?? ''),
ts: ev.getTs(),
eventId: ev.getId() ?? '',
@@ -209,7 +220,7 @@ function LightboxMedia({
) : (
<img
src={media.url}
alt={item.body}
alt={item.caption ?? item.body}
draggable={false}
onMouseDown={onMouseDown}
onTouchStart={onTouchStart}
@@ -356,7 +367,7 @@ export function Lightbox({
>
<Box grow="Yes" direction="Column" style={{ overflow: 'hidden' }}>
<Text size="T400" truncate style={{ color: '#fff', fontWeight: 500 }}>
{item.body || (item.msgtype === MsgType.Video ? 'Video' : 'Image')}
{item.filename || item.body || (item.msgtype === MsgType.Video ? 'Video' : 'Image')}
</Text>
<Text size="T200" style={{ color: 'rgba(255,255,255,0.5)' }}>
{item.sender} · {dateStr}
@@ -412,7 +423,7 @@ export function Lightbox({
{(ref) => (
<span ref={ref}>
<FileDownloadButton
filename={mediaFilename(item.body, item.mimeType)}
filename={mediaFilename(item.filename ?? item.body, item.mimeType)}
url={item.mxcUrl}
mimeType={item.mimeType ?? 'application/octet-stream'}
encInfo={item.encInfo}
@@ -515,6 +526,32 @@ export function Lightbox({
</IconButton>
)}
</Box>
{item.caption && (
// [Gitea #164] The full caption, readable — the header only has room
// for one truncated line.
<Box
shrink="No"
justifyContent="Center"
style={{
padding: `${config.space.S200} ${config.space.S400} ${config.space.S400}`,
maxHeight: '25vh',
overflowY: 'auto',
}}
>
<Text
size="T300"
style={{
color: 'rgba(255,255,255,0.88)',
whiteSpace: 'pre-wrap',
overflowWrap: 'anywhere',
maxWidth: '70ch',
textAlign: 'center',
}}
>
{item.caption}
</Text>
</Box>
)}
</div>
</FocusTrap>
</Overlay>