import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { Box, Header, IconButton, Scroll, Spinner, Text, as, config, Chip, Avatar, } from 'folds'; import { MsgType, Room } from 'matrix-js-sdk'; import { useAtom } from 'jotai'; import classNames from 'classnames'; import { Icon, Icons } from '../../../components/icons'; import * as css from './RoomMediaMenu.css'; import { RoomMediaItem, useRoomMediaEvents } from '../../../hooks/useRoomMediaEvents'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { decryptFile, downloadEncryptedMedia, getMxIdLocalPart, isAuthenticatedMediaUrl, mxcUrlToHttp, } from '../../../utils/matrix'; import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; import { Image } from '../../../components/media'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { getCurrentAccessToken } from '../../../utils/auth'; import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes'; import { VideoFrameThumbnail } from '../../../components/message/content/VideoFrameThumbnail'; import { getMemberAvatarMxc, getMemberDisplayName } from '../../../utils/room'; import { UserAvatar } from '../../../components/user-avatar'; import { getMediaDrawerScrollTop, isMediaDrawerAtom, setMediaDrawerScrollTop, } from '../../../state/mediaDrawer'; import { ContainerColor } from '../../../styles/ContainerColor.css'; const fetchAuthenticatedMedia = async ( url: string, accessToken: string | null ): Promise => { try { let response = await fetch(url, { method: 'GET', headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined, }); if (!response.ok && response.status === 401 && accessToken) { response = await fetch(url, { method: 'GET' }); } if (!response.ok) return url; const blob = await response.blob(); return URL.createObjectURL(blob); } catch { return url; } }; type MediaTileThumbProps = { item: RoomMediaItem; }; function MediaTileImageThumb({ item }: { item: RoomMediaItem & { thumbMxc: string } }) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const [srcState, loadSrc] = useAsyncCallback( useCallback(async () => { const accessToken = getCurrentAccessToken(); if (item.thumbEncInfo) { const mediaUrl = mxcUrlToHttp(mx, item.thumbMxc, useAuthentication) ?? item.thumbMxc; const fileContent = await downloadEncryptedMedia( mediaUrl, (encBuf) => decryptFile(encBuf, item.thumbMimeType ?? FALLBACK_MIMETYPE, item.thumbEncInfo!), accessToken ); return URL.createObjectURL(fileContent); } const mediaUrl = mxcUrlToHttp(mx, item.thumbMxc, useAuthentication, 240, 240, 'crop') ?? item.thumbMxc; if (useAuthentication && isAuthenticatedMediaUrl(mediaUrl)) { return fetchAuthenticatedMedia(mediaUrl, accessToken); } return mediaUrl; }, [mx, item.thumbMxc, item.thumbEncInfo, item.thumbMimeType, useAuthentication]) ); useEffect(() => { loadSrc(); }, [loadSrc]); useEffect(() => { if (srcState.status !== AsyncStatus.Success) return undefined; const src = srcState.data; if (!src.startsWith('blob:')) return undefined; return () => URL.revokeObjectURL(src); }, [srcState]); if (srcState.status === AsyncStatus.Success) { return {item.body}; } return ( {srcState.status === AsyncStatus.Loading ? ( ) : ( )} ); } function MediaTileFullImageThumb({ item }: { item: RoomMediaItem }) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const [srcState, loadSrc] = useAsyncCallback( useCallback(async () => { const accessToken = getCurrentAccessToken(); const mediaUrl = mxcUrlToHttp(mx, item.fullMxc, useAuthentication) ?? item.fullMxc; if (item.fullEncInfo) { const fileContent = await downloadEncryptedMedia( mediaUrl, (encBuf) => decryptFile(encBuf, item.fullMimeType ?? FALLBACK_MIMETYPE, item.fullEncInfo!), accessToken ); return URL.createObjectURL(fileContent); } if (useAuthentication && isAuthenticatedMediaUrl(mediaUrl)) { return fetchAuthenticatedMedia(mediaUrl, accessToken); } return mediaUrl; }, [mx, item.fullMxc, item.fullEncInfo, item.fullMimeType, useAuthentication]) ); useEffect(() => { loadSrc(); }, [loadSrc]); useEffect(() => { if (srcState.status !== AsyncStatus.Success) return undefined; const src = srcState.data; if (!src.startsWith('blob:')) return undefined; return () => URL.revokeObjectURL(src); }, [srcState]); if (srcState.status === AsyncStatus.Success) { return {item.body}; } return ( {srcState.status === AsyncStatus.Loading ? ( ) : ( )} ); } function MediaTileThumb({ item }: MediaTileThumbProps) { if (item.thumbMxc) { return ; } if (item.msgtype === MsgType.Video) { return ( ( {item.body} )} /> ); } return ; } type MediaDayGroup = { dayKey: string; dayLabel: string; items: RoomMediaItem[]; }; type MediaMonthGroup = { monthKey: string; monthLabel: string; days: MediaDayGroup[]; }; const monthFormatter = new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric', }); const dayFormatter = new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', day: 'numeric', }); const sameDayFormatter = new Intl.DateTimeFormat(undefined, { year: 'numeric', month: '2-digit', day: '2-digit', }); const formatDayLabel = (ts: number): string => { const date = new Date(ts); const today = new Date(); const yesterday = new Date(); yesterday.setDate(today.getDate() - 1); if (sameDayFormatter.format(date) === sameDayFormatter.format(today)) return 'Today'; if (sameDayFormatter.format(date) === sameDayFormatter.format(yesterday)) return 'Yesterday'; return dayFormatter.format(date); }; const groupMediaByMonthAndDay = (items: RoomMediaItem[]): MediaMonthGroup[] => { const months: MediaMonthGroup[] = []; for (const item of items) { const date = new Date(item.ts); const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; const dayKey = `${monthKey}-${String(date.getDate()).padStart(2, '0')}`; let month = months[months.length - 1]; if (!month || month.monthKey !== monthKey) { month = { monthKey, monthLabel: monthFormatter.format(date), days: [], }; months.push(month); } let day = month.days[month.days.length - 1]; if (!day || day.dayKey !== dayKey) { day = { dayKey, dayLabel: formatDayLabel(item.ts), items: [], }; month.days.push(day); } day.items.push(item); } return months; }; type MediaSenderChip = { userId: string; name: string; avatarMxc?: string; count: number; }; export type MediaDrawerProps = { room: Room; /** Full-page takeover when the layout is too narrow for a side rail. */ solo?: boolean; }; export const MediaDrawer = as<'div', MediaDrawerProps>(({ room, solo = false, className, ...props }, ref) => { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const { navigateRoom } = useRoomNavigate(); const [, setMediaDrawer] = useAtom(isMediaDrawerAtom); const { items, loading, hasMore, loadMore } = useRoomMediaEvents(room); const [excludedSenders, setExcludedSenders] = useState>(() => new Set()); const scrollRef = useRef(null); const restoredScrollRef = useRef(false); const senders = useMemo(() => { const counts = new Map(); for (const item of items) { counts.set(item.sender, (counts.get(item.sender) ?? 0) + 1); } return Array.from(counts.entries()) .map(([userId, count]) => ({ userId, count, name: getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId, avatarMxc: getMemberAvatarMxc(room, userId), })) .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)); }, [items, room]); const filteredItems = useMemo( () => items.filter((item) => !excludedSenders.has(item.sender)), [items, excludedSenders] ); const sections = useMemo(() => groupMediaByMonthAndDay(filteredItems), [filteredItems]); useLayoutEffect(() => { restoredScrollRef.current = false; }, [room.roomId]); useLayoutEffect(() => { if (restoredScrollRef.current || items.length === 0) return; const el = scrollRef.current; if (!el) return; const saved = getMediaDrawerScrollTop(room.roomId); if (typeof saved === 'number') { el.scrollTop = saved; } restoredScrollRef.current = true; }, [room.roomId, items.length]); const handleClose = useCallback(() => { setMediaDrawer(false); }, [setMediaDrawer]); const handleSenderClick = useCallback( (evt: React.MouseEvent, userId: string) => { if (evt.ctrlKey || evt.metaKey) { evt.preventDefault(); const allIds = senders.map((sender) => sender.userId); setExcludedSenders((prev) => { const enabledIds = allIds.filter((id) => !prev.has(id)); const alreadySolo = enabledIds.length === 1 && enabledIds[0] === userId; if (alreadySolo) return new Set(); return new Set(allIds.filter((id) => id !== userId)); }); return; } setExcludedSenders((prev) => { const next = new Set(prev); if (next.has(userId)) next.delete(userId); else next.add(userId); return next; }); }, [senders] ); const handleOpen = useCallback( (eventId: string) => { navigateRoom(room.roomId, eventId); if (solo) setMediaDrawer(false); }, [navigateRoom, room.roomId, solo, setMediaDrawer] ); const handleScroll = useCallback( (evt: React.UIEvent) => { const el = evt.currentTarget; setMediaDrawerScrollTop(room.roomId, el.scrollTop); const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 80; if (nearBottom && hasMore && !loading) { loadMore(); } }, [hasMore, loading, loadMore, room.roomId] ); return (
Shared Media
{senders.length > 0 && (
{senders.map((sender) => { const enabled = !excludedSenders.has(sender.userId); const avatarUrl = sender.avatarMxc ? mxcUrlToHttp(mx, sender.avatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined : undefined; return ( handleSenderClick(evt, sender.userId)} before={ ( {sender.name.slice(0, 1).toUpperCase()} )} /> } > {sender.name} ); })}
)}
{items.length === 0 && !loading && ( No shared photos or videos yet. )} {items.length > 0 && filteredItems.length === 0 && ( No media from the selected people. )} {sections.map((month) => (
{month.monthLabel} {month.days.map((day) => (
{day.dayLabel}
{day.items.map((item) => ( ))}
))}
))} {(loading || hasMore) && (
{loading && }
)}
); });