diff --git a/src/app/components/AnimatedOutlet.tsx b/src/app/components/AnimatedOutlet.tsx
index 83e56ff..01262f7 100644
--- a/src/app/components/AnimatedOutlet.tsx
+++ b/src/app/components/AnimatedOutlet.tsx
@@ -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 (
(
className={css.EditorTextareaScroll}
variant="SurfaceVariant"
style={scrollStyle}
- size="300"
+ size="0"
visibility="Hover"
hideTrack
ref={editableRef}
diff --git a/src/app/components/message/Reaction.css.ts b/src/app/components/message/Reaction.css.ts
index 3a78776..8baf524 100644
--- a/src/app/components/message/Reaction.css.ts
+++ b/src/app/components/message/Reaction.css.ts
@@ -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))`,
},
]);
diff --git a/src/app/components/message/Reaction.tsx b/src/app/components/message/Reaction.tsx
index c6ff295..2dfb7de 100644
--- a/src/app/components/message/Reaction.tsx
+++ b/src/app/components/message/Reaction.tsx
@@ -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 ? (
+

+ ) : (
+
+ {reaction}
+
+ );
+
+ // Stationery: fanned sticker stack. Everywhere else: compact emoji + count.
+ if (!stationery) {
+ return (
+
+
+ {emoji}
+
+
+ {count}
+
+
+ );
+ }
+
+ const stackCount = Math.min(Math.max(count, 1), MAX_STICKER_STACK);
+ const showCount = count > 2;
+
return (
{Array.from({ length: stackCount }, (_, i) => {
- // Draw back-to-front so the top sticker is the last layer
const layer = stackCount - 1 - i;
return (
[...nonDirectRooms, ...directRooms],
+ [nonDirectRooms, directRooms]
+ );
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
diff --git a/src/app/features/message-search/useMessageSearch.ts b/src/app/features/message-search/useMessageSearch.ts
index ded6d4b..a8c586c 100644
--- a/src/app/features/message-search/useMessageSearch.ts
+++ b/src/app/features/message-search/useMessageSearch.ts
@@ -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]
);
diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx
index 27ac886..57439c9 100644
--- a/src/app/features/room/Room.tsx
+++ b/src/app/features/room/Room.tsx
@@ -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 (
- {forumRoom ? : }
- {screenSize === ScreenSize.Desktop && isDrawer && (
+ {!showMediaSolo &&
+ (forumRoom ? (
+
+ ) : (
+
+ ))}
+ {showMediaSolo && }
+ {!showMediaSolo && showRightDrawer && (
<>
-
+ {isMediaDrawer ? (
+
+ ) : (
+
+ )}
>
)}
diff --git a/src/app/features/room/RoomInput.css.ts b/src/app/features/room/RoomInput.css.ts
index 3c4ac0f..d4a1a47 100644
--- a/src/app/features/room/RoomInput.css.ts
+++ b/src/app/features/room/RoomInput.css.ts
@@ -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}`,
diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx
index 68cb697..3208c2b 100644
--- a/src/app/features/room/RoomInput.tsx
+++ b/src/app/features/room/RoomInput.tsx
@@ -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(
});
}, [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(
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"
diff --git a/src/app/features/room/RoomTimeline.css.ts b/src/app/features/room/RoomTimeline.css.ts
index f42b411..8d95a7e 100644
--- a/src/app/features/room/RoomTimeline.css.ts
+++ b/src/app/features/room/RoomTimeline.css.ts
@@ -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',
},
],
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index ce4353b..8dd194e 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -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(
+ () => 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(null);
const timelineContentRef = useRef(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('[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 (
- {unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && (
-
- }
- onClick={handleJumpToUnread}
- >
- Jump to Unread
-
-
- }
- onClick={handleMarkAsRead}
- >
- Mark as Read
-
-
- )}
- {!atBottom && (
+ {(showUnreadTop || returnToEventId) && (
+
+ {returnToEventId && (
+ <>
+ }
+ onClick={handleReturnToPrevious}
+ >
+ Return to Previous
+
+
+
+
+ >
+ )}
+ {showUnreadTop && (
+ <>
+ }
+ onClick={handleJumpToUnread}
+ >
+ Jump to Unread
+
+
+ }
+ onClick={handleMarkAsRead}
+ >
+ Mark as Read
+
+ >
+ )}
+
+ )}
+ {!atLiveBottom && (
{latestUnreadMessage && (
diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx
index d388923..55f746d 100644
--- a/src/app/features/room/RoomViewHeader.tsx
+++ b/src/app/features/room/RoomViewHeader.tsx
@@ -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();
const [pinMenuAnchor, setPinMenuAnchor] = useState();
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 (
@@ -611,23 +633,21 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
- {!ecryptedRoom && (
-
- Search
-
- }
- >
- {(triggerRef) => (
-
-
-
- )}
-
- )}
+
+ Search
+
+ }
+ >
+ {(triggerRef) => (
+
+
+
+ )}
+
{(triggerRef) => (
- setPeopleDrawer((drawer) => !drawer)}>
-
+
+
)}
@@ -720,6 +744,26 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
)}
+
+ {isMediaDrawer ? 'Hide Shared Media' : 'Show Shared Media'}
+
+ }
+ >
+ {(triggerRef) => (
+
+
+
+ )}
+
=> {
+ 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 ;
+ }
+
+ return (
+
+ {srcState.status === AsyncStatus.Loading ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+function MediaTileFullImageThumb({ item }: { item: RoomMediaItem }) {
+ const mx = useMatrixClient();
+ const useAuthentication = useMediaAuthentication();
+
+ const [srcState, loadSrc] = useAsyncCallback(
+ useCallback(async () => {
+ const accessToken = getCurrentAccessToken();
+ const mediaUrl = mxcUrlToHttp(mx, item.fullMxc, useAuthentication) ?? item.fullMxc;
+
+ if (item.fullEncInfo) {
+ const fileContent = await downloadEncryptedMedia(
+ mediaUrl,
+ (encBuf) =>
+ decryptFile(encBuf, item.fullMimeType ?? FALLBACK_MIMETYPE, item.fullEncInfo!),
+ accessToken
+ );
+ return URL.createObjectURL(fileContent);
+ }
+
+ if (useAuthentication && isAuthenticatedMediaUrl(mediaUrl)) {
+ return fetchAuthenticatedMedia(mediaUrl, accessToken);
+ }
+
+ return mediaUrl;
+ }, [mx, item.fullMxc, item.fullEncInfo, item.fullMimeType, useAuthentication])
+ );
+
+ useEffect(() => {
+ loadSrc();
+ }, [loadSrc]);
+
+ useEffect(() => {
+ if (srcState.status !== AsyncStatus.Success) return undefined;
+ const src = srcState.data;
+ if (!src.startsWith('blob:')) return undefined;
+ return () => URL.revokeObjectURL(src);
+ }, [srcState]);
+
+ if (srcState.status === AsyncStatus.Success) {
+ return ;
+ }
+
+ return (
+
+ {srcState.status === AsyncStatus.Loading ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+function MediaTileThumb({ item }: MediaTileThumbProps) {
+ if (item.thumbMxc) {
+ return ;
+ }
+
+ if (item.msgtype === MsgType.Video) {
+ return (
+
+
+
+
+ (
+
+ )}
+ />
+
+ );
+ }
+
+ return ;
+}
+
+type MediaDayGroup = {
+ dayKey: string;
+ dayLabel: string;
+ items: RoomMediaItem[];
+};
+
+type MediaMonthGroup = {
+ monthKey: string;
+ monthLabel: string;
+ days: MediaDayGroup[];
+};
+
+const monthFormatter = new Intl.DateTimeFormat(undefined, {
+ month: 'long',
+ year: 'numeric',
+});
+
+const dayFormatter = new Intl.DateTimeFormat(undefined, {
+ weekday: 'short',
+ month: 'short',
+ day: 'numeric',
+});
+
+const sameDayFormatter = new Intl.DateTimeFormat(undefined, {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+});
+
+const formatDayLabel = (ts: number): string => {
+ const date = new Date(ts);
+ const today = new Date();
+ const yesterday = new Date();
+ yesterday.setDate(today.getDate() - 1);
+
+ if (sameDayFormatter.format(date) === sameDayFormatter.format(today)) return 'Today';
+ if (sameDayFormatter.format(date) === sameDayFormatter.format(yesterday)) return 'Yesterday';
+ return dayFormatter.format(date);
+};
+
+const groupMediaByMonthAndDay = (items: RoomMediaItem[]): MediaMonthGroup[] => {
+ const months: MediaMonthGroup[] = [];
+
+ for (const item of items) {
+ const date = new Date(item.ts);
+ const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
+ const dayKey = `${monthKey}-${String(date.getDate()).padStart(2, '0')}`;
+
+ let month = months[months.length - 1];
+ if (!month || month.monthKey !== monthKey) {
+ month = {
+ monthKey,
+ monthLabel: monthFormatter.format(date),
+ days: [],
+ };
+ months.push(month);
+ }
+
+ let day = month.days[month.days.length - 1];
+ if (!day || day.dayKey !== dayKey) {
+ day = {
+ dayKey,
+ dayLabel: formatDayLabel(item.ts),
+ items: [],
+ };
+ month.days.push(day);
+ }
+
+ day.items.push(item);
+ }
+
+ return months;
+};
+
+type MediaSenderChip = {
+ userId: string;
+ name: string;
+ avatarMxc?: string;
+ count: number;
+};
+
+export type MediaDrawerProps = {
+ room: Room;
+ /** Full-page takeover when the layout is too narrow for a side rail. */
+ solo?: boolean;
+};
+
+export const MediaDrawer = as<'div', MediaDrawerProps>(({ room, solo = false, className, ...props }, ref) => {
+ const mx = useMatrixClient();
+ const useAuthentication = useMediaAuthentication();
+ const { navigateRoom } = useRoomNavigate();
+ const [, setMediaDrawer] = useAtom(isMediaDrawerAtom);
+ const { items, loading, hasMore, loadMore } = useRoomMediaEvents(room);
+ const [excludedSenders, setExcludedSenders] = useState>(() => new Set());
+ const scrollRef = useRef(null);
+ const restoredScrollRef = useRef(false);
+
+ const senders = useMemo(() => {
+ const counts = new Map();
+ for (const item of items) {
+ counts.set(item.sender, (counts.get(item.sender) ?? 0) + 1);
+ }
+
+ return Array.from(counts.entries())
+ .map(([userId, count]) => ({
+ userId,
+ count,
+ name: getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId,
+ avatarMxc: getMemberAvatarMxc(room, userId),
+ }))
+ .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
+ }, [items, room]);
+
+ const filteredItems = useMemo(
+ () => items.filter((item) => !excludedSenders.has(item.sender)),
+ [items, excludedSenders]
+ );
+ const sections = useMemo(() => groupMediaByMonthAndDay(filteredItems), [filteredItems]);
+
+ useLayoutEffect(() => {
+ restoredScrollRef.current = false;
+ }, [room.roomId]);
+
+ useLayoutEffect(() => {
+ if (restoredScrollRef.current || items.length === 0) return;
+ const el = scrollRef.current;
+ if (!el) return;
+ const saved = getMediaDrawerScrollTop(room.roomId);
+ if (typeof saved === 'number') {
+ el.scrollTop = saved;
+ }
+ restoredScrollRef.current = true;
+ }, [room.roomId, items.length]);
+
+ const handleClose = useCallback(() => {
+ setMediaDrawer(false);
+ }, [setMediaDrawer]);
+
+ const handleSenderClick = useCallback(
+ (evt: React.MouseEvent, userId: string) => {
+ if (evt.ctrlKey || evt.metaKey) {
+ evt.preventDefault();
+ const allIds = senders.map((sender) => sender.userId);
+ setExcludedSenders((prev) => {
+ const enabledIds = allIds.filter((id) => !prev.has(id));
+ const alreadySolo = enabledIds.length === 1 && enabledIds[0] === userId;
+ if (alreadySolo) return new Set();
+ return new Set(allIds.filter((id) => id !== userId));
+ });
+ return;
+ }
+
+ setExcludedSenders((prev) => {
+ const next = new Set(prev);
+ if (next.has(userId)) next.delete(userId);
+ else next.add(userId);
+ return next;
+ });
+ },
+ [senders]
+ );
+
+ const handleOpen = useCallback(
+ (eventId: string) => {
+ navigateRoom(room.roomId, eventId);
+ if (solo) setMediaDrawer(false);
+ },
+ [navigateRoom, room.roomId, solo, setMediaDrawer]
+ );
+
+ const handleScroll = useCallback(
+ (evt: React.UIEvent) => {
+ const el = evt.currentTarget;
+ setMediaDrawerScrollTop(room.roomId, el.scrollTop);
+ const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 80;
+ if (nearBottom && hasMore && !loading) {
+ loadMore();
+ }
+ },
+ [hasMore, loading, loadMore, room.roomId]
+ );
+
+ return (
+
+
+
+
+ Shared Media
+
+
+
+
+
+
+
+ {senders.length > 0 && (
+
+
+ {senders.map((sender) => {
+ const enabled = !excludedSenders.has(sender.userId);
+ const avatarUrl = sender.avatarMxc
+ ? mxcUrlToHttp(mx, sender.avatarMxc, useAuthentication, 48, 48, 'crop') ??
+ undefined
+ : undefined;
+
+ return (
+
handleSenderClick(evt, sender.userId)}
+ before={
+
+ (
+
+ {sender.name.slice(0, 1).toUpperCase()}
+
+ )}
+ />
+
+ }
+ >
+
+ {sender.name}
+
+
+ );
+ })}
+
+
+ )}
+
+
+
+
+ {items.length === 0 && !loading && (
+
+ No shared photos or videos yet.
+
+ )}
+
+ {items.length > 0 && filteredItems.length === 0 && (
+
+ No media from the selected people.
+
+ )}
+
+ {sections.map((month) => (
+
+
+ {month.monthLabel}
+
+ {month.days.map((day) => (
+
+
+ {day.dayLabel}
+
+
+ {day.items.map((item) => (
+
+ ))}
+
+
+ ))}
+
+ ))}
+
+ {(loading || hasMore) && (
+
+ {loading && }
+
+ )}
+
+
+
+
+ );
+});
diff --git a/src/app/features/room/room-media-menu/index.ts b/src/app/features/room/room-media-menu/index.ts
new file mode 100644
index 0000000..f9578d7
--- /dev/null
+++ b/src/app/features/room/room-media-menu/index.ts
@@ -0,0 +1 @@
+export * from './RoomMediaMenu';
diff --git a/src/app/features/search/Search.tsx b/src/app/features/search/Search.tsx
index ff3e9e1..7af0e84 100644
--- a/src/app/features/search/Search.tsx
+++ b/src/app/features/search/Search.tsx
@@ -145,11 +145,23 @@ export function Search({ requestClose }: SearchProps) {
const getTargetStr: SearchItemStrGetter = 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;
},
diff --git a/src/app/hooks/router/useDirectSelected.ts b/src/app/hooks/router/useDirectSelected.ts
index adb2851..ff6955f 100644
--- a/src/app/hooks/router/useDirectSelected.ts
+++ b/src/app/hooks/router/useDirectSelected.ts
@@ -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;
+};
diff --git a/src/app/hooks/useRoomMediaEvents.ts b/src/app/hooks/useRoomMediaEvents.ts
new file mode 100644
index 0000000..7b05533
--- /dev/null
+++ b/src/app/hooks/useRoomMediaEvents.ts
@@ -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;
+};
+
+/** Survives MediaDrawer remounts within the same room/session. */
+const mediaCache = new Map();
+
+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(() => 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 };
+};
diff --git a/src/app/hooks/useRoomNavigate.ts b/src/app/hooks/useRoomNavigate.ts
index 025661b..1ecef1b 100644
--- a/src/app/hooks/useRoomNavigate.ts
+++ b/src/app/hooks/useRoomNavigate.ts
@@ -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 {
diff --git a/src/app/hooks/useRoomUploadFiles.ts b/src/app/hooks/useRoomUploadFiles.ts
new file mode 100644
index 0000000..976a71b
--- /dev/null
+++ b/src/app/hooks/useRoomUploadFiles.ts
@@ -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 };
+};
diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx
index 3cc7ab4..4d77085 100644
--- a/src/app/pages/Router.tsx
+++ b/src/app/pages/Router.tsx
@@ -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 : } />}
} />
+ } />
} />
} />
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() {
+
+
+
+
+
+
+
+
+
+ Message Search
+
+
+
+
+
+
diff --git a/src/app/pages/client/direct/Search.tsx b/src/app/pages/client/direct/Search.tsx
new file mode 100644
index 0000000..61b38a2
--- /dev/null
+++ b/src/app/pages/client/direct/Search.tsx
@@ -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(null);
+ const rooms = useDirectRooms();
+ const screenSize = useScreenSizeContext();
+
+ return (
+
+
+
+
+ {screenSize === ScreenSize.Mobile && (
+
+ {(onBack) => (
+
+
+
+ )}
+
+ )}
+
+
+ {screenSize !== ScreenSize.Mobile && }
+
+ Message Search
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/pages/client/direct/index.ts b/src/app/pages/client/direct/index.ts
index d247bbc..770aa28 100644
--- a/src/app/pages/client/direct/index.ts
+++ b/src/app/pages/client/direct/index.ts
@@ -1,3 +1,4 @@
export * from './Direct';
export * from './RoomProvider';
export * from './DirectCreate';
+export * from './Search';
diff --git a/src/app/pages/pathUtils.ts b/src/app/pages/pathUtils.ts
index b055840..8d67619 100644
--- a/src/app/pages/pathUtils.ts
+++ b/src/app/pages/pathUtils.ts
@@ -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 => {
diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts
index d0ed020..59bca20 100644
--- a/src/app/pages/paths.ts
+++ b/src/app/pages/paths.ts
@@ -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/';
diff --git a/src/app/state/mediaDrawer.ts b/src/app/state/mediaDrawer.ts
new file mode 100644
index 0000000..a2c99e5
--- /dev/null
+++ b/src/app/state/mediaDrawer.ts
@@ -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();
+
+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);
+};
diff --git a/src/app/state/roomScrollCache.ts b/src/app/state/roomScrollCache.ts
index 3937396..9a4ebb8 100644
--- a/src/app/state/roomScrollCache.ts
+++ b/src/app/state/roomScrollCache.ts
@@ -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();
+
+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);
+};
diff --git a/src/index.css b/src/index.css
index 6449a95..9e5ae37 100644
--- a/src/index.css
+++ b/src/index.css
@@ -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%);
}