Add Shared Media drawer and harden same-room navigation.
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.
This commit is contained in:
@@ -3,9 +3,10 @@ import { Box, Line } from 'folds';
|
||||
import { decodeRouteParam } from '../../pages/pathUtils';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useAtom, useSetAtom } from 'jotai';
|
||||
import { RoomView } from './RoomView';
|
||||
import { MembersDrawer } from './MembersDrawer';
|
||||
import { MediaDrawer } from './room-media-menu';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
@@ -16,6 +17,7 @@ import { useMarkAsRead } from '../../hooks/useMarkAsRead';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
||||
import { activeRoomIdAtom } from '../../state/activeRoom';
|
||||
import { isMediaDrawerAtom } from '../../state/mediaDrawer';
|
||||
import { isForum } from '../../utils/room';
|
||||
import { ForumRoomView } from './ForumRoomView';
|
||||
|
||||
@@ -26,13 +28,18 @@ export function Room() {
|
||||
const mx = useMatrixClient();
|
||||
const setActiveRoomId = useSetAtom(activeRoomIdAtom);
|
||||
|
||||
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||
const [isPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||
const [isMediaDrawer] = useAtom(isMediaDrawerAtom);
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const screenSize = useScreenSizeContext();
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const members = useRoomMembers(mx, room.roomId);
|
||||
const markAsRead = useMarkAsRead(mx);
|
||||
const forumRoom = isForum(room);
|
||||
const skinny = screenSize !== ScreenSize.Desktop;
|
||||
const showMediaSolo = skinny && isMediaDrawer;
|
||||
const showRightDrawer =
|
||||
!skinny && (isPeopleDrawer || isMediaDrawer);
|
||||
|
||||
// Update titlebar with current room ID
|
||||
useEffect(() => {
|
||||
@@ -55,11 +62,21 @@ export function Room() {
|
||||
return (
|
||||
<PowerLevelsContextProvider value={powerLevels}>
|
||||
<Box grow="Yes">
|
||||
{forumRoom ? <ForumRoomView room={room} eventId={eventId} /> : <RoomView room={room} eventId={eventId} />}
|
||||
{screenSize === ScreenSize.Desktop && isDrawer && (
|
||||
{!showMediaSolo &&
|
||||
(forumRoom ? (
|
||||
<ForumRoomView room={room} eventId={eventId} />
|
||||
) : (
|
||||
<RoomView room={room} eventId={eventId} />
|
||||
))}
|
||||
{showMediaSolo && <MediaDrawer room={room} solo />}
|
||||
{!showMediaSolo && showRightDrawer && (
|
||||
<>
|
||||
<Line variant="Background" direction="Vertical" size="300" />
|
||||
<MembersDrawer key={room.roomId} room={room} members={members} />
|
||||
{isMediaDrawer ? (
|
||||
<MediaDrawer room={room} />
|
||||
) : (
|
||||
<MembersDrawer key={room.roomId} room={room} members={members} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -6,7 +6,8 @@ export const RoomInputWrap = style({
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
globalStyle(`${RoomInputWrap} .${editorCss.Editor}`, {
|
||||
/* Notebook-flat composer chrome — Stationery only (other themes keep Editor inset border) */
|
||||
globalStyle(`.stationery ${RoomInputWrap} .${editorCss.Editor}`, {
|
||||
borderRadius: 0,
|
||||
boxShadow: 'none',
|
||||
borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
|
||||
@@ -45,7 +45,6 @@ import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
import {
|
||||
TUploadContent,
|
||||
encryptFile,
|
||||
getImageInfo,
|
||||
getMxIdLocalPart,
|
||||
mxcUrlToHttp,
|
||||
@@ -54,6 +53,7 @@ import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
|
||||
import { useFilePicker } from '../../hooks/useFilePicker';
|
||||
import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
|
||||
import { useFileDropZone } from '../../hooks/useFileDrop';
|
||||
import { useRoomUploadFiles } from '../../hooks/useRoomUploadFiles';
|
||||
import {
|
||||
TUploadItem,
|
||||
TUploadMetadata,
|
||||
@@ -76,7 +76,6 @@ import {
|
||||
createUploadFamilyObserverAtom,
|
||||
} from '../../state/upload';
|
||||
import { getImageUrlBlob, loadImageElement } from '../../utils/dom';
|
||||
import { safeFile } from '../../utils/mimeTypes';
|
||||
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
@@ -101,7 +100,7 @@ import colorMXID from '../../../util/colorMXID';
|
||||
import { useIsDirectRoom } from '../../hooks/useRoom';
|
||||
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
|
||||
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||||
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
||||
import { useComposingCheck } from '../../hooks/useComposingCheck';
|
||||
@@ -224,42 +223,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
});
|
||||
}, [commands]);
|
||||
|
||||
const { handleFiles: enqueueFiles } = useRoomUploadFiles(room);
|
||||
const handleFiles = useCallback(
|
||||
async (files: File[]) => {
|
||||
setUploadBoard(true);
|
||||
const safeFiles = files.map(safeFile);
|
||||
const fileItems: TUploadItem[] = [];
|
||||
|
||||
if (room.hasEncryptionStateEvent()) {
|
||||
const encryptFiles = fulfilledPromiseSettledResult(
|
||||
await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
|
||||
);
|
||||
encryptFiles.forEach((ef) =>
|
||||
fileItems.push({
|
||||
...ef,
|
||||
metadata: {
|
||||
markedAsSpoiler: false,
|
||||
},
|
||||
})
|
||||
);
|
||||
} else {
|
||||
safeFiles.forEach((f) =>
|
||||
fileItems.push({
|
||||
file: f,
|
||||
originalFile: f,
|
||||
encInfo: undefined,
|
||||
metadata: {
|
||||
markedAsSpoiler: false,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
setSelectedFiles({
|
||||
type: 'PUT',
|
||||
item: fileItems,
|
||||
});
|
||||
await enqueueFiles(files);
|
||||
},
|
||||
[setSelectedFiles, room]
|
||||
[enqueueFiles]
|
||||
);
|
||||
const pickFile = useFilePicker(handleFiles, true);
|
||||
const handlePaste = useFilePasteHandler(handleFiles);
|
||||
@@ -724,7 +694,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
borderBottom: 'none',
|
||||
borderRadius: 0,
|
||||
borderRadius: isStationeryTheme(theme) ? 0 : undefined,
|
||||
borderTopLeftRadius: isStationeryTheme(theme) ? undefined : config.radii.R400,
|
||||
borderTopRightRadius: isStationeryTheme(theme) ? undefined : config.radii.R400,
|
||||
marginBottom: config.space.S100,
|
||||
}}
|
||||
direction="Column"
|
||||
|
||||
@@ -9,7 +9,8 @@ export const TimelineFloat = recipe({
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1,
|
||||
// Above timeline media carousels / sticky message chrome
|
||||
zIndex: 10,
|
||||
minWidth: 'max-content',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -85,7 +85,6 @@ import {
|
||||
useIntersectionObserver,
|
||||
} from '../../hooks/useIntersectionObserver';
|
||||
import { useMarkAsRead } from '../../hooks/useMarkAsRead';
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
|
||||
import * as css from './RoomTimeline.css';
|
||||
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
|
||||
@@ -104,9 +103,12 @@ import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||
import { useRoomUnread } from '../../state/hooks/unread';
|
||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||
import {
|
||||
clearRoomReturnAnchor,
|
||||
clearRoomScrollState,
|
||||
getRoomReturnAnchor,
|
||||
getRoomScrollState,
|
||||
saveRoomScrollState,
|
||||
setRoomReturnAnchor,
|
||||
} from '../../state/roomScrollCache';
|
||||
import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
|
||||
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
|
||||
@@ -650,6 +652,18 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
sender: string;
|
||||
content: string;
|
||||
} | null>(null);
|
||||
const [returnToEventId, setReturnToEventIdState] = useState<string | null>(
|
||||
() => getRoomReturnAnchor(room.roomId) ?? null
|
||||
);
|
||||
|
||||
const setReturnToEventId = useCallback(
|
||||
(eventId: string | null) => {
|
||||
if (eventId) setRoomReturnAnchor(room.roomId, eventId);
|
||||
else clearRoomReturnAnchor(room.roomId);
|
||||
setReturnToEventIdState(eventId);
|
||||
},
|
||||
[room.roomId]
|
||||
);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const timelineContentRef = useRef<HTMLDivElement>(null);
|
||||
@@ -661,6 +675,31 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
force: false,
|
||||
});
|
||||
|
||||
const getViewportAnchorEventId = useCallback((): string | undefined => {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (!scrollEl) return undefined;
|
||||
|
||||
const scrollRect = scrollEl.getBoundingClientRect();
|
||||
const centerY = scrollRect.top + scrollRect.height / 2;
|
||||
const items = scrollEl.querySelectorAll<HTMLElement>('[data-message-id]');
|
||||
|
||||
let bestId: string | undefined;
|
||||
let bestDist = Infinity;
|
||||
|
||||
items.forEach((el) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.bottom < scrollRect.top || rect.top > scrollRect.bottom) return;
|
||||
const mid = (rect.top + rect.bottom) / 2;
|
||||
const dist = Math.abs(mid - centerY);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
bestId = el.getAttribute('data-message-id') ?? undefined;
|
||||
}
|
||||
});
|
||||
|
||||
return bestId;
|
||||
}, []);
|
||||
|
||||
const [focusItem, setFocusItem] = useState<
|
||||
| {
|
||||
index: number;
|
||||
@@ -709,6 +748,15 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
const timelineRef = useRef(timeline);
|
||||
timelineRef.current = timeline;
|
||||
|
||||
const getRangeCenterEventId = useCallback((): string | undefined => {
|
||||
const { linkedTimelines, range } = timelineRef.current;
|
||||
if (range.end <= range.start) return undefined;
|
||||
const mid = Math.floor((range.start + range.end - 1) / 2);
|
||||
const [tm, baseIndex] = getTimelineAndBaseIndex(linkedTimelines, mid);
|
||||
if (!tm) return undefined;
|
||||
return getTimelineEvent(tm, getTimelineRelativeIndex(mid, baseIndex))?.getId();
|
||||
}, []);
|
||||
|
||||
const persistTimelineScroll = useCallback(
|
||||
(roomId: string) => {
|
||||
if (eventId) return;
|
||||
@@ -736,6 +784,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
const atLiveEndRef = useRef(liveTimelineLinked && rangeAtEnd);
|
||||
atLiveEndRef.current = liveTimelineLinked && rangeAtEnd;
|
||||
|
||||
// Historical / non-live windows are never "at bottom" of the room.
|
||||
useEffect(() => {
|
||||
if (!liveTimelineLinked || !rangeAtEnd) {
|
||||
setAtBottom(false);
|
||||
}
|
||||
}, [liveTimelineLinked, rangeAtEnd]);
|
||||
|
||||
const handleTimelinePagination = useTimelinePagination(
|
||||
mx,
|
||||
timeline,
|
||||
@@ -800,6 +855,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
const isOwnMessage = mEvt.getSender() === mx.getUserId();
|
||||
const isBottomPinnedEvent =
|
||||
mEvt.getType() === MessageEvent.RoomMessage || mEvt.getType() === MessageEvent.Sticker;
|
||||
const wasAwayFromBottom = !atBottomRef.current;
|
||||
const shouldStickToBottom = atBottomRef.current || (isOwnMessage && isBottomPinnedEvent);
|
||||
|
||||
// Always update timeline with new message
|
||||
@@ -820,6 +876,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
requestAnimationFrame(() => markAsRead(roomId, hideActivity));
|
||||
}
|
||||
}
|
||||
// Sending while scrolled up jumps to latest — offer return to previous spot
|
||||
if (wasAwayFromBottom && isBottomPinnedEvent) {
|
||||
const anchorEventId =
|
||||
eventId ?? getViewportAnchorEventId() ?? getRangeCenterEventId();
|
||||
if (anchorEventId) {
|
||||
setReturnToEventId(anchorEventId);
|
||||
}
|
||||
}
|
||||
} else if (!document.hasFocus() && !unreadInfo) {
|
||||
// Show unread notification for other users' messages when unfocused
|
||||
setUnreadInfo(getRoomUnreadInfo(room));
|
||||
@@ -846,7 +910,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
});
|
||||
}
|
||||
},
|
||||
[mx, room, unreadInfo, hideActivity, markAsRead]
|
||||
[mx, room, unreadInfo, hideActivity, markAsRead, eventId, getViewportAnchorEventId, getRangeCenterEventId, setReturnToEventId]
|
||||
)
|
||||
);
|
||||
|
||||
@@ -961,30 +1025,27 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
}
|
||||
}, [room, hideActivity, markAsRead]);
|
||||
|
||||
const debounceSetAtBottom = useDebounce(
|
||||
useCallback((entry: IntersectionObserverEntry) => {
|
||||
if (!entry.isIntersecting) {
|
||||
setAtBottom(false);
|
||||
}
|
||||
}, []),
|
||||
{ wait: 1000 }
|
||||
);
|
||||
useIntersectionObserver(
|
||||
useCallback(
|
||||
(entries) => {
|
||||
const target = atBottomAnchorRef.current;
|
||||
if (!target) return;
|
||||
const targetEntry = getIntersectionObserverEntry(target, entries);
|
||||
if (targetEntry) debounceSetAtBottom(targetEntry);
|
||||
if (targetEntry?.isIntersecting && atLiveEndRef.current) {
|
||||
setAtBottom(true);
|
||||
setLatestUnreadMessage(null);
|
||||
if (document.hasFocus()) {
|
||||
tryAutoMarkAsRead();
|
||||
}
|
||||
if (!targetEntry) return;
|
||||
|
||||
// Leave the live bottom immediately so Jump to Latest stays available.
|
||||
if (!targetEntry.isIntersecting || !atLiveEndRef.current) {
|
||||
setAtBottom(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setAtBottom(true);
|
||||
setLatestUnreadMessage(null);
|
||||
if (document.hasFocus()) {
|
||||
tryAutoMarkAsRead();
|
||||
}
|
||||
},
|
||||
[debounceSetAtBottom, tryAutoMarkAsRead]
|
||||
[tryAutoMarkAsRead]
|
||||
),
|
||||
useCallback(
|
||||
() => ({
|
||||
@@ -1048,6 +1109,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
}
|
||||
}, [eventId, loadEventTimeline]);
|
||||
|
||||
// Restore return-to-previous when switching rooms (survives remount via cache).
|
||||
useEffect(() => {
|
||||
setReturnToEventIdState(getRoomReturnAnchor(room.roomId) ?? null);
|
||||
}, [room.roomId]);
|
||||
|
||||
// Scroll to latest when clicking on already-selected room
|
||||
useEffect(() => {
|
||||
if (scrollToLatest) {
|
||||
@@ -1234,17 +1300,51 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
}, [room, hour24Clock]);
|
||||
|
||||
const handleJumpToLatest = () => {
|
||||
const anchorEventId =
|
||||
eventId ?? getViewportAnchorEventId() ?? getRangeCenterEventId();
|
||||
if (anchorEventId) {
|
||||
setReturnToEventId(anchorEventId);
|
||||
}
|
||||
|
||||
if (eventId) {
|
||||
navigateRoom(room.roomId, undefined, { replace: true });
|
||||
}
|
||||
clearRoomScrollState(room.roomId);
|
||||
setLatestUnreadMessage(null);
|
||||
setTimeline(getInitialTimeline(room));
|
||||
|
||||
// Already on the live timeline: only snap the virtual window to the end.
|
||||
// A full getInitialTimeline() rebuild feels like a chat reload when far back.
|
||||
if (!eventId && liveTimelineLinked) {
|
||||
setTimeline((ct) => {
|
||||
const evLength = getTimelinesEventsCount(ct.linkedTimelines);
|
||||
return {
|
||||
linkedTimelines: ct.linkedTimelines,
|
||||
range: {
|
||||
start: Math.max(evLength - PAGINATION_LIMIT, 0),
|
||||
end: evLength,
|
||||
},
|
||||
};
|
||||
});
|
||||
} else {
|
||||
setTimeline(getInitialTimeline(room));
|
||||
}
|
||||
|
||||
scrollToBottomRef.current.count += 1;
|
||||
scrollToBottomRef.current.smooth = false;
|
||||
scrollToBottomRef.current.force = true;
|
||||
};
|
||||
|
||||
const handleReturnToPrevious = () => {
|
||||
if (!returnToEventId) return;
|
||||
const targetId = returnToEventId;
|
||||
setReturnToEventId(null);
|
||||
handleOpenEvent(targetId);
|
||||
};
|
||||
|
||||
const handleClearReturnToPrevious = () => {
|
||||
setReturnToEventId(null);
|
||||
};
|
||||
|
||||
const handleJumpToUnread = () => {
|
||||
if (unreadInfo?.readUptoEventId) {
|
||||
setTimeline(getEmptyTimeline());
|
||||
@@ -2391,31 +2491,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
return callSummaryJSX || eventJSX;
|
||||
};
|
||||
|
||||
const showUnreadTop =
|
||||
!!unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline;
|
||||
const atLiveBottom = atBottom && liveTimelineLinked && rangeAtEnd;
|
||||
|
||||
return (
|
||||
<Box grow="Yes" style={{ position: 'relative' }}>
|
||||
{unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && (
|
||||
<TimelineFloat position="Top">
|
||||
<Chip
|
||||
variant="Primary"
|
||||
radii="Pill"
|
||||
outlined
|
||||
before={<Icon size="50" src={Icons.MessageUnread} />}
|
||||
onClick={handleJumpToUnread}
|
||||
>
|
||||
<Text size="L400">Jump to Unread</Text>
|
||||
</Chip>
|
||||
|
||||
<Chip
|
||||
variant="SurfaceVariant"
|
||||
radii="Pill"
|
||||
outlined
|
||||
before={<Icon size="50" src={Icons.CheckTwice} />}
|
||||
onClick={handleMarkAsRead}
|
||||
>
|
||||
<Text size="L400">Mark as Read</Text>
|
||||
</Chip>
|
||||
</TimelineFloat>
|
||||
)}
|
||||
<Scroll ref={scrollRef} visibility="Hover" data-room-timeline-scroll="">
|
||||
<Box
|
||||
ref={timelineContentRef}
|
||||
@@ -2510,7 +2591,57 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
<span ref={atBottomAnchorRef} />
|
||||
</Box>
|
||||
</Scroll>
|
||||
{!atBottom && (
|
||||
{(showUnreadTop || returnToEventId) && (
|
||||
<TimelineFloat position="Top">
|
||||
{returnToEventId && (
|
||||
<>
|
||||
<Chip
|
||||
variant="Primary"
|
||||
radii="Pill"
|
||||
outlined
|
||||
before={<Icon size="50" src={Icons.ArrowTop} />}
|
||||
onClick={handleReturnToPrevious}
|
||||
>
|
||||
<Text size="L400">Return to Previous</Text>
|
||||
</Chip>
|
||||
<IconButton
|
||||
title="Dismiss"
|
||||
aria-label="Dismiss return to previous"
|
||||
size="300"
|
||||
radii="Pill"
|
||||
variant="SurfaceVariant"
|
||||
onClick={handleClearReturnToPrevious}
|
||||
>
|
||||
<Icon size="50" src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
{showUnreadTop && (
|
||||
<>
|
||||
<Chip
|
||||
variant="Primary"
|
||||
radii="Pill"
|
||||
outlined
|
||||
before={<Icon size="50" src={Icons.MessageUnread} />}
|
||||
onClick={handleJumpToUnread}
|
||||
>
|
||||
<Text size="L400">Jump to Unread</Text>
|
||||
</Chip>
|
||||
|
||||
<Chip
|
||||
variant="SurfaceVariant"
|
||||
radii="Pill"
|
||||
outlined
|
||||
before={<Icon size="50" src={Icons.CheckTwice} />}
|
||||
onClick={handleMarkAsRead}
|
||||
>
|
||||
<Text size="L400">Mark as Read</Text>
|
||||
</Chip>
|
||||
</>
|
||||
)}
|
||||
</TimelineFloat>
|
||||
)}
|
||||
{!atLiveBottom && (
|
||||
<TimelineFloat position="Bottom">
|
||||
<Box direction="Column" gap="200" alignItems="Center">
|
||||
{latestUnreadMessage && (
|
||||
|
||||
@@ -7,18 +7,16 @@ import { JoinRule, Room } from 'matrix-js-sdk';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { activeThreadIdAtomFamily } from '../../state/activeThread';
|
||||
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { PageHeader } from '../../components/page';
|
||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
import { RoomTopicViewer } from '../../components/room-topic-viewer';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||
import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils';
|
||||
import { getHomeSearchPath, getSpaceSearchPath, getDirectSearchPath, withSearchParam } from '../../pages/pathUtils';
|
||||
import { getCanonicalAliasOrRoomId, guessDmRoomUserId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import { useOtherUserColor } from '../../hooks/useUserColor';
|
||||
@@ -43,6 +41,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useRoomPinnedEvents } from '../../hooks/useRoomPinnedEvents';
|
||||
import { RoomPinMenu } from './room-pin-menu';
|
||||
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
|
||||
import { isMediaDrawerAtom } from '../../state/mediaDrawer';
|
||||
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
|
||||
import {
|
||||
getRoomNotificationMode,
|
||||
@@ -58,6 +57,8 @@ import { useRoomCall, useRoomCallMembers } from '../call';
|
||||
import { CallState, CallType } from '../call/types';
|
||||
import { UserAvatar } from '../../components/user-avatar';
|
||||
import { getMemberDisplayName, getMemberAvatarMxc } from '../../utils/room';
|
||||
import { useDirectSelected } from '../../hooks/router/useDirectSelected';
|
||||
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
|
||||
|
||||
type RoomMenuProps = {
|
||||
room: Room;
|
||||
@@ -471,10 +472,11 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const onDirectPath = useDirectSelected();
|
||||
const theme = useTheme();
|
||||
const stationery = isStationeryTheme(theme);
|
||||
|
||||
const pinnedEvents = useRoomPinnedEvents(room);
|
||||
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
|
||||
const ecryptedRoom = !!encryptionEvent;
|
||||
const isDirect = mDirects.has(room.roomId);
|
||||
const avatarMxc = useRoomAvatar(room, isDirect);
|
||||
const name = useRoomName(room);
|
||||
@@ -488,11 +490,13 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
||||
(dmUserId ? room.getMember(dmUserId)?.getMxcAvatarUrl() : undefined) ||
|
||||
(isDirect ? avatarMxc : undefined);
|
||||
const dmCustomColor = useOtherUserColor(dmUserId ?? '', dmAvatarMxc);
|
||||
const dmFolderTabColor = isDirect
|
||||
? `color-mix(in srgb, ${dmCustomColor ?? colorMXID(dmUserId ?? room.roomId)} 50%, var(--folder-tab-room, #f3e6c8))`
|
||||
: undefined;
|
||||
const dmFolderTabColor =
|
||||
stationery && isDirect
|
||||
? `color-mix(in srgb, ${dmCustomColor ?? colorMXID(dmUserId ?? room.roomId)} 50%, var(--folder-tab-room, #f3e6c8))`
|
||||
: undefined;
|
||||
|
||||
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||
const [isMediaDrawer, setMediaDrawer] = useAtom(isMediaDrawerAtom);
|
||||
const [activeThreadId, setActiveThreadId] = useAtom(activeThreadIdAtomFamily(room.roomId));
|
||||
|
||||
const handleSearchClick = () => {
|
||||
@@ -501,7 +505,9 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
||||
};
|
||||
const path = space
|
||||
? getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, space.roomId))
|
||||
: getHomeSearchPath();
|
||||
: isDirect || onDirectPath
|
||||
? getDirectSearchPath()
|
||||
: getHomeSearchPath();
|
||||
navigate(withSearchParam(path, searchParams));
|
||||
};
|
||||
|
||||
@@ -513,6 +519,22 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
||||
setPinMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const handleTogglePeopleDrawer = () => {
|
||||
setPeopleDrawer((drawer) => {
|
||||
const next = !drawer;
|
||||
if (next) setMediaDrawer(false);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleMediaDrawer = () => {
|
||||
setMediaDrawer((open) => {
|
||||
const next = !open;
|
||||
if (next) setPeopleDrawer(false);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageHeader balance={showInPageHeader}>
|
||||
<Box grow="Yes" gap="300">
|
||||
@@ -611,23 +633,21 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
||||
<Box shrink="No" alignItems="Center" gap="100">
|
||||
<CallIndicator roomId={room.roomId} />
|
||||
<RoomCallButtons roomId={room.roomId} />
|
||||
{!ecryptedRoom && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Search</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton ref={triggerRef} onClick={handleSearchClick}>
|
||||
<Icon size="300" src={Icons.Search} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Search</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton ref={triggerRef} onClick={handleSearchClick}>
|
||||
<Icon size="300" src={Icons.Search} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
@@ -695,8 +715,12 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton ref={triggerRef} onClick={() => setPeopleDrawer((drawer) => !drawer)}>
|
||||
<Icon size="300" src={Icons.User} />
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
onClick={handleTogglePeopleDrawer}
|
||||
aria-pressed={peopleDrawer}
|
||||
>
|
||||
<Icon size="300" src={Icons.User} filled={peopleDrawer} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
@@ -720,6 +744,26 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>{isMediaDrawer ? 'Hide Shared Media' : 'Show Shared Media'}</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
onClick={handleToggleMediaDrawer}
|
||||
aria-pressed={isMediaDrawer}
|
||||
aria-label="Shared Media"
|
||||
>
|
||||
<Icon size="300" src={Icons.Photo} filled={isMediaDrawer} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
<PluginButtonSlot location="room-header" />
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
|
||||
149
src/app/features/room/room-media-menu/RoomMediaMenu.css.ts
Normal file
149
src/app/features/room/room-media-menu/RoomMediaMenu.css.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
|
||||
export const MediaDrawer = style({
|
||||
width: toRem(399),
|
||||
});
|
||||
|
||||
export const MediaDrawerSolo = style({
|
||||
width: '100%',
|
||||
flexGrow: 1,
|
||||
});
|
||||
|
||||
export const MediaDrawerHeader = style({
|
||||
flexShrink: 0,
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
});
|
||||
|
||||
export const MediaDrawerContentBase = style({
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const MediaDrawerContent = style({
|
||||
padding: `${config.space.S100} ${config.space.S200} ${config.space.S200}`,
|
||||
});
|
||||
|
||||
export const MediaMonthSection = style({
|
||||
marginBottom: config.space.S300,
|
||||
});
|
||||
|
||||
export const MediaMonthHeader = style({
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 2,
|
||||
margin: '5px 0',
|
||||
padding: `0 ${config.space.S100}`,
|
||||
lineHeight: 1.2,
|
||||
backgroundColor: color.Background.Container,
|
||||
color: color.Background.OnContainer,
|
||||
});
|
||||
|
||||
export const MediaDaySection = style({
|
||||
marginBottom: config.space.S300,
|
||||
});
|
||||
|
||||
export const MediaDayHeader = style({
|
||||
padding: `${config.space.S100} ${config.space.S100} ${config.space.S200}`,
|
||||
color: color.Background.OnContainer,
|
||||
});
|
||||
|
||||
export const MediaGrid = style({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
|
||||
gap: config.space.S200,
|
||||
});
|
||||
|
||||
export const MediaTile = style({
|
||||
position: 'relative',
|
||||
aspectRatio: '1',
|
||||
borderRadius: config.radii.R400,
|
||||
overflow: 'hidden',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
});
|
||||
|
||||
export const MediaTileImg = style({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
display: 'block',
|
||||
});
|
||||
|
||||
export const MediaTileBadge = style({
|
||||
position: 'absolute',
|
||||
right: config.space.S100,
|
||||
bottom: config.space.S100,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: toRem(24),
|
||||
height: toRem(24),
|
||||
borderRadius: config.radii.R400,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.55)',
|
||||
color: '#fff',
|
||||
});
|
||||
|
||||
export const MediaEmpty = style({
|
||||
padding: config.space.S400,
|
||||
textAlign: 'center',
|
||||
});
|
||||
|
||||
export const MediaFooter = style({
|
||||
padding: config.space.S200,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
});
|
||||
|
||||
export const MediaUserFiltersScroll = style({
|
||||
flexShrink: 0,
|
||||
flexGrow: 0,
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden',
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: `${color.SurfaceVariant.ContainerLine} ${color.SurfaceVariant.Container}`,
|
||||
selectors: {
|
||||
'&::-webkit-scrollbar': {
|
||||
height: toRem(8),
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
borderRadius: config.radii.R400,
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
backgroundColor: color.SurfaceVariant.ContainerLine,
|
||||
borderRadius: config.radii.R400,
|
||||
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.Container}`,
|
||||
backgroundClip: 'padding-box',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb:hover': {
|
||||
backgroundColor: color.Surface.ContainerLine,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const MediaUserFilters = style({
|
||||
display: 'flex',
|
||||
flexWrap: 'nowrap',
|
||||
alignItems: 'center',
|
||||
gap: config.space.S500,
|
||||
padding: `${config.space.S200} ${config.space.S200}`,
|
||||
width: 'max-content',
|
||||
minWidth: '100%',
|
||||
boxSizing: 'border-box',
|
||||
});
|
||||
|
||||
export const MediaUserChip = style({
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const MediaUserChipLabel = style({
|
||||
maxWidth: toRem(72),
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
524
src/app/features/room/room-media-menu/RoomMediaMenu.tsx
Normal file
524
src/app/features/room/room-media-menu/RoomMediaMenu.tsx
Normal file
@@ -0,0 +1,524 @@
|
||||
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>
|
||||
);
|
||||
});
|
||||
1
src/app/features/room/room-media-menu/index.ts
Normal file
1
src/app/features/room/room-media-menu/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './RoomMediaMenu';
|
||||
Reference in New Issue
Block a user