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:
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user