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:
2026-07-23 14:52:01 +10:00
parent 61d41900cc
commit 9509a9705e
30 changed files with 1749 additions and 168 deletions

View File

@@ -1,16 +1,53 @@
import React from 'react';
import React, { useMemo } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
const decodeSegment = (segment: string): string => {
let decoded = segment;
try {
let next = decodeURIComponent(decoded);
while (next !== decoded) {
decoded = next;
next = decodeURIComponent(decoded);
}
} catch {
// keep partially decoded value
}
return decoded;
};
/**
* Wrapper for Outlet that adds route-based animation
* Forces remount on route change by using location as key
* Room routes are `:roomIdOrAlias/:eventId?/`. Jumping to an event (or clearing it)
* changes the pathname but not the room — keep the outlet mounted so we don't replay
* the route enter animation or remount drawers like Shared Media.
*
* Path segments are decoded so `!room:server` and `%21room%3Aserver` share a key.
*/
const getOutletTransitionKey = (pathname: string): string => {
const segments = pathname.split('/').filter(Boolean).map(decodeSegment);
if (segments.length === 0) return pathname;
// Matrix event IDs start with `$`
if (segments[segments.length - 1].startsWith('$')) {
segments.pop();
}
return `/${segments.join('/')}/`;
};
/**
* Wrapper for Outlet that adds route-based animation.
* Remounts (and animates) when leaving a room / switching rooms, not on same-room event hops.
*/
export function AnimatedOutlet() {
const location = useLocation();
const transitionKey = useMemo(
() => getOutletTransitionKey(location.pathname),
[location.pathname]
);
return (
<div
key={location.pathname}
key={transitionKey}
data-route-transition="true"
style={{
flex: 1,

View File

@@ -256,7 +256,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
className={css.EditorTextareaScroll}
variant="SurfaceVariant"
style={scrollStyle}
size="300"
size="0"
visibility="Hover"
hideTrack
ref={editableRef}

View File

@@ -53,6 +53,17 @@ export const Reaction = style([
},
]);
export const ReactionText = style([
DefaultReset,
{
minWidth: 0,
maxWidth: toRem(150),
display: 'inline-flex',
alignItems: 'center',
lineHeight: toRem(20),
},
]);
export const ReactionStack = style([
DefaultReset,
{
@@ -66,7 +77,7 @@ export const ReactionStack = style([
},
]);
export const ReactionText = style([
export const ReactionSticker = style([
DefaultReset,
{
minWidth: 0,
@@ -77,11 +88,6 @@ export const ReactionText = style([
lineHeight: toRem(20),
gridArea: '1 / 1',
transformOrigin: 'center center',
// Fan each stacked copy so count>1 reads as multiple stickers
transform: `translate(
calc(var(--sticker-i, 0) * 7px - 2px),
calc(var(--sticker-i, 0) * -5px)
) rotate(calc((var(--sticker-i, 0) - 1) * 12deg))`,
},
]);

View File

@@ -6,6 +6,7 @@ import * as css from './Reaction.css';
import { getHexcodeForEmoji, getShortcodeFor } from '../../plugins/emoji';
import { getMemberDisplayName } from '../../utils/room';
import { eventWithShortcode, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { isStationeryTheme, useTheme } from '../../hooks/useTheme';
const MAX_STICKER_STACK = 4;
@@ -18,13 +19,48 @@ export const Reaction = as<
useAuthentication?: boolean;
}
>(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => {
const stackCount = Math.min(Math.max(count, 1), MAX_STICKER_STACK);
const showCount = count > 2;
const theme = useTheme();
const stationery = isStationeryTheme(theme);
const isCustomEmoji = reaction.startsWith('mxc://');
const customSrc = isCustomEmoji
? mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction
: undefined;
const emoji = isCustomEmoji ? (
<img className={css.ReactionImg} src={customSrc} alt={reaction} />
) : (
<Text as="span" size="Inherit" truncate>
{reaction}
</Text>
);
// Stationery: fanned sticker stack. Everywhere else: compact emoji + count.
if (!stationery) {
return (
<Box
as="button"
className={classNames(css.Reaction, className)}
alignItems="Center"
shrink="No"
gap="200"
data-reaction=""
aria-label={`${reaction}, ${count}`}
{...props}
ref={ref}
>
<Text className={css.ReactionText} as="span" size="T400">
{emoji}
</Text>
<Text as="span" size="T300" data-reaction-count="">
{count}
</Text>
</Box>
);
}
const stackCount = Math.min(Math.max(count, 1), MAX_STICKER_STACK);
const showCount = count > 2;
return (
<Box
as="button"
@@ -40,12 +76,11 @@ export const Reaction = as<
>
<span className={css.ReactionStack} data-reaction-stack-layer="">
{Array.from({ length: stackCount }, (_, i) => {
// Draw back-to-front so the top sticker is the last layer
const layer = stackCount - 1 - i;
return (
<Text
key={layer}
className={css.ReactionText}
className={css.ReactionSticker}
as="span"
size="T400"
data-reaction-sticker=""

View File

@@ -242,6 +242,7 @@ export const MessageTextBody = recipe({
lineHeight: 1,
overflow: 'visible',
overflowY: 'visible',
paddingBottom: config.space.S200,
},
},
emote: {

View File

@@ -16,7 +16,7 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { ScrollTopContainer } from '../../components/scroll-top-container';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { decodeSearchParamValueArray, encodeSearchParamValueArray } from '../../pages/pathUtils';
import { useRooms } from '../../state/hooks/roomList';
import { useRooms, useDirects } from '../../state/hooks/roomList';
import { allRoomsAtom } from '../../state/room-list/roomList';
import { mDirectAtom } from '../../state/mDirectList';
import { MessageSearchParams, useMessageSearch } from './useMessageSearch';
@@ -53,7 +53,13 @@ export function MessageSearch({
}: MessageSearchProps) {
const mx = useMatrixClient();
const mDirects = useAtomValue(mDirectAtom);
const allRooms = useRooms(mx, allRoomsAtom, mDirects);
const nonDirectRooms = useRooms(mx, allRoomsAtom, mDirects);
const directRooms = useDirects(mx, allRoomsAtom, mDirects);
// Include DMs — previously useRooms alone stripped them and broke DM search
const allRooms = useMemo(
() => [...nonDirectRooms, ...directRooms],
[nonDirectRooms, directRooms]
);
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');

View File

@@ -4,6 +4,9 @@ import {
ISearchRequestBody,
ISearchResponse,
ISearchResult,
MatrixClient,
MatrixEvent,
Room,
SearchOrderBy,
} from 'matrix-js-sdk';
import { useCallback } from 'react';
@@ -26,6 +29,12 @@ export type SearchResult = {
groups: ResultGroup[];
};
const EMPTY_CONTEXT: IResultContext = {
events_before: [],
events_after: [],
profile_info: {},
};
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
const groups: ResultGroup[] = [];
@@ -54,13 +63,108 @@ const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
const parseSearchResult = (result: ISearchResponse): SearchResult => {
const roomEvents = result.search_categories.room_events;
const searchResult: SearchResult = {
return {
nextToken: roomEvents?.next_batch,
highlights: roomEvents?.highlights ?? [],
groups: groupSearchResult(roomEvents?.results ?? []),
};
};
return searchResult;
const eventToSearchEvent = (event: MatrixEvent, roomId: string): IEventWithRoomId | undefined => {
const eventId = event.getId();
if (!eventId) return undefined;
const content = event.getClearContent() ?? event.getContent();
return {
event_id: eventId,
type: event.getWireType() === 'm.room.encrypted' ? 'm.room.message' : event.getType(),
sender: event.getSender() ?? '',
origin_server_ts: event.getTs(),
content,
room_id: roomId,
unsigned: event.getUnsigned(),
};
};
const getSearchableBody = (event: MatrixEvent): string | undefined => {
if (event.isRedacted()) return undefined;
// After decryption, clear content is available even if wire type was encrypted
const content = event.getClearContent() ?? event.getContent();
if (!content || typeof content !== 'object') return undefined;
// Skip still-encrypted payloads
if (event.isEncrypted() && !event.isDecryptionFailure() && !event.getClearContent()) {
return undefined;
}
const msgType = content.msgtype;
if (msgType && msgType !== 'm.text' && msgType !== 'm.notice' && msgType !== 'm.emote') {
// Still allow filename / body on media
}
const body = typeof content.body === 'string' ? content.body : undefined;
const formatted =
typeof content.formatted_body === 'string' ? content.formatted_body : undefined;
return [body, formatted].filter(Boolean).join('\n') || undefined;
};
const highlightsFromTerm = (term: string): string[] =>
term
.split(/\s+/)
.map((part) => part.trim())
.filter((part) => part.length > 1);
/**
* Search decrypted timeline events already loaded for a room.
* Server-side search cannot match E2EE message bodies.
*/
const searchLocalRoomTimeline = (room: Room, term: string): ResultItem[] => {
const needle = term.trim().toLowerCase();
if (!needle) return [];
const events = room.getLiveTimeline().getEvents();
const matches: ResultItem[] = [];
for (let i = events.length - 1; i >= 0; i -= 1) {
const event = events[i];
const haystack = getSearchableBody(event);
if (!haystack || !haystack.toLowerCase().includes(needle)) continue;
const searchEvent = eventToSearchEvent(event, room.roomId);
if (!searchEvent) continue;
matches.push({
rank: 1,
event: searchEvent,
context: EMPTY_CONTEXT,
});
}
return matches;
};
const isEncryptedRoom = (mx: MatrixClient, roomId: string): boolean => {
const room = mx.getRoom(roomId);
return !!room?.hasEncryptionStateEvent();
};
const searchLocalRooms = (mx: MatrixClient, roomIds: string[], term: string): SearchResult => {
const groups: ResultGroup[] = [];
roomIds.forEach((roomId) => {
const room = mx.getRoom(roomId);
if (!room) return;
const items = searchLocalRoomTimeline(room, term);
if (items.length > 0) {
groups.push({ roomId, items });
}
});
return {
highlights: highlightsFromTerm(term),
groups,
};
};
export type MessageSearchParams = {
@@ -69,19 +173,30 @@ export type MessageSearchParams = {
rooms?: string[];
senders?: string[];
};
export const useMessageSearch = (params: MessageSearchParams) => {
const mx = useMatrixClient();
const { term, order, rooms, senders } = params;
const searchMessages = useCallback(
async (nextBatch?: string) => {
if (!term)
if (!term) {
return {
highlights: [],
groups: [],
};
const limit = 20;
}
const scopedRooms = rooms?.filter(Boolean) ?? [];
const encryptedScoped =
scopedRooms.length > 0 && scopedRooms.every((roomId) => isEncryptedRoom(mx, roomId));
// Encrypted-only scope: server cannot see bodies — search loaded timeline locally
if (encryptedScoped && !nextBatch) {
return searchLocalRooms(mx, scopedRooms, term);
}
const limit = 20;
const requestBody: ISearchRequestBody = {
search_categories: {
room_events: {
@@ -102,11 +217,33 @@ export const useMessageSearch = (params: MessageSearchParams) => {
},
};
const r = await mx.search({
body: requestBody,
next_batch: nextBatch === '' ? undefined : nextBatch,
});
return parseSearchResult(r);
try {
const r = await mx.search({
body: requestBody,
next_batch: nextBatch === '' ? undefined : nextBatch,
});
const parsed = parseSearchResult(r);
if (
parsed.groups.length === 0 &&
!nextBatch &&
scopedRooms.length > 0 &&
scopedRooms.some((roomId) => isEncryptedRoom(mx, roomId))
) {
return searchLocalRooms(
mx,
scopedRooms.filter((id) => isEncryptedRoom(mx, id)),
term
);
}
return parsed;
} catch {
if (!nextBatch && scopedRooms.length > 0) {
return searchLocalRooms(mx, scopedRooms, term);
}
throw new Error('Message search failed');
}
},
[mx, term, order, rooms, senders]
);

View File

@@ -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>

View File

@@ -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}`,

View File

@@ -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"

View File

@@ -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',
},
],

View File

@@ -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 && (

View File

@@ -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"

View 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',
});

View 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>
);
});

View File

@@ -0,0 +1 @@
export * from './RoomMediaMenu';

View File

@@ -145,11 +145,23 @@ export function Search({ requestClose }: SearchProps) {
const getTargetStr: SearchItemStrGetter<string> = useCallback(
(roomId: string) => {
const roomName = getRoom(roomId)?.name ?? roomId;
const room = getRoom(roomId);
const roomName = room?.name ?? roomId;
if (mDirects.has(roomId)) {
const targetUserId = getDmUserId(roomId, getRoom, mx.getSafeUserId());
const targetUsername = targetUserId && getMxIdLocalPart(targetUserId);
if (targetUsername) return [roomName, targetUsername];
if (!targetUserId) return roomName;
const targetUsername = getMxIdLocalPart(targetUserId);
const targetServer = getMxIdServer(targetUserId);
const member = room?.getMember(targetUserId);
const displayName = member?.name || member?.rawDisplayName;
return [
roomName,
displayName,
targetUsername,
targetUserId,
targetServer ? `@${targetUsername}:${targetServer}` : undefined,
targetServer,
].filter((s): s is string => typeof s === 'string' && s.length > 0);
}
return roomName;
},

View File

@@ -1,5 +1,5 @@
import { useMatch } from 'react-router-dom';
import { getDirectCreatePath, getDirectPath } from '../../pages/pathUtils';
import { getDirectCreatePath, getDirectPath, getDirectSearchPath } from '../../pages/pathUtils';
export const useDirectSelected = (): boolean => {
const directMatch = useMatch({
@@ -20,3 +20,13 @@ export const useDirectCreateSelected = (): boolean => {
return !!match;
};
export const useDirectSearchSelected = (): boolean => {
const match = useMatch({
path: getDirectSearchPath(),
caseSensitive: true,
end: false,
});
return !!match;
};

View File

@@ -0,0 +1,256 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import to from 'await-to-js';
import {
Direction,
IRoomEvent,
MatrixEvent,
MsgType,
RelationType,
Room,
} from 'matrix-js-sdk';
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { useMatrixClient } from './useMatrixClient';
import { IEncryptedFile } from '../../types/matrix/common';
export type RoomMediaItem = {
eventId: string;
msgtype: typeof MsgType.Image | typeof MsgType.Video;
/** MXC used for an image thumbnail when available. */
thumbMxc?: string;
thumbMimeType?: string;
thumbEncInfo?: EncryptedAttachmentInfo;
/** Full media MXC — used to extract a video frame when no image thumb exists. */
fullMxc: string;
fullMimeType?: string;
fullEncInfo?: EncryptedAttachmentInfo;
body: string;
ts: number;
sender: string;
};
const PAGE_SIZE = 50;
const TARGET_BATCH = 24;
const MAX_PAGES_PER_LOAD = 8;
type RoomMediaCache = {
items: RoomMediaItem[];
hasMore: boolean;
nextToken: string | null;
seenIds: Set<string>;
};
/** Survives MediaDrawer remounts within the same room/session. */
const mediaCache = new Map<string, RoomMediaCache>();
const getOrCreateCache = (roomId: string): RoomMediaCache => {
const existing = mediaCache.get(roomId);
if (existing) return existing;
const created: RoomMediaCache = {
items: [],
hasMore: true,
nextToken: null,
seenIds: new Set(),
};
mediaCache.set(roomId, created);
return created;
};
const toMediaItem = (mEvent: MatrixEvent): RoomMediaItem | undefined => {
if (mEvent.isRedacted()) return undefined;
const relation = mEvent.getRelation();
if (relation?.rel_type === RelationType.Replace) return undefined;
const content = mEvent.getContent();
const msgtype = content.msgtype;
if (msgtype !== MsgType.Image && msgtype !== MsgType.Video) return undefined;
const file = content.file as IEncryptedFile | undefined;
const info = content.info as
| {
mimetype?: string;
thumbnail_url?: string;
thumbnail_info?: { mimetype?: string };
thumbnail_file?: IEncryptedFile;
}
| undefined;
const fullMxc =
(typeof content.url === 'string' && content.url) ||
(typeof file?.url === 'string' && file.url) ||
undefined;
if (!fullMxc) return undefined;
const thumbFile = info?.thumbnail_file;
const thumbMxc =
(typeof info?.thumbnail_url === 'string' && info.thumbnail_url) ||
(typeof thumbFile?.url === 'string' && thumbFile.url) ||
undefined;
const eventId = mEvent.getId();
const sender = mEvent.getSender();
if (!eventId || !sender) return undefined;
const usingThumbEnc = !!thumbMxc && thumbMxc === thumbFile?.url;
return {
eventId,
msgtype,
thumbMxc,
thumbMimeType: usingThumbEnc
? info?.thumbnail_info?.mimetype
: thumbMxc
? info?.thumbnail_info?.mimetype
: undefined,
thumbEncInfo: usingThumbEnc ? thumbFile : undefined,
fullMxc,
fullMimeType: info?.mimetype,
fullEncInfo: file,
body: typeof content.body === 'string' ? content.body : msgtype,
ts: mEvent.getTs(),
sender,
};
};
/**
* Paginates room history for image/video messages without mutating the live timeline.
*/
export const useRoomMediaEvents = (room: Room) => {
const mx = useMatrixClient();
const roomId = room.roomId;
const cacheRef = useRef(getOrCreateCache(roomId));
if (cacheRef.current !== mediaCache.get(roomId)) {
cacheRef.current = getOrCreateCache(roomId);
}
const [items, setItems] = useState<RoomMediaItem[]>(() => cacheRef.current.items);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(() => cacheRef.current.hasMore);
const loadingRef = useRef(false);
const decryptEvent = useCallback(
async (raw: IRoomEvent | MatrixEvent) => {
const mEvent = raw instanceof MatrixEvent ? raw : new MatrixEvent(raw);
if (mEvent.isEncrypted() && mx.getCrypto()) {
await to(mEvent.attemptDecryption(mx.getCrypto() as CryptoBackend));
}
return mEvent;
},
[mx]
);
const fetchPages = useCallback(async () => {
const cache = cacheRef.current;
const collected: RoomMediaItem[] = [];
for (let page = 0; page < MAX_PAGES_PER_LOAD; page += 1) {
const response = await mx.createMessagesRequest(
roomId,
cache.nextToken,
PAGE_SIZE,
Direction.Backward
);
cache.nextToken = response.end ?? null;
if (!response.end) {
cache.hasMore = false;
setHasMore(false);
}
for (const raw of response.chunk ?? []) {
const mEvent = await decryptEvent(raw);
const item = toMediaItem(mEvent);
if (!item || cache.seenIds.has(item.eventId)) continue;
cache.seenIds.add(item.eventId);
collected.push(item);
}
if (collected.length >= TARGET_BATCH || !response.end) break;
}
return collected;
}, [mx, roomId, decryptEvent]);
const loadMore = useCallback(async () => {
const cache = cacheRef.current;
if (loadingRef.current || !cache.hasMore) return;
loadingRef.current = true;
setLoading(true);
try {
const collected = await fetchPages();
if (collected.length > 0) {
cache.items = [...cache.items, ...collected];
setItems(cache.items);
}
} finally {
loadingRef.current = false;
setLoading(false);
}
}, [fetchPages]);
useEffect(() => {
const cache = getOrCreateCache(roomId);
cacheRef.current = cache;
// Already loaded for this room — restore without re-fetching.
if (cache.items.length > 0 || cache.nextToken !== null || !cache.hasMore) {
setItems(cache.items);
setHasMore(cache.hasMore);
return undefined;
}
let cancelled = false;
const bootstrap = async () => {
cache.seenIds = new Set();
cache.nextToken = null;
cache.hasMore = true;
cache.items = [];
setItems([]);
setHasMore(true);
setLoading(true);
loadingRef.current = true;
try {
const localFound: RoomMediaItem[] = [];
const localEvents = room.getLiveTimeline().getEvents();
for (let i = localEvents.length - 1; i >= 0; i -= 1) {
const mEvent = await decryptEvent(localEvents[i]);
const item = toMediaItem(mEvent);
if (!item || cache.seenIds.has(item.eventId)) continue;
cache.seenIds.add(item.eventId);
localFound.push(item);
}
if (cancelled) return;
if (localFound.length > 0) {
cache.items = localFound;
setItems(localFound);
}
const collected = await fetchPages();
if (cancelled) return;
if (collected.length > 0) {
cache.items = [...cache.items, ...collected];
setItems(cache.items);
}
} finally {
if (!cancelled) {
loadingRef.current = false;
setLoading(false);
}
}
};
bootstrap();
return () => {
cancelled = true;
};
// Only re-bootstrap when switching rooms — not when callback identities change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [roomId]);
return { items, loading, hasMore, loadMore };
};

View File

@@ -1,5 +1,5 @@
import { useCallback } from 'react';
import { NavigateOptions, useNavigate } from 'react-router-dom';
import { NavigateOptions, useLocation, useNavigate } from 'react-router-dom';
import { useAtomValue } from 'jotai';
import { getCanonicalAliasOrRoomId } from '../utils/matrix';
import {
@@ -7,21 +7,25 @@ import {
getHomeRoomPath,
getSpacePath,
getSpaceRoomPath,
withRoomEventId,
} from '../pages/pathUtils';
import { useMatrixClient } from './useMatrixClient';
import { getOrphanParents, guessPerfectParent, isSpace } from '../utils/room';
import { roomToParentsAtom } from '../state/room/roomToParents';
import { mDirectAtom } from '../state/mDirectList';
import { useSelectedRoom } from './router/useSelectedRoom';
import { useSelectedSpace } from './router/useSelectedSpace';
import { settingsAtom } from '../state/settings';
import { useSetting } from '../state/hooks/settings';
export const useRoomNavigate = () => {
const navigate = useNavigate();
const location = useLocation();
const mx = useMatrixClient();
const roomToParents = useAtomValue(roomToParentsAtom);
const mDirects = useAtomValue(mDirectAtom);
const spaceSelectedId = useSelectedSpace();
const selectedRoomId = useSelectedRoom();
const [developerTools] = useSetting(settingsAtom, 'developerTools');
const navigateSpace = useCallback(
@@ -34,6 +38,14 @@ export const useRoomNavigate = () => {
const navigateRoom = useCallback(
(roomId: string, eventId?: string, opts?: NavigateOptions) => {
// Already viewing this room — keep the current home/direct/space URL and only
// change the event id. Otherwise guessPerfectParent can yank the sidebar to
// wherever the room "belongs".
if (selectedRoomId === roomId) {
navigate(withRoomEventId(location.pathname, eventId), opts);
return;
}
const room = mx.getRoom(roomId);
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
const openSpaceTimeline = developerTools && spaceSelectedId === roomId;
@@ -68,7 +80,16 @@ export const useRoomNavigate = () => {
navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts);
},
[mx, navigate, spaceSelectedId, roomToParents, mDirects, developerTools]
[
mx,
navigate,
location.pathname,
selectedRoomId,
spaceSelectedId,
roomToParents,
mDirects,
developerTools,
]
);
return {

View File

@@ -0,0 +1,60 @@
import { useCallback } from 'react';
import { useSetAtom } from 'jotai';
import { Room } from 'matrix-js-sdk';
import {
roomIdToUploadItemsAtomFamily,
TUploadItem,
} from '../state/room/roomInputDrafts';
import { encryptFile } from '../utils/matrix';
import { safeFile } from '../utils/mimeTypes';
import { fulfilledPromiseSettledResult } from '../utils/common';
import { useFilePicker } from './useFilePicker';
/**
* Enqueues files into the room composer upload board (shared by RoomInput and header media).
*/
export const useRoomUploadFiles = (room: Room) => {
const setSelectedFiles = useSetAtom(roomIdToUploadItemsAtomFamily(room.roomId));
const handleFiles = useCallback(
async (files: File[]) => {
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,
});
},
[setSelectedFiles, room]
);
const pickFiles = useFilePicker(handleFiles, true);
return { handleFiles, pickFiles };
};

View File

@@ -41,7 +41,7 @@ import {
} from './pathUtils';
import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home';
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
import { Direct, DirectCreate, DirectRouteRoomProvider, DirectSearch } from './client/direct';
import { RouteSpaceProvider, SpaceRouteRoomProvider, SpaceSearch, SpaceIndexRedirect } from './client/space';
import { ForumAwareSpaceLayout } from '../features/forum/ForumAwareSpaceLayout';
import { ForumFeedPage } from '../features/forum/ForumFeedPage';
@@ -223,6 +223,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
>
{mobile ? null : <Route index element={<WelcomePage />} />}
<Route path={_CREATE_PATH} element={<DirectCreate />} />
<Route path={_SEARCH_PATH} element={<DirectSearch />} />
<Route path={_NOTIFICATIONS_PATH} element={<Notifications />} />
<Route path={_INVITES_PATH} element={<Invites />} />
<Route

View File

@@ -17,7 +17,7 @@ import {
NavItemContent,
NavLink,
} from '../../../components/nav';
import { getDirectCreatePath, getDirectRoomPath, getDirectNotificationsPath, getDirectInvitesPath } from '../../pathUtils';
import { getDirectCreatePath, getDirectRoomPath, getDirectNotificationsPath, getDirectInvitesPath, getDirectSearchPath } from '../../pathUtils';
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { VirtualTile } from '../../../components/virtualizer';
@@ -41,10 +41,9 @@ import {
getRoomNotificationMode,
useRoomsNotificationPreferencesContext,
} from '../../../hooks/useRoomsNotificationPreferences';
import { useDirectCreateSelected } from '../../../hooks/router/useDirectSelected';
import { useDirectCreateSelected, useDirectSearchSelected } from '../../../hooks/router/useDirectSelected';
import { allInvitesAtom } from '../../../state/room-list/inviteList';
import { UnreadBadge } from '../../../components/unread-badge';
type DirectMenuProps = {
requestClose: () => void;
};
@@ -228,6 +227,7 @@ export function Direct() {
const navigate = useNavigate();
const createDirectSelected = useDirectCreateSelected();
const searchSelected = useDirectSearchSelected();
const selectedRoomId = useSelectedRoom();
const noRoomToDisplay = directs.length === 0;
@@ -283,6 +283,22 @@ export function Direct() {
</NavButton>
</NavItem>
<PluginNavSlot location="direct-messages" />
<NavItem variant="Background" radii="400" aria-selected={searchSelected}>
<NavLink to={getDirectSearchPath()}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Search} size="100" filled={searchSelected} />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
Message Search
</Text>
</Box>
</Box>
</NavItemContent>
</NavLink>
</NavItem>
</NavCategory>
<NavCategory>
<NotificationsNavItem />

View File

@@ -0,0 +1,55 @@
import React, { useRef } from 'react';
import { Box, Text, Scroll, IconButton } from 'folds';
import { Icon, Icons } from '../../../components/icons';
import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
import { MessageSearch } from '../../../features/message-search';
import { useDirectRooms } from './useDirectRooms';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { BackRouteHandler } from '../../../components/BackRouteHandler';
export function DirectSearch() {
const scrollRef = useRef<HTMLDivElement>(null);
const rooms = useDirectRooms();
const screenSize = useScreenSizeContext();
return (
<Page>
<PageHeader balance>
<Box grow="Yes" alignItems="Center" gap="200">
<Box grow="Yes" basis="No">
{screenSize === ScreenSize.Mobile && (
<BackRouteHandler>
{(onBack) => (
<IconButton onClick={onBack}>
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}
</BackRouteHandler>
)}
</Box>
<Box justifyContent="Center" alignItems="Center" gap="200">
{screenSize !== ScreenSize.Mobile && <Icon size="400" src={Icons.Search} />}
<Text size="H3" truncate>
Message Search
</Text>
</Box>
<Box grow="Yes" basis="No" />
</Box>
</PageHeader>
<Box style={{ position: 'relative' }} grow="Yes">
<Scroll ref={scrollRef} hideTrack visibility="Hover">
<PageContent>
<PageContentCenter>
<MessageSearch
defaultRoomsFilterName="Direct Messages"
allowGlobal
rooms={rooms}
scrollRef={scrollRef}
/>
</PageContentCenter>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View File

@@ -1,3 +1,4 @@
export * from './Direct';
export * from './RoomProvider';
export * from './DirectCreate';
export * from './Search';

View File

@@ -3,6 +3,7 @@ import {
DIRECT_CREATE_PATH,
DIRECT_PATH,
DIRECT_ROOM_PATH,
DIRECT_SEARCH_PATH,
DIRECT_NOTIFICATIONS_PATH,
DIRECT_INVITES_PATH,
EXPLORE_FEATURED_PATH,
@@ -121,6 +122,7 @@ export const getHomeRoomPath = (roomIdOrAlias: string, eventId?: string): string
export const getDirectPath = (): string => DIRECT_PATH;
export const getDirectCreatePath = (): string => DIRECT_CREATE_PATH;
export const getDirectSearchPath = (): string => DIRECT_SEARCH_PATH;
export const getDirectRoomPath = (roomIdOrAlias: string, eventId?: string): string => {
const params = {
roomIdOrAlias,
@@ -180,6 +182,28 @@ export const getSpaceRoomPath = (
return generatePath(SPACE_ROOM_PATH, params);
};
/**
* Keep the current room URL (home / direct / space) and only add, replace, or clear
* the optional `$eventId` segment. Used so same-room jumps (media, permalinks) don't
* re-home the room into a different sidebar section.
*/
export const withRoomEventId = (pathname: string, eventId?: string): string => {
const parts = pathname.split('/').filter(Boolean);
if (parts.length > 0) {
const lastDecoded = decodeRouteParam(parts[parts.length - 1]);
if (lastDecoded?.startsWith('$')) {
parts.pop();
}
}
if (eventId) {
parts.push(encodeURIComponent(eventId));
}
return `/${parts.join('/')}/`;
};
export const getExplorePath = (): string => EXPLORE_PATH;
export const getExploreFeaturedPath = (): string => EXPLORE_FEATURED_PATH;
export const getExploreServerPath = (server: string): string => {

View File

@@ -54,6 +54,7 @@ export type DirectCreateSearchParams = {
userId?: string;
};
export const DIRECT_CREATE_PATH = `/direct/${_CREATE_PATH}`;
export const DIRECT_SEARCH_PATH = `/direct/${_SEARCH_PATH}`;
export const DIRECT_ROOM_PATH = `/direct/${_ROOM_PATH}`;
export const SPACE_PATH = '/:spaceIdOrAlias/';

View File

@@ -0,0 +1,17 @@
import { atom } from 'jotai';
/** Session flag for the Shared Media side drawer (mutually exclusive with Members). */
export const isMediaDrawerAtom = atom(false);
const scrollTops = new Map<string, number>();
export const getMediaDrawerScrollTop = (roomId: string): number | undefined =>
scrollTops.get(roomId);
export const setMediaDrawerScrollTop = (roomId: string, top: number): void => {
scrollTops.set(roomId, top);
};
export const clearMediaDrawerScrollTop = (roomId: string): void => {
scrollTops.delete(roomId);
};

View File

@@ -30,3 +30,17 @@ export const saveRoomScrollState = (roomId: string, state: RoomScrollState): voi
export const clearRoomScrollState = (roomId: string): void => {
cache.delete(roomId);
};
/** Survives RoomTimeline remounts (e.g. clearing an event permalink URL). */
const returnAnchors = new Map<string, string>();
export const getRoomReturnAnchor = (roomId: string): string | undefined =>
returnAnchors.get(roomId);
export const setRoomReturnAnchor = (roomId: string, eventId: string): void => {
returnAnchors.set(roomId, eventId);
};
export const clearRoomReturnAnchor = (roomId: string): void => {
returnAnchors.delete(roomId);
};

View File

@@ -259,24 +259,35 @@ body.stationery-dark-theme {
transition: width 0.2s ease;
}
/* Scrollbar styling */
/* Scrollbar styling — transparent until hover so Folds Hover/hideTrack
scroll areas (e.g. message composer) don't show a permanent side bar */
.twilight-theme ::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.twilight-theme ::-webkit-scrollbar-track {
background: rgba(20, 18, 31, 0.5);
background: transparent;
}
.twilight-theme ::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #3E3A6A 0%, #565096 100%);
background: transparent;
border-radius: 5px;
border: 2px solid rgba(20, 18, 31, 0.5);
transition: background 0.2s ease;
border: 2px solid transparent;
background-clip: padding-box;
transition: background 0.2s ease, border-color 0.2s ease;
}
.twilight-theme ::-webkit-scrollbar-thumb:hover {
.twilight-theme *:hover::-webkit-scrollbar-track {
background: rgba(20, 18, 31, 0.5);
}
.twilight-theme *:hover::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #3E3A6A 0%, #565096 100%);
border-color: rgba(20, 18, 31, 0.5);
}
.twilight-theme *:hover::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, #4A4580 0%, #625BAC 100%);
}
@@ -357,24 +368,34 @@ body.stationery-dark-theme {
transition: width 0.2s ease;
}
/* Scrollbar styling */
/* Scrollbar styling — transparent until hover (see twilight note above) */
.mocha-theme ::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.mocha-theme ::-webkit-scrollbar-track {
background: rgba(26, 22, 20, 0.5);
background: transparent;
}
.mocha-theme ::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #54493F 0%, #706151 100%);
background: transparent;
border-radius: 5px;
border: 2px solid rgba(26, 22, 20, 0.5);
transition: background 0.2s ease;
border: 2px solid transparent;
background-clip: padding-box;
transition: background 0.2s ease, border-color 0.2s ease;
}
.mocha-theme ::-webkit-scrollbar-thumb:hover {
.mocha-theme *:hover::-webkit-scrollbar-track {
background: rgba(26, 22, 20, 0.5);
}
.mocha-theme *:hover::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #54493F 0%, #706151 100%);
border-color: rgba(26, 22, 20, 0.5);
}
.mocha-theme *:hover::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, #625548 0%, #7E6D5A 100%);
}
@@ -1762,16 +1783,26 @@ body.stationery-dark-theme {
}
.stationery ::-webkit-scrollbar-track {
background: color-mix(in srgb, var(--manila) 45%, transparent);
background: transparent;
}
.stationery ::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, var(--manila-deep) 0%, var(--manila-edge) 100%);
background: transparent;
border-radius: 2px;
border: 2px solid color-mix(in srgb, var(--manila) 45%, transparent);
border: 2px solid transparent;
background-clip: padding-box;
}
.stationery ::-webkit-scrollbar-thumb:hover {
.stationery *:hover::-webkit-scrollbar-track {
background: color-mix(in srgb, var(--manila) 45%, transparent);
}
.stationery *:hover::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, var(--manila-deep) 0%, var(--manila-edge) 100%);
border-color: color-mix(in srgb, var(--manila) 45%, transparent);
}
.stationery *:hover::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, var(--manila-edge) 0%, var(--manila-dark) 100%);
}