Widen the media panel, support skinny full-page mode, keep jump-to-latest and return-to-previous reliable, and stop same-room event jumps from remounting the outlet or yanking the sidebar.
525 lines
16 KiB
TypeScript
525 lines
16 KiB
TypeScript
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<string> => {
|
|
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 <Image className={css.MediaTileImg} src={srcState.data} alt={item.body} loading="lazy" />;
|
|
}
|
|
|
|
return (
|
|
<Box alignItems="Center" justifyContent="Center" style={{ width: '100%', height: '100%' }}>
|
|
{srcState.status === AsyncStatus.Loading ? (
|
|
<Spinner size="200" />
|
|
) : (
|
|
<Icon src={Icons.Photo} size="400" />
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
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 <Image className={css.MediaTileImg} src={srcState.data} alt={item.body} loading="lazy" />;
|
|
}
|
|
|
|
return (
|
|
<Box alignItems="Center" justifyContent="Center" style={{ width: '100%', height: '100%' }}>
|
|
{srcState.status === AsyncStatus.Loading ? (
|
|
<Spinner size="200" />
|
|
) : (
|
|
<Icon src={Icons.Photo} size="400" />
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function MediaTileThumb({ item }: MediaTileThumbProps) {
|
|
if (item.thumbMxc) {
|
|
return <MediaTileImageThumb item={{ ...item, thumbMxc: item.thumbMxc }} />;
|
|
}
|
|
|
|
if (item.msgtype === MsgType.Video) {
|
|
return (
|
|
<Box style={{ width: '100%', height: '100%', position: 'relative' }}>
|
|
<Box
|
|
alignItems="Center"
|
|
justifyContent="Center"
|
|
style={{ position: 'absolute', inset: 0 }}
|
|
>
|
|
<Spinner size="200" />
|
|
</Box>
|
|
<VideoFrameThumbnail
|
|
mimeType={item.fullMimeType ?? 'video/mp4'}
|
|
url={item.fullMxc}
|
|
encInfo={item.fullEncInfo}
|
|
renderImage={(src) => (
|
|
<Image className={css.MediaTileImg} src={src} alt={item.body} loading="lazy" />
|
|
)}
|
|
/>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
return <MediaTileFullImageThumb item={item} />;
|
|
}
|
|
|
|
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<Set<string>>(() => new Set());
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const restoredScrollRef = useRef(false);
|
|
|
|
const senders = useMemo<MediaSenderChip[]>(() => {
|
|
const counts = new Map<string, number>();
|
|
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<HTMLDivElement>) => {
|
|
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 (
|
|
<Box
|
|
shrink="No"
|
|
grow={solo ? 'Yes' : undefined}
|
|
direction="Column"
|
|
className={classNames(
|
|
solo ? css.MediaDrawerSolo : css.MediaDrawer,
|
|
ContainerColor({ variant: 'Background' }),
|
|
className
|
|
)}
|
|
{...props}
|
|
ref={ref}
|
|
>
|
|
<Header className={css.MediaDrawerHeader} size="600" variant="Background">
|
|
<Box grow="Yes" alignItems="Center">
|
|
<Text size="H5" truncate>
|
|
Shared Media
|
|
</Text>
|
|
</Box>
|
|
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
|
|
<Icon src={Icons.Cross} size="100" />
|
|
</IconButton>
|
|
</Header>
|
|
|
|
{senders.length > 0 && (
|
|
<div className={css.MediaUserFiltersScroll}>
|
|
<div className={css.MediaUserFilters}>
|
|
{senders.map((sender) => {
|
|
const enabled = !excludedSenders.has(sender.userId);
|
|
const avatarUrl = sender.avatarMxc
|
|
? mxcUrlToHttp(mx, sender.avatarMxc, useAuthentication, 48, 48, 'crop') ??
|
|
undefined
|
|
: undefined;
|
|
|
|
return (
|
|
<Chip
|
|
key={sender.userId}
|
|
className={css.MediaUserChip}
|
|
variant={enabled ? 'Primary' : 'SurfaceVariant'}
|
|
outlined
|
|
radii="Pill"
|
|
aria-pressed={enabled}
|
|
onClick={(evt) => handleSenderClick(evt, sender.userId)}
|
|
before={
|
|
<Avatar size="200">
|
|
<UserAvatar
|
|
userId={sender.userId}
|
|
src={avatarUrl}
|
|
alt={sender.name}
|
|
renderFallback={() => (
|
|
<Text as="span" size="L400">
|
|
{sender.name.slice(0, 1).toUpperCase()}
|
|
</Text>
|
|
)}
|
|
/>
|
|
</Avatar>
|
|
}
|
|
>
|
|
<Text className={css.MediaUserChipLabel} size="T200" truncate>
|
|
{sender.name}
|
|
</Text>
|
|
</Chip>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<Box grow="Yes" direction="Column" className={css.MediaDrawerContentBase}>
|
|
<Scroll
|
|
ref={scrollRef}
|
|
visibility="Hover"
|
|
hideTrack
|
|
variant="Background"
|
|
onScroll={handleScroll}
|
|
>
|
|
<div className={css.MediaDrawerContent}>
|
|
{items.length === 0 && !loading && (
|
|
<Text className={css.MediaEmpty} size="T300" priority="300">
|
|
No shared photos or videos yet.
|
|
</Text>
|
|
)}
|
|
|
|
{items.length > 0 && filteredItems.length === 0 && (
|
|
<Text className={css.MediaEmpty} size="T300" priority="300">
|
|
No media from the selected people.
|
|
</Text>
|
|
)}
|
|
|
|
{sections.map((month) => (
|
|
<section key={month.monthKey} className={css.MediaMonthSection}>
|
|
<Text className={css.MediaMonthHeader} size="H6" as="h3">
|
|
{month.monthLabel}
|
|
</Text>
|
|
{month.days.map((day) => (
|
|
<div key={day.dayKey} className={css.MediaDaySection}>
|
|
<Text className={css.MediaDayHeader} size="T200" priority="300" as="h4">
|
|
{day.dayLabel}
|
|
</Text>
|
|
<div className={css.MediaGrid}>
|
|
{day.items.map((item) => (
|
|
<button
|
|
key={item.eventId}
|
|
type="button"
|
|
className={css.MediaTile}
|
|
onClick={() => handleOpen(item.eventId)}
|
|
title={item.body}
|
|
aria-label={item.body}
|
|
>
|
|
<MediaTileThumb item={item} />
|
|
{item.msgtype === MsgType.Video && (
|
|
<span className={css.MediaTileBadge}>
|
|
<Icon src={Icons.Play} size="100" />
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</section>
|
|
))}
|
|
|
|
{(loading || hasMore) && (
|
|
<div className={css.MediaFooter} style={{ minHeight: config.space.S700 }}>
|
|
{loading && <Spinner size="400" />}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Scroll>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
});
|