From 9509a9705ec174276af3ebaad563d16891f07d37 Mon Sep 17 00:00:00 2001 From: litruv Date: Thu, 23 Jul 2026 14:52:01 +1000 Subject: [PATCH] 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. --- src/app/components/AnimatedOutlet.tsx | 47 +- src/app/components/editor/Editor.tsx | 2 +- src/app/components/message/Reaction.css.ts | 18 +- src/app/components/message/Reaction.tsx | 43 +- .../components/message/layout/layout.css.ts | 1 + .../features/message-search/MessageSearch.tsx | 10 +- .../message-search/useMessageSearch.ts | 155 +++++- src/app/features/room/Room.tsx | 27 +- src/app/features/room/RoomInput.css.ts | 3 +- src/app/features/room/RoomInput.tsx | 44 +- src/app/features/room/RoomTimeline.css.ts | 3 +- src/app/features/room/RoomTimeline.tsx | 217 ++++++-- src/app/features/room/RoomViewHeader.tsx | 100 +++- .../room/room-media-menu/RoomMediaMenu.css.ts | 149 +++++ .../room/room-media-menu/RoomMediaMenu.tsx | 524 ++++++++++++++++++ .../features/room/room-media-menu/index.ts | 1 + src/app/features/search/Search.tsx | 18 +- src/app/hooks/router/useDirectSelected.ts | 12 +- src/app/hooks/useRoomMediaEvents.ts | 256 +++++++++ src/app/hooks/useRoomNavigate.ts | 25 +- src/app/hooks/useRoomUploadFiles.ts | 60 ++ src/app/pages/Router.tsx | 3 +- src/app/pages/client/direct/Direct.tsx | 24 +- src/app/pages/client/direct/Search.tsx | 55 ++ src/app/pages/client/direct/index.ts | 1 + src/app/pages/pathUtils.ts | 24 + src/app/pages/paths.ts | 1 + src/app/state/mediaDrawer.ts | 17 + src/app/state/roomScrollCache.ts | 14 + src/index.css | 63 ++- 30 files changed, 1749 insertions(+), 168 deletions(-) create mode 100644 src/app/features/room/room-media-menu/RoomMediaMenu.css.ts create mode 100644 src/app/features/room/room-media-menu/RoomMediaMenu.tsx create mode 100644 src/app/features/room/room-media-menu/index.ts create mode 100644 src/app/hooks/useRoomMediaEvents.ts create mode 100644 src/app/hooks/useRoomUploadFiles.ts create mode 100644 src/app/pages/client/direct/Search.tsx create mode 100644 src/app/state/mediaDrawer.ts 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} + ) : ( + + {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 {item.body}; + } + + 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 {item.body}; + } + + return ( + + {srcState.status === AsyncStatus.Loading ? ( + + ) : ( + + )} + + ); +} + +function MediaTileThumb({ item }: MediaTileThumbProps) { + if (item.thumbMxc) { + return ; + } + + if (item.msgtype === MsgType.Video) { + return ( + + + + + ( + {item.body} + )} + /> + + ); + } + + 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%); }