From 9bc92efe2cdce4157733dd505c731880372c1211 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Wed, 11 Mar 2026 01:39:20 -0500 Subject: [PATCH 01/16] reverse message render order (bottom -> top instead of top -> bottom) --- src/app/features/room/RoomTimeline.css.ts | 2 +- src/app/features/room/RoomTimeline.tsx | 234 ++++++++++++---------- 2 files changed, 131 insertions(+), 105 deletions(-) diff --git a/src/app/features/room/RoomTimeline.css.ts b/src/app/features/room/RoomTimeline.css.ts index 29f18f3a5..5561f7016 100644 --- a/src/app/features/room/RoomTimeline.css.ts +++ b/src/app/features/room/RoomTimeline.css.ts @@ -32,7 +32,7 @@ export type TimelineFloatVariants = RecipeVariants; export const messageList = style({ display: 'flex', - flexDirection: 'column', + flexDirection: 'column-reverse', width: '100%', }); diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 668b33f7b..ffce24b38 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -2039,101 +2039,74 @@ export function RoomTimeline({ } ); - let prevEvent: MatrixEvent | undefined; - let isPrevRendered = false; - let newDivider = false; - let dayDivider = false; - const timelineItems = getItems(); - const eventRenderer = (item: number) => { - const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item); - if (!eventTimeline) return null; - const timelineSet = eventTimeline?.getTimelineSet(); - const mEvent = getTimelineEvent(eventTimeline, getTimelineRelativeIndex(item, baseIndex)); - const mEventId = mEvent?.getId(); - - if (!mEvent || !mEventId) return null; - - const eventSender = mEvent.getSender(); - if (eventSender && ignoredUsersSet.has(eventSender)) { - return null; - } - if (mEvent.isRedacted() && !showHiddenEvents) { - return null; - } + const processedEvents = useMemo(() => { + const items = getItems(); + let prevEvent: MatrixEvent | undefined; + let isPrevRendered = false; + let newDivider = false; + let dayDivider = false; + + const chronologicallyProcessed = items + .map((item) => { + const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item); + if (!eventTimeline) return null; + + const timelineSet = eventTimeline.getTimelineSet(); + const mEvent = getTimelineEvent(eventTimeline, getTimelineRelativeIndex(item, baseIndex)); + const mEventId = mEvent?.getId(); + + if (!mEvent || !mEventId) return null; + + const eventSender = mEvent.getSender(); + if (eventSender && ignoredUsersSet.has(eventSender)) return null; + if (mEvent.isRedacted() && !showHiddenEvents) return null; + + if (!newDivider && readUptoEventIdRef.current) { + newDivider = prevEvent?.getId() === readUptoEventIdRef.current; + } + if (!dayDivider) { + dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false; + } - if (!newDivider && readUptoEventIdRef.current) { - newDivider = prevEvent?.getId() === readUptoEventIdRef.current; - } - if (!dayDivider) { - dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false; - } + const isReactionOrEdit = reactionOrEditEvent(mEvent); + const willBeRendered = !isReactionOrEdit; - const collapsed = - isPrevRendered && - !dayDivider && - (!newDivider || eventSender === mx.getUserId()) && - prevEvent !== undefined && - prevEvent.getSender() === eventSender && - prevEvent.getType() === mEvent.getType() && - minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2; - - const eventJSX = reactionOrEditEvent(mEvent) - ? null - : renderMatrixEvent( - mEvent.getType(), - typeof mEvent.getStateKey() === 'string', - mEventId, - mEvent, - item, - timelineSet, - collapsed - ); - prevEvent = mEvent; - isPrevRendered = !!eventJSX; - - const newDividerJSX = - newDivider && eventJSX && eventSender !== mx.getUserId() ? ( - - - - New Messages - - - - ) : null; - - const dayDividerJSX = - dayDivider && eventJSX ? ( - - - - - {(() => { - if (today(mEvent.getTs())) return 'Today'; - if (yesterday(mEvent.getTs())) return 'Yesterday'; - return timeDayMonthYear(mEvent.getTs()); - })()} - - - - - ) : null; - - if (eventJSX && (newDividerJSX || dayDividerJSX)) { - if (newDividerJSX) newDivider = false; - if (dayDividerJSX) dayDivider = false; + const collapsed = + isPrevRendered && + !dayDivider && + (!newDivider || eventSender === mx.getUserId()) && + prevEvent !== undefined && + prevEvent.getSender() === eventSender && + prevEvent.getType() === mEvent.getType() && + minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2; - return ( - - {newDividerJSX} - {dayDividerJSX} - {eventJSX} - - ); - } + const willRenderNewDivider = newDivider && willBeRendered && eventSender !== mx.getUserId(); + const willRenderDayDivider = dayDivider && willBeRendered; - return eventJSX; - }; + prevEvent = mEvent; + isPrevRendered = willBeRendered; + + if (willRenderNewDivider) newDivider = false; + if (willRenderDayDivider) dayDivider = false; + + if (!willBeRendered) return null; + + return { + id: mEventId, + itemIndex: item, + mEvent, + timelineSet, + eventSender, + collapsed, + willRenderNewDivider, + willRenderDayDivider, + }; + }) + .filter((e): e is NonNullable => e !== null); + + // Reverse for column-reverse rendering + return chronologicallyProcessed.reverse(); + }, [timeline.linkedTimelines, getItems, ignoredUsersSet, showHiddenEvents, mx]); let backPaginationJSX: ReactNode | undefined; if (canPaginateBack || !rangeAtStart || backwardStatus !== 'idle') { @@ -2158,13 +2131,13 @@ export function RoomTimeline({ ); - } else if (backwardStatus === 'loading' && timelineItems.length > 0) { + } else if (backwardStatus === 'loading' && getItems().length > 0) { backPaginationJSX = ( ); - } else if (timelineItems.length === 0) { + } else if (getItems().length === 0) { // When eventsLength===0 AND liveTimelineLinked the live EventTimeline was // just reset by a sliding sync TimelineRefresh and new events haven't // arrived yet. Attaching the IntersectionObserver anchor here would @@ -2238,13 +2211,13 @@ export function RoomTimeline({ ); - } else if (forwardStatus === 'loading' && timelineItems.length > 0) { + } else if (forwardStatus === 'loading' && getItems().length > 0) { frontPaginationJSX = ( ); - } else if (timelineItems.length === 0) { + } else if (getItems().length === 0) { frontPaginationJSX = messageLayout === MessageLayout.Compact ? ( <> @@ -2310,11 +2283,70 @@ export function RoomTimeline({ - {!canPaginateBack && rangeAtStart && getItems().length > 0 && ( + + {frontPaginationJSX} + + {processedEvents.map((eventData) => { + const { + id, + itemIndex, + mEvent, + timelineSet, + willRenderNewDivider, + willRenderDayDivider, + collapsed, + } = eventData; + + const eventJSX = renderMatrixEvent( + mEvent.getType(), + typeof mEvent.getStateKey() === 'string', + id, + mEvent, + itemIndex, + timelineSet, + collapsed + ); + + const newDividerJSX = willRenderNewDivider ? ( + + + + New Messages + + + + ) : null; + + const dayDividerJSX = willRenderDayDivider ? ( + + + + + {(() => { + if (today(mEvent.getTs())) return 'Today'; + if (yesterday(mEvent.getTs())) return 'Yesterday'; + return timeDayMonthYear(mEvent.getTs()); + })()} + + + + + ) : null; + + return ( + + {eventJSX} + {dayDividerJSX} + {newDividerJSX} + + ); + })} + + {backPaginationJSX} + + {!canPaginateBack && rangeAtStart && processedEvents.length > 0 && (
)} - {backPaginationJSX} - - {timelineItems.map(eventRenderer)} - - {frontPaginationJSX} -
{(!atBottom || !(liveTimelineLinked && rangeAtEnd)) && ( From 921b0d6e9beaf9ca4e69186665973c9b4f862f09 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Wed, 11 Mar 2026 01:43:43 -0500 Subject: [PATCH 02/16] fix context menu not being hoverable --- src/app/features/room/RoomTimeline.css.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/app/features/room/RoomTimeline.css.ts b/src/app/features/room/RoomTimeline.css.ts index 5561f7016..b3037070f 100644 --- a/src/app/features/room/RoomTimeline.css.ts +++ b/src/app/features/room/RoomTimeline.css.ts @@ -38,8 +38,14 @@ export const messageList = style({ globalStyle(`body ${messageList} [data-message-id]`, { transition: 'background-color 0.1s ease-in-out !important', + position: 'relative', + zIndex: 1, }); -globalStyle(`body ${messageList} [data-message-id]:hover`, { - backgroundColor: 'var(--sable-surface-container-hover) !important', -}); +globalStyle( + `body ${messageList} [data-message-id]:hover, body ${messageList} [data-message-id]:focus-within`, + { + backgroundColor: 'var(--sable-surface-container-hover) !important', + zIndex: 10, + } +); From 071453493890696c9ad07841e5bd6acc0513ad24 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Wed, 11 Mar 2026 01:51:11 -0500 Subject: [PATCH 03/16] stabilize pagination hopefully --- src/app/features/room/RoomTimeline.tsx | 64 +++++++++++++------------- 1 file changed, 33 insertions(+), 31 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index ffce24b38..c723881f4 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -345,9 +345,10 @@ const useTimelinePagination = ( const [backwardStatus, setBackwardStatus] = useState('idle'); const [forwardStatus, setForwardStatus] = useState('idle'); - const paginate = useMemo(() => { - let fetching = false; + // Strict lock so timeline no do shift shift + const fetchingRef = useRef({ backward: false, forward: false }); + const paginate = useMemo(() => { const recalibratePagination = ( linkedTimelines: EventTimeline[], timelinesEventsCount: number[], @@ -377,7 +378,11 @@ const useTimelinePagination = ( }; return async (backwards: boolean) => { - if (fetching) return; + const directionKey = backwards ? 'backward' : 'forward'; + + // Enforce the lock + if (fetchingRef.current[directionKey]) return; + const { linkedTimelines: lTimelines } = timelineRef.current; const timelinesEventsCount = lTimelines.map(timelineToEventsCount); @@ -396,7 +401,8 @@ const useTimelinePagination = ( return; } - fetching = true; + // Engage the lock + fetchingRef.current[directionKey] = true; if (alive()) { (backwards ? setBackwardStatus : setForwardStatus)('loading'); } @@ -430,7 +436,8 @@ const useTimelinePagination = ( (backwards ? setBackwardStatus : setForwardStatus)('idle'); } } finally { - fetching = false; + // Release the lock + fetchingRef.current[directionKey] = false; } }; }, [mx, alive, setTimeline, limit, setBackwardStatus, setForwardStatus]); @@ -980,7 +987,7 @@ export function RoomTimeline({ useCallback( () => ({ root: getScrollElement(), - rootMargin: '100px', + rootMargin: '150px 0px 150px 0px', }), [getScrollElement] ), @@ -2131,24 +2138,7 @@ export function RoomTimeline({ ); - } else if (backwardStatus === 'loading' && getItems().length > 0) { - backPaginationJSX = ( - - - - ); } else if (getItems().length === 0) { - // When eventsLength===0 AND liveTimelineLinked the live EventTimeline was - // just reset by a sliding sync TimelineRefresh and new events haven't - // arrived yet. Attaching the IntersectionObserver anchor here would - // immediately fire a server-side /messages request before current events - // land — potentially causing a "/messages hangs → spinner stuck" scenario. - // Suppressing the anchor for this transient state is safe: the rangeAtEnd - // self-heal useEffect will call getInitialTimeline once events arrive, and - // at that point the correct anchor (below) will be re-observed. - // eventsLength>0 covers the range={K,K} case from recalibratePagination - // where items=0 but events exist — that needs the anchor for local range - // extension (no server call since start>0). const placeholderBackAnchor = eventsLength > 0 || !liveTimelineLinked ? observeBackAnchor : undefined; backPaginationJSX = @@ -2184,7 +2174,16 @@ export function RoomTimeline({ ); } else { - backPaginationJSX =
; + backPaginationJSX = ( + <> + {backwardStatus === 'loading' && ( + + + + )} +
+ + ); } } @@ -2211,12 +2210,6 @@ export function RoomTimeline({ ); - } else if (forwardStatus === 'loading' && getItems().length > 0) { - frontPaginationJSX = ( - - - - ); } else if (getItems().length === 0) { frontPaginationJSX = messageLayout === MessageLayout.Compact ? ( @@ -2251,7 +2244,16 @@ export function RoomTimeline({ ); } else { - frontPaginationJSX =
; + frontPaginationJSX = ( + <> +
+ {forwardStatus === 'loading' && ( + + + + )} + + ); } } From 2528f83f1dac14a95d53b5ba8b35fea3df7921fc Mon Sep 17 00:00:00 2001 From: 7w1 Date: Wed, 11 Mar 2026 01:54:33 -0500 Subject: [PATCH 04/16] fix a possible memory leak :p --- src/app/features/room/RoomTimeline.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index c723881f4..d0c2e6916 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -1102,13 +1102,15 @@ export function RoomTimeline({ }); } - setTimeout(() => { + const timeoutId = setTimeout(() => { if (!alive()) return; setFocusItem((currentItem) => { if (currentItem === focusItem) return undefined; return currentItem; }); }, 2000); + + return () => clearTimeout(timeoutId); }, [alive, focusItem, scrollToItem]); // scroll to bottom of timeline From 6fc18296133ca08bb8920010fbcd2f62fb82f09d Mon Sep 17 00:00:00 2001 From: 7w1 Date: Wed, 11 Mar 2026 02:01:08 -0500 Subject: [PATCH 05/16] make the listeners listen better by not destroying themselves every frame i think smh --- src/app/features/room/RoomTimeline.tsx | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index d0c2e6916..66b1f0cd6 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -627,6 +627,12 @@ export function RoomTimeline({ readUptoEventIdRef.current = unreadInfo.readUptoEventId; } + const hideReadsRef = useRef(hideReads); + hideReadsRef.current = hideReads; + + const unreadInfoRef = useRef(unreadInfo); + unreadInfoRef.current = unreadInfo; + const atBottomAnchorRef = useRef(null); const [atBottom, setAtBottomState] = useState(true); @@ -779,14 +785,15 @@ export function RoomTimeline({ // otherwise we update timeline without paginating // so timeline can be updated with evt like: edits, reactions etc if (atBottomRef.current && atLiveEndRef.current) { - if (document.hasFocus() && (!unreadInfo || mEvt.getSender() === mx.getUserId())) { - // Check if the document is in focus (user is actively viewing the app), - // and either there are no unread messages or the latest message is from the current user. - // If either condition is met, trigger the markAsRead function to send a read receipt. - requestAnimationFrame(() => markAsRead(mx, mEvt.getRoomId()!, hideReads)); + if ( + document.hasFocus() && + (!unreadInfoRef.current || mEvt.getSender() === mx.getUserId()) + ) { + // Check if the document is in focus and trigger markAsRead + requestAnimationFrame(() => markAsRead(mx, mEvt.getRoomId()!, hideReadsRef.current)); } - if (!document.hasFocus() && !unreadInfo) { + if (!document.hasFocus() && !unreadInfoRef.current) { setUnreadInfo(getRoomUnreadInfo(room)); } @@ -805,11 +812,11 @@ export function RoomTimeline({ return; } setTimeline((ct) => ({ ...ct })); - if (!unreadInfo) { + if (!unreadInfoRef.current) { setUnreadInfo(getRoomUnreadInfo(room)); } }, - [mx, room, unreadInfo, hideReads] + [mx, room, setUnreadInfo] ) ); From 9810a9984013df6ec20c5bab9c3b024e6a49ecca Mon Sep 17 00:00:00 2001 From: 7w1 Date: Wed, 11 Mar 2026 02:13:19 -0500 Subject: [PATCH 06/16] plz work mobile scroll thanks --- src/app/features/room/RoomTimeline.css.ts | 2 ++ src/app/features/room/RoomTimeline.tsx | 11 ++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/app/features/room/RoomTimeline.css.ts b/src/app/features/room/RoomTimeline.css.ts index b3037070f..8db3a10ae 100644 --- a/src/app/features/room/RoomTimeline.css.ts +++ b/src/app/features/room/RoomTimeline.css.ts @@ -34,9 +34,11 @@ export const messageList = style({ display: 'flex', flexDirection: 'column-reverse', width: '100%', + overflowAnchor: 'none', }); globalStyle(`body ${messageList} [data-message-id]`, { + overflowAnchor: 'auto', transition: 'background-color 0.1s ease-in-out !important', position: 'relative', zIndex: 1, diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 66b1f0cd6..661b225a5 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -2184,14 +2184,19 @@ export function RoomTimeline({ ); } else { backPaginationJSX = ( - <> +
{backwardStatus === 'loading' && ( )} -
- +
); } } From 6fc2aecd22e062962fb5a3a0542c8e33bc56fa4b Mon Sep 17 00:00:00 2001 From: 7w1 Date: Wed, 11 Mar 2026 02:23:12 -0500 Subject: [PATCH 07/16] plz work attempt 2 --- src/app/features/room/RoomTimeline.tsx | 39 ++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 661b225a5..2d680610d 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -2271,6 +2271,45 @@ export function RoomTimeline({ } } + const prevBackwardStatus = useRef(backwardStatus); + const scrollAnchor = useRef<{ id: string; offsetTop: number } | null>(null); + + useLayoutEffect(() => { + const scrollEl = scrollRef.current; + if (!scrollEl) return; + + // When going to loading store the exact position + if (prevBackwardStatus.current === 'idle' && backwardStatus === 'loading') { + const topMessage = scrollEl.querySelector('[data-message-id]'); + if (topMessage) { + scrollAnchor.current = { + id: topMessage.getAttribute('data-message-id') || '', + offsetTop: (topMessage as HTMLElement).offsetTop, + }; + } + } + + // After fetch finishes calculate new content and force scroll to position hopefully + if (prevBackwardStatus.current === 'loading' && backwardStatus === 'idle') { + if (scrollAnchor.current) { + const anchorNode = scrollEl.querySelector( + `[data-message-id="${scrollAnchor.current.id}"]` + ) as HTMLElement; + + if (anchorNode) { + const delta = anchorNode.offsetTop - scrollAnchor.current.offsetTop; + + if (delta > 0) { + scrollEl.scrollBy({ top: delta, behavior: 'instant' }); + } + } + scrollAnchor.current = null; + } + } + + prevBackwardStatus.current = backwardStatus; + }, [backwardStatus, processedEvents]); + return ( {unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && ( From 9beff5d4fb0bee4b2bee5f1c973281d16603f3ab Mon Sep 17 00:00:00 2001 From: 7w1 Date: Wed, 11 Mar 2026 02:35:08 -0500 Subject: [PATCH 08/16] plz work part 3 electric boogalee --- src/app/features/room/RoomTimeline.tsx | 57 +++++++++----------------- 1 file changed, 19 insertions(+), 38 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 2d680610d..5d7136001 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -2185,18 +2185,18 @@ export function RoomTimeline({ } else { backPaginationJSX = (
- {backwardStatus === 'loading' && ( - - - - )} -
+ /> + ); + const backwardLoadingJSX = backwardStatus === 'loading' && ( + + + ); } } @@ -2270,45 +2270,26 @@ export function RoomTimeline({ ); } } - - const prevBackwardStatus = useRef(backwardStatus); - const scrollAnchor = useRef<{ id: string; offsetTop: number } | null>(null); + const lastScrollHeight = useRef(0); useLayoutEffect(() => { const scrollEl = scrollRef.current; if (!scrollEl) return; - // When going to loading store the exact position - if (prevBackwardStatus.current === 'idle' && backwardStatus === 'loading') { - const topMessage = scrollEl.querySelector('[data-message-id]'); - if (topMessage) { - scrollAnchor.current = { - id: topMessage.getAttribute('data-message-id') || '', - offsetTop: (topMessage as HTMLElement).offsetTop, - }; - } + if (backwardStatus === 'loading') { + lastScrollHeight.current = scrollEl.scrollHeight; } - // After fetch finishes calculate new content and force scroll to position hopefully - if (prevBackwardStatus.current === 'loading' && backwardStatus === 'idle') { - if (scrollAnchor.current) { - const anchorNode = scrollEl.querySelector( - `[data-message-id="${scrollAnchor.current.id}"]` - ) as HTMLElement; - - if (anchorNode) { - const delta = anchorNode.offsetTop - scrollAnchor.current.offsetTop; + if (backwardStatus === 'idle' && lastScrollHeight.current > 0) { + const newScrollHeight = scrollEl.scrollHeight; + const heightDifference = newScrollHeight - lastScrollHeight.current; - if (delta > 0) { - scrollEl.scrollBy({ top: delta, behavior: 'instant' }); - } - } - scrollAnchor.current = null; + if (heightDifference > 0) { + scrollEl.scrollTop += heightDifference; } + lastScrollHeight.current = 0; } - - prevBackwardStatus.current = backwardStatus; - }, [backwardStatus, processedEvents]); + }, [backwardStatus, processedEvents.length]); return ( From b4bff803edba38f17267f5fa956b29fa81c6488a Mon Sep 17 00:00:00 2001 From: 7w1 Date: Wed, 11 Mar 2026 02:40:57 -0500 Subject: [PATCH 09/16] fix thingy --- src/app/features/room/RoomTimeline.tsx | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 5d7136001..b6f5eed4e 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -2184,18 +2184,20 @@ export function RoomTimeline({ ); } else { backPaginationJSX = ( -
- ); - const backwardLoadingJSX = backwardStatus === 'loading' && ( - - + + {backwardStatus === 'loading' && ( + + + + )} +
); } From 88e9efa95c152056cd5c819eb5bd236a03a20a62 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Wed, 11 Mar 2026 12:32:09 -0500 Subject: [PATCH 10/16] fix hover and maybe scroll idk --- src/app/features/room/RoomTimeline.css.ts | 20 ++++++++++++-------- src/app/features/room/RoomTimeline.tsx | 8 ++------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/app/features/room/RoomTimeline.css.ts b/src/app/features/room/RoomTimeline.css.ts index 8db3a10ae..fd60630fb 100644 --- a/src/app/features/room/RoomTimeline.css.ts +++ b/src/app/features/room/RoomTimeline.css.ts @@ -37,17 +37,21 @@ export const messageList = style({ overflowAnchor: 'none', }); -globalStyle(`body ${messageList} [data-message-id]`, { +globalStyle(`body ${messageList} > *`, { overflowAnchor: 'auto', +}); + +globalStyle(`body ${messageList} [data-message-id]`, { transition: 'background-color 0.1s ease-in-out !important', position: 'relative', zIndex: 1, }); -globalStyle( - `body ${messageList} [data-message-id]:hover, body ${messageList} [data-message-id]:focus-within`, - { - backgroundColor: 'var(--sable-surface-container-hover) !important', - zIndex: 10, - } -); +globalStyle(`body ${messageList} [data-message-id]:hover`, { + backgroundColor: 'var(--sable-surface-container-hover) !important', + zIndex: 2, +}); + +globalStyle(`body ${messageList} [data-message-id]:focus-within`, { + zIndex: 10, +}); diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index b6f5eed4e..355bfe362 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -2186,17 +2186,13 @@ export function RoomTimeline({ backPaginationJSX = ( {backwardStatus === 'loading' && ( - + )}
); From fecfe3d02499ae17d7ec9793462ce61789b12154 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Wed, 11 Mar 2026 16:54:03 -0500 Subject: [PATCH 11/16] delete the manual math and hope it works? --- src/app/features/room/RoomTimeline.tsx | 55 ++++++++++++-------------- 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 355bfe362..dadd2f245 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -2184,16 +2184,22 @@ export function RoomTimeline({ ); } else { backPaginationJSX = ( - + +
{backwardStatus === 'loading' && ( - + )} -
); } @@ -2257,37 +2263,26 @@ export function RoomTimeline({ ); } else { frontPaginationJSX = ( - <> -
+ +
{forwardStatus === 'loading' && ( - + )} - + ); } } - const lastScrollHeight = useRef(0); - - useLayoutEffect(() => { - const scrollEl = scrollRef.current; - if (!scrollEl) return; - - if (backwardStatus === 'loading') { - lastScrollHeight.current = scrollEl.scrollHeight; - } - - if (backwardStatus === 'idle' && lastScrollHeight.current > 0) { - const newScrollHeight = scrollEl.scrollHeight; - const heightDifference = newScrollHeight - lastScrollHeight.current; - - if (heightDifference > 0) { - scrollEl.scrollTop += heightDifference; - } - lastScrollHeight.current = 0; - } - }, [backwardStatus, processedEvents.length]); return ( From 74ece9e6ac0bfabd2045391bb4610d0d5cd4752a Mon Sep 17 00:00:00 2001 From: 7w1 Date: Tue, 17 Mar 2026 23:18:55 -0500 Subject: [PATCH 12/16] merge --- ...stone_settings_toggle_for_redacted_msgs.md | 5 - ...disable_quick_add_for_encrypted_sticker.md | 5 - .changeset/error_page_with_report.md | 5 - .changeset/feat-group-dm-triangle-avatars.md | 5 - .changeset/fix-badge-positioning.md | 5 - .changeset/fix-bubble-layout-overflow.md | 5 - .changeset/fix-notification-handling.md | 5 - .changeset/fix-presence-badge-member-list.md | 5 + .changeset/fix-pronoun-array-bug.md | 5 - .changeset/fix-ui-bugs.md | 5 - .changeset/mention-highlight-toggle.md | 5 + .changeset/perf-sliding-sync.md | 5 - .changeset/pronoun-pills.md | 5 + .github/CODEOWNERS | 1 + .github/actions/prepare-tofu/action.yml | 12 + .github/dependabot.yml | 2 +- .github/workflows/cloudflare-web-deploy.yml | 28 +- .github/workflows/cloudflare-web-preview.yml | 18 +- .github/workflows/docker-publish.yml | 26 +- .github/workflows/prepare-release.yml | 12 +- .github/workflows/quality-checks.yml | 40 + .github/workflows/require-changeset.yml | 2 + .github/workflows/sentry-preview-issues.yml | 231 ++++ .gitignore | 2 + CHANGELOG.md | 751 ++++++----- CODE_OF_CONDUCT.md | 2 +- CONTRIBUTING.md | 5 +- Caddyfile | 3 + contrib/nginx/cinny.domain.tld.conf | 3 + docs/PRIVACY.md | 122 ++ docs/SENTRY_INTEGRATION.md | 492 +++++++ docs/SENTRY_PRIVACY.md | 340 +++++ docs/TESTING.md | 137 ++ index.html | 3 +- knip.json | 5 +- knope.toml | 4 +- package.json | 20 +- pnpm-lock.yaml | 1164 ++++++++++++++++- pnpm-workspace.yaml | 18 +- public/manifest.json | 4 +- src/app/components/CallEmbedProvider.tsx | 15 +- .../components/ClientConfigLoader.test.tsx | 99 ++ src/app/components/DefaultErrorPage.tsx | 62 +- src/app/components/DeviceVerification.tsx | 13 + .../components/DeviceVerificationSetup.tsx | 6 +- src/app/components/IncomingCallModal.tsx | 18 + src/app/components/Pdf-viewer/PdfViewer.tsx | 2 +- src/app/components/RenderMessageContent.tsx | 8 +- src/app/components/editor/Editor.css.ts | 1 + src/app/components/editor/Editor.tsx | 20 +- .../autocomplete/AutocompleteMenu.css.tsx | 10 +- .../autocomplete/AutocompleteMenu.test.tsx | 193 +++ .../editor/autocomplete/AutocompleteMenu.tsx | 59 +- .../autocomplete/BaseAutocompleteMenu.tsx | 4 +- .../autocomplete/EmoticonAutocomplete.tsx | 13 +- .../autocomplete/RoomMentionAutocomplete.tsx | 2 + .../autocomplete/UserMentionAutocomplete.tsx | 2 + src/app/components/editor/input.ts | 4 +- src/app/components/editor/output.ts | 13 +- src/app/components/emoji-board/EmojiBoard.tsx | 74 +- .../emoji-board/components/Item.tsx | 45 +- .../components/event-history/EventHistory.tsx | 7 +- .../leave-room-prompt/LeaveRoomPrompt.tsx | 7 +- src/app/components/media/Video.tsx | 30 +- .../components/message/MsgTypeRenderers.tsx | 2 +- src/app/components/message/RenderBody.tsx | 5 +- src/app/components/message/Reply.tsx | 18 +- .../message/content/AudioContent.tsx | 138 +- .../message/content/ImageContent.tsx | 116 +- .../message/content/VideoContent.tsx | 101 +- .../message/modals/MessageDelete.tsx | 14 +- .../message/modals/MessageForward.tsx | 49 +- .../message/modals/MessageReport.tsx | 19 +- .../NotificationBanner.css.ts | 3 +- .../components/room-avatar/AvatarImage.tsx | 55 +- src/app/components/sidebar/Sidebar.css.ts | 8 +- .../TelemetryConsentBanner.css.ts | 82 ++ .../TelemetryConsentBanner.test.tsx | 95 ++ .../TelemetryConsentBanner.tsx | 71 + src/app/components/telemetry-consent/index.ts | 1 + src/app/components/time-date/DatePicker.tsx | 8 +- src/app/components/time-date/TimePicker.tsx | 8 +- .../components/upload-card/UploadCard.css.ts | 64 +- .../upload-card/UploadCardRenderer.tsx | 201 ++- .../upload-card/UploadDescriptionEditor.tsx | 2 +- .../components/url-preview/UrlPreviewCard.tsx | 97 +- .../user-profile/UserRoomProfile.tsx | 12 +- .../features/bug-report/BugReportModal.tsx | 137 +- src/app/features/call/CallControls.tsx | 21 +- .../emojis-stickers/RoomPacks.tsx | 10 +- .../permissions/PowersEditor.tsx | 6 +- src/app/features/create-room/CreateRoom.tsx | 19 + src/app/features/lobby/DnD.tsx | 14 +- src/app/features/lobby/Lobby.tsx | 7 +- .../features/message-search/MessageSearch.tsx | 6 +- .../features/room/AudioMessageRecorder.css.ts | 120 ++ .../features/room/AudioMessageRecorder.tsx | 168 +++ src/app/features/room/MembersDrawer.tsx | 6 +- src/app/features/room/Room.tsx | 108 +- src/app/features/room/RoomCallButton.tsx | 4 +- src/app/features/room/RoomInput.tsx | 440 ++++++- src/app/features/room/RoomTimeline.tsx | 830 ++++++++++-- src/app/features/room/RoomViewFollowing.tsx | 100 +- src/app/features/room/RoomViewHeader.tsx | 190 ++- src/app/features/room/ThreadBrowser.tsx | 387 ++++++ src/app/features/room/ThreadDrawer.css.ts | 66 + src/app/features/room/ThreadDrawer.tsx | 830 ++++++++++++ .../room/message/EncryptedContent.tsx | 18 +- src/app/features/room/message/Message.tsx | 114 +- .../features/room/message/MessageEditor.tsx | 293 +++-- src/app/features/room/msgContent.ts | 52 +- src/app/features/search/Search.tsx | 4 +- src/app/features/settings/about/About.tsx | 6 +- .../settings/account/NameColorEditor.tsx | 4 +- src/app/features/settings/account/Profile.tsx | 16 +- .../features/settings/cosmetics/Cosmetics.tsx | 26 +- .../features/settings/cosmetics/Themes.tsx | 8 +- .../developer-tools/DebugLogViewer.tsx | 631 +++++++++ .../settings/developer-tools/DevelopTools.tsx | 12 + .../developer-tools/SentrySettings.tsx | 153 +++ .../experimental/BandwithSavingEmojis.tsx | 33 + .../settings/experimental/Experimental.tsx | 10 +- .../experimental/MSC4268HistoryShare.tsx | 42 + src/app/features/settings/general/General.tsx | 150 ++- .../notifications/PushNotifications.tsx | 31 +- .../notifications/SystemNotification.tsx | 37 + .../features/widgets/GenericWidgetDriver.ts | 14 +- src/app/hooks/useAppVisibility.ts | 8 + src/app/hooks/useAsyncCallback.test.tsx | 126 ++ src/app/hooks/useBlobCache.ts | 16 +- src/app/hooks/useCallEmbed.ts | 43 +- src/app/hooks/useCallSignaling.ts | 74 +- src/app/hooks/useClientConfig.ts | 2 + src/app/hooks/useCommands.ts | 346 ++--- src/app/hooks/useDebounce.test.tsx | 100 ++ src/app/hooks/useKeyBackup.ts | 10 + src/app/hooks/usePreviousValue.test.tsx | 51 + src/app/hooks/useRoomWidgets.ts | 22 +- src/app/hooks/useThrottle.test.tsx | 92 ++ src/app/hooks/useTimeoutToggle.test.tsx | 101 ++ src/app/hooks/useUserProfile.ts | 13 +- src/app/pages/App.tsx | 37 +- src/app/pages/Router.tsx | 134 +- src/app/pages/auth/AuthLayout.tsx | 18 +- src/app/pages/auth/ServerPicker.tsx | 25 +- src/app/pages/auth/login/loginUtil.ts | 86 +- .../pages/client/BackgroundNotifications.tsx | 127 +- src/app/pages/client/ClientNonUIFeatures.tsx | 212 ++- src/app/pages/client/ClientRoot.tsx | 65 + src/app/pages/client/SyncStatus.tsx | 13 + src/app/pages/client/explore/Server.tsx | 16 +- src/app/pages/client/inbox/Notifications.tsx | 10 +- .../client/sidebar/AccountSwitcherTab.tsx | 53 +- .../pages/client/sidebar/DirectDMsList.tsx | 71 +- src/app/pages/client/sidebar/DirectTab.tsx | 14 +- src/app/pages/client/sidebar/HomeTab.tsx | 2 +- src/app/pages/client/sidebar/SpaceTabs.tsx | 24 +- .../client/sidebar/useSidebarDirectRoomIds.ts | 54 + src/app/pages/client/space/Space.tsx | 4 + src/app/plugins/call/CallEmbed.ts | 59 +- src/app/plugins/call/CallWidgetDriver.ts | 35 +- src/app/plugins/call/utils.ts | 12 +- src/app/plugins/matrix-to.test.ts | 231 ++++ src/app/plugins/matrix-to.ts | 58 +- src/app/plugins/voice-recorder-kit/index.ts | 7 + .../voice-recorder-kit/supportedCodec.ts | 79 ++ src/app/plugins/voice-recorder-kit/types.ts | 40 + .../voice-recorder-kit/useVoiceRecorder.ts | 910 +++++++++++++ src/app/state/callEmbed.ts | 16 + src/app/state/debugLogger.ts | 43 + src/app/state/hooks/callPreferences.ts | 2 + src/app/state/room/roomInputDrafts.ts | 2 + src/app/state/room/roomToOpenThread.ts | 14 + src/app/state/room/roomToThreadBrowser.ts | 13 + src/app/state/settings.ts | 15 +- src/app/utils/ASCIILexicalTable.ts | 6 +- src/app/utils/AsyncSearch.ts | 12 +- src/app/utils/MegolmExportEncryption.ts | 12 +- src/app/utils/colorMXID.test.ts | 36 + src/app/utils/colorMXID.ts | 4 +- src/app/utils/common.test.ts | 173 +++ src/app/utils/debug.ts | 3 +- src/app/utils/debugLogger.ts | 364 ++++++ src/app/utils/findAndReplace.test.ts | 59 + src/app/utils/matrix.ts | 31 +- src/app/utils/mimeTypes.test.ts | 78 ++ src/app/utils/pronouns.test.ts | 89 ++ src/app/utils/pronouns.ts | 42 + src/app/utils/regex.test.ts | 95 ++ src/app/utils/room.ts | 30 +- src/app/utils/sanitize.test.ts | 124 ++ src/app/utils/sendFeedbackToUser.ts | 12 + src/app/utils/sentryScrubbers.test.ts | 245 ++++ src/app/utils/sentryScrubbers.ts | 100 ++ src/app/utils/sort.test.ts | 99 ++ src/app/utils/time.test.ts | 99 ++ src/app/utils/user-agent.ts | 8 +- src/client/initMatrix.ts | 167 ++- src/client/slidingSync.ts | 482 ++++++- src/index.css | 1 + src/index.tsx | 38 +- src/instrument.ts | 309 +++++ src/sw-session.ts | 3 +- src/sw.ts | 381 +++++- src/test/setup.ts | 1 + src/types/matrix-sdk.ts | 3 + tsconfig.json | 2 +- vite.config.ts | 25 +- vitest.config.ts | 51 + 209 files changed, 15448 insertions(+), 1752 deletions(-) delete mode 100644 .changeset/add_tombstone_settings_toggle_for_redacted_msgs.md delete mode 100644 .changeset/disable_quick_add_for_encrypted_sticker.md delete mode 100644 .changeset/error_page_with_report.md delete mode 100644 .changeset/feat-group-dm-triangle-avatars.md delete mode 100644 .changeset/fix-badge-positioning.md delete mode 100644 .changeset/fix-bubble-layout-overflow.md delete mode 100644 .changeset/fix-notification-handling.md create mode 100644 .changeset/fix-presence-badge-member-list.md delete mode 100644 .changeset/fix-pronoun-array-bug.md delete mode 100644 .changeset/fix-ui-bugs.md create mode 100644 .changeset/mention-highlight-toggle.md delete mode 100644 .changeset/perf-sliding-sync.md create mode 100644 .changeset/pronoun-pills.md create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/sentry-preview-issues.yml create mode 100644 docs/PRIVACY.md create mode 100644 docs/SENTRY_INTEGRATION.md create mode 100644 docs/SENTRY_PRIVACY.md create mode 100644 docs/TESTING.md create mode 100644 src/app/components/ClientConfigLoader.test.tsx create mode 100644 src/app/components/editor/autocomplete/AutocompleteMenu.test.tsx create mode 100644 src/app/components/telemetry-consent/TelemetryConsentBanner.css.ts create mode 100644 src/app/components/telemetry-consent/TelemetryConsentBanner.test.tsx create mode 100644 src/app/components/telemetry-consent/TelemetryConsentBanner.tsx create mode 100644 src/app/components/telemetry-consent/index.ts create mode 100644 src/app/features/room/AudioMessageRecorder.css.ts create mode 100644 src/app/features/room/AudioMessageRecorder.tsx create mode 100644 src/app/features/room/ThreadBrowser.tsx create mode 100644 src/app/features/room/ThreadDrawer.css.ts create mode 100644 src/app/features/room/ThreadDrawer.tsx create mode 100644 src/app/features/settings/developer-tools/DebugLogViewer.tsx create mode 100644 src/app/features/settings/developer-tools/SentrySettings.tsx create mode 100644 src/app/features/settings/experimental/BandwithSavingEmojis.tsx create mode 100644 src/app/features/settings/experimental/MSC4268HistoryShare.tsx create mode 100644 src/app/hooks/useAsyncCallback.test.tsx create mode 100644 src/app/hooks/useDebounce.test.tsx create mode 100644 src/app/hooks/usePreviousValue.test.tsx create mode 100644 src/app/hooks/useThrottle.test.tsx create mode 100644 src/app/hooks/useTimeoutToggle.test.tsx create mode 100644 src/app/pages/client/sidebar/useSidebarDirectRoomIds.ts create mode 100644 src/app/plugins/matrix-to.test.ts create mode 100644 src/app/plugins/voice-recorder-kit/index.ts create mode 100644 src/app/plugins/voice-recorder-kit/supportedCodec.ts create mode 100644 src/app/plugins/voice-recorder-kit/types.ts create mode 100644 src/app/plugins/voice-recorder-kit/useVoiceRecorder.ts create mode 100644 src/app/state/debugLogger.ts create mode 100644 src/app/state/room/roomToOpenThread.ts create mode 100644 src/app/state/room/roomToThreadBrowser.ts create mode 100644 src/app/utils/colorMXID.test.ts create mode 100644 src/app/utils/common.test.ts create mode 100644 src/app/utils/debugLogger.ts create mode 100644 src/app/utils/findAndReplace.test.ts create mode 100644 src/app/utils/mimeTypes.test.ts create mode 100644 src/app/utils/pronouns.test.ts create mode 100644 src/app/utils/regex.test.ts create mode 100644 src/app/utils/sanitize.test.ts create mode 100644 src/app/utils/sendFeedbackToUser.ts create mode 100644 src/app/utils/sentryScrubbers.test.ts create mode 100644 src/app/utils/sentryScrubbers.ts create mode 100644 src/app/utils/sort.test.ts create mode 100644 src/app/utils/time.test.ts create mode 100644 src/instrument.ts create mode 100644 src/test/setup.ts create mode 100644 vitest.config.ts diff --git a/.changeset/add_tombstone_settings_toggle_for_redacted_msgs.md b/.changeset/add_tombstone_settings_toggle_for_redacted_msgs.md deleted file mode 100644 index 06b497a1e..000000000 --- a/.changeset/add_tombstone_settings_toggle_for_redacted_msgs.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -sable: patch ---- - -added settings toggle in (General>Messages) to enable showing a tombstone for deleted messages without having to set all hidden events to visible diff --git a/.changeset/disable_quick_add_for_encrypted_sticker.md b/.changeset/disable_quick_add_for_encrypted_sticker.md deleted file mode 100644 index c7c7cde9b..000000000 --- a/.changeset/disable_quick_add_for_encrypted_sticker.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -sable: patch ---- - -disabling quick add for encrypted sticker, this mitigates the issue of being unable to use quick to add encrypted sticker diff --git a/.changeset/error_page_with_report.md b/.changeset/error_page_with_report.md deleted file mode 100644 index 3a619c8d1..000000000 --- a/.changeset/error_page_with_report.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -sable: minor ---- - -added error page making it easier to report errors when they occur in the field diff --git a/.changeset/feat-group-dm-triangle-avatars.md b/.changeset/feat-group-dm-triangle-avatars.md deleted file mode 100644 index a93b6a8c8..000000000 --- a/.changeset/feat-group-dm-triangle-avatars.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -default: minor ---- - -Show group DM participants with triangle avatar layout. Group DMs now display up to 3 member avatars in a triangle formation (most recent sender on top), with bot filtering and DM count badge support. diff --git a/.changeset/fix-badge-positioning.md b/.changeset/fix-badge-positioning.md deleted file mode 100644 index a6d3e50cd..000000000 --- a/.changeset/fix-badge-positioning.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'default': patch ---- - -Fix badge positioning and alignment across all sidebar components diff --git a/.changeset/fix-bubble-layout-overflow.md b/.changeset/fix-bubble-layout-overflow.md deleted file mode 100644 index 18198baff..000000000 --- a/.changeset/fix-bubble-layout-overflow.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -default: patch ---- - -Fix bubble layout messages overflowing off the screen with embeds/images. diff --git a/.changeset/fix-notification-handling.md b/.changeset/fix-notification-handling.md deleted file mode 100644 index 537a1d520..000000000 --- a/.changeset/fix-notification-handling.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'default': patch ---- - -Fix notification handling with null safety and improved logic diff --git a/.changeset/fix-presence-badge-member-list.md b/.changeset/fix-presence-badge-member-list.md new file mode 100644 index 000000000..f227b478b --- /dev/null +++ b/.changeset/fix-presence-badge-member-list.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Hide presence badge in members list for users without homeserver support, mimicking room profile apperance. diff --git a/.changeset/fix-pronoun-array-bug.md b/.changeset/fix-pronoun-array-bug.md deleted file mode 100644 index 1ab4d20ae..000000000 --- a/.changeset/fix-pronoun-array-bug.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -default: patch ---- - -Fix cosmetics tab crashing if global/room/space pronouns weren't already set. diff --git a/.changeset/fix-ui-bugs.md b/.changeset/fix-ui-bugs.md deleted file mode 100644 index 63ae872ae..000000000 --- a/.changeset/fix-ui-bugs.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'default': patch ---- - -Fix reaction clicks, zoom persistence, and empty message rendering diff --git a/.changeset/mention-highlight-toggle.md b/.changeset/mention-highlight-toggle.md new file mode 100644 index 000000000..d2c7ddd2b --- /dev/null +++ b/.changeset/mention-highlight-toggle.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Added a toggle to notifications to disable full message mention highlighting. diff --git a/.changeset/perf-sliding-sync.md b/.changeset/perf-sliding-sync.md deleted file mode 100644 index 48dd8aa39..000000000 --- a/.changeset/perf-sliding-sync.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'default': minor ---- - -Optimize sliding sync with progressive loading and improved timeline management diff --git a/.changeset/pronoun-pills.md b/.changeset/pronoun-pills.md new file mode 100644 index 000000000..416f3764f --- /dev/null +++ b/.changeset/pronoun-pills.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Added a setting to Appearance that attempts to convert text in names like (it/its) into a pronoun pill, enlabed by default. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..4a7250577 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @7w1 @hazre diff --git a/.github/actions/prepare-tofu/action.yml b/.github/actions/prepare-tofu/action.yml index 409a50ee4..c64332e3c 100644 --- a/.github/actions/prepare-tofu/action.yml +++ b/.github/actions/prepare-tofu/action.yml @@ -6,6 +6,10 @@ inputs: description: The OpenTofu version to install. required: false default: '1.11.5' + is_release_tag: + description: Whether the build is for a release tag. Passed through to VITE_IS_RELEASE_TAG. + required: false + default: 'false' runs: using: composite @@ -14,6 +18,14 @@ runs: uses: ./.github/actions/setup with: build: 'true' + env: + VITE_IS_RELEASE_TAG: ${{ inputs.is_release_tag }} + VITE_SENTRY_DSN: ${{ env.VITE_SENTRY_DSN }} + VITE_SENTRY_ENVIRONMENT: ${{ env.VITE_SENTRY_ENVIRONMENT }} + VITE_APP_VERSION: ${{ env.VITE_APP_VERSION }} + SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ env.SENTRY_ORG }} + SENTRY_PROJECT: ${{ env.SENTRY_PROJECT }} - name: Setup OpenTofu uses: opentofu/setup-opentofu@9d84900f3238fab8cd84ce47d658d25dd008be2f # v1.0.8 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e6ba80eef..848674637 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -36,7 +36,7 @@ updates: - package-ecosystem: npm cooldown: - default-days: 1 + default-days: 7 directory: / schedule: interval: daily diff --git a/.github/workflows/cloudflare-web-deploy.yml b/.github/workflows/cloudflare-web-deploy.yml index 819851b05..413b7104a 100644 --- a/.github/workflows/cloudflare-web-deploy.yml +++ b/.github/workflows/cloudflare-web-deploy.yml @@ -9,8 +9,13 @@ on: - '.github/actions/setup/**' push: tags: - - 'sable/v*' + - 'v*' workflow_dispatch: + inputs: + git_tag: + description: 'Git tag to deploy (e.g. v1.2.3). Leave empty to deploy current HEAD.' + required: false + type: string env: CLOUDFLARE_API_TOKEN: ${{ secrets.TF_CLOUDFLARE_API_TOKEN }} @@ -49,6 +54,15 @@ jobs: - name: Prepare OpenTofu deployment uses: ./.github/actions/prepare-tofu + with: + is_release_tag: ${{ startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.git_tag != '') }} + env: + VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} + VITE_SENTRY_ENVIRONMENT: production + VITE_APP_VERSION: ${{ github.ref_name }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} - name: Comment PR plan uses: dflook/tofu-plan@3f5dc358343fb58cd60f83b019e810315aa8258f # v2.2.3 @@ -57,7 +71,7 @@ jobs: label: production apply: - if: (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/sable/v')) || github.event_name == 'workflow_dispatch' + if: (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest permissions: contents: read @@ -69,9 +83,19 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.git_tag || '' }} - name: Prepare OpenTofu deployment uses: ./.github/actions/prepare-tofu + with: + is_release_tag: ${{ startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.git_tag != '') }} + env: + VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} + VITE_SENTRY_ENVIRONMENT: production + VITE_APP_VERSION: ${{ github.ref_name }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} - name: Plan infrastructure run: tofu plan -input=false -no-color diff --git a/.github/workflows/cloudflare-web-preview.yml b/.github/workflows/cloudflare-web-preview.yml index 892fd8eab..8b93a4bb9 100644 --- a/.github/workflows/cloudflare-web-preview.yml +++ b/.github/workflows/cloudflare-web-preview.yml @@ -54,6 +54,22 @@ jobs: echo EOF } >> "$GITHUB_OUTPUT" + - name: Set Sentry build environment for PR preview + if: github.event_name == 'pull_request' + env: + VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + shell: bash + run: | + echo "VITE_SENTRY_DSN=$VITE_SENTRY_DSN" >> "$GITHUB_ENV" + echo "VITE_SENTRY_ENVIRONMENT=preview" >> "$GITHUB_ENV" + echo "VITE_SENTRY_PR=${{ github.event.pull_request.number }}" >> "$GITHUB_ENV" + echo "SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN" >> "$GITHUB_ENV" + echo "SENTRY_ORG=$SENTRY_ORG" >> "$GITHUB_ENV" + echo "SENTRY_PROJECT=$SENTRY_PROJECT" >> "$GITHUB_ENV" + - name: Setup app and build uses: ./.github/actions/setup with: @@ -66,7 +82,7 @@ jobs: if [ "${{ github.event_name }}" = "pull_request" ]; then echo "alias=pr-${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT" else - branch="${{ github.ref_name }}" + branch="${GITHUB_REF_NAME}" alias="$(echo "$branch" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-\|-$//g')" echo "alias=${alias}" >> "$GITHUB_OUTPUT" fi diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index f977ee2f6..03a63ef99 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -4,11 +4,17 @@ on: push: branches: [dev] tags: - - 'sable/v*' + - 'v*' pull_request: paths: - 'Dockerfile' - '.github/workflows/docker-publish.yml' + workflow_dispatch: + inputs: + git_tag: + description: 'Git tag to build and publish (e.g. v1.2.3). Leave empty to build current HEAD as a dev image.' + required: false + type: string env: REGISTRY: ghcr.io @@ -29,6 +35,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.git_tag || '' }} - name: Log in to GitHub Container Registry if: github.event_name != 'pull_request' @@ -42,8 +49,13 @@ jobs: id: release_tag shell: bash run: | - if [[ "${GITHUB_REF}" == refs/tags/sable/v* ]]; then - echo "value=${GITHUB_REF#refs/tags/sable/}" >> "$GITHUB_OUTPUT" + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + TAG="${{ inputs.git_tag }}" + else + TAG="${GITHUB_REF#refs/tags/}" + fi + if [[ "${TAG}" == v* ]]; then + echo "value=${TAG}" >> "$GITHUB_OUTPUT" echo "is_release=true" >> "$GITHUB_OUTPUT" else echo "value=" >> "$GITHUB_OUTPUT" @@ -58,11 +70,11 @@ jobs: flavor: | latest=false tags: | - # dev branch: short commit SHA + latest - type=sha,prefix=,format=short,enable=${{ github.ref == 'refs/heads/dev' }} - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/dev' }} + # dev branch or manual dispatch without a tag: short commit SHA + latest + type=sha,prefix=,format=short,enable=${{ github.ref == 'refs/heads/dev' || (github.event_name == 'workflow_dispatch' && inputs.git_tag == '') }} + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/dev' || (github.event_name == 'workflow_dispatch' && inputs.git_tag == '') }} - # git tags: semver breakdown + # git tags (push or manual dispatch with a tag): semver breakdown type=semver,pattern={{version}},value=${{ steps.release_tag.outputs.value }},enable=${{ steps.release_tag.outputs.is_release == 'true' }} type=semver,pattern={{major}}.{{minor}},value=${{ steps.release_tag.outputs.value }},enable=${{ steps.release_tag.outputs.is_release == 'true' }} type=semver,pattern={{major}},value=${{ steps.release_tag.outputs.value }},enable=${{ steps.release_tag.outputs.is_release == 'true' && !startsWith(steps.release_tag.outputs.value, 'v0.') }} diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index cfd494af7..e88da3a79 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -14,10 +14,18 @@ jobs: contents: write pull-requests: write steps: + - name: Generate bot token + id: generate-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 persist-credentials: true + token: ${{ steps.generate-token.outputs.token }} - uses: fregante/setup-git-user@024bc0b8e177d7e77203b48dab6fb45666854b35 # v2.0.2 @@ -28,12 +36,12 @@ jobs: - name: Prepare Release run: knope prepare-release --verbose env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} - name: Enrich changelog and update release PR uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: - github-token: ${{ secrets.GITHUB_TOKEN }} + github-token: ${{ steps.generate-token.outputs.token }} script: | const fs = require("fs"); const { owner, repo } = context.repo; diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml index 811c83a98..a9ac43708 100644 --- a/.github/workflows/quality-checks.yml +++ b/.github/workflows/quality-checks.yml @@ -4,11 +4,13 @@ on: pull_request: push: branches: [dev] + merge_group: jobs: format: name: Format check runs-on: ubuntu-latest + if: github.head_ref != 'release' permissions: contents: read steps: @@ -26,6 +28,7 @@ jobs: lint: name: Lint runs-on: ubuntu-latest + if: github.head_ref != 'release' permissions: contents: read steps: @@ -43,6 +46,7 @@ jobs: typecheck: name: Typecheck runs-on: ubuntu-latest + if: github.head_ref != 'release' permissions: contents: read steps: @@ -60,6 +64,7 @@ jobs: knip: name: Knip runs-on: ubuntu-latest + if: github.head_ref != 'release' permissions: contents: read steps: @@ -73,3 +78,38 @@ jobs: - name: Run Knip run: pnpm run knip + + tests: + name: Tests + runs-on: ubuntu-latest + if: github.head_ref != 'release' + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup app + uses: ./.github/actions/setup + + - name: Run tests + run: pnpm run test:run + + build: + name: Build + runs-on: ubuntu-latest + if: github.head_ref != 'release' + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup app and build + uses: ./.github/actions/setup + with: + build: 'true' diff --git a/.github/workflows/require-changeset.yml b/.github/workflows/require-changeset.yml index d01286639..17dfda277 100644 --- a/.github/workflows/require-changeset.yml +++ b/.github/workflows/require-changeset.yml @@ -3,6 +3,7 @@ name: Require Changeset on: pull_request: types: [opened, synchronize, reopened, labeled, unlabeled] + merge_group: branches: [dev] permissions: {} @@ -10,6 +11,7 @@ permissions: {} jobs: require-changeset: runs-on: ubuntu-latest + if: github.head_ref != 'release' && github.event_name != 'merge_group' permissions: contents: read pull-requests: write diff --git a/.github/workflows/sentry-preview-issues.yml b/.github/workflows/sentry-preview-issues.yml new file mode 100644 index 000000000..c81787e74 --- /dev/null +++ b/.github/workflows/sentry-preview-issues.yml @@ -0,0 +1,231 @@ +name: Sentry Preview Error Triage + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'src/**' + - 'index.html' + - 'package.json' + - 'vite.config.ts' + - 'tsconfig.json' + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to triage' + required: true + type: number + +jobs: + triage: + # Only run for PRs from the same repo (not forks) or manual dispatch + if: > + (github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository) || + github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - name: Triage Sentry preview errors + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + PR_NUMBER: ${{ github.event.pull_request.number || github.event.inputs.pr_number }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const sentryToken = process.env.SENTRY_AUTH_TOKEN; + const sentryOrg = process.env.SENTRY_ORG; + const sentryProject = process.env.SENTRY_PROJECT; + const prNumber = Number(process.env.PR_NUMBER); + + if (!prNumber) { + core.info('No PR number available — skipping triage.'); + return; + } + if (!sentryToken || !sentryOrg || !sentryProject) { + core.warning('Sentry credentials not configured — skipping triage.'); + return; + } + + const COMMENT_MARKER = ''; + const { owner, repo } = context.repo; + + // Create a label if it doesn't already exist + async function ensureLabel(name, description, color) { + try { + await github.rest.issues.getLabel({ owner, repo, name }); + } catch { + try { + await github.rest.issues.createLabel({ owner, repo, name, description, color }); + } catch (err) { + core.warning(`Could not create label "${name}": ${err.message}`); + } + } + } + + // Find an existing GitHub issue that tracks a given Sentry issue ID + async function findExistingGhIssue(sentryIssueId) { + const marker = `sentry-id:${sentryIssueId}`; + const result = await github.rest.search.issuesAndPullRequests({ + q: `repo:${owner}/${repo} is:issue label:sentry-preview "${marker}" in:body`, + }); + return result.data.total_count > 0 ? result.data.items[0] : null; + } + + // Create or update the sticky PR comment with the triage summary table + async function upsertPrComment(rows) { + const now = new Date().toUTCString().replace(':00 GMT', ' UTC'); + let body; + + if (rows.length === 0) { + body = [ + COMMENT_MARKER, + '## Sentry Preview Error Triage', + '', + `No Sentry errors found for this PR's preview deployment as of ${now}.`, + '', + '_This comment updates automatically after each push._', + ].join('\n'); + } else { + const tableRows = rows.map( + (r) => + `| [${r.title.slice(0, 70)}](${r.permalink}) | ${r.count} | ${new Date(r.firstSeen).toLocaleDateString()} | #${r.ghIssueNumber} |` + ); + body = [ + COMMENT_MARKER, + '## Sentry Preview Error Triage', + '', + `**${rows.length} error type(s)** detected in this PR's preview deployment:`, + '', + '| Error | Events | First seen | Issue |', + '| ----- | ------ | ---------- | ----- |', + ...tableRows, + '', + `_Last checked: ${now}. Exclude these from your issues view with \`-label:sentry-preview\`._`, + ].join('\n'); + } + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: prNumber, + }); + const existing = comments.find( + (c) => c.user.type === 'Bot' && c.body.includes(COMMENT_MARKER) + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body, + }); + } + } + + // Query Sentry for unresolved issues tagged with this PR number in the preview env + const query = encodeURIComponent(`is:unresolved pr:${prNumber}`); + const sentryUrl = + `https://sentry.io/api/0/projects/${sentryOrg}/${sentryProject}/issues/` + + `?query=${query}&environment=preview&limit=100`; + + let sentryIssues; + try { + const resp = await fetch(sentryUrl, { + headers: { Authorization: `Bearer ${sentryToken}` }, + }); + if (!resp.ok) { + const msg = await resp.text(); + core.warning(`Sentry API returned ${resp.status}: ${msg.slice(0, 200)}`); + return; + } + sentryIssues = await resp.json(); + } catch (err) { + core.warning(`Sentry API unreachable: ${err.message}`); + return; + } + + if (!Array.isArray(sentryIssues) || sentryIssues.length === 0) { + await upsertPrComment([]); + return; + } + + // Ensure the shared and PR-specific labels exist + await ensureLabel('sentry-preview', 'Automated Sentry preview error', 'e4e669'); + await ensureLabel(`pr-${prNumber}`, `Preview errors from PR #${prNumber}`, 'fbca04'); + + const rows = []; + for (const issue of sentryIssues) { + const { + id: sentryId, + title, + culprit, + permalink, + count, + userCount, + firstSeen, + lastSeen, + } = issue; + const displayTitle = (title || culprit || 'Unknown error').trim(); + const sentryMarker = `sentry-id:${sentryId}`; + + const existing = await findExistingGhIssue(sentryId); + let ghIssueNumber; + + if (existing) { + ghIssueNumber = existing.number; + // Reopen if it was closed (e.g. after a previous fix that regressed) + if (existing.state === 'closed') { + await github.rest.issues.update({ + owner, + repo, + issue_number: ghIssueNumber, + state: 'open', + }); + core.info(`Reopened GH issue #${ghIssueNumber} for Sentry issue ${sentryId}`); + } + } else { + const issueBody = [ + ``, + `## Sentry Error — PR #${prNumber} Preview`, + '', + `**Error:** [${displayTitle}](${permalink})`, + `**First seen:** ${new Date(firstSeen).toUTCString()}`, + `**Last seen:** ${new Date(lastSeen).toUTCString()}`, + `**Events:** ${count} | **Affected users:** ${userCount}`, + '', + `This issue was automatically created from a Sentry error detected in the preview deployment for PR #${prNumber}.`, + '', + '> [!NOTE]', + '> To exclude automated preview issues from your issues view, filter with: `-label:sentry-preview`', + ].join('\n'); + + const created = await github.rest.issues.create({ + owner, + repo, + title: `[Sentry] ${displayTitle.slice(0, 120)}`, + body: issueBody, + labels: ['sentry-preview', `pr-${prNumber}`], + }); + ghIssueNumber = created.data.number; + core.info(`Created GH issue #${ghIssueNumber} for Sentry issue ${sentryId}`); + } + + rows.push({ title: displayTitle, permalink, count, firstSeen, ghIssueNumber }); + } + + await upsertPrComment(rows); + core.info(`Triage complete: ${rows.length} Sentry issue(s) processed for PR #${prNumber}.`); diff --git a/.gitignore b/.gitignore index 414b4a31e..d6c83cfb1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ experiment dist +coverage node_modules devAssets @@ -18,6 +19,7 @@ devAssets *.tfbackend !*.tfbackend.example crash.log +build.sh # the following line was added with the "git ignore" tool by itsrye.dev, version 0.1.0 .lh diff --git a/CHANGELOG.md b/CHANGELOG.md index 76773dbf0..46b8a1989 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,329 +1,422 @@ -# Sable Client Changelog - -## 1.7.0 (2026-03-12) - -### Features - -* Added ability to start calls in DMs and rooms. DM calls will trigger a notification popup & ringtone (for other sable users/compatible clients, probably). -* Merge in upstream call things and remove the duplicate new voice room button. -* Add button to save a sticker you see in the message timeline to your personal account sticker pack. -* Added config option `hideUsernamePasswordFields` for hosts to hide username and password fields from login page. -* Add silent replies when clicking the bell icon during composing a reply. -* Device names are now dynamic, showing your browser and OS (e.g., "Sable on Firefox for Windows") instead of just "Sable Web". -* Implement an interface to allow room/space profile customization without needing to call the relating commands directly. -* Added hover menu inside Message Version Pop-out. - -### Fixes - -* Added a few accessibility tags to the elements involved in message composing. -* Clarify notification settings and functionality once and for all. -* Fix DM notifications, encrypted event notifications, and enable reaction notifications -* Fix images without an empty body display as "Broken Message" -* Prevent overly wide emotes from taking up the entire screen width. -* Change to more standard compliant msgtype `m.emote` for `/headpat` event. -* fix message forwarding metadata leak when forwarding from private rooms [see issue 190](https://github.com/SableClient/Sable/issues/190) -* "Underline Links" setting no longer affects the entire app, only links in chat, bios, and room descriptions. - -## 1.6.0 (2026-03-10) - -### Features - -* GitHub repo moved to [SableClient/Sable](https://github.com/SableClient/Sable) go star it! -* Added a pop-up for showing a message's edit history -* In-app bug report and feature request modal. -* Mentions now receive a full-width background highlight in the room timeline. - -* Adds a **Presence Status** toggle under Settings → General. - -* Rewrites the sliding sync implementation to match the Element Web approach (MSC4186). - -### Fixes - -* Enhance UnsupportedContent and BrokenContent to display message body. -* Notification settings page improvements. -* In-app notification banner placement fixes. -* Notification delivery bug fixes. -* Prevent multiple forwards of a message if sending is slow. - -## 1.5.3 (2026-03-08) - -### Fixes - -* Fix scroll clamping to bottom while scrolling up. -* Fix message links sometimes scrolling to bottom of timeline instead of message + maybe other scroll bugs. -* Merge upstream call fixes -* Fix crash when invalid location events are sent. -* Add rendering of per-message-profiles. -* custom emojis are now also visible in forwards, instead of being reduced to it's shortcode - -* fix: default badge unread counts to off - -## 1.5.2 (2026-03-08) - -### Fixes - -* Add `/hug`, `/cuddle`, `/wave`, `/headpat`, and `/poke` slash commands. -* Swap Caddy port to 8080 + fixes for MDAD setups. -* Adjust media sizing and URL preview layout -* Fix picture in picture setting not effecting element-call -* Fixed an issue where the app would fail to load after completing SSO login (e.g., logging in with matrix.org). Users are now correctly redirected to the app after SSO authentication completes. - -## 1.5.1 (2026-03-08) - -### Fixes - -* Fix recent emojis ignoring letter threshold. -* Disable in-app banners on desktop. - -## 1.5.0 (2026-03-08) - -### Features - -* Merge Voice Call updates from upstream. -* Allow for replying to state events. -* Add message forwarding with metadata -* Add setting to enable picture-in-picture in element-call -* Add support for audio and video in URL previews if homeserver provides it. -* Added a new setting "Emoji Selector Character Threshold" to set the number of characters to type before the suggestions pop up. -* Add keyboard navigation shortcuts for unread rooms (Alt+N, Alt+Shift+Up/Down), ARIA form label associations for screen reader accessibility, and a keyboard shortcuts settings page. -* Added setting to always underline links. -* Added settings for disabling autoplay of gifs (& banners), stickers, and emojis. -* Added reduced motion setting. Let us know if any elements were missed! -* Replaced the monochrome mode with a saturation slider for more control. -* Added settings to Account that let you set if you are a cat or have cats, or not display other peoples cat status. - -### Fixes - -* change indexdb warning phrasing to include low disk space as possible reason -* Fix Element Call video/audio calls in DM and non-voice rooms: after joining through the lobby, the in-call grid now displays correctly instead of showing only the control bar. -* Disable autocorrect and spellcheck on the login page. -* Fix Tuwunel quotes often breaking timezones -* Improved the UI of file descriptions -* Timeline message avatars now use the room-specific avatar and display name instead of the user's global profile, when set via `/myroomavatar` or `/myroomnick`. -* In-app notification banners now appear for DMs by default; desktop banner setting defaults to off; fixed space room navigation from banner tap. -* Executing /myroomnick or /myroomavatar without a new nickname/avatar now removes the nickname/avatar. -* Split typing and read status settings, allowing toggling off one and not the other. - -## 1.4.0 (2026-03-06) - -### Features - -* Add option to filter user pronouns based on the pronouns language -* Added a "Badge Counts for DMs Only" setting: when enabled, unread count numbers only appear on Direct Message room badges; non-DM rooms and spaces show a plain dot instead of a number, even when Show Unread Counts is on. -* Added the ability to add descriptions to uploaded files -* Fixed in-app notification banners in encrypted rooms showing "Encrypted Message" instead of the actual content. Banners now also render rich text (mentions, inline images) using the same pipeline as the timeline renderer. -* You can now remove your own reactions even if you don't have the permission to add new ones, as long as you are able to delete your own events (messages). -* Add a method of quickly adding a new text reaction to the latest message, just like emote quick react, using the prefix `+#` -* Added two toggles in Settings > Appearance > Identity for disabling rendering room/space fonts and colors. -* Added an additional toggle, Show Unread Ping Counts, to override the Show Unread Counts allowing for only pings to have counts. - -### Fixes - -* Rename gcolor, gfont, and gpronoun commands to scolor, sfont, and spronoun respectively. -* Improved emoji board performance by deferring image pack cache reads so the board opens instantly rather than blocking on the initial render. -* Fix dm room nicknames applying to non-dm private rooms. -* Hide delete modal after successfully deleting a message. -* Fixed media authentication handling: removed unnecessary redirect following, added error fallback UI for failed media loads, and ensured authentication headers are applied consistently when rendering inline media in HTML message bodies. -* Failed message retry and delete actions now use Chip buttons for visual consistency with the rest of the interface. -* Adds a new message pill and background app highlighted unread count. -* Mobile: changed scheduled send chevron to tap + hold -* Reply quotes now automatically retry decryption for E2EE messages, display a distinct placeholder for replies from blocked users, and fix edge cases where reply event loading could silently fail. -* Service worker push notifications now correctly deep-link to the right account and room on cold PWA launch. Notifications are automatically suppressed when the app window is already visible. The In-App (pill banner) and System (OS) notification settings are now independent: desktop shows both controls, mobile shows Push and In-App only. Tapping an in-app notification pill on mobile now opens the room timeline directly instead of routing through the space navigation panel. -* Fixed several room timeline issues with sliding sync: corrected event rendering order, more accurate scroll-to-bottom detection, phantom unread count clearing when the timeline is already at the bottom, and fixed pagination spinner state. -* In-app notification banners now appear for DMs by default, even without a mention or keyword match. -* Notification banner on desktop now defaults to off, consistent with push notification defaults. -* Fixed space room navigation when tapping an in-app notification banner for a room inside a space. - -## 1.3.3 - 3/4/2026 - -- Fix unread counts and dot badges for muted rooms ([#118](https://github.com/7w1/sable/pull/118)) - [Evie Gauthier](https://github.com/Just-Insane) -- /raw, /rawmsg, /rawacc, /delacc, /setext, /delext for modifying arbitrary data in various places. Do not use them if you don't know what they mean. It can break things. Locked behind developer tools settings. ([#120](https://github.com/7w1/sable/pull/120)) -- Quick reactions by typing +:emoji and hitting tab ([#132](https://github.com/7w1/sable/pull/132)) - [mini-bomba](https://github.com/mini-bomba) -- Add support for [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) scheduled messages on homeservers that support it ([#113](https://github.com/7w1/sable/pull/113)) -- Add /discardsession command to force discard e2ee session in current room ([#119](https://github.com/7w1/sable/issues/119), [#123](https://github.com/7w1/sable/pull/123)) -- Fix consistency of nicknames in dm rooms ([#122](https://github.com/7w1/sable/pull/122)) - [Rose](https://github.com/dozro) -- Message sending improvements, color change instead of "Sending..." message. ([#128](https://github.com/7w1/sable/pull/128)) - [Evie Gauthier](https://github.com/Just-Insane) -- Fix view source scroll bar. ([#125](https://github.com/7w1/sable/pull/125)) -- Added back Cinny Light theme as an option ([#80](https://github.com/7w1/sable/issues/80), [#126](https://github.com/7w1/sable/pull/126)) -- Fix auto capitalization in login screen ([#131](https://github.com/7w1/sable/pull/131)) - [Rose](https://github.com/dozro) -- Automated deployments with Cloudflare Workers IaC ([#116](https://github.com/7w1/sable/pull/116)) - [haz](https://github.com/hazre) -- Notification delivery, account switching, and unread count toggle fixes ([#127](https://github.com/7w1/sable/pull/127)) - [Evie Gauthier](https://github.com/Just-Insane) -- More sliding sync fixes: cache emoji packs and fix edit message rendering ([#134](https://github.com/7w1/sable/pull/134)) - [Evie Gauthier](https://github.com/Just-Insane) - -## 1.3.2 - 3/3/2026 - -- Content toggles in push notifications ([#88](https://github.com/7w1/sable/pull/88)) - [Evie Gauthier](https://github.com/Just-Insane) -- /rainbow command, supports markdown ([#105](https://github.com/7w1/sable/pull/105)) -- Settings interface consistency updates ([#89](https://github.com/7w1/sable/pull/89), [#97](https://github.com/7w1/sable/pull/97)) - [Rosy-iso](https://github.com/Rosy-iso) -- Display statuses ([#98](https://github.com/7w1/sable/pull/98)) - [Shea](https://github.com/nushea) -- Set statuses and improve member list status apperance ([#110](https://github.com/7w1/sable/pull/110)) -- More sliding sync bug fixes and improvements ([#87](https://github.com/7w1/sable/pull/87)) - [Evie Gauthier](https://github.com/Just-Insane) -- Replace `-#` small html tag with sub html tag to comply with spec. ([#90](https://github.com/7w1/sable/pull/90)) -- Update reset all notifications button styles to conform better. ([#100](https://github.com/7w1/sable/pull/100)) -- Fix user registration flow ([#101](https://github.com/7w1/sable/pull/101)) - [Evie Gauthier](https://github.com/Just-Insane) -- Add homeserver info to About page ([#84](https://github.com/7w1/sable/pull/84)) - [Rosy-iso](https://github.com/Rosy-iso) -- Add Accord theme, similar to another -cord ([#102](https://github.com/7w1/sable/pull/102)) - [kr0nst](https://github.com/kr0nst) -- Add Cinny Silver theme ([#80](https://github.com/7w1/sable/issues/80), [#108](https://github.com/7w1/sable/pull/108)) -- Potentially fix bio scroll appearing when it shouldn't ([#104](https://github.com/7w1/sable/pull/104)) -- Add /raw command to send raw message events ([#96](https://github.com/7w1/sable/issues/96), [#106](https://github.com/7w1/sable/pull/106)) -- Adds a reset button and changes the system sync button to text for clarity ([#103](https://github.com/7w1/sable/issues/103), [#107](https://github.com/7w1/sable/pull/107)) -- Fix logout flow to improve UX ([#111](https://github.com/7w1/sable/pull/111)) - -## 1.3.1 - 3/3/2026 - -- Important sliding sync config patches, notifications fixes, and client side toggle ([#85](https://github.com/7w1/sable/pull/85)) - -## 1.3.0 - 3/2/2026 - -- Mobile push notifications! ([#44](https://github.com/7w1/sable/issues/44), [#49](https://github.com/7w1/sable/pull/49)) - [Evie Gauthier](https://github.com/Just-Insane) -- Beta Simplified Sliding Sync support ([#67](https://github.com/7w1/sable/pull/67), [#75](https://github.com/7w1/sable/pull/75)) - [Evie Gauthier](https://github.com/Just-Insane) -- Codebase cleanups, CI improvements, and docker builds ([#26](https://github.com/7w1/sable/pull/26), [#35](https://github.com/7w1/sable/pull/35), [#62](https://github.com/7w1/sable/pull/62), [#64](https://github.com/7w1/sable/pull/64), [#65](https://github.com/7w1/sable/pull/65)) - [haz](https://github.com/hazre) -- Add room/space specific pronouns, when enabled by room/space admin. ([#30](https://github.com/7w1/sable/issues/30)) -- Add validation to timezones before rendering. -- Fix invalid matrix.to event link generation ([cinnyapp#2717](https://github.com/cinnyapp/cinny/pull/2717)) - [tulir](https://github.com/tulir) -- Fix Call Rooms' chat button ([#58](https://github.com/7w1/sable/pull/58)) - [Rosy-iso](https://github.com/Rosy-iso) -- Strip quotes for mxc urls converted to http for tuwunel ([#56](https://github.com/7w1/sable/pull/56)) - [Rosy-iso](https://github.com/Rosy-iso) -- Add Sable space and announcements room to featured communities. -- Unobfusticate css in production builds. - -## 1.2.3 - 3/2/2026 - -- Actually fix quotes around colors for tuwunel homeservers ([#46](https://github.com/7w1/sable/issues/46)) -- Option to have your own message bubbles in bubble layout right aligned ([#38](https://github.com/7w1/sable/issues/38)) -- Allow responding to and rendering replies with files ([#54](https://github.com/7w1/sable/pull/54)) - [nushea](https://github.com/nushea) -- Added Gruvbox theme ([#51](https://github.com/7w1/sable/pull/51)) - [dollth.ing](https://github.com/dollth-ing) - -## 1.2.2 v2 - -- hotfix for stupid firefox cors crash - -## 1.2.2 - 3/1/2026 - -- Fixed/updated unknown extended profile keys rendering. -- Added support for `---`, `-#`, and fixed `-` to be unordered. -- Fix quotes around colors for tuwunel homeservers ([#46](https://github.com/7w1/sable/issues/46)) -- Added Rosé Pine theme ([#41](https://github.com/7w1/sable/pull/41)) - [wrigglebug](https://github.com/wrigglebug) -- Add back default Cinny Dark theme. -- Merge time formatting improvements from ([cinnyapp#2710](https://github.com/cinnyapp/cinny/pull/2710)) - [nushea](https://github.com/nushea) -- Merge Uniform avatar appearance in space/room navigation from ([cinnyapp#2713](https://github.com/cinnyapp/cinny/pull/2713)) - [wolterkam](https://github.com/wolterkam) -- Merge Streamline the confusing DM invite user experience from ([cinnyapp#2709](https://github.com/cinnyapp/cinny/pull/2709)) - [wolterkam](https://github.com/wolterkam) - -## 1.2.1 - -- Update pronouns to match [MSC4247](https://github.com/matrix-org/matrix-spec-proposals/pull/4247) format better and support up to 3 pronoun pills on desktop, 1 on mobile ([#23](https://github.com/7w1/sable/issues/23), [#33](https://github.com/7w1/sable/pull/33)) - [ranidspace](https://github.com/ranidspace) - - Unfortunately, **everyone who set pronouns in Sable will need to reset them.** -- Fix jumbo-ified non-emojis with colons. ([#32](https://github.com/7w1/sable/issues/32)) -- Show full timestamps on hover. ([cinnyapp#2699](https://github.com/cinnyapp/cinny/issues/2699)) -- Enable Twitter-style emojis by default. -- Make inline editor buttons buttons. -- Name colors in pinned messages. -- Rename "Heating up" to "Petting cats" -- Concurrency guard for profile lookups. -- Hex color input for power level editor. -- Editing previous messages with keybinds no longer breaks message bar ([#36](https://github.com/7w1/sable/issues/36)) - -## 1.2.0 - -- Codebase cleanup ([#22](https://github.com/7w1/sable/pull/22)) - [haz](https://github.com/hazre) -- Fix mono font ([#18](https://github.com/7w1/sable/pull/18)) - [Alexia](https://github.com/cyrneko) -- Merge final commits from ([cinnyapp#2599](https://github.com/cinnyapp/cinny/pull/2599)) -- Unread pin counter & highlighting ([#25](https://github.com/7w1/sable/pull/25), [#31](https://github.com/7w1/sable/pull/31)) - -## 1.1.7 - -- Fix delete and report button colors. -- Fix modal backgrounds missing in some menus. -- Reply is now a toggle. When you click/swipe to reply to the message you're already replying to, it's reset. -- Option to hide member events in read-only rooms, like announcement channels, so you can actually read them. Enabled by default. -- Improvements to image and pdf viewers. Touch pan/zoom, scroll wheel zoom, and better reponsiveness. -- Fixed gestures occasionally triggering inside image and pdf viewer. - -## 1.1.6 - -- Fix crash if too many emojis present [cinnyapp#2570](https://github.com/cinnyapp/cinny/issues/2570) - -## Version 1.1.5 - -- Various performance improvements. See commits for details. -- Fix typing indicator z index on mobile. -- Fix room nicknames not displaying. -- Fix rare crash with colorMXID [(#15)](https://github.com/7w1/sable/pull/15) -- Fix crash from long pronoun pills [(#16)](https://github.com/7w1/sable/pull/16) - -## Version 1.1.4 - -- Various performance improvements -- Fix bio editor crashing when faced with commet bio format. - -## Version 1.1.3 - -_skipped a number since lots of updates :3_ - -- Profile banners. Support's Commet's format. -- Global name colors. Can be disabled in settings. -- Even more refractoring to improve timeline & user profile speeds and caches and fix that stupid bug. - - probably introduced more bugs tbh, but things should be faster and less intense on resources? -- Pinned messages actually jump to the damn pins instead of somewhere vaguely nearby half the time. - - that is... if they've been seen before. otherwise it just gives up sadly. -- Mobile context menu is opened by double tapping on a message instead of holding. -- ~~Fixed bio editor formatting.~~ This was a lie. -- Properly clear user data when account settings are updated. - -## Version 1.1.1 - -- More cache fixes and improvements. -- Fix flash of extra info when click on profiles sometimes. -- Added minimum width for widgit drawer. - -## Version 1.1.0 - -- Global profile cache & automatic cache clearing on member update events along with other improvements. -- Fix unexpected bio field formats. -- Widgets support. -- (potentially) fixed rare crash when ~~changing rooms~~ existing. please... - -## Version 1.0.1 - -- (potentially) fixed rare crash when changing rooms -- Support Commet bios. -- Raise bio limit to 1024 characters. -- Account switcher toggle in config.json. - -## Version 1.0.0 - -- Releases provided in releases tab. Versions for Windows & Linux built via electron. -- Account switcher made by TastelessVoid. PR #1 -- Gestures for mobile. -- Notifications jump to location instead of inbox. -- Merged voice & video calls from Cinny PR #2599. -- Client-side previews for some image and video formats. - - Will attempt to preview all image/video links in a message, if there are none, it generates a standard preview, forwarding the request to the homeserver. - - Bug fix for images/links/anything that loads after room is opened, properly shifting the scroll to the bottom. -- Client-side nicknames for other users. PR #3 -- Inline editor for editing messages. -- Pronouns, bios, and timezones. -- Pressing send on mobile no longer closes keyboard. - - Pressing enter on mobile will always provide a newline, ignores the setting for desktop. -- More reactive UI (literally just buttons shifting up and shrinking on click) -- Added name colors and fonts to the member list sidebar. -- Added a reset button to the room name input box for dms. - - Reset's the dm room name to the name of the other user (on both ends). - - Same as saving a blank room name. -- New UI colors & fonts. -- Pronoun pills. -- Updated legacy colors (aka random name colors) to no longer be legacy and now be pretty. -- Fixed background & header for PWA on iOS devices. -- Lighten on currently hovered message. -- Privacy blur options in Appearance tab in user settings. -- Jumbo emoji size selector in Appearance tab in user settings. -- Added Cosmetics tab to room and space settings. -- New cosmetic commands, requires power level 50. Permission located in Cosmetics tab. - - /color #ffffff -> change your name color for a room - - /gcolor #111111 -> change your name color for a space - - /font monospace -> change your name font for a room - - /font Courier New -> change your name font for a space -- Hierarchies - - Room Color > Space Color > Role Color > Default - - Room Font > Space Font > Default - - _Note, if the room font is invaild it will fallback directly to default, not the space font._ -- Includes all features in Cinny v4.10.5 \ No newline at end of file +# Sable Client Changelog + +## 1.9.3 (2026-03-17) + +### Fixes + +* Fix autocomplete Enter & Tab key always selecting the first item and the first item not being highlighted on open. ([#310](https://github.com/SableClient/Sable/pull/310) by @Just-Insane) +* Fix messages with body empty but formatted body filled rendering as empty. ([#337](https://github.com/SableClient/Sable/pull/337) by @7w1) +* Fix emoticon autocomplete not respecting character threshold setting. ([#337](https://github.com/SableClient/Sable/pull/337) by @7w1) +* Fix images without explicit dimensions not appearing. ([#338](https://github.com/SableClient/Sable/pull/338) by @7w1) +* Fix Mac OS to macOS in the the devices tab ([#328](https://github.com/SableClient/Sable/pull/328) by @DidiDidi129) +* Improved voice message recording UI, it should now feel a lot more integrated. ([#311](https://github.com/SableClient/Sable/pull/311) by @hazre) +* Add opt-in Sentry crash reporting with a consent banner. ([#333](https://github.com/SableClient/Sable/pull/333) by @Just-Insane) + +## 1.9.2 (2026-03-17) + +### Fixes + +* Fix opacity rendering in name colors. ([#325](https://github.com/SableClient/Sable/pull/325) by @7w1) +* Fix sending scheduled file attachments. ([#325](https://github.com/SableClient/Sable/pull/325) by @7w1) +* Fix replies rendering new lines when messages have lists. ([#325](https://github.com/SableClient/Sable/pull/325) by @7w1) +* Fix threads rendering fallback replies. ([#325](https://github.com/SableClient/Sable/pull/325) by @7w1) +* Remove pip video setting now that we have sable call ([#324](https://github.com/SableClient/Sable/pull/324) by @beef331) + +## 1.9.1 (2026-03-17) + +### Fixes + +* Fix docker builds. ([#322](https://github.com/SableClient/Sable/pull/322) by @7w1) + +## 1.9.0 (2026-03-17) + +### Features + +* Bring in Sable Call, our fork of element call, which introduces camera settings, screenshare settings, echo cancellation, noise suppression, automatic gain control, and avatars in calls. ([#127](https://github.com/SableClient/Sable/pull/127) by @melogale) +* added a `/sharehistory` command to [share encrypted history with a user](https://github.com/matrix-org/matrix-spec-proposals/blob/rav/proposal/encrypted_history_sharing/proposals/4268-encrypted-history-sharing.md) ([#296](https://github.com/SableClient/Sable/pull/296) by @dozro) +* added error page making it easier to report errors when they occur in the field ([#240](https://github.com/SableClient/Sable/pull/240) by @dozro) +* Push notifications now use `event_id_only` format — Sygnal never sees message content or sender metadata, and encrypted messages are decrypted client-side when the app tab is open ([#295](https://github.com/SableClient/Sable/pull/295) by @Just-Insane) +* Added a toggle to enable/disable showing the call button for large (> 10 member) rooms. ([#308](https://github.com/SableClient/Sable/pull/308) by @7w1) +* Add Sentry integration for error tracking and bug reporting ([#280](https://github.com/SableClient/Sable/pull/280) by @Just-Insane) +* Added the ability to edit the description of a file and streamlined the image and video ui ([#282](https://github.com/SableClient/Sable/pull/282) by @nushea) + +### Fixes + +* Add Ctrl+F / Cmd+F keyboard shortcut to open Sable search instead of browser find-in-page ([#304](https://github.com/SableClient/Sable/pull/304) by @Just-Insane) +* Add Vitest testing infrastructure with example tests and contributor documentation ([#297](https://github.com/SableClient/Sable/pull/297) by @Just-Insane) +* Account switcher: show a confirmation dialog before signing out of an account. ([#310](https://github.com/SableClient/Sable/pull/310) by @Just-Insane) +* Fix animated avatars not looping. ([#307](https://github.com/SableClient/Sable/pull/307) by @7w1) +* Autocomplete: pressing Enter now selects the highlighted item instead of sending the message. The first item is highlighted on open and ArrowUp/Down navigate the list while keeping typing focus in the editor. Focus returns to the message editor after completing a mention or emoji. ([#310](https://github.com/SableClient/Sable/pull/310) by @Just-Insane) +* Fix camera turning on by default when starting a call from the room header button ([#305](https://github.com/SableClient/Sable/pull/305) by @Just-Insane) +* Adding account: show a "Cancel" button next to the "Adding account" label so users can abort the flow. ([#310](https://github.com/SableClient/Sable/pull/310) by @Just-Insane) +* Fix duplicate unread badges on the /direct/ icon for DM rooms already shown as individual sidebar avatars ([#289](https://github.com/SableClient/Sable/pull/289) by @Just-Insane) +* Message editor: add `autoCapitalize="sentences"` to respect the OS/keyboard capitalisation setting on mobile. ([#310](https://github.com/SableClient/Sable/pull/310) by @Just-Insane) +* Fix emoji color bleeding into adjacent text in read receipt display names on Safari/WebKit ([#303](https://github.com/SableClient/Sable/pull/303) by @Just-Insane) +* Notifications: add "Favicon Dot: Mentions Only" setting — when enabled, the favicon badge only changes for mentions/keywords, not plain unreads. ([#310](https://github.com/SableClient/Sable/pull/310) by @Just-Insane) +* Support `matrixToBaseUrl` in `config.json` to override the default `matrix.to` link base URL. ([#314](https://github.com/SableClient/Sable/pull/314) by @Just-Insane) +* Video and audio messages: volume level is now persisted across page loads via `localStorage` and shared between all media players. ([#310](https://github.com/SableClient/Sable/pull/310) by @Just-Insane) +* Fix notification dot badge appearing off-center on sidebar avatars ([#306](https://github.com/SableClient/Sable/pull/306) by @Just-Insane) +* Reduced-motion: add `animation-iteration-count: 1` so spinners stop after one cycle instead of running indefinitely at near-zero speed. ([#310](https://github.com/SableClient/Sable/pull/310) by @Just-Insane) +* Server picker: prevent iOS from restoring the old server name while the user is actively editing the input. ([#310](https://github.com/SableClient/Sable/pull/310) by @Just-Insane) +* Browser tab/PWA: use the correct light (`#ffffff`) and dark (`#1b1a21`) theme-color values via `media` attribute on the meta tags. ([#310](https://github.com/SableClient/Sable/pull/310) by @Just-Insane) +* Fix excessive whitespace between the thread root message and replies in the thread drawer ([#302](https://github.com/SableClient/Sable/pull/302) by @Just-Insane) +* Fix thread messages to include the required `m.in_reply_to` fallback pointing to the latest thread event, so unthreaded clients can display the reply chain correctly per the Matrix spec. ([#288](https://github.com/SableClient/Sable/pull/288) by @Just-Insane) +* Fix spurious scroll-to-bottom and MaxListeners warnings on sync gap: stable callback refs and prevEventsLength guard in RoomTimeline, correct CallEmbed .bind(this) listener leak, stable refs in useCallSignaling, and unreadInfoRef to stop per-message listener churn ([#279](https://github.com/SableClient/Sable/pull/279) by @Just-Insane) +* Fix URL preview scroll arrows appearing when there is no content to scroll ([#301](https://github.com/SableClient/Sable/pull/301) by @Just-Insane) +* fix of compatibility of voice messages with element clients and style misshaps ([#286](https://github.com/SableClient/Sable/pull/286) by @dozro) + +## 1.8.0 (2026-03-14) + +### Features + +* add voice message composing ([#176](https://github.com/SableClient/Sable/pull/176) by @dozro) +* added error page making it easier to report errors when they occur in the field ([#240](https://github.com/SableClient/Sable/pull/240) by @dozro) +* Show group DM participants with triangle avatar layout. Group DMs now display up to 3 member avatars in a triangle formation (most recent sender on top), with bot filtering and DM count badge support. ([#212](https://github.com/SableClient/Sable/pull/212) by @Just-Insane) +* Add internal debug logging system with viewer UI, realtime updates, and instrumentation across sync, timeline, and messaging ([#245](https://github.com/SableClient/Sable/pull/245) by @Just-Insane) +* Add thread support with side panel, browser, unread badges, and cross-device sync ([#123](https://github.com/SableClient/Sable/pull/123) by @Just-Insane) +* Optimize sliding sync with progressive loading and improved timeline management ([#232](https://github.com/SableClient/Sable/pull/232) by @Just-Insane) + +### Fixes + +* added settings toggle in (General>Messages) to enable showing a tombstone for deleted messages without having to set all hidden events to visible ([#238](https://github.com/SableClient/Sable/pull/238) by @dozro) +* added for compatibility sake the forward meta data as defined in MSC2723 ([#257](https://github.com/SableClient/Sable/pull/257) by @dozro) +* disabling quick add for encrypted sticker, this mitigates the issue of being unable to use quick to add encrypted sticker ([#236](https://github.com/SableClient/Sable/pull/236) by @dozro) +* Fix badge positioning and alignment across all sidebar components ([#231](https://github.com/SableClient/Sable/pull/231) by @Just-Insane) +* Fix bubble layout messages overflowing off the screen with embeds/images. ([#237](https://github.com/SableClient/Sable/pull/237) by @7w1) +* Fixed unhandled promise rejections in media blob cache and added automatic retry for chunk loading failures after deployments. ([#255](https://github.com/SableClient/Sable/pull/255) by @Just-Insane) +* Fix notification handling with null safety and improved logic ([#233](https://github.com/SableClient/Sable/pull/233) by @Just-Insane) +* Fix cosmetics tab crashing if global/room/space pronouns weren't already set. ([#229](https://github.com/SableClient/Sable/pull/229) by @7w1) +* Fix reaction clicks, zoom persistence, and empty message rendering ([#234](https://github.com/SableClient/Sable/pull/234) by @Just-Insane) +* Fix call preferences not persisting. ([#273](https://github.com/SableClient/Sable/pull/273) by @7w1) +* Add width limit to notification banners ([#253](https://github.com/SableClient/Sable/pull/253) by @Vespe-r) +* removed forwarding of beeper's per message profile, as this might confuse clients ([#256](https://github.com/SableClient/Sable/pull/256) by @dozro) +* tweak emoji board for speed optimization (opt-in because of computational load increase on homeserver for thubmnail generation) ([#262](https://github.com/SableClient/Sable/pull/262) by @dozro) +* Handles a middle-click on the url preview card thumbnail image by downloading the full image from the homeserver proxy through a fetch request and opening received blob in the new tab ([#264](https://github.com/SableClient/Sable/pull/264) by @piko-piko) + +## 1.7.0 (2026-03-12) + +### Features + +* Added ability to start calls in DMs and rooms. DM calls will trigger a notification popup & ringtone (for other sable users/compatible clients, probably). ([#165](https://github.com/SableClient/Sable/pull/165) by @7w1) +* Merge in upstream call things and remove the duplicate new voice room button. ([#184](https://github.com/SableClient/Sable/pull/184) by @7w1) +* Add button to save a sticker you see in the message timeline to your personal account sticker pack. ([#107](https://github.com/SableClient/Sable/pull/107) by @dozro) +* Added config option `hideUsernamePasswordFields` for hosts to hide username and password fields from login page. ([#146](https://github.com/SableClient/Sable/pull/146) by @7w1) +* Add silent replies when clicking the bell icon during composing a reply. ([#153](https://github.com/SableClient/Sable/pull/153) by @dozro) +* Device names are now dynamic, showing your browser and OS (e.g., "Sable on Firefox for Windows") instead of just "Sable Web". ([#187](https://github.com/SableClient/Sable/pull/187) by @hazre) +* Implement an interface to allow room/space profile customization without needing to call the relating commands directly. ([#129](https://github.com/SableClient/Sable/pull/129) by @Rosy-iso) +* Added hover menu inside Message Version Pop-out. ([#170](https://github.com/SableClient/Sable/pull/170) by @nushea) + +### Fixes + +* Added a few accessibility tags to the elements involved in message composing. ([#163](https://github.com/SableClient/Sable/pull/163) by @dozro) +* Clarify notification settings and functionality once and for all. ([#148](https://github.com/SableClient/Sable/pull/148) by @7w1) +* Fix DM notifications, encrypted event notifications, and enable reaction notifications ([#178](https://github.com/SableClient/Sable/pull/178) by @Just-Insane) +* Fix images without an empty body display as "Broken Message" ([#143](https://github.com/SableClient/Sable/pull/143) by @7w1) +* Prevent overly wide emotes from taking up the entire screen width. ([#164](https://github.com/SableClient/Sable/pull/164) by @Sugaryyyy) +* Change to more standard compliant msgtype `m.emote` for `/headpat` event. ([#145](https://github.com/SableClient/Sable/pull/145) by @dozro) +* fix message forwarding metadata leak when forwarding from private rooms [see issue 190](https://github.com/SableClient/Sable/issues/190) ([#191](https://github.com/SableClient/Sable/pull/191) by @dozro) +* "Underline Links" setting no longer affects the entire app, only links in chat, bios, and room descriptions. ([#157](https://github.com/SableClient/Sable/pull/157) by @hazre) + +## 1.6.0 (2026-03-10) + +### Features + +* GitHub repo moved to [SableClient/Sable](https://github.com/SableClient/Sable) go star it! +* Added a pop-up for showing a message's edit history +* In-app bug report and feature request modal. +* Mentions now receive a full-width background highlight in the room timeline. + +* Adds a **Presence Status** toggle under Settings → General. + +* Rewrites the sliding sync implementation to match the Element Web approach (MSC4186). + +### Fixes + +* Enhance UnsupportedContent and BrokenContent to display message body. +* Notification settings page improvements. +* In-app notification banner placement fixes. +* Notification delivery bug fixes. +* Prevent multiple forwards of a message if sending is slow. + +## 1.5.3 (2026-03-08) + +### Fixes + +* Fix scroll clamping to bottom while scrolling up. +* Fix message links sometimes scrolling to bottom of timeline instead of message + maybe other scroll bugs. +* Merge upstream call fixes +* Fix crash when invalid location events are sent. +* Add rendering of per-message-profiles. +* custom emojis are now also visible in forwards, instead of being reduced to it's shortcode + +* fix: default badge unread counts to off + +## 1.5.2 (2026-03-08) + +### Fixes + +* Add `/hug`, `/cuddle`, `/wave`, `/headpat`, and `/poke` slash commands. +* Swap Caddy port to 8080 + fixes for MDAD setups. +* Adjust media sizing and URL preview layout +* Fix picture in picture setting not effecting element-call +* Fixed an issue where the app would fail to load after completing SSO login (e.g., logging in with matrix.org). Users are now correctly redirected to the app after SSO authentication completes. + +## 1.5.1 (2026-03-08) + +### Fixes + +* Fix recent emojis ignoring letter threshold. +* Disable in-app banners on desktop. + +## 1.5.0 (2026-03-08) + +### Features + +* Merge Voice Call updates from upstream. +* Allow for replying to state events. +* Add message forwarding with metadata +* Add setting to enable picture-in-picture in element-call +* Add support for audio and video in URL previews if homeserver provides it. +* Added a new setting "Emoji Selector Character Threshold" to set the number of characters to type before the suggestions pop up. +* Add keyboard navigation shortcuts for unread rooms (Alt+N, Alt+Shift+Up/Down), ARIA form label associations for screen reader accessibility, and a keyboard shortcuts settings page. +* Added setting to always underline links. +* Added settings for disabling autoplay of gifs (& banners), stickers, and emojis. +* Added reduced motion setting. Let us know if any elements were missed! +* Replaced the monochrome mode with a saturation slider for more control. +* Added settings to Account that let you set if you are a cat or have cats, or not display other peoples cat status. + +### Fixes + +* change indexdb warning phrasing to include low disk space as possible reason +* Fix Element Call video/audio calls in DM and non-voice rooms: after joining through the lobby, the in-call grid now displays correctly instead of showing only the control bar. +* Disable autocorrect and spellcheck on the login page. +* Fix Tuwunel quotes often breaking timezones +* Improved the UI of file descriptions +* Timeline message avatars now use the room-specific avatar and display name instead of the user's global profile, when set via `/myroomavatar` or `/myroomnick`. +* In-app notification banners now appear for DMs by default; desktop banner setting defaults to off; fixed space room navigation from banner tap. +* Executing /myroomnick or /myroomavatar without a new nickname/avatar now removes the nickname/avatar. +* Split typing and read status settings, allowing toggling off one and not the other. + +## 1.4.0 (2026-03-06) + +### Features + +* Add option to filter user pronouns based on the pronouns language +* Added a "Badge Counts for DMs Only" setting: when enabled, unread count numbers only appear on Direct Message room badges; non-DM rooms and spaces show a plain dot instead of a number, even when Show Unread Counts is on. +* Added the ability to add descriptions to uploaded files +* Fixed in-app notification banners in encrypted rooms showing "Encrypted Message" instead of the actual content. Banners now also render rich text (mentions, inline images) using the same pipeline as the timeline renderer. +* You can now remove your own reactions even if you don't have the permission to add new ones, as long as you are able to delete your own events (messages). +* Add a method of quickly adding a new text reaction to the latest message, just like emote quick react, using the prefix `+#` +* Added two toggles in Settings > Appearance > Identity for disabling rendering room/space fonts and colors. +* Added an additional toggle, Show Unread Ping Counts, to override the Show Unread Counts allowing for only pings to have counts. + +### Fixes + +* Rename gcolor, gfont, and gpronoun commands to scolor, sfont, and spronoun respectively. +* Improved emoji board performance by deferring image pack cache reads so the board opens instantly rather than blocking on the initial render. +* Fix dm room nicknames applying to non-dm private rooms. +* Hide delete modal after successfully deleting a message. +* Fixed media authentication handling: removed unnecessary redirect following, added error fallback UI for failed media loads, and ensured authentication headers are applied consistently when rendering inline media in HTML message bodies. +* Failed message retry and delete actions now use Chip buttons for visual consistency with the rest of the interface. +* Adds a new message pill and background app highlighted unread count. +* Mobile: changed scheduled send chevron to tap + hold +* Reply quotes now automatically retry decryption for E2EE messages, display a distinct placeholder for replies from blocked users, and fix edge cases where reply event loading could silently fail. +* Service worker push notifications now correctly deep-link to the right account and room on cold PWA launch. Notifications are automatically suppressed when the app window is already visible. The In-App (pill banner) and System (OS) notification settings are now independent: desktop shows both controls, mobile shows Push and In-App only. Tapping an in-app notification pill on mobile now opens the room timeline directly instead of routing through the space navigation panel. +* Fixed several room timeline issues with sliding sync: corrected event rendering order, more accurate scroll-to-bottom detection, phantom unread count clearing when the timeline is already at the bottom, and fixed pagination spinner state. +* In-app notification banners now appear for DMs by default, even without a mention or keyword match. +* Notification banner on desktop now defaults to off, consistent with push notification defaults. +* Fixed space room navigation when tapping an in-app notification banner for a room inside a space. + +## 1.3.3 - 3/4/2026 + +- Fix unread counts and dot badges for muted rooms ([#118](https://github.com/7w1/sable/pull/118)) - [Evie Gauthier](https://github.com/Just-Insane) +- /raw, /rawmsg, /rawacc, /delacc, /setext, /delext for modifying arbitrary data in various places. Do not use them if you don't know what they mean. It can break things. Locked behind developer tools settings. ([#120](https://github.com/7w1/sable/pull/120)) +- Quick reactions by typing +:emoji and hitting tab ([#132](https://github.com/7w1/sable/pull/132)) - [mini-bomba](https://github.com/mini-bomba) +- Add support for [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) scheduled messages on homeservers that support it ([#113](https://github.com/7w1/sable/pull/113)) +- Add /discardsession command to force discard e2ee session in current room ([#119](https://github.com/7w1/sable/issues/119), [#123](https://github.com/7w1/sable/pull/123)) +- Fix consistency of nicknames in dm rooms ([#122](https://github.com/7w1/sable/pull/122)) - [Rose](https://github.com/dozro) +- Message sending improvements, color change instead of "Sending..." message. ([#128](https://github.com/7w1/sable/pull/128)) - [Evie Gauthier](https://github.com/Just-Insane) +- Fix view source scroll bar. ([#125](https://github.com/7w1/sable/pull/125)) +- Added back Cinny Light theme as an option ([#80](https://github.com/7w1/sable/issues/80), [#126](https://github.com/7w1/sable/pull/126)) +- Fix auto capitalization in login screen ([#131](https://github.com/7w1/sable/pull/131)) - [Rose](https://github.com/dozro) +- Automated deployments with Cloudflare Workers IaC ([#116](https://github.com/7w1/sable/pull/116)) - [haz](https://github.com/hazre) +- Notification delivery, account switching, and unread count toggle fixes ([#127](https://github.com/7w1/sable/pull/127)) - [Evie Gauthier](https://github.com/Just-Insane) +- More sliding sync fixes: cache emoji packs and fix edit message rendering ([#134](https://github.com/7w1/sable/pull/134)) - [Evie Gauthier](https://github.com/Just-Insane) + +## 1.3.2 - 3/3/2026 + +- Content toggles in push notifications ([#88](https://github.com/7w1/sable/pull/88)) - [Evie Gauthier](https://github.com/Just-Insane) +- /rainbow command, supports markdown ([#105](https://github.com/7w1/sable/pull/105)) +- Settings interface consistency updates ([#89](https://github.com/7w1/sable/pull/89), [#97](https://github.com/7w1/sable/pull/97)) - [Rosy-iso](https://github.com/Rosy-iso) +- Display statuses ([#98](https://github.com/7w1/sable/pull/98)) - [Shea](https://github.com/nushea) +- Set statuses and improve member list status apperance ([#110](https://github.com/7w1/sable/pull/110)) +- More sliding sync bug fixes and improvements ([#87](https://github.com/7w1/sable/pull/87)) - [Evie Gauthier](https://github.com/Just-Insane) +- Replace `-#` small html tag with sub html tag to comply with spec. ([#90](https://github.com/7w1/sable/pull/90)) +- Update reset all notifications button styles to conform better. ([#100](https://github.com/7w1/sable/pull/100)) +- Fix user registration flow ([#101](https://github.com/7w1/sable/pull/101)) - [Evie Gauthier](https://github.com/Just-Insane) +- Add homeserver info to About page ([#84](https://github.com/7w1/sable/pull/84)) - [Rosy-iso](https://github.com/Rosy-iso) +- Add Accord theme, similar to another -cord ([#102](https://github.com/7w1/sable/pull/102)) - [kr0nst](https://github.com/kr0nst) +- Add Cinny Silver theme ([#80](https://github.com/7w1/sable/issues/80), [#108](https://github.com/7w1/sable/pull/108)) +- Potentially fix bio scroll appearing when it shouldn't ([#104](https://github.com/7w1/sable/pull/104)) +- Add /raw command to send raw message events ([#96](https://github.com/7w1/sable/issues/96), [#106](https://github.com/7w1/sable/pull/106)) +- Adds a reset button and changes the system sync button to text for clarity ([#103](https://github.com/7w1/sable/issues/103), [#107](https://github.com/7w1/sable/pull/107)) +- Fix logout flow to improve UX ([#111](https://github.com/7w1/sable/pull/111)) + +## 1.3.1 - 3/3/2026 + +- Important sliding sync config patches, notifications fixes, and client side toggle ([#85](https://github.com/7w1/sable/pull/85)) + +## 1.3.0 - 3/2/2026 + +- Mobile push notifications! ([#44](https://github.com/7w1/sable/issues/44), [#49](https://github.com/7w1/sable/pull/49)) - [Evie Gauthier](https://github.com/Just-Insane) +- Beta Simplified Sliding Sync support ([#67](https://github.com/7w1/sable/pull/67), [#75](https://github.com/7w1/sable/pull/75)) - [Evie Gauthier](https://github.com/Just-Insane) +- Codebase cleanups, CI improvements, and docker builds ([#26](https://github.com/7w1/sable/pull/26), [#35](https://github.com/7w1/sable/pull/35), [#62](https://github.com/7w1/sable/pull/62), [#64](https://github.com/7w1/sable/pull/64), [#65](https://github.com/7w1/sable/pull/65)) - [haz](https://github.com/hazre) +- Add room/space specific pronouns, when enabled by room/space admin. ([#30](https://github.com/7w1/sable/issues/30)) +- Add validation to timezones before rendering. +- Fix invalid matrix.to event link generation ([cinnyapp#2717](https://github.com/cinnyapp/cinny/pull/2717)) - [tulir](https://github.com/tulir) +- Fix Call Rooms' chat button ([#58](https://github.com/7w1/sable/pull/58)) - [Rosy-iso](https://github.com/Rosy-iso) +- Strip quotes for mxc urls converted to http for tuwunel ([#56](https://github.com/7w1/sable/pull/56)) - [Rosy-iso](https://github.com/Rosy-iso) +- Add Sable space and announcements room to featured communities. +- Unobfusticate css in production builds. + +## 1.2.3 - 3/2/2026 + +- Actually fix quotes around colors for tuwunel homeservers ([#46](https://github.com/7w1/sable/issues/46)) +- Option to have your own message bubbles in bubble layout right aligned ([#38](https://github.com/7w1/sable/issues/38)) +- Allow responding to and rendering replies with files ([#54](https://github.com/7w1/sable/pull/54)) - [nushea](https://github.com/nushea) +- Added Gruvbox theme ([#51](https://github.com/7w1/sable/pull/51)) - [dollth.ing](https://github.com/dollth-ing) + +## 1.2.2 v2 + +- hotfix for stupid firefox cors crash + +## 1.2.2 - 3/1/2026 + +- Fixed/updated unknown extended profile keys rendering. +- Added support for `---`, `-#`, and fixed `-` to be unordered. +- Fix quotes around colors for tuwunel homeservers ([#46](https://github.com/7w1/sable/issues/46)) +- Added Rosé Pine theme ([#41](https://github.com/7w1/sable/pull/41)) - [wrigglebug](https://github.com/wrigglebug) +- Add back default Cinny Dark theme. +- Merge time formatting improvements from ([cinnyapp#2710](https://github.com/cinnyapp/cinny/pull/2710)) - [nushea](https://github.com/nushea) +- Merge Uniform avatar appearance in space/room navigation from ([cinnyapp#2713](https://github.com/cinnyapp/cinny/pull/2713)) - [wolterkam](https://github.com/wolterkam) +- Merge Streamline the confusing DM invite user experience from ([cinnyapp#2709](https://github.com/cinnyapp/cinny/pull/2709)) - [wolterkam](https://github.com/wolterkam) + +## 1.2.1 + +- Update pronouns to match [MSC4247](https://github.com/matrix-org/matrix-spec-proposals/pull/4247) format better and support up to 3 pronoun pills on desktop, 1 on mobile ([#23](https://github.com/7w1/sable/issues/23), [#33](https://github.com/7w1/sable/pull/33)) - [ranidspace](https://github.com/ranidspace) + - Unfortunately, **everyone who set pronouns in Sable will need to reset them.** +- Fix jumbo-ified non-emojis with colons. ([#32](https://github.com/7w1/sable/issues/32)) +- Show full timestamps on hover. ([cinnyapp#2699](https://github.com/cinnyapp/cinny/issues/2699)) +- Enable Twitter-style emojis by default. +- Make inline editor buttons buttons. +- Name colors in pinned messages. +- Rename "Heating up" to "Petting cats" +- Concurrency guard for profile lookups. +- Hex color input for power level editor. +- Editing previous messages with keybinds no longer breaks message bar ([#36](https://github.com/7w1/sable/issues/36)) + +## 1.2.0 + +- Codebase cleanup ([#22](https://github.com/7w1/sable/pull/22)) - [haz](https://github.com/hazre) +- Fix mono font ([#18](https://github.com/7w1/sable/pull/18)) - [Alexia](https://github.com/cyrneko) +- Merge final commits from ([cinnyapp#2599](https://github.com/cinnyapp/cinny/pull/2599)) +- Unread pin counter & highlighting ([#25](https://github.com/7w1/sable/pull/25), [#31](https://github.com/7w1/sable/pull/31)) + +## 1.1.7 + +- Fix delete and report button colors. +- Fix modal backgrounds missing in some menus. +- Reply is now a toggle. When you click/swipe to reply to the message you're already replying to, it's reset. +- Option to hide member events in read-only rooms, like announcement channels, so you can actually read them. Enabled by default. +- Improvements to image and pdf viewers. Touch pan/zoom, scroll wheel zoom, and better reponsiveness. +- Fixed gestures occasionally triggering inside image and pdf viewer. + +## 1.1.6 + +- Fix crash if too many emojis present [cinnyapp#2570](https://github.com/cinnyapp/cinny/issues/2570) + +## Version 1.1.5 + +- Various performance improvements. See commits for details. +- Fix typing indicator z index on mobile. +- Fix room nicknames not displaying. +- Fix rare crash with colorMXID [(#15)](https://github.com/7w1/sable/pull/15) +- Fix crash from long pronoun pills [(#16)](https://github.com/7w1/sable/pull/16) + +## Version 1.1.4 + +- Various performance improvements +- Fix bio editor crashing when faced with commet bio format. + +## Version 1.1.3 + +_skipped a number since lots of updates :3_ + +- Profile banners. Support's Commet's format. +- Global name colors. Can be disabled in settings. +- Even more refractoring to improve timeline & user profile speeds and caches and fix that stupid bug. + - probably introduced more bugs tbh, but things should be faster and less intense on resources? +- Pinned messages actually jump to the damn pins instead of somewhere vaguely nearby half the time. + - that is... if they've been seen before. otherwise it just gives up sadly. +- Mobile context menu is opened by double tapping on a message instead of holding. +- ~~Fixed bio editor formatting.~~ This was a lie. +- Properly clear user data when account settings are updated. + +## Version 1.1.1 + +- More cache fixes and improvements. +- Fix flash of extra info when click on profiles sometimes. +- Added minimum width for widgit drawer. + +## Version 1.1.0 + +- Global profile cache & automatic cache clearing on member update events along with other improvements. +- Fix unexpected bio field formats. +- Widgets support. +- (potentially) fixed rare crash when ~~changing rooms~~ existing. please... + +## Version 1.0.1 + +- (potentially) fixed rare crash when changing rooms +- Support Commet bios. +- Raise bio limit to 1024 characters. +- Account switcher toggle in config.json. + +## Version 1.0.0 + +- Releases provided in releases tab. Versions for Windows & Linux built via electron. +- Account switcher made by TastelessVoid. PR #1 +- Gestures for mobile. +- Notifications jump to location instead of inbox. +- Merged voice & video calls from Cinny PR #2599. +- Client-side previews for some image and video formats. + - Will attempt to preview all image/video links in a message, if there are none, it generates a standard preview, forwarding the request to the homeserver. + - Bug fix for images/links/anything that loads after room is opened, properly shifting the scroll to the bottom. +- Client-side nicknames for other users. PR #3 +- Inline editor for editing messages. +- Pronouns, bios, and timezones. +- Pressing send on mobile no longer closes keyboard. + - Pressing enter on mobile will always provide a newline, ignores the setting for desktop. +- More reactive UI (literally just buttons shifting up and shrinking on click) +- Added name colors and fonts to the member list sidebar. +- Added a reset button to the room name input box for dms. + - Reset's the dm room name to the name of the other user (on both ends). + - Same as saving a blank room name. +- New UI colors & fonts. +- Pronoun pills. +- Updated legacy colors (aka random name colors) to no longer be legacy and now be pretty. +- Fixed background & header for PWA on iOS devices. +- Lighten on currently hovered message. +- Privacy blur options in Appearance tab in user settings. +- Jumbo emoji size selector in Appearance tab in user settings. +- Added Cosmetics tab to room and space settings. +- New cosmetic commands, requires power level 50. Permission located in Cosmetics tab. + - /color #ffffff -> change your name color for a room + - /gcolor #111111 -> change your name color for a space + - /font monospace -> change your name font for a room + - /font Courier New -> change your name font for a space +- Hierarchies + - Room Color > Space Color > Role Color > Default + - Room Font > Space Font > Default + - _Note, if the room font is invaild it will fallback directly to default, not the space font._ +- Includes all features in Cinny v4.10.5 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 37a7e9c8b..adbdab882 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -60,7 +60,7 @@ representative at an online or offline event. Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at -cinnyapp@gmail.com. +executioner@sable.moe. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9bf1e3cae..c417e2071 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,12 +37,15 @@ It is not always possible to phrase every change in such a manner, but it is des Also, we use [ESLint](https://eslint.org/) for clean and stylistically consistent code syntax, so make sure your pull request follow it. -**Pull requests are not merged unless all quality checks are passing.** At minimum, `format`, `lint`, `typecheck`, and `knip` must all be green before a pull request can be merged. Run these locally before opening or updating a pull request: +**Pull requests are not merged unless all quality checks are passing.** At minimum, `format`, `lint`, `typecheck`, `knip`, and `tests` must all be green before a pull request can be merged. Run these locally before opening or updating a pull request: - `pnpm run fmt:check` - `pnpm run lint` - `pnpm run typecheck` - `pnpm run knip` +- `pnpm run test:run` + +If your change touches logic with testable behaviour, please include tests. See [docs/TESTING.md](./docs/TESTING.md) for a guide on how to write them. ## Restrictions on Generative AI Usage diff --git a/Caddyfile b/Caddyfile index d807e8c2b..63fec50e6 100644 --- a/Caddyfile +++ b/Caddyfile @@ -15,4 +15,7 @@ } try_files {path} /index.html + + # Required for Sentry browser profiling (JS Self-Profiling API) + header Document-Policy "js-profiling" } diff --git a/contrib/nginx/cinny.domain.tld.conf b/contrib/nginx/cinny.domain.tld.conf index 02c7ead9f..9dcdbb4b7 100644 --- a/contrib/nginx/cinny.domain.tld.conf +++ b/contrib/nginx/cinny.domain.tld.conf @@ -20,6 +20,9 @@ server { location / { root /opt/cinny/dist/; + # Required for Sentry browser profiling (JS Self-Profiling API) + add_header Document-Policy "js-profiling" always; + rewrite ^/config.json$ /config.json break; rewrite ^/manifest.json$ /manifest.json break; diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md new file mode 100644 index 000000000..aa300dae4 --- /dev/null +++ b/docs/PRIVACY.md @@ -0,0 +1,122 @@ +# Privacy Policy + +**Effective date:** 2026-03-15 + +Sable is an open-source Matrix client developed by 7w1. + +It is designed to keep data collection to a minimum. Most of the app works on your device and communicates directly with the Matrix homeserver you choose. + +## Who is responsible + +For official Sable builds distributed by the project, the data controller is **7w1**. + +Contact: **privacy@sable.moe** +Project website: [**https://sable.moe**](https://sable.moe) + +If you use a self-hosted, modified, or third-party build of Sable, that operator may use different diagnostics settings and may be responsible for their own privacy practices. + +## What we collect + +We only collect limited diagnostic data to help find bugs and improve the stability and security of the app. + +Diagnostic data is sent only when error reporting is enabled. + +This data may include: + +- Crash and error details, such as exception type, stack trace, and error message +- Device, browser, or operating system name and version +- Application version and environment +- Anonymous performance information, such as page load, sync, or message-send timing + +Before any diagnostic data is sent, sensitive values are scrubbed in the browser on your device. + +## What we do not collect + +Sable is designed not to collect or transmit: + +- Matrix message content +- Room names or aliases +- User display names or avatars +- Contact lists or room member lists +- Authentication tokens or passwords +- Encryption keys or cryptographic session data +- IP addresses +- Precise or approximate location data + +## Optional features + +### Session replay + +Session replay may be available for debugging, but it is **disabled by default** and must be turned on by the user. + +When session replay is enabled, all text is masked, media is blocked, and form inputs are masked before any data leaves the device. + +This is intended to ensure that Matrix messages, room names, user names, and other personal content are not visible in replays. + +### Bug reports + +You may choose to submit a bug report from within the app. + +A bug report may include the description you write, platform and app version details, and optional diagnostic logs that you choose to attach. + +Submitting a bug report is voluntary, and the app shows what will be sent before submission. + +## Third-party services + +Sable uses **Sentry** for crash reporting and performance diagnostics. + +Sentry receives only the diagnostic data described in this policy. + +Sentry handles that data under its own privacy policy: +[**https://sentry.io/privacy/**](https://sentry.io/privacy/) + +Technical details about Sable's Sentry integration are documented here: +[**https://github.com/SableClient/Sable/dev/docs/SENTRY_PRIVACY.md**](https://github.com/SableClient/Sable/dev/docs/SENTRY_PRIVACY.md) + +If a Sentry DSN is not configured, Sentry is inactive and no Sentry data is sent. + +Self-hosted deployments may use a different Sentry instance or disable diagnostics entirely. + +## Your controls + +You can manage diagnostic features in: **Settings → General → Diagnostics & Privacy** + +Depending on the build, you can disable error reporting, enable or disable session replay, and adjust breadcrumb categories. + +### First-time consent notice + +When a build has crash reporting configured, a notice appears the first time you open Sable. It explains that Sable can send anonymous crash reports to help fix bugs, and gives you the option to enable it. Dismissing the notice without enabling keeps crash reporting off. + +This notice only appears once. Your choice is saved and can be changed at any time in **Settings → General → Diagnostics & Privacy**. + +You can also stop all app-based data transmission by uninstalling the app. + +## Legal basis + +For users in the European Economic Area, diagnostic data is processed on the basis of legitimate interest for app reliability and security, and on the basis of consent where optional features such as session replay are explicitly enabled. + +## Retention and transfers + +Diagnostic data is stored by Sentry according to the retention settings of the Sentry project. + +The Sable project does not keep a separate copy of that diagnostic data. + +Because Sentry is a cloud service, diagnostic data may be processed outside your country of residence. Sentry states that it provides safeguards such as Standard Contractual Clauses where required. + +## Children + +Sable is not directed to children under 13. + +We do not knowingly collect personal information from children through the app. + +If you believe a child has submitted information through Sable, contact **privacy@sable.moe** so it can be removed. + +## Changes to this policy + +We may update this Privacy Policy from time to time. + +When we do, we will publish the updated version at [**https://github.com/SableClient/Sable/blob/dev/docs/PRIVACY.md**](https://github.com/SableClient/Sable/blob/dev/docs/PRIVACY.md) and/or [**https://sable.moe**](https://sable.moe). + +## Contact + +If you have questions about this Privacy Policy or want to request deletion of data connected to a bug report, contact **privacy@sable.moe**. diff --git a/docs/SENTRY_INTEGRATION.md b/docs/SENTRY_INTEGRATION.md new file mode 100644 index 000000000..df5511e57 --- /dev/null +++ b/docs/SENTRY_INTEGRATION.md @@ -0,0 +1,492 @@ +# Sentry Integration for Sable + +This document describes the Sentry error tracking and monitoring integration added to Sable. +For a detailed breakdown of what data is collected and how it is protected, see [SENTRY_PRIVACY.md](./SENTRY_PRIVACY.md). + +## Overview + +Sentry is integrated with Sable to provide: + +- **Error tracking**: Automatic capture and reporting of errors and exceptions +- **Performance monitoring**: Track application performance and identify bottlenecks +- **User feedback**: Collect bug reports with context from users +- **Session replay**: Record user sessions (with privacy controls) for debugging +- **Breadcrumbs**: Track user actions leading up to errors +- **Debug log integration**: Attach internal debug logs to error reports + +## Bug Fixes (found via Sentry Replay) + +Two non-Sentry bugs were found and fixed in the course of building this integration: + +### Scroll-to-bottom after list→subscription timeline expansion + +**Problem**: When a room with a single cached event (list subscription, `timeline_limit=1`) becomes +fully subscribed and the SDK delivers N new events, the `TimelineReset` fires before any events land +on the fresh timeline. The "stay at bottom" effect queues a `scrollToBottom` while the DOM is still +empty (range `end=0`). By the time real events load, the scroll has already fired against an empty +container and is a no-op — the user's view stalls mid-list. + +**Fix**: The stay-at-bottom `useEffect` now increments `scrollToBottomRef.current.count` after +calling `setTimeline(getInitialTimeline(room))`, re-queuing the scroll for after the first batch of +events arrives and the DOM has content. + +**File**: `src/app/features/room/RoomTimeline.tsx` + +### TS2367 redundant phase guard in `useCallSignaling` + +**Problem**: A `phase !== undefined` guard was always evaluating to `true` because the TypeScript +type for `phase` had no `undefined` branch at that point in the control flow. + +**Fix**: Removed the dead branch. TypeScript no longer emits a TS2367 comparison error here. + +**File**: `src/app/hooks/useCallSignaling.ts` + +--- + +## Features + +### 1. Automatic Error Tracking + +All errors are automatically captured and sent to Sentry with: + +- Stack traces +- User context (anonymized) +- Device and browser information +- Recent breadcrumbs (user actions) +- Debug logs (when enabled) + +### 2. Debug Logger Integration + +The internal debug logger now integrates with Sentry: + +- **Breadcrumbs**: All debug logs are added as breadcrumbs for context +- **Error capture**: Errors logged to the debug logger are automatically sent to Sentry +- **Warning sampling**: 10% of warnings are sent to Sentry to avoid overwhelming the system +- **Log attachment**: Recent logs can be attached to bug reports for additional context + +Key integration points: + +- `src/app/utils/debugLogger.ts` - Enhanced with Sentry breadcrumb and error capture +- Automatic breadcrumb creation for all log entries +- Error objects in log data are captured as exceptions +- 10% sampling rate for warnings to control volume + +### 3. Bug Report Modal Integration + +The bug report modal (`/bugreport` command or "Bug Report" button) now includes: + +- **Optional Sentry reporting**: Checkbox to send anonymous reports to Sentry +- **Debug log attachment**: Option to include recent debug logs (last 100 entries) +- **User feedback API**: Bug reports are sent as Sentry user feedback for better visibility +- **Privacy controls**: Users can opt-in to Sentry reporting + +Integration points: + +- `src/app/features/bug-report/BugReportModal.tsx` - Added Sentry options and submission logic +- Automatically attaches platform info, version, and user agent +- Links bug reports to Sentry events for tracking + +### 4. Privacy & Security + +Comprehensive data scrubbing (full details in [SENTRY_PRIVACY.md](./SENTRY_PRIVACY.md)): + +- **Token masking**: All access tokens, passwords, and authentication data are redacted +- **Matrix ID anonymization**: User IDs, room IDs, and event IDs are masked +- **Session replay privacy**: All text, media, and form inputs are masked when replay is enabled +- **request header sanitization**: Authorization headers are removed +- **User opt-in**: Users can enable Sentry via settings + +Sensitive patterns automatically redacted: + +- `access_token`, `password`, `token`, `refresh_token` +- `session_id`, `sync_token`, `next_batch` +- Matrix user IDs (`@user:server`) +- Matrix room IDs (`!room:server`) +- Matrix event IDs (`$event_id`) + +### 5. Settings UI + +Sentry controls are split across two settings locations: + +**Settings → General → Diagnostics & Privacy** (user-facing): + +- **Enable/disable error reporting**: Toggle Sentry error tracking on/off +- **Session replay control**: Enable/disable session recording (opt-in) +- Link to the privacy policy + +**Settings → Developer Tools → Error Tracking (Sentry)** (power-user): + +- **Breadcrumb categories**: Granular control over which log categories are sent as breadcrumbs +- **Session stats**: Live error/warning counts for the current page load +- **Export debug logs**: Download the in-memory log buffer as JSON for offline analysis +- **Attach debug logs**: Manually attach recent logs to next error report +- **Test buttons**: Force an error, test feedback, test message capture + +### 6. First-Login Consent Banner + +When `VITE_SENTRY_DSN` is set and a user has never seen the crash-reporting notice (i.e. `sable_sentry_enabled` is absent from `localStorage`), a dismissible banner slides in from the bottom of the screen on first load. It explains that anonymous crash reporting is available and asks if the user wants to enable it. + +**Actions available in the banner:** + +| Button | Effect | +| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| **Enable** | Sets `sable_sentry_enabled = true` in `localStorage` and reloads the page so Sentry initialises. Reporting begins after reload. | +| **No thanks** / × (close) | Sets `sable_sentry_enabled = false` in `localStorage` and dismisses the banner with a fade-out animation. Sentry stays disabled. | + +Once the user has interacted with the banner (either action), it never appears again. The same preference can be changed later in **Settings → General → Diagnostics & Privacy**. + +**Implementation:** `src/app/components/telemetry-consent/TelemetryConsentBanner.tsx` — rendered inside the logged-in client layout so it only appears after a session is established. + +> **Self-hosters**: If you do not set `VITE_SENTRY_DSN`, the banner is never shown and Sentry is entirely disabled at build time. No network requests are made to Sentry. + +## Configuration + +### Environment Variables + +Configure Sentry via environment variables: + +```env +# Required: Your Sentry DSN (if not set, Sentry is disabled) +VITE_SENTRY_DSN=https://your-sentry-dsn@sentry.io/project-id + +# Required: Environment name - controls sampling rates +# - "production" = 10% trace/replay sampling (cost-effective for production) +# - "preview" = 100% trace/replay sampling (full debugging for PR previews) +# - "development" = 100% trace/replay sampling (full debugging for local dev) +VITE_SENTRY_ENVIRONMENT=production + +# Optional: Release version for tracking (defaults to VITE_APP_VERSION) +VITE_SENTRY_RELEASE=1.7.0 + +# Optional: For uploading source maps to Sentry (CI/CD only) +SENTRY_AUTH_TOKEN=your-sentry-auth-token +SENTRY_ORG=your-org-slug +SENTRY_PROJECT=your-project-slug +``` + +### Self-Hosting with Docker + +Sable is compiled at build time, so `VITE_*` variables must be passed as Docker +**build arguments** — they cannot be injected at container runtime via a plain +`docker run -e` flag. The easiest way for self-hosters to supply them is with +a `.env` file and `docker-compose`. + +#### 1. Create a `.env` file + +```env +# .env — never commit this file +VITE_SENTRY_DSN=https://your-key@oXXXXX.ingest.sentry.io/XXXXXXX +VITE_SENTRY_ENVIRONMENT=production +``` + +The `VITE_SENTRY_ENVIRONMENT` value controls sampling rates (see table below). +Leave it as `production` for a live deployment. + +#### 2. Reference it in `docker-compose.yml` + +The `args` block forwards the variables from `.env` into the Docker build +stage so Vite can embed them in the bundle: + +```yaml +services: + sable: + build: + context: . + args: + - VITE_SENTRY_DSN=${VITE_SENTRY_DSN} + - VITE_SENTRY_ENVIRONMENT=${VITE_SENTRY_ENVIRONMENT} + ports: + - '8080:8080' +``` + +Then build and start with: + +```bash +docker compose --env-file .env up --build +``` + +#### 3. Verify it worked + +Open the browser console after loading your instance — you should see: + +``` +[Sentry] Initialized for production environment +[Sentry] DSN configured: https://your-key@o... +``` + +If you see `[Sentry] Disabled - no DSN provided`, the build arg was not +picked up — double-check the `args` block and that your `.env` file is in the +same directory as `docker-compose.yml`. + +#### Building without Compose + +If you use plain `docker build`, pass build args directly: + +```bash +docker build \ + --build-arg VITE_SENTRY_DSN="https://your-key@oXXXXX.ingest.sentry.io/XXXXXXX" \ + --build-arg VITE_SENTRY_ENVIRONMENT="production" \ + -t sable . +``` + +> **Security note:** DSN values embedded in the JavaScript bundle are visible +> to any user who opens DevTools. This is normal and expected for Sentry DSNs — +> they are designed to be public-facing ingest keys. Rate-limiting and origin +> restrictions on the Sentry project side are the correct controls. + +### Deployment Configuration + +**Production deployment (from `dev` branch):** + +- Set `VITE_SENTRY_ENVIRONMENT=production` +- Gets 10% sampling for traces and session replay +- Cost-effective for production usage +- Configured in `.github/workflows/cloudflare-web-deploy.yml` + +**Preview deployments (PR previews, Cloudflare Pages):** + +- Set `VITE_SENTRY_ENVIRONMENT=preview` +- Gets 100% sampling for traces and session replay +- Full debugging capabilities for testing +- Configured in `.github/workflows/cloudflare-web-preview.yml` + +**Local development:** + +- `VITE_SENTRY_ENVIRONMENT` not set (defaults to `development` via Vite MODE) +- Gets 100% sampling for traces and session replay +- Full debugging capabilities + +**Sampling rates by environment:** + +``` +Environment | Traces | Profiles | Session Replay | Error Replay +---------------|--------|----------|----------------|------------- +production | 10% | 10% | 10% | 100% +preview | 100% | 100% | 100% | 100% +development | 100% | 100% | 100% | 100% +``` + +> **Browser profiling requires a `Document-Policy: js-profiling` response header** on your HTML document. +> This is already included in the provided `Caddyfile` and nginx config. For other servers, add the header to +> the response serving `index.html`. + +### User Preferences + +Users can control Sentry via localStorage: + +```javascript +// Disable Sentry entirely (requires page refresh) +localStorage.setItem('sable_sentry_enabled', 'false'); + +// Disable session replay only (requires page refresh) +localStorage.setItem('sable_sentry_replay_enabled', 'false'); +``` + +Or use the UI in Settings → General → Diagnostics & Privacy. + +## Custom Instrumentation + +Beyond automatic error capture, Sable has hand-crafted monitoring at key +lifecycle points. See [SENTRY_PRIVACY.md](./SENTRY_PRIVACY.md) for the full +metrics reference. Key areas: + +| Area | What's tracked | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| **Auth** | Login failures (by `errcode`), forced server logouts | +| **Sync** | Transport type, degraded states, cycle stats, initial sync latency, time-to-ready, total rooms loaded, active subscriptions | +| **Cryptography** | Decryption failures (by failure reason), key backup errors, store wipes, E2E verification outcomes, bulk decryption latency | +| **Messaging** | Send latency, send errors, local-echo `NOT_SENT` events | +| **Timeline** | Opens, virtual window size, jump-load latency, re-initialisations, `limited` sync resets, scroll offset at load, pagination errors | +| **Pagination** | Pagination latency (`sable.pagination.latency_ms`) and errors per direction | +| **Sliding sync** | Room subscription latency (`sable.sync.room_sub_latency_ms`), events per subscription batch (`sable.sync.room_sub_event_count`) | +| **Scroll / UX** | `atBottom` transitions with rapid-flip anomaly detection, scroll-to-bottom trigger warnings when user is scrolled up | +| **Calls** | `sable.call.start.attempt/error`, `sable.call.answered`, `sable.call.declined`, active/ended/timeout counters | +| **Message actions** | `sable.message.delete.*`, `sable.message.forward.*`, `sable.message.report.*`, `sable.message.reaction.toggle` | +| **Media** | Upload latency, upload size, cache stats | +| **Background clients** | Per-account notification client count, startup failures | + +Fatal errors that are caught by `useAsyncCallback` state (and therefore never +reach React's ErrorBoundary) are explicitly forwarded with `captureException`: + +- Client load failure (`phase: load`) +- Client start failure (`phase: start`) +- Background notification client startup failure + +### Breadcrumb categories + +All hand-crafted breadcrumbs use structured Sentry categories that appear in +the Sentry issue timeline and can be filtered in the developer settings panel. + +| Category | Where emitted | What it records | +| ----------------- | ---------------------------------------------- | ------------------------------------------------------------------------------ | +| `auth` | `ClientRoot.tsx` | Login session start, forced logout | +| `sync` | `initMatrix.ts`, `SyncStatus.tsx` | Sync state transitions, degraded states, client ready | +| `sync.sliding` | `slidingSync.ts` | First room subscription data: latency, event count | +| `timeline.sync` | `RoomTimeline.tsx` | SDK-initiated `TimelineReset` (limited sync gap) — fires before events arrive | +| `timeline.events` | `RoomTimeline.tsx` | Every `eventsLength` batch: delta, batch size label, range gap, `atBottom` | +| `ui.scroll` | `RoomTimeline.tsx` | `atBottom` true→false transitions, rapid-flip warnings, scroll-to-bottom fires | +| `ui.timeline` | `RoomTimeline.tsx` | Virtual paginator window shifts (range start/end changes) | +| `call.signal` | `useCallSignaling.ts`, `IncomingCallModal.tsx` | Call signal state changes, answer/decline | +| `crypto` | `useKeyBackup.ts` | Key backup errors | +| `media` | `ClientNonUIFeatures.tsx` | Blob cache stats on blob URL creation | + +## Implementation Details + +### Files Modified + +1. **`src/instrument.ts`** + - Enhanced Sentry initialization with privacy controls + - Added user preference checks + - Improved data scrubbing for Matrix-specific data + - Conditional session replay based on user settings + +2. **`src/app/utils/debugLogger.ts`** + - Added Sentry import + - New `sendToSentry()` method for breadcrumbs and error capture + - New `exportLogsForSentry()` method + - New `attachLogsToSentry()` method + - Integrated into main `log()` method + +3. **`src/app/features/bug-report/BugReportModal.tsx`** + - Added Sentry and debug logger imports + - New state for Sentry options (`sendToSentry`, `includeDebugLogs`) + - Enhanced `handleSubmit()` with Sentry user feedback + - New UI checkboxes for Sentry options + +4. **`src/app/features/settings/developer-tools/SentrySettings.tsx`** _(new file)_ + - New settings panel component + - Controls for Sentry and session replay + - Manual log attachment + +5. **`src/app/features/settings/developer-tools/DevelopTools.tsx`** + - Added SentrySettings import and component + +### Sentry Configuration + +- **Tracing sample rate**: 100% in development, 10% in production +- **Session replay sample rate**: 10% of all sessions, 100% of error sessions +- **Warning capture rate**: 10% to avoid overwhelming Sentry +- **Breadcrumb retention**: All breadcrumbs retained for context +- **Log attachment limit**: Last 100 debug log entries + +### Performance Considerations + +- Breadcrumbs are added synchronously but are low-overhead +- Error capture is asynchronous and non-blocking +- Warning sampling (10%) prevents excessive Sentry usage +- Session replay only captures when enabled by user +- Debug log attachment limited to most recent entries + +## Usage Examples + +### For Developers + +```typescript +import { getDebugLogger } from '$utils/debugLogger'; + +// Errors are automatically sent to Sentry +const logger = createDebugLogger('myNamespace'); +logger.error('sync', 'Sync failed', error); // Sent to Sentry + +// Manually attach logs before capturing an error +const debugLogger = getDebugLogger(); +debugLogger.attachLogsToSentry(100); +Sentry.captureException(error); +``` + +### For Users + +1. **Report a bug with Sentry**: + - Type `/bugreport` or click "Bug Report" button + - Fill in the form + - Check "Send anonymous report to Sentry" + - Check "Include recent debug logs" for more context + - Submit + +2. **Disable Sentry**: + - Go to Settings → Developer Tools + - Enable Developer Tools + - Scroll to "Error Tracking (Sentry)" + - Toggle off "Enable Sentry Error Tracking" + - Refresh the page + +## Benefits + +### For Users + +- Better bug tracking and faster fixes +- Optional participation with privacy controls +- Transparent data usage + +### For Developers + +- Real-time error notifications +- Rich context with breadcrumbs and logs +- Performance monitoring +- User feedback integrated with errors +- Replay sessions to reproduce bugs + +## Privacy Commitment + +See [SENTRY_PRIVACY.md](./SENTRY_PRIVACY.md) for a complete, code-linked breakdown of what is collected, what is masked, and how user controls work. + +In summary, all data sent to Sentry is: + +- **Off by default**: Sentry is disabled until the user explicitly opts in +- **Anonymized**: No personal data or message content +- **Filtered**: Tokens, passwords, and IDs are redacted +- **Minimal**: Only error context and debug info +- **Transparent**: Users can see what's being sent + +No message content, room conversations, or personal information is ever sent to Sentry. + +## Testing + +To test the integration: + +1. **Test error reporting**: + - Go to Settings → General → Diagnostics & Privacy + - Check that Sentry is enabled and `VITE_SENTRY_DSN` is set + - Open the browser console and run: `window.Sentry?.captureMessage('Test message')` + - Check the Sentry dashboard for the event + +2. **Test bug report integration**: + - Type `/bugreport` + - Fill in form with test data + - Enable "Send anonymous report to Sentry" + - Submit and check Sentry + +3. **Test privacy controls**: + - Disable Sentry in settings + - Refresh page + - Trigger an error (should not appear in Sentry) + - Re-enable and verify errors are captured again + +## Troubleshooting + +### Sentry not capturing errors + +1. Check that `VITE_SENTRY_DSN` is set +2. Check that Sentry is enabled in settings +3. Check browser console for Sentry initialization message +4. Verify network requests to Sentry are not blocked + +### Sensitive data in reports + +1. Check `beforeSend` hook in `instrument.ts` +2. Add new patterns to the scrubbing regex +3. Test with actual data to verify masking + +### Performance impact + +1. Reduce tracing sample rate in production +2. Disable session replay if not needed +3. Monitor Sentry quota usage +4. Adjust warning sampling rate + +## Resources + +- [Sentry React Documentation](https://docs.sentry.io/platforms/javascript/guides/react/) +- [Sentry Error Monitoring Best Practices](https://docs.sentry.io/product/error-monitoring/) +- [Sentry Session Replay](https://docs.sentry.io/product/session-replay/) +- [Sentry User Feedback](https://docs.sentry.io/product/user-feedback/) diff --git a/docs/SENTRY_PRIVACY.md b/docs/SENTRY_PRIVACY.md new file mode 100644 index 000000000..39be916ce --- /dev/null +++ b/docs/SENTRY_PRIVACY.md @@ -0,0 +1,340 @@ +# Sentry Privacy Policy + +This document describes exactly what data the Sentry integration collects, what +is masked or blocked, and where the relevant code lives. For setup and +configuration details see [SENTRY_INTEGRATION.md](./SENTRY_INTEGRATION.md). + +--- + +## What Is Collected + +Sentry is **disabled by default when no DSN is configured** and can be **opted +in to by users** at any time via Settings → General → Diagnostics & Privacy. + +### First-Login Consent Notice + +When Sentry is configured, the app shows a dismissible notice the first time a +user loads Sable. The notice explains that crash reporting is available and +provides a one-click opt-in before any data is sent. + +| Action | Effect | +| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| **"Enable"** | Sentry enabled (`sable_sentry_enabled = 'true'`), page reloads so Sentry initialises — data collection begins after reload | +| **"No thanks"** or **✕ dismiss** | Preference saved as opted-out (`sable_sentry_enabled = 'false'`); notice does not appear again; no Sentry data is ever sent | + +The preference persists in `localStorage` and can be changed at any time in +**Settings → General → Diagnostics & Privacy**. + +**Code:** `src/app/components/telemetry-consent/TelemetryConsentBanner.tsx` + +When enabled, the following categories of data are sent: + +### Error Reports + +- Exception type and stack trace (function names, file names, line numbers) +- Error message text — scrubbed of tokens and Matrix IDs before sending (see + [What Is Scrubbed](#what-is-scrubbed)) +- Browser and OS name/version +- JavaScript engine version +- Application release version (`VITE_APP_VERSION`) +- Sentry environment tag (`VITE_SENTRY_ENVIRONMENT`) +- Current URL path — tokens in query strings are redacted before sending + +**Code:** `src/instrument.ts` — `beforeSend` callback + +### Breadcrumbs (Action Trail) + +Leading up to an error, Sentry records a trail of recent user actions: + +- Navigation events (route changes) +- `console.error` and `console.warn` calls — filtered for sensitive patterns + before sending +- Internal debug log entries (category, level, summary message) — filtered + before sending + +Breadcrumbs containing any of the patterns listed in +[What Is Scrubbed](#what-is-scrubbed) are sanitised in-place before leaving the +browser. + +**Code:** `src/instrument.ts` — `beforeBreadcrumb` callback +**Code:** `src/app/utils/debugLogger.ts` — Sentry breadcrumb integration + +### Application Breadcrumbs + +In addition to automatic navigation/console breadcrumbs, the following named +events are explicitly recorded as breadcrumbs: + +| Event | Category | Level | Source | +| ------------------------------------------- | -------- | ------------- | ------------------------- | +| Session forcibly logged out by server | `auth` | warning | `ClientRoot.tsx` | +| Sync state changed to Reconnecting/Error | `sync` | warning/error | `SyncStatus.tsx` | +| Sliding sync first run completed | `sync` | info | `initMatrix.ts` | +| Crypto store mismatch — wiping local stores | `crypto` | warning | `initMatrix.ts` | +| Key backup failed | `crypto` | error | `useKeyBackup.ts` | +| High media inflight request count | `media` | warning | `ClientNonUIFeatures.tsx` | + +**Code:** `src/app/pages/client/ClientRoot.tsx`, `src/app/pages/client/SyncStatus.tsx`, +`src/client/initMatrix.ts`, `src/app/hooks/useKeyBackup.ts`, +`src/app/pages/client/ClientNonUIFeatures.tsx` + +### Component Error Capture + +The following failure paths use explicit `captureException` because they are +caught by state management hooks and never propagate to React's ErrorBoundary: + +| Failure | Tag | Source | +| ---------------------------------------------- | ------------------------------------ | ----------------------------- | +| Client failed to load (fetch/init) | `phase: load` | `ClientRoot.tsx` | +| Client failed to start (sync start) | `phase: start` | `ClientRoot.tsx` | +| Background notification client failed to start | `component: BackgroundNotifications` | `BackgroundNotifications.tsx` | + +**Code:** `src/app/pages/client/ClientRoot.tsx`, +`src/app/pages/client/BackgroundNotifications.tsx` + +### Performance Traces + +- Timing of React Router navigations (page-load and route-change latency) +- Custom spans for Matrix sync cycles, message send, and room data loading +- JavaScript CPU profiles during traced transactions (call-stack samples) + +Performance data contains **no message content, no room names, and no user +identifiers**. Spans are labelled with operation names only. + +| Span name | Operation | Source | +| ----------------------- | ----------------- | ---------------------- | +| `auth.login` | `auth` | `loginUtil.ts` | +| `decrypt.event` | `matrix.crypto` | `EncryptedContent.tsx` | +| `decrypt.bulk` | `matrix.crypto` | `room.ts` | +| `timeline.jump_load` | `matrix.timeline` | `RoomTimeline.tsx` | +| `message.send` | `matrix.message` | `RoomInput.tsx` | +| Sliding sync processing | `matrix.sync` | `slidingSync.ts` | + +**Sample rates:** + +| Environment | Traces | Profiles | +| ------------------------- | ------ | -------- | +| `production` | 10% | 10% | +| `preview` / `development` | 100% | 100% | + +**Code:** `src/instrument.ts` — `tracesSampleRate`, `profilesSampleRate` +**Code:** `src/app/features/room/RoomInput.tsx` — message send span +**Code:** `src/app/utils/room.ts`, `src/client/slidingSync.ts` — room/sync spans + +### Custom Metrics + +All metrics contain no message content, room names, or user identifiers. +Attribute values are limited to short enumerated strings (error codes, states) +or numeric measurements. + +#### Authentication + +| Metric | Type | Attributes | What it tracks | +| ------------------------- | ----- | ---------- | ------------------------------------ | +| `sable.auth.login_failed` | count | `errcode` | Login attempt failures by error code | + +**Code:** `src/app/pages/auth/login/loginUtil.ts` + +#### Cryptography + +| Metric | Type | Attributes | What it tracks | +| ----------------------------------- | ------------ | ----------------------------------- | ------------------------------------------------ | +| `sable.decryption.failure` | count | `reason` | Unable-to-decrypt events by failure reason | +| `sable.decryption.event_ms` | distribution | — | Per-event decryption latency | +| `sable.decryption.bulk_latency_ms` | distribution | `event_count` | Bulk re-decryption time on room open | +| `sable.crypto.key_backup_failures` | count | `errcode` | Key backup errors by code | +| `sable.crypto.store_wipe` | count | — | Crypto store mismatch wipe-and-retry occurrences | +| `sable.crypto.verification_outcome` | count | `outcome` (`completed`/`cancelled`) | E2E device verification outcomes | + +**Code:** `src/app/features/room/message/EncryptedContent.tsx`, +`src/app/utils/room.ts`, `src/app/hooks/useKeyBackup.ts`, +`src/client/initMatrix.ts`, `src/app/components/DeviceVerification.tsx` + +#### Messaging + +| Metric | Type | Attributes | What it tracks | +| ------------------------------- | ------------ | ----------- | ----------------------------------- | +| `sable.message.send_latency_ms` | distribution | `encrypted` | Message send round-trip time | +| `sable.message.send_error` | count | — | Send errors from message composer | +| `sable.message.send_failed` | count | — | Local-echo `NOT_SENT` status events | + +**Code:** `src/app/features/room/RoomInput.tsx`, +`src/app/features/room/RoomTimeline.tsx` + +#### Timeline + +| Metric | Type | Attributes | What it tracks | +| ------------------------------ | ------------ | ----------- | -------------------------------- | +| `sable.timeline.open` | count | `mode` | Timeline render initiations | +| `sable.timeline.render_window` | distribution | `mode` | Initial virtual window size | +| `sable.timeline.jump_load_ms` | distribution | — | Event-jump timeline load latency | +| `sable.timeline.reinit` | count | — | Full timeline re-initialisations | +| `sable.pagination.error` | count | `direction` | Pagination errors by direction | + +**Code:** `src/app/features/room/RoomTimeline.tsx` + +#### Sync + +| Metric | Type | Attributes | What it tracks | +| --------------------------------- | ------------ | ---------------------------- | -------------------------------------- | +| `sable.sync.transport` | count | `type` (`sliding`/`classic`) | Sync transport type used | +| `sable.sync.cycle` | count | (various) | Completed sliding sync cycles | +| `sable.sync.error` | count | `errcode` | Sliding sync errors | +| `sable.sync.initial_ms` | distribution | — | Initial sync completion time | +| `sable.sync.processing_ms` | distribution | — | Per-cycle sync processing time | +| `sable.sync.lists_loaded_ms` | distribution | — | Time for room lists to fully load | +| `sable.sync.total_rooms` | gauge | `sync_type` | Total rooms known at list load | +| `sable.sync.active_subscriptions` | gauge | — | Active room subscription count | +| `sable.sync.client_ready_ms` | distribution | `type` | Time from init to client ready | +| `sable.sync.time_to_ready_ms` | distribution | — | Wall-clock time to first sync ready | +| `sable.sync.degraded` | count | `state` | Sync reconnect/error state transitions | + +**Code:** `src/client/initMatrix.ts`, `src/client/slidingSync.ts`, +`src/app/pages/client/ClientRoot.tsx`, `src/app/pages/client/SyncStatus.tsx` + +#### Media + +| Metric | Type | Attributes | What it tracks | +| ------------------------------- | ------------ | ---------- | ---------------------------- | +| `sable.media.upload_latency_ms` | distribution | `mimetype` | Media upload round-trip time | +| `sable.media.upload_bytes` | distribution | `mimetype` | Upload size distribution | +| `sable.media.upload_error` | count | `reason` | Upload failures by reason | +| `sable.media.blob_cache_size` | gauge | — | Blob URL cache entry count | +| `sable.media.inflight_requests` | gauge | — | Concurrent media requests | + +**Code:** `src/app/utils/matrix.ts`, `src/app/pages/client/ClientNonUIFeatures.tsx` + +#### Background clients & debug telemetry + +| Metric | Type | Attributes | What it tracks | +| ------------------------------- | ----- | ---------- | -------------------------------------- | +| `sable.background.client_count` | gauge | — | Active background notification clients | +| `sable.errors` | count | `category` | Error-level debug log entries | +| `sable.warnings` | count | `category` | Warning-level debug log entries | + +**Code:** `src/app/pages/client/BackgroundNotifications.tsx`, +`src/app/utils/debugLogger.ts` + +### Session Replay _(opt-in, disabled by default)_ + +When session replay is explicitly enabled by the user, Sentry records UI +interactions to help reproduce bugs. **All content is masked at the browser +level before any data leaves the device:** + +- All text on screen → replaced with `█` characters +- All images, video, and audio → blocked entirely (replaced with a grey box) +- All form inputs, including the message composer → replaced with `*` characters + +This means **no Matrix messages, no room names, no user display names, and no +media are ever visible in a replay**. + +Sample rates for replay: + +| Trigger | Production | Preview / Dev | +| -------------------- | ---------- | ------------- | +| Regular sessions | 10% | 100% | +| Sessions with errors | 100% | 100% | + +**Code:** `src/instrument.ts` — `replayIntegration` call with `maskAllText`, +`blockAllMedia`, `maskAllInputs` + +### Bug Reports _(manual, opt-in per report)_ + +When a user submits a bug report via `/bugreport` or the "Bug Report" button: + +- Free-text description written by the user +- Optional: recent debug log entries (last 100) attached as a file +- Platform info, browser version, application version +- Checkbox to send or not send to Sentry is **shown before submission** + +**Code:** `src/app/features/bug-report/BugReportModal.tsx` + +--- + +## What Is Never Collected + +- Matrix message content +- Room names or aliases +- User display names or avatars +- Contact lists or room member lists +- Encryption keys or session data +- IP addresses (`sendDefaultPii: false`) +- Authentication tokens (scrubbed — see below) + +--- + +## What Is Scrubbed + +All scrubbing happens **in the browser before data is transmitted**. Nothing +leaves the device in unredacted form. + +### Tokens and Credentials + +The following patterns are replaced with `[REDACTED]` in error messages, +exception values, breadcrumb messages, and request URLs: + +- `access_token` +- `password` +- `token` +- `refresh_token` +- `session_id` +- `sync_token` +- `next_batch` +- HTTP `Authorization` headers + +**Code:** `src/instrument.ts` — `beforeSend` and `beforeBreadcrumb` callbacks +Regex: `/(access_token|password|token|refresh_token|session_id|sync_token|next_batch)([=:]\s*)([^\s&]+)/gi` + +### Matrix Identifiers + +Matrix IDs are replaced with placeholder tokens before sending: + +| Original form | Replaced with | +| -------------- | ------------- | +| `@user:server` | `@[USER_ID]` | +| `!room:server` | `![ROOM_ID]` | +| `$event_id` | `$[EVENT_ID]` | + +**Code:** `src/instrument.ts` — `beforeSend` callback (applied to `event.message` +and all `event.exception.values`) + +--- + +## User Controls + +Users can adjust Sentry behaviour without restarting the app: + +| Setting | Location | `localStorage` key | Default | +| ----------------------------- | ---------------------------------------------------------------------------- | ---------------------------------- | ----------------- | +| Disable Sentry entirely | Settings → General → Diagnostics & Privacy | `sable_sentry_enabled` | Enabled | +| Enable session replay | Settings → General → Diagnostics & Privacy | `sable_sentry_replay_enabled` | Disabled (opt-in) | +| Disable breadcrumb categories | Settings → Developer Tools → Error Tracking (Sentry) → Breadcrumb Categories | `sable_sentry_breadcrumb_disabled` | All enabled | + +**Rate limiting:** A maximum of 50 error events are forwarded to Sentry per page load (session). +Subsequent errors are silently dropped, protecting against quota exhaustion without affecting +in-app behaviour. Performance traces are not subject to this cap. + +Changes to Sentry enable/disable and session replay take effect after the next page refresh +(the SDK is initialised once at startup). Breadcrumb category changes take effect immediately. + +**Code:** `src/instrument.ts` — reads `localStorage` before `Sentry.init()`, enforces rate limit in `beforeSend` +**Code:** `src/app/features/settings/developer-tools/SentrySettings.tsx` — settings UI +**Code:** `src/app/utils/debugLogger.ts` — per-category breadcrumb filtering and session stats + +--- + +## Data Residency + +Sentry data is sent to the Sentry.io cloud service. The destination project is +configured by the operator via `VITE_SENTRY_DSN`. Self-hosted Sentry instances +are supported by changing the DSN. + +When `VITE_SENTRY_DSN` is not set, the integration is entirely inactive — no +code path in the Sentry SDK is reached and no data is transmitted. + +--- + +## Further Reading + +- [SENTRY_INTEGRATION.md](./SENTRY_INTEGRATION.md) — setup, configuration, environment variables, and deployment instructions +- [Sentry Privacy Policy](https://sentry.io/privacy/) — Sentry's own data handling commitments +- [Sentry Session Replay privacy documentation](https://docs.sentry.io/product/explore/session-replay/privacy/) — details on masking and blocking behaviour diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 000000000..c5907c059 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,137 @@ +# Testing Guide + +Sable uses [Vitest](https://vitest.dev/) as its test runner and [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/) for component tests. Tests run in a [jsdom](https://github.com/jsdom/jsdom) environment and coverage is collected via V8. + +--- + +## Running tests + +```sh +# Watch mode — reruns affected tests on file save (recommended during development) +pnpm test + +# Single run — equivalent to what CI runs +pnpm test:run + +# With browser UI (interactive results viewer) +pnpm test:ui + +# With coverage report +pnpm test:coverage +``` + +Coverage reports are written to `coverage/`. Open `coverage/index.html` in your browser for the full HTML report. + +--- + +## Writing tests + +### Where to put test files + +Place test files next to the source file they cover, with a `.test.ts` or `.test.tsx` suffix: + +``` +src/app/utils/colorMXID.ts +src/app/utils/colorMXID.test.ts + +src/app/features/room/RoomTimeline.tsx +src/app/features/room/RoomTimeline.test.tsx +``` + +### Testing plain utility functions + +For pure functions in `src/app/utils/`, no special setup is needed — just import and assert: + +```ts +import { describe, it, expect } from 'vitest'; +import { bytesToSize } from './common'; + +describe('bytesToSize', () => { + it('converts bytes to KB', () => { + expect(bytesToSize(1500)).toBe('1.5 KB'); + }); +}); +``` + +### Testing React components + +Use `@testing-library/react` to render components inside the jsdom environment. Query by accessible role/text rather than CSS classes or implementation details: + +```tsx +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MyButton } from './MyButton'; + +describe('MyButton', () => { + it('calls onClick when pressed', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + + render(Click me); + + await user.click(screen.getByRole('button', { name: 'Click me' })); + + expect(onClick).toHaveBeenCalledOnce(); + }); +}); +``` + +### Testing hooks + +Use `renderHook` from `@testing-library/react`: + +```ts +import { describe, it, expect } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useMyHook } from './useMyHook'; + +describe('useMyHook', () => { + it('returns the expected initial value', () => { + const { result } = renderHook(() => useMyHook()); + expect(result.current.value).toBe(0); + }); +}); +``` + +### Mocking + +Vitest has Jest-compatible mocking APIs: + +```ts +import { vi } from 'vitest'; + +// Mock a module +vi.mock('./someModule', () => ({ doThing: vi.fn(() => 'mocked') })); + +// Spy on a method +const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + +// Restore after test +afterEach(() => vi.restoreAllMocks()); +``` + +### Path aliases + +All the project's path aliases work inside tests — you can import using `$utils/`, `$components/`, `$features/`, etc., just like in application code. + +--- + +## What to test + +Not every file needs tests. Focus on logic that would be painful to debug when broken: + +| Worth testing | Less valuable | +| ----------------------------------------------- | ---------------------------------------------- | +| Pure utility functions (`src/app/utils/`) | Purely presentational components with no logic | +| Custom hooks with non-trivial state transitions | Thin wrappers around third-party APIs | +| State atoms and reducers | Generated or declarative config | +| Data transformation / formatting functions | | + +When you fix a bug, consider adding a regression test that would have caught it — the description in the test is useful documentation. + +--- + +## CI + +`pnpm test:run` is part of the required quality checks and runs on every pull request alongside `lint`, `typecheck`, and `knip`. A PR with failing tests cannot be merged. diff --git a/index.html b/index.html index 21c34e6d1..62ce1dabb 100644 --- a/index.html +++ b/index.html @@ -23,7 +23,8 @@ property="og:description" content="A Matrix client where you can enjoy the conversation using (not) simple, elegant and secure interface protected by e2ee with the power of open source. And colorful cosmetics. Because yes." /> - + + diff --git a/knip.json b/knip.json index 3f415d940..c6cca1d75 100644 --- a/knip.json +++ b/knip.json @@ -7,8 +7,9 @@ }, "ignoreDependencies": [ "buffer", - "@element-hq/element-call-embedded", - "@matrix-org/matrix-sdk-crypto-wasm" + "@sableclient/sable-call-embedded", + "@matrix-org/matrix-sdk-crypto-wasm", + "@testing-library/user-event" ], "ignoreBinaries": ["knope"], "rules": { diff --git a/knope.toml b/knope.toml index 7442b6bf9..cb3b69a71 100644 --- a/knope.toml +++ b/knope.toml @@ -64,6 +64,6 @@ repo = "Sable" [release_notes] # The marker is used by prepare-release.yml change_templates = [ - "### $summary \n\n$details", + # "### $summary \n\n$details", "* $summary ", -] \ No newline at end of file +] diff --git a/package.json b/package.json index cbdee7db2..6c52a2e05 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sable", - "version": "1.7.0", + "version": "1.9.3", "description": "An almost stable Matrix client", "type": "module", "packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be", @@ -17,6 +17,10 @@ "fmt": "prettier --write .", "fmt:check": "prettier --check .", "typecheck": "tsc", + "test": "vitest", + "test:ui": "vitest --ui", + "test:run": "vitest run", + "test:coverage": "vitest run --coverage", "knip": "knip", "knope": "knope", "document-change": "knope document-change", @@ -30,7 +34,9 @@ "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^1.4.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0", "@fontsource-variable/nunito": "5.2.7", + "@sentry/react": "^10.43.0", "@fontsource/space-mono": "5.2.9", + "@phosphor-icons/react": "^2.1.10", "@tanstack/react-query": "^5.90.21", "@tanstack/react-query-devtools": "^5.91.3", "@tanstack/react-virtual": "^3.13.19", @@ -65,7 +71,7 @@ "linkify-react": "^4.3.2", "linkifyjs": "^4.3.2", "matrix-js-sdk": "^38.4.0", - "matrix-widget-api": "1.13.0", + "matrix-widget-api": "^1.16.1", "millify": "^6.1.0", "pdfjs-dist": "^5.4.624", "prismjs": "^1.30.0", @@ -89,12 +95,16 @@ }, "devDependencies": { "@cloudflare/vite-plugin": "^1.26.0", - "@element-hq/element-call-embedded": "0.16.3", + "@sableclient/sable-call-embedded": "v1.1.3", "@esbuild-plugins/node-globals-polyfill": "^0.2.3", "@eslint/compat": "2.0.2", "@eslint/js": "9.39.3", "@rollup/plugin-inject": "^5.0.5", "@rollup/plugin-wasm": "^6.2.2", + "@sentry/vite-plugin": "^5.1.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/chroma-js": "^3.1.2", "@types/file-saver": "^2.0.7", "@types/is-hotkey": "^0.1.10", @@ -106,12 +116,15 @@ "@types/sanitize-html": "^2.16.0", "@types/ua-parser-js": "^0.7.39", "@vitejs/plugin-react": "^5.1.4", + "@vitest/coverage-v8": "^4.1.0", + "@vitest/ui": "^4.1.0", "buffer": "^6.0.3", "eslint": "9.39.3", "eslint-config-airbnb-extended": "3.0.1", "eslint-config-prettier": "10.1.8", "eslint-plugin-prettier": "5.5.5", "globals": "17.3.0", + "jsdom": "^29.0.0", "knip": "5.85.0", "prettier": "3.8.1", "typescript": "^5.9.3", @@ -121,6 +134,7 @@ "vite-plugin-static-copy": "^3.2.0", "vite-plugin-svgr": "4.5.0", "vite-plugin-top-level-await": "^1.6.0", + "vitest": "^4.1.0", "wrangler": "^4.70.0" } } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 129484b42..be96be6a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,12 +5,12 @@ settings: excludeLinksFromLockfile: false overrides: - serialize-javascript: '>=7.0.3' - rollup: '>=4.59.0' - minimatch: '>=10.2.3' - lodash: '>=4.17.23' - esbuild: '>=0.25.0' brace-expansion: '>=1.1.12' + esbuild: '>=0.25.0' + lodash: '>=4.17.23' + minimatch: '>=10.2.3' + rollup: '>=4.59.0' + serialize-javascript: '>=7.0.3' importers: @@ -31,6 +31,12 @@ importers: '@fontsource/space-mono': specifier: 5.2.9 version: 5.2.9 + '@phosphor-icons/react': + specifier: ^2.1.10 + version: 2.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@sentry/react': + specifier: ^10.43.0 + version: 10.43.0(react@18.3.1) '@tanstack/react-query': specifier: ^5.90.21 version: 5.90.21(react@18.3.1) @@ -134,8 +140,8 @@ importers: specifier: ^38.4.0 version: 38.4.0 matrix-widget-api: - specifier: 1.13.0 - version: 1.13.0 + specifier: ^1.16.1 + version: 1.17.0 millify: specifier: ^6.1.0 version: 6.1.0 @@ -200,9 +206,6 @@ importers: '@cloudflare/vite-plugin': specifier: ^1.26.0 version: 1.27.0(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2))(workerd@1.20260310.1)(wrangler@4.72.0) - '@element-hq/element-call-embedded': - specifier: 0.16.3 - version: 0.16.3 '@esbuild-plugins/node-globals-polyfill': specifier: ^0.2.3 version: 0.2.3(esbuild@0.27.3) @@ -218,6 +221,21 @@ importers: '@rollup/plugin-wasm': specifier: ^6.2.2 version: 6.2.2(rollup@4.59.0) + '@sableclient/sable-call-embedded': + specifier: v1.1.3 + version: 1.1.3 + '@sentry/vite-plugin': + specifier: ^5.1.1 + version: 5.1.1(rollup@4.59.0) + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) '@types/chroma-js': specifier: ^3.1.2 version: 3.1.2 @@ -251,6 +269,12 @@ importers: '@vitejs/plugin-react': specifier: ^5.1.4 version: 5.1.4(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2)) + '@vitest/coverage-v8': + specifier: ^4.1.0 + version: 4.1.0(vitest@4.1.0) + '@vitest/ui': + specifier: ^4.1.0 + version: 4.1.0(vitest@4.1.0) buffer: specifier: ^6.0.3 version: 6.0.3 @@ -269,6 +293,9 @@ importers: globals: specifier: 17.3.0 version: 17.3.0 + jsdom: + specifier: ^29.0.0 + version: 29.0.0 knip: specifier: 5.85.0 version: 5.85.0(@types/node@24.10.13)(typescript@5.9.3) @@ -296,18 +323,35 @@ importers: vite-plugin-top-level-await: specifier: ^1.6.0 version: 1.6.0(@swc/helpers@0.5.19)(rollup@4.59.0)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2)) + vitest: + specifier: ^4.1.0 + version: 4.1.0(@types/node@24.10.13)(@vitest/ui@4.1.0)(jsdom@29.0.0)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2)) wrangler: specifier: ^4.70.0 version: 4.72.0 packages: + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@apideck/better-ajv-errors@0.3.6': resolution: {integrity: sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==} engines: {node: '>=10'} peerDependencies: ajv: '>=8' + '@asamuzakjp/css-color@5.0.1': + resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.0.3': + resolution: {integrity: sha512-Q6mU0Z6bfj6YvnX2k9n0JxiIwrCFN59x/nWmYQnAqP000ruX/yV+5bp/GRcF5T8ncvfwJQ7fgfP74DlpKExILA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@atlaskit/pragmatic-drag-and-drop-auto-scroll@1.4.0': resolution: {integrity: sha512-5GoikoTSW13UX76F9TDeWB8x3jbbGlp/Y+3aRkHe1MOBMkrWkwNpJ42MIVhhX/6NSeaZiPumP0KbGJVs2tOWSQ==} @@ -830,6 +874,14 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@cloudflare/kv-asset-handler@0.4.2': resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==} engines: {node: '>=18.0.0'} @@ -883,8 +935,41 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} - '@element-hq/element-call-embedded@0.16.3': - resolution: {integrity: sha512-OViKJonDaDNVBUW9WdV9mk78/Ruh34C7XsEgt3O8D9z+64C39elbIgllHSoH5S12IRlv9RYrrV37FZLo6QWsDQ==} + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.1.1': + resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.0.2': + resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.1': + resolution: {integrity: sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} @@ -1110,6 +1195,15 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@fontsource-variable/nunito@5.2.7': resolution: {integrity: sha512-2N8QhatkyKgSUbAGZO2FYLioxA32+RyI1EplVLawbpkGjUeui9Qg9VMrpkCaik1ydjFjfLV+kzQ0cGEsMrMenQ==} @@ -1549,10 +1643,20 @@ packages: cpu: [x64] os: [win32] + '@phosphor-icons/react@2.1.10': + resolution: {integrity: sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==} + engines: {node: '>=10'} + peerDependencies: + react: '>= 16.8' + react-dom: '>= 16.8' + '@pkgr/core@0.2.9': resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@poppinss/colors@4.1.6': resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} @@ -2322,6 +2426,109 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@sableclient/sable-call-embedded@1.1.3': + resolution: {integrity: sha512-HNxppMEF8am6qhABbvJNc2mlkex7SntUeAMATOoNo2QkiTrutrJ9LPWy0TZskjAp++RrSpEpypKcN3MmOlZEWA==} + + '@sentry-internal/browser-utils@10.43.0': + resolution: {integrity: sha512-8zYTnzhAPvNkVH1Irs62wl0J/c+0QcJ62TonKnzpSFUUD3V5qz8YDZbjIDGfxy+1EB9fO0sxtddKCzwTHF/MbQ==} + engines: {node: '>=18'} + + '@sentry-internal/feedback@10.43.0': + resolution: {integrity: sha512-YoXuwluP6eOcQxTeTtaWb090++MrLyWOVsUTejzUQQ6LFL13Jwt+bDPF1kvBugMq4a7OHw/UNKQfd6//rZMn2g==} + engines: {node: '>=18'} + + '@sentry-internal/replay-canvas@10.43.0': + resolution: {integrity: sha512-ZIw1UNKOFXo1LbPCJPMAx9xv7D8TMZQusLDUgb6BsPQJj0igAuwd7KRGTkjjgnrwBp2O/sxcQFRhQhknWk7QPg==} + engines: {node: '>=18'} + + '@sentry-internal/replay@10.43.0': + resolution: {integrity: sha512-khCXlGrlH1IU7P5zCEAJFestMeH97zDVCekj8OsNNDtN/1BmCJ46k6Xi0EqAUzdJgrOLJeLdoYdgtiIjovZ8Sg==} + engines: {node: '>=18'} + + '@sentry/babel-plugin-component-annotate@5.1.1': + resolution: {integrity: sha512-x2wEpBHwsTyTF2rWsLKJlzrRF1TTIGOfX+ngdE+Yd5DBkoS58HwQv824QOviPGQRla4/ypISqAXzjdDPL/zalg==} + engines: {node: '>= 18'} + + '@sentry/browser@10.43.0': + resolution: {integrity: sha512-2V3I3sXi3SMeiZpKixd9ztokSgK27cmvsD9J5oyOyjhGLTW/6QKCwHbKnluMgQMXq20nixQk5zN4wRjRUma3sg==} + engines: {node: '>=18'} + + '@sentry/bundler-plugin-core@5.1.1': + resolution: {integrity: sha512-F+itpwR9DyQR7gEkrXd2tigREPTvtF5lC8qu6e4anxXYRTui1+dVR0fXNwjpyAZMhIesLfXRN7WY7ggdj7hi0Q==} + engines: {node: '>= 18'} + + '@sentry/cli-darwin@2.58.5': + resolution: {integrity: sha512-lYrNzenZFJftfwSya7gwrHGxtE+Kob/e1sr9lmHMFOd4utDlmq0XFDllmdZAMf21fxcPRI1GL28ejZ3bId01fQ==} + engines: {node: '>=10'} + os: [darwin] + + '@sentry/cli-linux-arm64@2.58.5': + resolution: {integrity: sha512-/4gywFeBqRB6tR/iGMRAJ3HRqY6Z7Yp4l8ZCbl0TDLAfHNxu7schEw4tSnm2/Hh9eNMiOVy4z58uzAWlZXAYBQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux, freebsd, android] + + '@sentry/cli-linux-arm@2.58.5': + resolution: {integrity: sha512-KtHweSIomYL4WVDrBrYSYJricKAAzxUgX86kc6OnlikbyOhoK6Fy8Vs6vwd52P6dvWPjgrMpUYjW2M5pYXQDUw==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux, freebsd, android] + + '@sentry/cli-linux-i686@2.58.5': + resolution: {integrity: sha512-G7261dkmyxqlMdyvyP06b+RTIVzp1gZNgglj5UksxSouSUqRd/46W/2pQeOMPhloDYo9yLtCN2YFb3Mw4aUsWw==} + engines: {node: '>=10'} + cpu: [x86, ia32] + os: [linux, freebsd, android] + + '@sentry/cli-linux-x64@2.58.5': + resolution: {integrity: sha512-rP04494RSmt86xChkQ+ecBNRYSPbyXc4u0IA7R7N1pSLCyO74e5w5Al+LnAq35cMfVbZgz5Sm0iGLjyiUu4I1g==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux, freebsd, android] + + '@sentry/cli-win32-arm64@2.58.5': + resolution: {integrity: sha512-AOJ2nCXlQL1KBaCzv38m3i2VmSHNurUpm7xVKd6yAHX+ZoVBI8VT0EgvwmtJR2TY2N2hNCC7UrgRmdUsQ152bA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@sentry/cli-win32-i686@2.58.5': + resolution: {integrity: sha512-EsuboLSOnlrN7MMPJ1eFvfMDm+BnzOaSWl8eYhNo8W/BIrmNgpRUdBwnWn9Q2UOjJj5ZopukmsiMYtU/D7ml9g==} + engines: {node: '>=10'} + cpu: [x86, ia32] + os: [win32] + + '@sentry/cli-win32-x64@2.58.5': + resolution: {integrity: sha512-IZf+XIMiQwj+5NzqbOQfywlOitmCV424Vtf9c+ep61AaVScUFD1TSrQbOcJJv5xGxhlxNOMNgMeZhdexdzrKZg==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@sentry/cli@2.58.5': + resolution: {integrity: sha512-tavJ7yGUZV+z3Ct2/ZB6mg339i08sAk6HDkgqmSRuQEu2iLS5sl9HIvuXfM6xjv8fwlgFOSy++WNABNAcGHUbg==} + engines: {node: '>= 10'} + hasBin: true + + '@sentry/core@10.43.0': + resolution: {integrity: sha512-l0SszQAPiQGWl/ferw8GP3ALyHXiGiRKJaOvNmhGO+PrTQyZTZ6OYyPnGijAFRg58dE1V3RCH/zw5d2xSUIiNg==} + engines: {node: '>=18'} + + '@sentry/react@10.43.0': + resolution: {integrity: sha512-shvErEpJ41i0Q3lIZl0CDWYQ7m8yHLi7ECG0gFvN8zf8pEdl5grQIOoe3t/GIUzcpCcor16F148ATmKJJypc/Q==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.14.0 || 17.x || 18.x || 19.x + + '@sentry/rollup-plugin@5.1.1': + resolution: {integrity: sha512-1d5NkdRR6aKWBP7czkY8sFFWiKnfmfRpQOj+m9bJTsyTjbMiEQJst6315w5pCVlRItPhBqpAraqAhutZFgvyVg==} + engines: {node: '>= 18'} + peerDependencies: + rollup: '>=4.59.0' + + '@sentry/vite-plugin@5.1.1': + resolution: {integrity: sha512-i6NWUDi2SDikfSUeMJvJTRdwEKYSfTd+mvBO2Ja51S1YK+hnickBuDfD+RvPerIXLuyRu3GamgNPbNqgCGUg/Q==} + engines: {node: '>= 18'} + '@sindresorhus/is@7.2.0': resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} @@ -2329,6 +2536,9 @@ packages: '@speed-highlight/core@1.2.14': resolution: {integrity: sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@stylistic/eslint-plugin@5.10.0': resolution: {integrity: sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2517,9 +2727,41 @@ packages: '@tanstack/virtual-core@3.13.21': resolution: {integrity: sha512-ww+fmLHyCbPSf7JNbWZP3g7wl6SdNo3ah5Aiw+0e9FDErkVHLKprYUrwTm7dF646FtEkN/KkAKPYezxpmvOjxw==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -2532,9 +2774,15 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/chroma-js@3.1.2': resolution: {integrity: sha512-YBTQqArPN8A0niHXCwrO1z5x++a+6l0mLBykncUpr23oIPW7L4h39s6gokdK/bDrPmSh8+TjMmrhBPnyiaWPmQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@0.0.39': resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==} @@ -2789,6 +3037,49 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/coverage-v8@4.1.0': + resolution: {integrity: sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ==} + peerDependencies: + '@vitest/browser': 4.1.0 + vitest: 4.1.0 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.0': + resolution: {integrity: sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==} + + '@vitest/mocker@4.1.0': + resolution: {integrity: sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.0': + resolution: {integrity: sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==} + + '@vitest/runner@4.1.0': + resolution: {integrity: sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==} + + '@vitest/snapshot@4.1.0': + resolution: {integrity: sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==} + + '@vitest/spy@4.1.0': + resolution: {integrity: sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==} + + '@vitest/ui@4.1.0': + resolution: {integrity: sha512-sTSDtVM1GOevRGsCNhp1mBUHKo9Qlc55+HCreFT4fe99AHxl1QQNXSL3uj4Pkjh5yEuWZIx8E2tVC94nnBZECQ==} + peerDependencies: + vitest: 4.1.0 + + '@vitest/utils@4.1.0': + resolution: {integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2799,6 +3090,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + ajv@6.14.0: resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} @@ -2816,6 +3111,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -2823,6 +3122,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -2859,9 +3161,16 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + ast-v8-to-istanbul@1.0.0: + resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -2922,6 +3231,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -2987,6 +3299,10 @@ packages: caniuse-lite@1.0.30001777: resolution: {integrity: sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -3070,10 +3386,17 @@ packages: resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} engines: {node: '>=8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css-what@6.2.2: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -3085,6 +3408,10 @@ packages: damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -3146,6 +3473,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -3158,6 +3489,12 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -3174,6 +3511,10 @@ packages: dot-case@3.0.4: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -3208,6 +3549,10 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} @@ -3237,6 +3582,9 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -3445,6 +3793,9 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -3460,6 +3811,10 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -3498,6 +3853,9 @@ packages: picomatch: optional: true + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -3520,6 +3878,9 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} + flatted@3.4.0: + resolution: {integrity: sha512-kC6Bb+ooptOIvWj5B63EQWkF0FEnNjV2ZNkLMLZRDDduIiWeFF4iKnslwhiWxjAdbg4NzTNo6h0qLuvFrcx+Sw==} + flatted@3.4.1: resolution: {integrity: sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==} @@ -3632,6 +3993,10 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -3700,6 +4065,13 @@ packages: html-dom-parser@5.1.8: resolution: {integrity: sha512-MCIUng//mF2qTtGHXJWr6OLfHWmg3Pm8ezpfiltF83tizPWY17JxT4dRLE8lykJ5bChJELoY3onQKPbufJHxYA==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-parse-stringify@3.0.1: resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} @@ -3717,6 +4089,10 @@ packages: htmlparser2@9.0.0: resolution: {integrity: sha512-uxbSI98wmFT/G4P2zXx4OVx04qWUmyFPrD2/CNepa2Zo3GPNaCaaxElDgwUrwYWkK1nr9fft0Ya8dws8coDLLQ==} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + i18next-browser-languagedetector@8.2.1: resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} @@ -3756,6 +4132,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + inline-style-parser@0.2.2: resolution: {integrity: sha512-EcKzdTHVe8wFVOGEYXiW9WmJXPjqi1T+234YpJr98RiFYKHV3cdy1+3mkTE+KHTHxFFLH51SfaGOoUdW+v7ViQ==} @@ -3862,6 +4242,9 @@ packages: resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -3912,6 +4295,18 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} @@ -3950,6 +4345,9 @@ packages: react: optional: true + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3957,6 +4355,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@29.0.0: + resolution: {integrity: sha512-9FshNB6OepopZ08unmmGpsF7/qCjxGPbo3NbgfJAnPeHXnsODE9WWffXZtRFRFe0ntzaAOcSKNJFz8wiyvF1jQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -4080,15 +4487,30 @@ packages: resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} engines: {node: 20 || >=22} + lru-cache@11.2.7: + resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.25.9: resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -4100,8 +4522,11 @@ packages: resolution: {integrity: sha512-Xs9/6pE1eL/F5bP11jrtsZXiPlCda+mW5UC21DifvpjHWvAZsz4rq24rXd4s5/oPrIZKJqP8Fnfcic870ben2w==} engines: {node: '>=22.0.0'} - matrix-widget-api@1.13.0: - resolution: {integrity: sha512-+LrvwkR1izL4h2euX8PDrvG/3PZZDEd6As+lmnR3jAVwbFJtU5iTnwmZGnCca9ddngCvXvAHkcpJBEPyPTZneQ==} + matrix-widget-api@1.17.0: + resolution: {integrity: sha512-5FHoo3iEP3Bdlv5jsYPWOqj+pGdFQNLWnJLiB0V7Ygne7bb+Gsj3ibyFyHWC6BVw+Z+tSW4ljHpO17I9TwStwQ==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} media-query-parser@2.0.2: resolution: {integrity: sha512-1N4qp+jE0pL5Xv4uEcwVUhIkwdUO3S/9gML90nqKA7v7FcOS5vUtatfzok9S9U1EJU8dHWlcv95WLnKmmxZI9w==} @@ -4118,6 +4543,10 @@ packages: resolution: {integrity: sha512-H/E3J6t+DQs/F2YgfDhxUVZz/dF8JXPPKTLHL/yHCcLZLtCXJDUaqvhJXQwqOVBvbyNn4T0WjLpIHd7PAw7fBA==} hasBin: true + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + miniflare@4.20260310.0: resolution: {integrity: sha512-uC5vNPenFpDSj5aUU3wGSABG6UUqMr+Xs1m4AkCrTHo37F4Z6xcQw5BXqViTfPDVT/zcYH1UgTVoXhr1l6ZMXw==} engines: {node: '>=18.0.0'} @@ -4146,6 +4575,10 @@ packages: motion-utils@12.29.2: resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -4220,6 +4653,9 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + oidc-client-ts@3.4.1: resolution: {integrity: sha512-jNdst/U28Iasukx/L5MP6b274Vr7ftQs6qAhPBCvz6Wt5rPCA+Q/tUmCzfCHHWweWw5szeMy2Gfrm1rITwUKrw==} engines: {node: '>=18'} @@ -4265,6 +4701,9 @@ packages: parse-srcset@1.0.2: resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -4337,13 +4776,24 @@ packages: resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} engines: {node: ^14.13.1 || >=16.0.0} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prismjs@1.30.0: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -4411,6 +4861,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-property@2.0.2: resolution: {integrity: sha512-+PbtI3VuDV0l6CleQMsx2gtK0JZbZKbpdu5ynr+lbsuvtmgbNcS3VM0tuY2QjFNOcWxvXeHjDpy42RO+4U2rug==} @@ -4445,6 +4898,10 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -4526,6 +4983,10 @@ packages: sanitize-html@2.17.1: resolution: {integrity: sha512-ehFCW+q1a4CSOWRAdX97BX/6/PDEkCqw7/0JXZAGQV57FQB3YOkTa/rrzHPeJ+Aghy4vZAFfWMYyfxIiB7F/gw==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -4589,10 +5050,17 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + slate-dom@0.123.0: resolution: {integrity: sha512-OUinp4tvSrAlt64JL9y20Xin08jgnnj1gJmIuPdGvU5MELKXRNZh17a7EKKNOS6OZPAE8Dk9NI1MAIS/Qz0YBw==} peerDependencies: @@ -4649,6 +5117,12 @@ packages: resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} engines: {node: '>=12.0.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -4696,6 +5170,10 @@ packages: resolution: {integrity: sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==} engines: {node: '>=10'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -4725,6 +5203,9 @@ packages: svg-parser@2.0.4: resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + synckit@0.11.12: resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} engines: {node: ^14.18.0 || >=16.0.0} @@ -4755,20 +5236,50 @@ packages: tiny-invariant@1.3.1: resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.0.25: + resolution: {integrity: sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw==} + + tldts@7.0.25: + resolution: {integrity: sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} tr46@1.0.1: resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -4840,6 +5351,10 @@ packages: resolution: {integrity: sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==} engines: {node: '>=20.18.1'} + undici@7.24.3: + resolution: {integrity: sha512-eJdUmK/Wrx2d+mnWWmwwLRyA7OQCkLap60sk3dOK4ViZR7DKwwptwuIvFBg2HaiP9ESaEdhtpSymQPvytpmkCA==} + engines: {node: '>=20.18.1'} + unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} @@ -4975,10 +5490,49 @@ packages: yaml: optional: true + vitest@4.1.0: + resolution: {integrity: sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.0 + '@vitest/browser-preview': 4.1.0 + '@vitest/browser-webdriverio': 4.1.0 + '@vitest/ui': 4.1.0 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + walk-up-path@4.0.0: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} @@ -4989,6 +5543,18 @@ packages: webidl-conversions@4.0.2: resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -5016,6 +5582,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -5100,6 +5671,13 @@ packages: utf-8-validate: optional: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -5141,6 +5719,8 @@ packages: snapshots: + '@adobe/css-tools@4.4.4': {} + '@apideck/better-ajv-errors@0.3.6(ajv@8.18.0)': dependencies: ajv: 8.18.0 @@ -5148,6 +5728,24 @@ snapshots: jsonpointer: 5.0.1 leven: 3.1.0 + '@asamuzakjp/css-color@5.0.1': + dependencies: + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.2.7 + + '@asamuzakjp/dom-selector@7.0.3': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.7 + + '@asamuzakjp/nwsapi@2.3.9': {} + '@atlaskit/pragmatic-drag-and-drop-auto-scroll@1.4.0': dependencies: '@atlaskit/pragmatic-drag-and-drop': 1.7.9 @@ -5833,6 +6431,12 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bcoe/v8-coverage@1.0.2': {} + + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@cloudflare/kv-asset-handler@0.4.2': {} '@cloudflare/unenv-preset@2.15.0(unenv@2.0.0-rc.24)(workerd@1.20260310.1)': @@ -5873,7 +6477,29 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 - '@element-hq/element-call-embedded@0.16.3': {} + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.1(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} '@emnapi/core@1.8.1': dependencies: @@ -6031,6 +6657,8 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@exodus/bytes@1.15.0': {} + '@fontsource-variable/nunito@5.2.7': {} '@fontsource/space-mono@5.2.9': {} @@ -6360,8 +6988,15 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.19.1': optional: true + '@phosphor-icons/react@2.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + '@pkgr/core@0.2.9': {} + '@polka/url@1.0.0-next.29': {} + '@poppinss/colors@4.1.6': dependencies: kleur: 4.1.5 @@ -7484,10 +8119,125 @@ snapshots: '@rtsao/scc@1.1.0': {} + '@sableclient/sable-call-embedded@1.1.3': {} + + '@sentry-internal/browser-utils@10.43.0': + dependencies: + '@sentry/core': 10.43.0 + + '@sentry-internal/feedback@10.43.0': + dependencies: + '@sentry/core': 10.43.0 + + '@sentry-internal/replay-canvas@10.43.0': + dependencies: + '@sentry-internal/replay': 10.43.0 + '@sentry/core': 10.43.0 + + '@sentry-internal/replay@10.43.0': + dependencies: + '@sentry-internal/browser-utils': 10.43.0 + '@sentry/core': 10.43.0 + + '@sentry/babel-plugin-component-annotate@5.1.1': {} + + '@sentry/browser@10.43.0': + dependencies: + '@sentry-internal/browser-utils': 10.43.0 + '@sentry-internal/feedback': 10.43.0 + '@sentry-internal/replay': 10.43.0 + '@sentry-internal/replay-canvas': 10.43.0 + '@sentry/core': 10.43.0 + + '@sentry/bundler-plugin-core@5.1.1': + dependencies: + '@babel/core': 7.29.0 + '@sentry/babel-plugin-component-annotate': 5.1.1 + '@sentry/cli': 2.58.5 + dotenv: 16.6.1 + find-up: 5.0.0 + glob: 13.0.6 + magic-string: 0.30.21 + transitivePeerDependencies: + - encoding + - supports-color + + '@sentry/cli-darwin@2.58.5': + optional: true + + '@sentry/cli-linux-arm64@2.58.5': + optional: true + + '@sentry/cli-linux-arm@2.58.5': + optional: true + + '@sentry/cli-linux-i686@2.58.5': + optional: true + + '@sentry/cli-linux-x64@2.58.5': + optional: true + + '@sentry/cli-win32-arm64@2.58.5': + optional: true + + '@sentry/cli-win32-i686@2.58.5': + optional: true + + '@sentry/cli-win32-x64@2.58.5': + optional: true + + '@sentry/cli@2.58.5': + dependencies: + https-proxy-agent: 5.0.1 + node-fetch: 2.7.0 + progress: 2.0.3 + proxy-from-env: 1.1.0 + which: 2.0.2 + optionalDependencies: + '@sentry/cli-darwin': 2.58.5 + '@sentry/cli-linux-arm': 2.58.5 + '@sentry/cli-linux-arm64': 2.58.5 + '@sentry/cli-linux-i686': 2.58.5 + '@sentry/cli-linux-x64': 2.58.5 + '@sentry/cli-win32-arm64': 2.58.5 + '@sentry/cli-win32-i686': 2.58.5 + '@sentry/cli-win32-x64': 2.58.5 + transitivePeerDependencies: + - encoding + - supports-color + + '@sentry/core@10.43.0': {} + + '@sentry/react@10.43.0(react@18.3.1)': + dependencies: + '@sentry/browser': 10.43.0 + '@sentry/core': 10.43.0 + react: 18.3.1 + + '@sentry/rollup-plugin@5.1.1(rollup@4.59.0)': + dependencies: + '@sentry/bundler-plugin-core': 5.1.1 + magic-string: 0.30.21 + rollup: 4.59.0 + transitivePeerDependencies: + - encoding + - supports-color + + '@sentry/vite-plugin@5.1.1(rollup@4.59.0)': + dependencies: + '@sentry/bundler-plugin-core': 5.1.1 + '@sentry/rollup-plugin': 5.1.1(rollup@4.59.0) + transitivePeerDependencies: + - encoding + - rollup + - supports-color + '@sindresorhus/is@7.2.0': {} '@speed-highlight/core@1.2.14': {} + '@standard-schema/spec@1.1.0': {} + '@stylistic/eslint-plugin@5.10.0(eslint@9.39.3(jiti@2.6.1))': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1)) @@ -7657,11 +8407,47 @@ snapshots: '@tanstack/virtual-core@3.13.21': {} + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.28.6 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.6 + '@testing-library/dom': 10.4.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.0 @@ -7683,8 +8469,15 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/chroma-js@3.1.2': {} + '@types/deep-eql@4.0.2': {} + '@types/estree@0.0.39': {} '@types/estree@1.0.8': {} @@ -7985,12 +8778,84 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/coverage-v8@4.1.0(vitest@4.1.0)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.0 + ast-v8-to-istanbul: 1.0.0 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.2 + obug: 2.1.1 + std-env: 4.0.0 + tinyrainbow: 3.1.0 + vitest: 4.1.0(@types/node@24.10.13)(@vitest/ui@4.1.0)(jsdom@29.0.0)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2)) + + '@vitest/expect@4.1.0': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.0 + '@vitest/utils': 4.1.0 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.0(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.1.0 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2) + + '@vitest/pretty-format@4.1.0': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.0': + dependencies: + '@vitest/utils': 4.1.0 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.0': + dependencies: + '@vitest/pretty-format': 4.1.0 + '@vitest/utils': 4.1.0 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.0': {} + + '@vitest/ui@4.1.0(vitest@4.1.0)': + dependencies: + '@vitest/utils': 4.1.0 + fflate: 0.8.2 + flatted: 3.4.0 + pathe: 2.0.3 + sirv: 3.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vitest: 4.1.0(@types/node@24.10.13)(@vitest/ui@4.1.0)(jsdom@29.0.0)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2)) + + '@vitest/utils@4.1.0': + dependencies: + '@vitest/pretty-format': 4.1.0 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 acorn@8.16.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 @@ -8013,6 +8878,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -8020,6 +8887,10 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -8089,8 +8960,16 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + ast-types-flow@0.0.8: {} + ast-v8-to-istanbul@1.0.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + async-function@1.0.0: {} async@3.2.6: {} @@ -8141,6 +9020,10 @@ snapshots: baseline-browser-mapping@2.10.0: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + binary-extensions@2.3.0: {} bind-event-listener@3.0.0: {} @@ -8203,6 +9086,8 @@ snapshots: caniuse-lite@1.0.30001777: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -8283,14 +9168,28 @@ snapshots: crypto-random-string@2.0.0: {} + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + css-what@6.2.2: {} + css.escape@1.5.1: {} + cssesc@3.0.0: {} csstype@3.2.3: {} damerau-levenshtein@1.0.8: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -8341,6 +9240,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + dequal@2.0.3: {} + detect-libc@2.1.2: {} direction@1.0.4: {} @@ -8349,6 +9250,10 @@ snapshots: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -8372,6 +9277,8 @@ snapshots: no-case: 3.0.4 tslib: 2.8.1 + dotenv@16.6.1: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -8401,6 +9308,8 @@ snapshots: entities@4.5.0: {} + entities@6.0.1: {} + entities@7.0.1: {} error-ex@1.3.4: @@ -8491,6 +9400,8 @@ snapshots: es-module-lexer@1.7.0: {} + es-module-lexer@2.0.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -8821,6 +9732,10 @@ snapshots: estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} eval@0.1.8: @@ -8832,6 +9747,8 @@ snapshots: events@3.3.0: {} + expect-type@1.3.0: {} + fast-deep-equal@3.1.3: {} fast-diff@1.3.0: {} @@ -8870,6 +9787,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fflate@0.8.2: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -8894,6 +9813,8 @@ snapshots: flatted: 3.4.1 keyv: 4.5.4 + flatted@3.4.0: {} + flatted@3.4.1: {} focus-trap-react@10.3.1(prop-types@15.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -9014,6 +9935,12 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 2.0.2 + glob@13.0.6: + dependencies: + minimatch: 10.2.4 + minipass: 7.1.3 + path-scurry: 2.0.2 + globals@14.0.0: {} globals@15.15.0: {} @@ -9073,6 +10000,14 @@ snapshots: domhandler: 5.0.3 htmlparser2: 10.1.0 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.0 + transitivePeerDependencies: + - '@noble/hashes' + + html-escaper@2.0.2: {} + html-parse-stringify@3.0.1: dependencies: void-elements: 3.1.0 @@ -9106,6 +10041,13 @@ snapshots: domutils: 3.2.2 entities: 4.5.0 + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + i18next-browser-languagedetector@8.2.1: dependencies: '@babel/runtime': 7.28.6 @@ -9139,6 +10081,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + inline-style-parser@0.2.2: {} internal-slot@1.1.0: @@ -9245,6 +10189,8 @@ snapshots: is-plain-object@5.0.0: {} + is-potential-custom-element-name@1.0.1: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -9292,6 +10238,19 @@ snapshots: isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -9322,12 +10281,40 @@ snapshots: '@types/react': 18.3.28 react: 18.3.1 + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: dependencies: argparse: 2.0.1 + jsdom@29.0.0: + dependencies: + '@asamuzakjp/css-color': 5.0.1 + '@asamuzakjp/dom-selector': 7.0.3 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.1(css-tree@3.2.1) + '@exodus/bytes': 1.15.0 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.7 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.24.3 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -9436,10 +10423,14 @@ snapshots: lru-cache@11.2.6: {} + lru-cache@11.2.7: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 + lz-string@1.5.0: {} + magic-string@0.25.9: dependencies: sourcemap-codec: 1.4.8 @@ -9448,6 +10439,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.2: + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + math-intrinsics@1.1.0: {} matrix-events-sdk@0.0.1: {} @@ -9462,18 +10463,20 @@ snapshots: jwt-decode: 4.0.0 loglevel: 1.9.2 matrix-events-sdk: 0.0.1 - matrix-widget-api: 1.13.0 + matrix-widget-api: 1.17.0 oidc-client-ts: 3.4.1 p-retry: 7.1.1 sdp-transform: 2.15.0 unhomoglyph: 1.0.6 uuid: 13.0.0 - matrix-widget-api@1.13.0: + matrix-widget-api@1.17.0: dependencies: '@types/events': 3.0.3 events: 3.3.0 + mdn-data@2.27.1: {} + media-query-parser@2.0.2: dependencies: '@babel/runtime': 7.28.6 @@ -9489,6 +10492,8 @@ snapshots: dependencies: yargs: 17.7.2 + min-indent@1.0.1: {} + miniflare@4.20260310.0: dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -9524,6 +10529,8 @@ snapshots: motion-utils@12.29.2: {} + mrmime@2.0.1: {} + ms@2.1.3: {} nanoid@3.3.11: {} @@ -9597,6 +10604,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + obug@2.1.1: {} + oidc-client-ts@3.4.1: dependencies: jwt-decode: 4.0.0 @@ -9668,6 +10677,10 @@ snapshots: parse-srcset@1.0.2: {} + parse5@8.0.0: + dependencies: + entities: 6.0.1 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -9722,14 +10735,24 @@ snapshots: pretty-bytes@6.1.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + prismjs@1.30.0: {} + progress@2.0.3: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 object-assign: 4.1.1 react-is: 16.13.1 + proxy-from-env@1.1.0: {} + punycode@2.3.1: {} queue-microtask@1.2.3: {} @@ -9829,6 +10852,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-property@2.0.2: {} react-range@1.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -9858,6 +10883,11 @@ snapshots: dependencies: picomatch: 2.3.1 + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -9989,6 +11019,10 @@ snapshots: parse-srcset: 1.0.2 postcss: 8.5.8 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -10092,8 +11126,16 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@4.1.0: {} + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + slate-dom@0.123.0(slate@0.123.0): dependencies: '@juggle/resize-observer': 3.4.0 @@ -10151,6 +11193,10 @@ snapshots: stable-hash-x@0.2.0: {} + stackback@0.0.2: {} + + std-env@4.0.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -10226,6 +11272,10 @@ snapshots: strip-comments@2.0.1: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@3.1.1: {} strip-json-comments@5.0.3: {} @@ -10248,6 +11298,8 @@ snapshots: svg-parser@2.0.4: {} + symbol-tree@3.2.4: {} + synckit@0.11.12: dependencies: '@pkgr/core': 0.2.9 @@ -10276,21 +11328,43 @@ snapshots: tiny-invariant@1.3.1: {} + tinybench@2.9.0: {} + + tinyexec@1.0.4: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.1.0: {} + + tldts-core@7.0.25: {} + + tldts@7.0.25: + dependencies: + tldts-core: 7.0.25 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + totalist@3.0.1: {} + + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.25 + tr46@0.0.3: {} tr46@1.0.1: dependencies: punycode: 2.3.1 + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -10376,6 +11450,8 @@ snapshots: undici@7.18.2: {} + undici@7.24.3: {} + unenv@2.0.0-rc.24: dependencies: pathe: 2.0.3 @@ -10527,14 +11603,59 @@ snapshots: terser: 5.46.0 yaml: 2.8.2 + vitest@4.1.0(@types/node@24.10.13)(@vitest/ui@4.1.0)(jsdom@29.0.0)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2)): + dependencies: + '@vitest/expect': 4.1.0 + '@vitest/mocker': 4.1.0(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.1.0 + '@vitest/runner': 4.1.0 + '@vitest/snapshot': 4.1.0 + '@vitest/spy': 4.1.0 + '@vitest/utils': 4.1.0 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.10.13 + '@vitest/ui': 4.1.0(vitest@4.1.0) + jsdom: 29.0.0 + transitivePeerDependencies: + - msw + void-elements@3.1.0: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + walk-up-path@4.0.0: {} webidl-conversions@3.0.1: {} webidl-conversions@4.0.2: {} + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -10591,6 +11712,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} workbox-background-sync@7.4.0: @@ -10738,6 +11864,10 @@ snapshots: ws@8.18.0: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + y18n@5.0.8: {} yallist@3.1.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 47b97ee36..b3fbcdfa3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,20 +1,22 @@ -engineStrict: true -minimumReleaseAge: 1440 - allowBuilds: + '@sentry/cli': true '@swc/core': true esbuild: true sharp: true unrs-resolver: true workerd: true +engineStrict: true +minimumReleaseAge: 1440 +minimumReleaseAgeExclude: + - '@sableclient/sable-call-embedded' overrides: - serialize-javascript: '>=7.0.3' - rollup: '>=4.59.0' - minimatch: '>=10.2.3' - lodash: '>=4.17.23' - esbuild: '>=0.25.0' brace-expansion: '>=1.1.12' + esbuild: '>=0.25.0' + lodash: '>=4.17.23' + minimatch: '>=10.2.3' + rollup: '>=4.59.0' + serialize-javascript: '>=7.0.3' peerDependencyRules: allowedVersions: diff --git a/public/manifest.json b/public/manifest.json index 67deb47a2..696b875ee 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -7,8 +7,8 @@ "display": "standalone", "orientation": "portrait", "start_url": "./", - "background_color": "#fff", - "theme_color": "#fff", + "background_color": "#1b1a21", + "theme_color": "#1b1a21", "icons": [ { "src": "./public/android/android-chrome-36x36.png", diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index a8e9e7b6a..fe2f84468 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -9,8 +9,8 @@ import { useCallThemeSync, useCallMemberSoundSync, } from '$hooks/useCallEmbed'; +import { CallEmbed, useClientWidgetApiEvent, ElementWidgetActions } from '$plugins/call'; import { callChatAtom, callEmbedAtom } from '$state/callEmbed'; -import { CallEmbed } from '$plugins/call'; import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; import { IncomingCallModal } from './IncomingCallModal'; @@ -20,12 +20,13 @@ function CallUtils({ embed }: { embed: CallEmbed }) { useCallMemberSoundSync(embed); useCallThemeSync(embed); - useCallHangupEvent( - embed, - useCallback(() => { - setCallEmbed(undefined); - }, [setCallEmbed]) - ); + + const handleCallEnd = useCallback(() => { + setCallEmbed(undefined); + }, [setCallEmbed]); + + useCallHangupEvent(embed, handleCallEnd); + useClientWidgetApiEvent(embed.call, ElementWidgetActions.Close, handleCallEnd); return null; } diff --git a/src/app/components/ClientConfigLoader.test.tsx b/src/app/components/ClientConfigLoader.test.tsx new file mode 100644 index 000000000..b80e40884 --- /dev/null +++ b/src/app/components/ClientConfigLoader.test.tsx @@ -0,0 +1,99 @@ +/** + * Integration tests: exercises the full config-load → setMatrixToBase → URL + * generation pipeline that App.tsx runs on startup. + * + * The pattern under test mirrors App.tsx: + * + * {(config) => { setMatrixToBase(config.matrixToBaseUrl); ... }} + * + * + * We mock fetch so we don't need a real config.json or a live matrix.to instance. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { setMatrixToBase, getMatrixToRoom, getMatrixToUser } from '$plugins/matrix-to'; +import { ClientConfigLoader } from './ClientConfigLoader'; + +afterEach(() => { + setMatrixToBase(); // reset module state to 'https://matrix.to' + vi.unstubAllGlobals(); +}); + +const mockFetch = (config: object) => + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ json: () => Promise.resolve(config) })); + +describe('ClientConfigLoader + matrix-to wiring', () => { + it('generates a standard matrix.to URL when no custom base is configured', async () => { + mockFetch({}); + + render( + + {(config) => { + setMatrixToBase(config.matrixToBaseUrl); + return {getMatrixToRoom('!room:example.com')}; + }} + + ); + + await waitFor(() => + expect(screen.getByTestId('link')).toHaveTextContent('https://matrix.to/#/!room:example.com') + ); + }); + + it('generates a custom-base URL for rooms when matrixToBaseUrl is set', async () => { + mockFetch({ matrixToBaseUrl: 'https://custom.example.org' }); + + render( + + {(config) => { + setMatrixToBase(config.matrixToBaseUrl); + return {getMatrixToRoom('!room:example.com')}; + }} + + ); + + await waitFor(() => + expect(screen.getByTestId('link')).toHaveTextContent( + 'https://custom.example.org/#/!room:example.com' + ) + ); + }); + + it('generates a custom-base URL for users when matrixToBaseUrl is set', async () => { + mockFetch({ matrixToBaseUrl: 'https://custom.example.org' }); + + render( + + {(config) => { + setMatrixToBase(config.matrixToBaseUrl); + return {getMatrixToUser('@alice:example.com')}; + }} + + ); + + await waitFor(() => + expect(screen.getByTestId('user')).toHaveTextContent( + 'https://custom.example.org/#/@alice:example.com' + ) + ); + }); + + it('strips a trailing slash from matrixToBaseUrl', async () => { + mockFetch({ matrixToBaseUrl: 'https://custom.example.org/' }); + + render( + + {(config) => { + setMatrixToBase(config.matrixToBaseUrl); + return {getMatrixToRoom('!room:example.com')}; + }} + + ); + + await waitFor(() => + expect(screen.getByTestId('link')).toHaveTextContent( + 'https://custom.example.org/#/!room:example.com' + ) + ); + }); +}); diff --git a/src/app/components/DefaultErrorPage.tsx b/src/app/components/DefaultErrorPage.tsx index edf8acf5f..62042cef1 100644 --- a/src/app/components/DefaultErrorPage.tsx +++ b/src/app/components/DefaultErrorPage.tsx @@ -1,9 +1,12 @@ import { Box, Button, Dialog, Icon, Icons, Text, color, config } from 'folds'; +import * as Sentry from '@sentry/react'; import { SplashScreen } from '$components/splash-screen'; import { buildGitHubUrl } from '$features/bug-report/BugReportModal'; type ErrorPageProps = { error: Error; + /** Sentry event ID — present when Sentry.ErrorBoundary captured the crash */ + eventId?: string; }; function createIssueUrl(error: Error): string { @@ -29,7 +32,9 @@ ${stacktrace} // It provides a user-friendly error message and options to report the issue or reload the page. // Motivation of the design is to encourage users to report issues while also providing them with the necessary information to do so, and to give them an easy way to recover by reloading the page. // Note: Since this component is rendered in response to an error, it should be as resilient as possible and avoid any complex logic or dependencies that could potentially throw additional errors. -export function ErrorPage({ error }: ErrorPageProps) { +export function ErrorPage({ error, eventId }: ErrorPageProps) { + const sentryEnabled = Sentry.isInitialized(); + const reportedToSentry = sentryEnabled && !!eventId; return ( @@ -52,20 +57,49 @@ export function ErrorPage({ error }: ErrorPageProps) { Oops! Something went wrong - An unexpected error occurred. Please try again. If it continues, report the issue on - our GitHub using the button below, it will include error details to help us - investigate. Thank you for helping improve the app. + {reportedToSentry + ? 'An unexpected error occurred. This crash has been automatically reported to our team. You can add more details to help us investigate.' + : 'An unexpected error occurred. Please try again. If it continues, report the issue on our GitHub using the button below, it will include error details to help us investigate. Thank you for helping improve the app.'} - + {reportedToSentry ? ( + + + + + ) : ( + + )} { + if (phase === VerificationPhase.Done) { + Sentry.metrics.count('sable.crypto.verification_outcome', 1, { + attributes: { outcome: 'completed' }, + }); + } else if (phase === VerificationPhase.Cancelled) { + Sentry.metrics.count('sable.crypto.verification_outcome', 1, { + attributes: { outcome: 'cancelled' }, + }); + } + }, [phase]); + return ( }> diff --git a/src/app/components/DeviceVerificationSetup.tsx b/src/app/components/DeviceVerificationSetup.tsx index 83c7187c1..78fe8ded2 100644 --- a/src/app/components/DeviceVerificationSetup.tsx +++ b/src/app/components/DeviceVerificationSetup.tsx @@ -70,7 +70,7 @@ function makeUIAAction( type SetupVerificationProps = { onComplete: (recoveryKey: string) => void; }; -function SetupVerification({ onComplete }: SetupVerificationProps) { +function SetupVerification({ onComplete }: Readonly) { const mx = useMatrixClient(); const alive = useAlive(); @@ -227,7 +227,7 @@ function SetupVerification({ onComplete }: SetupVerificationProps) { type RecoveryKeyDisplayProps = { recoveryKey: string; }; -function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) { +function RecoveryKeyDisplay({ recoveryKey }: Readonly) { const [show, setShow] = useState(false); const handleCopy = () => { @@ -241,7 +241,7 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) { FileSaver.saveAs(blob, 'recovery-key.txt'); }; - const safeToDisplayKey = show ? recoveryKey : recoveryKey.replace(/[^\s]/g, '*'); + const safeToDisplayKey = show ? recoveryKey : recoveryKey.replaceAll(/[^\s]/g, '*'); return ( diff --git a/src/app/components/IncomingCallModal.tsx b/src/app/components/IncomingCallModal.tsx index da17db4f3..3ab4e8457 100644 --- a/src/app/components/IncomingCallModal.tsx +++ b/src/app/components/IncomingCallModal.tsx @@ -19,14 +19,18 @@ import { getRoomAvatarUrl } from '$utils/room'; import { useRoomNavigate } from '$hooks/useRoomNavigate'; import FocusTrap from 'focus-trap-react'; import { stopPropagation } from '$utils/keyboard'; +import * as Sentry from '@sentry/react'; import { useAtom, useSetAtom } from 'jotai'; import { autoJoinCallIntentAtom, incomingCallRoomIdAtom, mutedCallRoomIdAtom, } from '$state/callEmbed'; +import { createDebugLogger } from '$utils/debugLogger'; import { RoomAvatar } from './room-avatar'; +const debugLog = createDebugLogger('IncomingCall'); + type IncomingCallInternalProps = { room: any; onClose: () => void; @@ -41,6 +45,13 @@ export function IncomingCallInternal({ room, onClose }: IncomingCallInternalProp const setMutedRoomId = useSetAtom(mutedCallRoomIdAtom); const handleAnswer = () => { + debugLog.info('call', 'Incoming call answered', { roomId: room.roomId }); + Sentry.addBreadcrumb({ + category: 'call.signal', + message: 'Incoming call answered', + data: { roomId: room.roomId }, + }); + Sentry.metrics.count('sable.call.answered', 1); setMutedRoomId(room.roomId); setAutoJoinIntent(room.roomId); onClose(); @@ -48,6 +59,13 @@ export function IncomingCallInternal({ room, onClose }: IncomingCallInternalProp }; const handleDecline = async () => { + debugLog.info('call', 'Incoming call declined', { roomId: room.roomId }); + Sentry.addBreadcrumb({ + category: 'call.signal', + message: 'Incoming call declined', + data: { roomId: room.roomId }, + }); + Sentry.metrics.count('sable.call.declined', 1); setMutedRoomId(room.roomId); onClose(); }; diff --git a/src/app/components/Pdf-viewer/PdfViewer.tsx b/src/app/components/Pdf-viewer/PdfViewer.tsx index e3072e6ff..71ab77efb 100644 --- a/src/app/components/Pdf-viewer/PdfViewer.tsx +++ b/src/app/components/Pdf-viewer/PdfViewer.tsx @@ -85,7 +85,7 @@ export const PdfViewer = as<'div', PdfViewerProps>( if (docState.status !== AsyncStatus.Success) return; const jumpInput = evt.currentTarget.jumpInput as HTMLInputElement; if (!jumpInput) return; - const jumpTo = parseInt(jumpInput.value, 10); + const jumpTo = Number.parseInt(jumpInput.value, 10); setPageNo(Math.max(1, Math.min(docState.data.numPages, jumpTo))); setJumpAnchor(undefined); }; diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index 1a1a5376f..b892f7691 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -28,7 +28,7 @@ import { VideoContent, } from './message'; import { UrlPreviewCard, UrlPreviewHolder } from './url-preview'; -import { Image, MediaControl, Video } from './media'; +import { Image, MediaControl, PersistedVolumeVideo } from './media'; import { ImageViewer } from './image-viewer'; import { PdfViewer } from './Pdf-viewer'; import { TextViewer } from './text-viewer'; @@ -47,6 +47,7 @@ type RenderMessageContentProps = { htmlReactParserOptions: HTMLReactParserOptions; linkifyOpts: Opts; outlineAttachment?: boolean; + hideCaption?: boolean; }; const getMediaType = (url: string) => { @@ -71,6 +72,7 @@ function RenderMessageContentInternal({ htmlReactParserOptions, linkifyOpts, outlineAttachment, + hideCaption, }: RenderMessageContentProps) { const content = useMemo(() => getContent(), [getContent]); @@ -121,7 +123,7 @@ function RenderMessageContentInternal({ const renderCaption = () => { const hasCaption = content.body && content.body.trim().length > 0; - if (captionPosition === CaptionPosition.Hidden) return null; + if (captionPosition === CaptionPosition.Hidden || hideCaption) return null; if (hasCaption && content.filename && content.filename !== content.body) { if (captionPosition !== CaptionPosition.Inline) return ( @@ -321,7 +323,7 @@ function RenderMessageContentInternal({ ) : undefined } - renderVideo={(p) => + + + + + + + + + + {enabled && ( + + + + Recent Logs ( + {filterLevel !== 'all' || filterCategory !== 'all' + ? `${filteredLogs.length}/${logs.length}` + : `${logs.length}/1000`} + ) + + + + + + {filteredLogs.length === 0 ? ( + + + {logs.length === 0 + ? 'No logs captured yet. Use the app to generate log entries.' + : 'No logs match the current filters.'} + + + ) : ( + filteredLogs.map((log) => ( + + )) + )} + + + )} + + + ); +} diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index d65c97db0..b717f2261 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -11,6 +11,8 @@ import { copyToClipboard } from '$utils/dom'; import { SequenceCardStyle } from '$features/settings/styles.css'; import { AccountData } from './AccountData'; import { SyncDiagnostics } from './SyncDiagnostics'; +import { DebugLogViewer } from './DebugLogViewer'; +import { SentrySettings } from './SentrySettings'; type DeveloperToolsProps = { requestClose: () => void; @@ -120,6 +122,16 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { onSelect={setAccountDataType} /> )} + {developerTools && ( + + + + )} + {developerTools && ( + + + + )} diff --git a/src/app/features/settings/developer-tools/SentrySettings.tsx b/src/app/features/settings/developer-tools/SentrySettings.tsx new file mode 100644 index 000000000..b0425e76f --- /dev/null +++ b/src/app/features/settings/developer-tools/SentrySettings.tsx @@ -0,0 +1,153 @@ +import { useState, useEffect } from 'react'; +import { Box, Text, Switch, Button } from 'folds'; +import { SequenceCard } from '$components/sequence-card'; +import { SettingTile } from '$components/setting-tile'; +import { SequenceCardStyle } from '$features/settings/styles.css'; +import { getDebugLogger, LogCategory } from '$utils/debugLogger'; + +const ALL_CATEGORIES: LogCategory[] = [ + 'sync', + 'network', + 'notification', + 'message', + 'call', + 'ui', + 'timeline', + 'error', + 'general', +]; + +export function SentrySettings() { + const [categoryEnabled, setCategoryEnabled] = useState>(() => { + const logger = getDebugLogger(); + return Object.fromEntries( + ALL_CATEGORIES.map((c) => [c, logger.getBreadcrumbCategoryEnabled(c)]) + ) as Record; + }); + const [sentryStats, setSentryStats] = useState(() => getDebugLogger().getSentryStats()); + + useEffect(() => { + const interval = setInterval(() => { + setSentryStats(getDebugLogger().getSentryStats()); + }, 5000); + return () => clearInterval(interval); + }, []); + + const handleCategoryToggle = (category: LogCategory, enabled: boolean) => { + getDebugLogger().setBreadcrumbCategoryEnabled(category, enabled); + setCategoryEnabled((prev) => ({ ...prev, [category]: enabled })); + }; + + const handleExportLogs = () => { + const data = getDebugLogger().exportLogs(); + const blob = new Blob([data], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `sable-debug-logs-${Date.now()}.json`; + a.click(); + URL.revokeObjectURL(url); + }; + + const isSentryConfigured = Boolean(import.meta.env.VITE_SENTRY_DSN); + const sentryEnabled = localStorage.getItem('sable_sentry_enabled') === 'true'; + const environment = import.meta.env.VITE_SENTRY_ENVIRONMENT || import.meta.env.MODE; + const isProd = environment === 'production'; + const traceSampleRate = isProd ? '10%' : '100%'; + const replaySampleRate = isProd ? '10%' : '100%'; + + return ( + + Error Tracking (Sentry) + + Error reporting toggles are in Settings → General → Diagnostics & Privacy. + + {!isSentryConfigured && ( + + + Sentry is not configured. Set VITE_SENTRY_DSN to enable error tracking. + + + )} + + {isSentryConfigured && sentryEnabled && ( + <> + Performance Metrics + + + + + + + Breadcrumb Categories + + Control which log categories are included as breadcrumbs in Sentry error reports. + Disabling a category reduces noise without affecting error capture. + + + {ALL_CATEGORIES.map((cat) => ( + handleCategoryToggle(cat, v)} + /> + } + /> + ))} + + + Debug Logs + + + + Export JSON + + } + /> + + + )} + + ); +} diff --git a/src/app/features/settings/experimental/BandwithSavingEmojis.tsx b/src/app/features/settings/experimental/BandwithSavingEmojis.tsx new file mode 100644 index 000000000..7e660fba0 --- /dev/null +++ b/src/app/features/settings/experimental/BandwithSavingEmojis.tsx @@ -0,0 +1,33 @@ +import { SequenceCard } from '$components/sequence-card'; +import { SettingTile } from '$components/setting-tile'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { Box, Switch, Text } from 'folds'; +import { SequenceCardStyle } from '../styles.css'; + +export function BandwidthSavingEmojis() { + const [useBandwidthSaving, setUseBandwidthSaving] = useSetting( + settingsAtom, + 'saveStickerEmojiBandwidth' + ); + + return ( + + Save Bandwidth for Sticker and Emoji Images + + + } + /> + + + ); +} diff --git a/src/app/features/settings/experimental/Experimental.tsx b/src/app/features/settings/experimental/Experimental.tsx index d8375271c..c61bb5954 100644 --- a/src/app/features/settings/experimental/Experimental.tsx +++ b/src/app/features/settings/experimental/Experimental.tsx @@ -3,11 +3,13 @@ import { Page, PageContent, PageHeader } from '$components/page'; import { InfoCard } from '$components/info-card'; import { LanguageSpecificPronouns } from '../cosmetics/LanguageSpecificPronouns'; import { Sync } from '../general'; +import { BandwidthSavingEmojis } from './BandwithSavingEmojis'; +import { MSC4268HistoryShare } from './MSC4268HistoryShare'; type ExperimentalProps = { requestClose: () => void; }; -export function Experimental({ requestClose }: ExperimentalProps) { +export function Experimental({ requestClose }: Readonly) { return ( @@ -32,8 +34,8 @@ export function Experimental({ requestClose }: ExperimentalProps) { variant="Warning" description={ <> - The features listed below may be unstable or incomplete, - use at your own risk. + The features listed below may be unstable or incomplete,{' '} + use at your own risk.
Please report any new issues potentially caused by these features! @@ -42,7 +44,9 @@ export function Experimental({ requestClose }: ExperimentalProps) {
+ + diff --git a/src/app/features/settings/experimental/MSC4268HistoryShare.tsx b/src/app/features/settings/experimental/MSC4268HistoryShare.tsx new file mode 100644 index 000000000..d93a41031 --- /dev/null +++ b/src/app/features/settings/experimental/MSC4268HistoryShare.tsx @@ -0,0 +1,42 @@ +import { SequenceCard } from '$components/sequence-card'; +import { SettingTile } from '$components/setting-tile'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { Box, Switch, Text } from 'folds'; +import { SequenceCardStyle } from '../styles.css'; + +export function MSC4268HistoryShare() { + const [enabledMSC4268Command, setEnabledMSC4268Command] = useSetting( + settingsAtom, + 'enableMSC4268CMD' + ); + + return ( + + Enable Sharing of Encrypted History + + + } + /> + + + ); +} diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index fd0d9d705..0392e47da 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -56,7 +56,7 @@ type DateHintProps = { hasChanges: boolean; handleReset: () => void; }; -function DateHint({ hasChanges, handleReset }: DateHintProps) { +function DateHint({ hasChanges, handleReset }: Readonly) { const [anchor, setAnchor] = useState(); const categoryPadding = { padding: config.space.S200, paddingTop: 0 }; @@ -223,7 +223,7 @@ type CustomDateFormatProps = { value: string; onChange: (format: string) => void; }; -function CustomDateFormat({ value, onChange }: CustomDateFormatProps) { +function CustomDateFormat({ value, onChange }: Readonly) { const [dateFormatCustom, setDateFormatCustom] = useState(value); useEffect(() => { @@ -288,12 +288,12 @@ type PresetDateFormatProps = { value: string; onChange: (format: string) => void; }; -function PresetDateFormat({ value, onChange }: PresetDateFormatProps) { +function PresetDateFormat({ value, onChange }: Readonly) { const [menuCords, setMenuCords] = useState(); const dateFormatItems = useDateFormatItems(); const getDisplayDate = (format: string): string => - format !== '' ? dayjs().format(format) : 'Custom'; + format === '' ? 'Custom' : dayjs().format(format); const handleMenu: MouseEventHandler = (evt) => { setMenuCords(evt.currentTarget.getBoundingClientRect()); @@ -415,7 +415,7 @@ function DateAndTime() { ); } -function Editor({ isMobile }: { isMobile: boolean }) { +function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline'); const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown'); const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity'); @@ -681,7 +681,7 @@ function SelectMessageSpacing() { ); } -function SelectRightSwipeAction({ disabled }: { disabled?: boolean }) { +function SelectRightSwipeAction({ disabled }: Readonly<{ disabled?: boolean }>) { const [menuCords, setMenuCords] = useState(); const [action, setAction] = useSetting(settingsAtom, 'rightSwipeAction'); @@ -749,7 +749,7 @@ function SelectRightSwipeAction({ disabled }: { disabled?: boolean }) { ); } -function Gestures({ isMobile }: { isMobile: boolean }) { +function Gestures({ isMobile }: Readonly<{ isMobile: boolean }>) { const [mobileGestures, setMobileGestures] = useSetting(settingsAtom, 'mobileGestures'); return ( @@ -788,7 +788,7 @@ function EmojiSelectorThresholdInput() { const val = evt.target.value; setInputValue(val); - const parsed = parseInt(val, 10); + const parsed = Number.parseInt(val, 10); if (!Number.isNaN(parsed) && parsed >= 1 && parsed <= 10) { setEmojiThreshold(parsed); } @@ -809,7 +809,7 @@ function EmojiSelectorThresholdInput() { return ( + Calls + + + } + /> + + + ); +} + function Messages() { const [hideMembershipEvents, setHideMembershipEvents] = useSetting( settingsAtom, @@ -1053,7 +1078,110 @@ export function Sync() { type GeneralProps = { requestClose: () => void; }; -export function General({ requestClose }: GeneralProps) { + +function DiagnosticsAndPrivacy() { + const [sentryEnabled, setSentryEnabled] = useState( + localStorage.getItem('sable_sentry_enabled') === 'true' + ); + const [sessionReplayEnabled, setSessionReplayEnabled] = useState( + localStorage.getItem('sable_sentry_replay_enabled') === 'true' + ); + const [needsRefresh, setNeedsRefresh] = useState(false); + + const isSentryConfigured = Boolean(import.meta.env.VITE_SENTRY_DSN); + + const handleSentryToggle = (enabled: boolean) => { + setSentryEnabled(enabled); + if (enabled) { + localStorage.setItem('sable_sentry_enabled', 'true'); + } else { + localStorage.setItem('sable_sentry_enabled', 'false'); + } + setNeedsRefresh(true); + }; + + const handleReplayToggle = (enabled: boolean) => { + setSessionReplayEnabled(enabled); + if (enabled) { + localStorage.setItem('sable_sentry_replay_enabled', 'true'); + } else { + localStorage.removeItem('sable_sentry_replay_enabled'); + } + setNeedsRefresh(true); + }; + + return ( + + Diagnostics & Privacy + {needsRefresh && ( + + + Please refresh the page for these settings to take effect. + + + )} + + + } + /> + {sentryEnabled && isSentryConfigured && ( + + } + /> + )} + + + + + + ); +} + +export function General({ requestClose }: Readonly) { return ( @@ -1078,6 +1206,8 @@ export function General({ requestClose }: GeneralProps) { + + diff --git a/src/app/features/settings/notifications/PushNotifications.tsx b/src/app/features/settings/notifications/PushNotifications.tsx index 08310d28d..46c0ebb0d 100644 --- a/src/app/features/settings/notifications/PushNotifications.tsx +++ b/src/app/features/settings/notifications/PushNotifications.tsx @@ -1,6 +1,9 @@ import { MatrixClient } from '$types/matrix-sdk'; +import { createDebugLogger } from '$utils/debugLogger'; import { ClientConfig } from '../../../hooks/useClientConfig'; +const debugLog = createDebugLogger('PushNotifications'); + type PushSubscriptionState = [ PushSubscriptionJSON | null, (subscription: PushSubscription | null) => void, @@ -8,12 +11,18 @@ type PushSubscriptionState = [ export async function requestBrowserNotificationPermission(): Promise { if (!('Notification' in window)) { + debugLog.warn('notification', 'Notification API not available in this browser'); return 'denied'; } try { + debugLog.info('notification', 'Requesting browser notification permission'); const permission: NotificationPermission = await Notification.requestPermission(); + debugLog.info('notification', 'Notification permission result', { permission }); return permission; - } catch { + } catch (error) { + debugLog.error('notification', 'Failed to request notification permission', { + error: error instanceof Error ? error.message : String(error), + }); return 'denied'; } } @@ -24,8 +33,13 @@ export async function enablePushNotifications( pushSubscriptionAtom: PushSubscriptionState ): Promise { if (!('serviceWorker' in navigator) || !('PushManager' in window)) { + debugLog.error( + 'notification', + 'Push messaging not supported - missing serviceWorker or PushManager' + ); throw new Error('Push messaging is not supported in this browser.'); } + debugLog.info('notification', 'Enabling push notifications'); const [pushSubAtom, setPushSubscription] = pushSubscriptionAtom; const registration = await navigator.serviceWorker.ready; const currentBrowserSub = await registration.pushManager.getSubscription(); @@ -34,6 +48,9 @@ export async function enablePushNotifications( only when necessary. This prevents us from needing an external call to get back the web push info. */ if (currentBrowserSub && pushSubAtom && currentBrowserSub.endpoint === pushSubAtom.endpoint) { + debugLog.info('notification', 'Push subscription already exists and is valid - reusing', { + endpoint: pushSubAtom.endpoint, + }); const { keys } = pushSubAtom; if (!keys?.p256dh || !keys.auth) return; const pusherData = { @@ -45,8 +62,7 @@ export async function enablePushNotifications( lang: navigator.language || 'en', data: { url: clientConfig.pushNotificationDetails?.pushNotifyUrl, - // format: 'event_id_only' as const, - events_only: true, + format: 'event_id_only' as const, endpoint: pushSubAtom.endpoint, p256dh: keys.p256dh, auth: keys.auth, @@ -63,19 +79,25 @@ export async function enablePushNotifications( } if (currentBrowserSub) { + debugLog.info('notification', 'Unsubscribing old push subscription'); await currentBrowserSub.unsubscribe(); } + debugLog.info('notification', 'Creating new push subscription'); const newSubscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: clientConfig.pushNotificationDetails?.vapidPublicKey, }); + debugLog.info('notification', 'Push subscription created successfully', { + endpoint: newSubscription.endpoint, + }); setPushSubscription(newSubscription); const subJson = newSubscription.toJSON(); const { keys } = subJson; if (!keys?.p256dh || !keys.auth) { + debugLog.error('notification', 'Push subscription missing required keys'); throw new Error('Push subscription keys missing.'); } const pusherData = { @@ -88,7 +110,7 @@ export async function enablePushNotifications( lang: navigator.language || 'en', data: { url: clientConfig.pushNotificationDetails?.pushNotifyUrl, - // format: 'event_id_only' as const, + format: 'event_id_only' as const, endpoint: newSubscription.endpoint, p256dh: keys.p256dh, auth: keys.auth, @@ -113,6 +135,7 @@ export async function disablePushNotifications( clientConfig: ClientConfig, pushSubscriptionAtom: PushSubscriptionState ): Promise { + debugLog.info('notification', 'Disabling push notifications'); const [pushSubAtom] = pushSubscriptionAtom; const pusherData = { diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx index 87ab5fc09..c9b59549a 100644 --- a/src/app/features/settings/notifications/SystemNotification.tsx +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -189,6 +189,11 @@ export function SystemNotification() { const [showUnreadCounts, setShowUnreadCounts] = useSetting(settingsAtom, 'showUnreadCounts'); const [badgeCountDMsOnly, setBadgeCountDMsOnly] = useSetting(settingsAtom, 'badgeCountDMsOnly'); const [showPingCounts, setShowPingCounts] = useSetting(settingsAtom, 'showPingCounts'); + const [faviconForMentionsOnly, setFaviconForMentionsOnly] = useSetting( + settingsAtom, + 'faviconForMentionsOnly' + ); + const [highlightMentions, setHighlightMentions] = useSetting(settingsAtom, 'highlightMentions'); // Describe what the current badge combo actually does so users aren't left guessing. const badgeBehaviourSummary = (): string => { @@ -338,6 +343,24 @@ export function SystemNotification() { {badgeBehaviourSummary()} + + + } + /> + } /> + + + } + /> + ); } diff --git a/src/app/features/widgets/GenericWidgetDriver.ts b/src/app/features/widgets/GenericWidgetDriver.ts index 979d873c0..ee143b4f3 100644 --- a/src/app/features/widgets/GenericWidgetDriver.ts +++ b/src/app/features/widgets/GenericWidgetDriver.ts @@ -30,7 +30,7 @@ import { export type CapabilityApprovalCallback = (requested: Set) => Promise>; -// Unlike SmallWidgetDriver which auto-grants all capabilities for Element Call, +// Unlike CallWidgetDriver which auto-grants all capabilities for Element Call, // this driver provides a capability approval mechanism for untrusted widgets. export class GenericWidgetDriver extends WidgetDriver { private readonly mxClient: MatrixClient; @@ -164,6 +164,18 @@ export class GenericWidgetDriver extends WidgetDriver { await this.mxClient._unstable_updateDelayedEvent(delayId, action); } + public async cancelScheduledDelayedEvent(delayId: string): Promise { + await this.updateDelayedEvent(delayId, UpdateDelayedEventAction.Cancel); + } + + public async restartScheduledDelayedEvent(delayId: string): Promise { + await this.updateDelayedEvent(delayId, UpdateDelayedEventAction.Restart); + } + + public async sendScheduledDelayedEvent(delayId: string): Promise { + await this.updateDelayedEvent(delayId, UpdateDelayedEventAction.Send); + } + public async sendToDevice( eventType: string, encrypted: boolean, diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index 0688f2637..7fd5f2325 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -8,6 +8,9 @@ import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; import { pushSubscriptionAtom } from '../state/pushSubscription'; import { mobileOrTablet } from '../utils/user-agent'; +import { createDebugLogger } from '../utils/debugLogger'; + +const debugLog = createDebugLogger('AppVisibility'); export function useAppVisibility(mx: MatrixClient | undefined) { const clientConfig = useClientConfig(); @@ -18,6 +21,11 @@ export function useAppVisibility(mx: MatrixClient | undefined) { useEffect(() => { const handleVisibilityChange = () => { const isVisible = document.visibilityState === 'visible'; + debugLog.info( + 'general', + `App visibility changed: ${isVisible ? 'visible (foreground)' : 'hidden (background)'}`, + { visibilityState: document.visibilityState } + ); appEvents.onVisibilityChange?.(isVisible); if (!isVisible) { appEvents.onVisibilityHidden?.(); diff --git a/src/app/hooks/useAsyncCallback.test.tsx b/src/app/hooks/useAsyncCallback.test.tsx new file mode 100644 index 000000000..c27d06478 --- /dev/null +++ b/src/app/hooks/useAsyncCallback.test.tsx @@ -0,0 +1,126 @@ +// Integration tests: renderHook exercises the full React lifecycle including +// useAlive (cleanup on unmount) and retry-race-condition logic. +import { describe, it, expect } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useAsyncCallback, AsyncStatus } from './useAsyncCallback'; + +describe('useAsyncCallback', () => { + it('starts in Idle state', () => { + const { result } = renderHook(() => useAsyncCallback(async () => 'value')); + const [state] = result.current; + expect(state.status).toBe(AsyncStatus.Idle); + }); + + it('transitions to Success with returned data', async () => { + const { result } = renderHook(() => useAsyncCallback(async () => 42)); + + await act(async () => { + await result.current[1](); + }); + + expect(result.current[0]).toEqual({ status: AsyncStatus.Success, data: 42 }); + }); + + it('transitions to Error when the async function throws', async () => { + const boom = new Error('boom'); + const { result } = renderHook(() => + useAsyncCallback(async () => { + throw boom; + }) + ); + + await act(async () => { + await result.current[1]().catch(() => {}); + }); + + expect(result.current[0]).toEqual({ status: AsyncStatus.Error, error: boom }); + }); + + it('ignores the result of a stale (superseded) request', async () => { + // Two calls are made. The first resolves AFTER the second — its result should + // be discarded so the final state reflects only the second call. + let resolveFirst!: (v: string) => void; + let resolveSecond!: (v: string) => void; + let callCount = 0; + + const { result } = renderHook(() => + useAsyncCallback(async () => { + callCount += 1; + if (callCount === 1) { + return new Promise((res) => { + resolveFirst = res; + }); + } + return new Promise((res) => { + resolveSecond = res; + }); + }) + ); + + // Fire both requests before either resolves + act(() => { + result.current[1](); + }); + act(() => { + result.current[1](); + }); + + // Resolve the stale first request — its result should be ignored + await act(async () => { + resolveFirst('stale'); + await Promise.resolve(); + }); + + // Resolve the fresh second request — this should be the final state + await act(async () => { + resolveSecond('fresh'); + await Promise.resolve(); + }); + + const successStates = result.current[0]; + expect(successStates.status).toBe(AsyncStatus.Success); + if (successStates.status === AsyncStatus.Success) { + expect(successStates.data).toBe('fresh'); + } + }); + + it('does not call setState after the component unmounts', async () => { + let resolveAfterUnmount!: (v: string) => void; + const stateChanges: string[] = []; + + const { result, unmount } = renderHook(() => + useAsyncCallback( + async () => + new Promise((res) => { + resolveAfterUnmount = res; + }) + ) + ); + + // Track state changes via the third returned setter + const [, callback, setState] = result.current; + const originalSetState = setState; + // Patch setState to record calls + result.current[2] = (s) => { + stateChanges.push(typeof s === 'function' ? 'fn' : s.status); + originalSetState(s); + }; + + act(() => { + callback(); + }); + + unmount(); + + // Resolve after unmount — alive() returns false, so state should NOT be updated + await act(async () => { + resolveAfterUnmount('late'); + await Promise.resolve(); + }); + + // Only the Loading state (queued before unmount) may have been emitted; + // Success must not appear after unmount. + const successCalls = stateChanges.filter((s) => s === AsyncStatus.Success); + expect(successCalls).toHaveLength(0); + }); +}); diff --git a/src/app/hooks/useBlobCache.ts b/src/app/hooks/useBlobCache.ts index a3d5493cf..96ac18f74 100644 --- a/src/app/hooks/useBlobCache.ts +++ b/src/app/hooks/useBlobCache.ts @@ -3,6 +3,10 @@ import { useState, useEffect } from 'react'; const imageBlobCache = new Map(); const inflightRequests = new Map>(); +export function getBlobCacheStats(): { cacheSize: number; inflightCount: number } { + return { cacheSize: imageBlobCache.size, inflightCount: inflightRequests.size }; +} + export function useBlobCache(url?: string): string | undefined { const [cacheState, setCacheState] = useState<{ sourceUrl?: string; blobUrl?: string }>({ sourceUrl: url, @@ -23,15 +27,21 @@ export function useBlobCache(url?: string): string | undefined { const fetchBlob = async () => { if (inflightRequests.has(url)) { - const existingBlobUrl = await inflightRequests.get(url); - if (isMounted) setCacheState({ sourceUrl: url, blobUrl: existingBlobUrl }); + try { + const existingBlobUrl = await inflightRequests.get(url); + if (isMounted) setCacheState({ sourceUrl: url, blobUrl: existingBlobUrl }); + } catch { + // Inflight request failed, silently ignore (consistent with fetchBlob behavior) + } return; } const requestPromise = (async () => { try { const res = await fetch(url, { mode: 'cors' }); - if (!res.ok) throw new Error(); + if (!res.ok) { + throw new Error(`Failed to fetch blob: ${res.status} ${res.statusText}`); + } const blob = await res.blob(); const objectUrl = URL.createObjectURL(blob); diff --git a/src/app/hooks/useCallEmbed.ts b/src/app/hooks/useCallEmbed.ts index e666bc3ae..a5bd94b9d 100644 --- a/src/app/hooks/useCallEmbed.ts +++ b/src/app/hooks/useCallEmbed.ts @@ -2,8 +2,7 @@ import { createContext, RefObject, useCallback, useContext, useEffect, useState import { MatrixRTCSession } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession'; import { MatrixClient, Room } from 'matrix-js-sdk'; import { useSetAtom } from 'jotai'; -import { settingsAtom } from '$state/settings'; -import { useSetting } from '$state/hooks/settings'; +import * as Sentry from '@sentry/react'; import { CallEmbed, ElementCallThemeKind, @@ -17,6 +16,9 @@ import { useResizeObserver } from './useResizeObserver'; import { CallControlState } from '../plugins/call/CallControlState'; import { useCallMembersChange, useCallSession } from './useCall'; import { CallPreferences } from '../state/callPreferences'; +import { createDebugLogger } from '../utils/debugLogger'; + +const debugLog = createDebugLogger('useCallEmbed'); const CallEmbedContext = createContext(undefined); @@ -69,11 +71,29 @@ export const useCallStart = (dm = false) => { (room: Room, pref?: CallPreferences) => { const container = callEmbedRef.current; if (!container) { + debugLog.error('call', 'Failed to start call — no embed container', { + roomId: room.roomId, + }); + Sentry.metrics.count('sable.call.start.error', 1, { + attributes: { reason: 'no_container' }, + }); throw new Error('Failed to start call, No embed container element found!'); } - const callEmbed = createCallEmbed(mx, room, dm, theme.kind, container, pref); - - setCallEmbed(callEmbed); + try { + debugLog.info('call', 'Starting call', { roomId: room.roomId, dm }); + Sentry.metrics.count('sable.call.start.attempt', 1, { attributes: { dm: String(dm) } }); + const callEmbed = createCallEmbed(mx, room, dm, theme.kind, container, pref); + setCallEmbed(callEmbed); + } catch (err) { + debugLog.error('call', 'Call embed creation failed', { + roomId: room.roomId, + error: err instanceof Error ? err.message : String(err), + }); + Sentry.metrics.count('sable.call.start.error', 1, { + attributes: { reason: 'embed_create_failed' }, + }); + throw err; + } }, [mx, dm, theme, setCallEmbed, callEmbedRef] ); @@ -83,19 +103,6 @@ export const useCallStart = (dm = false) => { export const useCallJoined = (embed?: CallEmbed): boolean => { const [joined, setJoined] = useState(embed?.joined ?? false); - const [allowPip] = useSetting(settingsAtom, 'allowPipVideos'); - - if (embed && allowPip) { - const removeDisablePictureInPicture = (mutated: any) => { - mutated.forEach((event: any) => { - Array.from(event.target.getElementsByTagName('video')).forEach((video: any) => { - video.removeAttribute('disablepictureinpicture'); - }); - }); - }; - const pipObserver = new MutationObserver(removeDisablePictureInPicture); - pipObserver.observe(embed.iframe.contentDocument!, { subtree: true, childList: true }); - } useClientWidgetApiEvent( embed?.call, diff --git a/src/app/hooks/useCallSignaling.ts b/src/app/hooks/useCallSignaling.ts index cd657e477..16620acb9 100644 --- a/src/app/hooks/useCallSignaling.ts +++ b/src/app/hooks/useCallSignaling.ts @@ -1,4 +1,5 @@ import { useEffect, useRef, useCallback } from 'react'; +import * as Sentry from '@sentry/react'; import { RoomStateEvent } from 'matrix-js-sdk'; import { MatrixRTCSession } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession'; import { MatrixRTCSessionManagerEvents } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSessionManager'; @@ -7,6 +8,9 @@ import { mDirectAtom } from '$state/mDirectList'; import { incomingCallRoomIdAtom, mutedCallRoomIdAtom } from '$state/callEmbed'; import RingtoneSound from '$public/sound/ringtone.webm'; import { useMatrixClient } from './useMatrixClient'; +import { createDebugLogger } from '../utils/debugLogger'; + +const debugLog = createDebugLogger('CallSignaling'); type CallPhase = 'IDLE' | 'RINGING_OUT' | 'RINGING_IN' | 'ACTIVE' | 'ENDED'; @@ -29,6 +33,13 @@ export function useCallSignaling() { const mutedRoomId = useAtomValue(mutedCallRoomIdAtom); const setMutedRoomId = useSetAtom(mutedCallRoomIdAtom); + // Stable refs so volatile values (mutedRoomId, ring callbacks) don't force + // the listener registration effect to re-run — which would cause the + // SessionEnded and RoomState.events listeners to accumulate when muting + // or when call state changes rapidly during a sync retry cycle. + const mutedRoomIdRef = useRef(mutedRoomId); + mutedRoomIdRef.current = mutedRoomId; + useEffect(() => { const inc = new Audio(RingtoneSound); inc.loop = true; @@ -72,6 +83,16 @@ export function useCallSignaling() { [setIncomingCall] ); + // Must be declared after the callbacks above so the initial useRef(value) call + // sees their current identity. Updated on every render so the effect closure + // always calls the latest version without needing them in the dep array. + const playRingingRef = useRef(playRinging); + playRingingRef.current = playRinging; + const stopRingingRef = useRef(stopRinging); + stopRingingRef.current = stopRinging; + const playOutgoingRingingRef = useRef(playOutgoingRinging); + playOutgoingRingingRef.current = playOutgoingRinging; + useEffect(() => { if (!mx || !mx.matrixRTC) return undefined; @@ -81,7 +102,7 @@ export function useCallSignaling() { const signal = Array.from(mDirects).reduce( (acc, roomId) => { - if (acc.incoming || mutedRoomId === roomId) return acc; + if (acc.incoming || mutedRoomIdRef.current === roomId) return acc; const room = mx.getRoom(roomId); if (!room) return acc; @@ -104,12 +125,32 @@ export function useCallSignaling() { // being called if (remoteMembers.length > 0 && !isSelfInCall) { + if (currentPhase !== 'RINGING_IN') { + debugLog.info('call', 'Incoming call detected', { + roomId, + remoteCount: remoteMembers.length, + }); + Sentry.addBreadcrumb({ + category: 'call.signal', + message: 'Incoming call ringing', + data: { roomId }, + }); + } callPhaseRef.current[roomId] = 'RINGING_IN'; return { ...acc, incoming: roomId }; } // multiple people no ringtone if (isSelfInCall && remoteMembers.length > 0) { + if (currentPhase !== 'ACTIVE') { + debugLog.info('call', 'Call became active', { roomId }); + Sentry.addBreadcrumb({ + category: 'call.signal', + message: 'Call active', + data: { roomId }, + }); + Sentry.metrics.count('sable.call.active', 1); + } callPhaseRef.current[roomId] = 'ACTIVE'; return acc; } @@ -118,6 +159,15 @@ export function useCallSignaling() { if (isSelfInCall && remoteMembers.length === 0) { // Check if post call if (currentPhase === 'ACTIVE' || currentPhase === 'ENDED') { + if (currentPhase !== 'ENDED') { + debugLog.info('call', 'Call ended', { roomId }); + Sentry.addBreadcrumb({ + category: 'call.signal', + message: 'Call ended', + data: { roomId }, + }); + Sentry.metrics.count('sable.call.ended', 1); + } callPhaseRef.current[roomId] = 'ENDED'; return acc; } @@ -127,10 +177,20 @@ export function useCallSignaling() { if (!outgoingStartRef.current) outgoingStartRef.current = now; if (now - outgoingStartRef.current < 30000) { + if (currentPhase !== 'RINGING_OUT') { + debugLog.info('call', 'Outgoing call ringing', { roomId }); + Sentry.addBreadcrumb({ + category: 'call.signal', + message: 'Outgoing call ringing', + data: { roomId }, + }); + } callPhaseRef.current[roomId] = 'RINGING_OUT'; return { ...acc, outgoing: roomId }; } + debugLog.info('call', 'Outgoing call timed out (unanswered)', { roomId }); + Sentry.metrics.count('sable.call.timeout', 1); callPhaseRef.current[roomId] = 'ENDED'; } } @@ -141,11 +201,11 @@ export function useCallSignaling() { ); if (signal.incoming) { - playRinging(signal.incoming); + playRingingRef.current(signal.incoming); } else if (signal.outgoing) { - playOutgoingRinging(signal.outgoing); + playOutgoingRingingRef.current(signal.outgoing); } else { - stopRinging(); + stopRingingRef.current(); if (!signal.outgoing) outgoingStartRef.current = null; } }; @@ -155,7 +215,7 @@ export function useCallSignaling() { const handleUpdate = () => checkDMsForActiveCalls(); const handleSessionEnded = (roomId: string) => { - if (mutedRoomId === roomId) setMutedRoomId(null); + if (mutedRoomIdRef.current === roomId) setMutedRoomId(null); callPhaseRef.current[roomId] = 'IDLE'; checkDMsForActiveCalls(); }; @@ -171,9 +231,9 @@ export function useCallSignaling() { mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, handleUpdate); mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, handleSessionEnded); mx.off(RoomStateEvent.Events, handleUpdate); - stopRinging(); + stopRingingRef.current(); }; - }, [mx, mDirects, playRinging, stopRinging, mutedRoomId, setMutedRoomId, playOutgoingRinging]); + }, [mx, mDirects, setMutedRoomId]); // stable: volatile deps accessed via refs above return null; } diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 4d6cec62a..87685337d 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -40,6 +40,8 @@ export type ClientConfig = { }; hashRouter?: HashRouterConfig; + + matrixToBaseUrl?: string; }; const ClientConfigContext = createContext(null); diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index 0c2008c01..199927883 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -10,7 +10,6 @@ import { Visibility, RoomServerAclEventContent, MsgType, - MatrixEvent, } from '$types/matrix-sdk'; import { useMemo } from 'react'; import { Membership, StateEvent } from '$types/matrix/room'; @@ -32,20 +31,21 @@ import { settingsAtom } from '$state/settings'; import { useOpenBugReportModal } from '$state/hooks/bugReportModal'; import { createRoomEncryptionState } from '$components/create-room'; import { parsePronounsInput } from '$utils/pronouns'; +import { sendFeedback } from '$utils/sendFeedbackToUser'; import { useRoomNavigate } from './useRoomNavigate'; import { enrichWidgetUrl } from './useRoomWidgets'; import { useUserProfile } from './useUserProfile'; -export const SHRUG = '¯\\_(ツ)_/¯'; +export const SHRUG = String.raw`¯\_(ツ)_/¯`; export const TABLEFLIP = '(╯°□°)╯︵ ┻━┻'; export const UNFLIP = '┬─┬ノ( º_ºノ)'; -const FLAG_PAT = '(?:^|\\s)-(\\w+)\\b'; +const FLAG_PAT = String.raw`(?:^|\s)-(\w+)\b`; const FLAG_REG = new RegExp(FLAG_PAT); const FLAG_REG_G = new RegExp(FLAG_PAT, 'g'); export const splitPayloadContentAndFlags = (payload: string): [string, string | undefined] => { - const flagMatch = payload.match(FLAG_REG); + const flagMatch = new RegExp(FLAG_REG).exec(payload); if (!flagMatch) { return [payload, undefined]; @@ -116,7 +116,7 @@ export const parseTimestampFlag = (input: string): number | undefined => { return undefined; } - const value = parseFloat(match[1]); // supports decimal values + const value = Number.parseFloat(match[1]); // supports decimal values const unit = match[2]; const now = Date.now(); // in milliseconds @@ -251,6 +251,8 @@ export enum Command { Headpat = 'headpat', // Meta Report = 'bugreport', + // Experimental + ShareE2EEHistory = 'sharehistory', } export type CommandContent = { @@ -264,6 +266,7 @@ export type CommandRecord = Record; export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { const { navigateRoom } = useRoomNavigate(); const [developerTools] = useSetting(settingsAtom, 'developerTools'); + const [enableMSC4268CMD] = useSetting(settingsAtom, 'enableMSC4268CMD'); const profile = useUserProfile(mx.getSafeUserId()); const openBugReport = useOpenBugReportModal(); @@ -282,7 +285,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { }, [Command.Shrug]: { name: Command.Shrug, - description: 'Send ¯\\_(ツ)_/¯ as message', + description: String.raw`Send ¯\_(ツ)_/¯ as message`, exe: async () => undefined, }, [Command.TableFlip]: { @@ -559,8 +562,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { room.roomId, token, 20, - Direction.Forward, - undefined + Direction.Forward ); const { end, chunk } = response; // remove until the latest event; @@ -641,17 +643,6 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { const input = payload.trim().toLowerCase(); const userId = mx.getSafeUserId(); - const sendFeedback = (msg: string) => { - const localNotice = new MatrixEvent({ - type: 'm.room.message', - content: { msgtype: 'm.notice', body: msg }, - event_id: `~sable-${Date.now()}`, - room_id: room.roomId, - sender: userId, - }); - (room as any).addLiveEvents([localNotice], { duplicateStrategy: 'ignore' } as any); - }; - try { if (input === 'reset' || input === 'clear') { await mx.sendStateEvent( @@ -660,7 +651,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { {}, userId ); - sendFeedback('Room color has been reset.'); + sendFeedback('Room color has been reset.', room, userId); return; } @@ -671,14 +662,16 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { { color: input }, userId ); - sendFeedback(`Room color set to ${input}.`); + sendFeedback(`Room color set to ${input}.`, room, userId); } else { - sendFeedback('Invalid format. Use #RRGGBB.'); + sendFeedback('Invalid format. Use #RRGGBB.', room, userId); } } catch (e: any) { if (e.errcode === 'M_FORBIDDEN') { sendFeedback( - 'Permission Denied. An admin must enable "Room Colors" in Settings > Cosmetics in app.sable.moe or another supported client.' + 'Permission Denied. An admin must enable "Room Colors" in Settings > Cosmetics in app.sable.moe or another supported client.', + room, + userId ); } } @@ -692,17 +685,6 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { const input = payload.trim().toLowerCase(); const userId = mx.getSafeUserId(); - const sendFeedback = (msg: string) => { - const localNotice = new MatrixEvent({ - type: 'm.room.message', - content: { msgtype: 'm.notice', body: msg }, - event_id: `~sable-g-${Date.now()}`, - room_id: room.roomId, - sender: userId, - }); - (room as any).addLiveEvents([localNotice], { duplicateStrategy: 'ignore' } as any); - }; - const parents = room .getLiveTimeline() .getState(EventTimeline.FORWARDS) @@ -719,7 +701,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { {}, userId ); - sendFeedback('Global space color reset.'); + sendFeedback('Global space color reset.', room, userId); return; } @@ -730,14 +712,16 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { { color: input }, userId ); - sendFeedback(`Global space color set to ${input}.`); + sendFeedback(`Global space color set to ${input}.`, room, userId); } else { - sendFeedback('Invalid format. Use #RRGGBB.'); + sendFeedback('Invalid format. Use #RRGGBB.', room, userId); } } catch (e: any) { if (e.errcode === 'M_FORBIDDEN') { sendFeedback( - 'Permission Denied. An admin must enable "Space-Wide Colors" in Settings > Cosmetics in app.sable.moe or another supported client.' + 'Permission Denied. An admin must enable "Space-Wide Colors" in Settings > Cosmetics in app.sable.moe or another supported client.', + room, + userId ); } } @@ -749,25 +733,14 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { exe: async (payload) => { const input = payload .trim() - .replace(/[;{}<>]/g, '') + .replaceAll(/[;{}<>]/g, '') .slice(0, 32); const userId = mx.getSafeUserId(); - const sendFeedback = (msg: string) => { - const localNotice = new MatrixEvent({ - type: 'm.room.message', - content: { msgtype: 'm.notice', body: msg }, - event_id: `~font-${Date.now()}`, - room_id: room.roomId, - sender: userId, - }); - (room as any).addLiveEvents([localNotice], { duplicateStrategy: 'ignore' } as any); - }; - try { if (input.toLowerCase() === 'reset' || input === '') { await mx.sendStateEvent(room.roomId, StateEvent.RoomCosmeticsFont as any, {}, userId); - sendFeedback('Room font reset.'); + sendFeedback('Room font reset.', room, userId); return; } @@ -777,11 +750,13 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { { font: input }, userId ); - sendFeedback(`Room font set to "${input}".`); + sendFeedback(`Room font set to "${input}".`, room, userId); } catch (e: any) { if (e.errcode === 'M_FORBIDDEN') { sendFeedback( - 'Permission Denied. An admin must enable "Room Fonts" in Settings > Cosmetics in app.sable.moe or another supported client.' + 'Permission Denied. An admin must enable "Room Fonts" in Settings > Cosmetics in app.sable.moe or another supported client.', + room, + userId ); } } @@ -793,21 +768,10 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { exe: async (payload) => { const input = payload .trim() - .replace(/[;{}<>]/g, '') + .replaceAll(/[;{}<>]/g, '') .slice(0, 32); const userId = mx.getSafeUserId(); - const sendFeedback = (msg: string) => { - const localNotice = new MatrixEvent({ - type: 'm.room.message', - content: { msgtype: 'm.notice', body: msg }, - event_id: `~sfont-${Date.now()}`, - room_id: room.roomId, - sender: userId, - }); - (room as any).addLiveEvents([localNotice], { duplicateStrategy: 'ignore' } as any); - }; - const parents = room .getLiveTimeline() .getState(EventTimeline.FORWARDS) @@ -824,7 +788,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { {}, userId ); - sendFeedback('Space font reset.'); + sendFeedback('Space font reset.', room, userId); return; } @@ -834,11 +798,13 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { { font: input }, userId ); - sendFeedback(`Space font set to "${input}".`); + sendFeedback(`Space font set to "${input}".`, room, userId); } catch (e: any) { if (e.errcode === 'M_FORBIDDEN') { sendFeedback( - 'Permission Denied. An admin must enable "Space-Wide Fonts" in Settings > Cosmetics in app.sable.moe or another supported client.' + 'Permission Denied. An admin must enable "Space-Wide Fonts" in Settings > Cosmetics in app.sable.moe or another supported client.', + room, + userId ); } } @@ -850,23 +816,12 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { exe: async (payload) => { const userId = mx.getSafeUserId(); - const sendFeedback = (msg: string) => { - const localNotice = new MatrixEvent({ - type: 'm.room.message', - content: { msgtype: 'm.notice', body: msg }, - event_id: `~nullptr-widget-${Date.now()}`, - room_id: room.roomId, - sender: userId, - }); - (room as any).addLiveEvents([localNotice], { duplicateStrategy: 'ignore' } as any); - }; - const parts = payload.trim().split(/\s+/); const url = parts[0]; const name = parts.slice(1).join(' ') || 'Widget'; if (!url) { - sendFeedback('Usage: /addwidget [name]'); + sendFeedback('Usage: /addwidget [name]', room, userId); return; } @@ -874,7 +829,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { try { parsedUrl = new URL(url); } catch { - sendFeedback('Invalid URL. Please provide a valid widget URL.'); + sendFeedback('Invalid URL. Please provide a valid widget URL.', room, userId); return; } @@ -892,14 +847,16 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { } as any, widgetId ); - sendFeedback(`Widget "${name}" added.`); + sendFeedback(`Widget "${name}" added.`, room, userId); } catch (e: any) { if (e.errcode === 'M_FORBIDDEN') { sendFeedback( - 'Permission denied. You need permission to manage widgets in this room.' + 'Permission denied. You need permission to manage widgets in this room.', + room, + userId ); } else { - sendFeedback(`Failed to add widget: ${e.message || 'Unknown error'}`); + sendFeedback(`Failed to add widget: ${e.message || 'Unknown error'}`, room, userId); } } }, @@ -913,17 +870,6 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { const rawInput = match ? match[1].trim() : payload.trim(); const userId = mx.getSafeUserId(); - const sendFeedback = (msg: string) => { - const localNotice = new MatrixEvent({ - type: 'm.room.message', - content: { msgtype: 'm.notice', body: msg }, - event_id: `~pronoun-${Date.now()}`, - room_id: room.roomId, - sender: userId, - }); - (room as any).addLiveEvents([localNotice], { duplicateStrategy: 'ignore' } as any); - }; - try { if (['reset', 'clear', ''].includes(rawInput.toLowerCase())) { await mx.sendStateEvent( @@ -932,7 +878,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { {}, userId ); - sendFeedback('Room pronouns have been reset.'); + sendFeedback('Room pronouns have been reset.', room, userId); return; } @@ -949,10 +895,10 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { .map((p) => (p.language ? `for ${p.language} "${p.summary}" was set` : p.summary)) .join(', '); - sendFeedback(`Room pronouns set: ${feedbackString}`); + sendFeedback(`Room pronouns set: ${feedbackString}`, room, userId); } catch (e: any) { if (e.errcode === 'M_FORBIDDEN') { - sendFeedback('Permission Denied. Could not update room pronouns.'); + sendFeedback('Permission Denied. Could not update room pronouns.', room, userId); } } }, @@ -966,17 +912,6 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { const rawInput = match ? match[1].trim() : payload.trim(); const userId = mx.getSafeUserId(); - const sendFeedback = (msg: string) => { - const localNotice = new MatrixEvent({ - type: 'm.room.message', - content: { msgtype: 'm.notice', body: msg }, - event_id: `~gpronoun-${Date.now()}`, - room_id: room.roomId, - sender: userId, - }); - (room as any).addLiveEvents([localNotice], { duplicateStrategy: 'ignore' } as any); - }; - const parents = room .getLiveTimeline() .getState(EventTimeline.FORWARDS) @@ -993,7 +928,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { {}, userId ); - sendFeedback('Global space pronouns reset.'); + sendFeedback('Global space pronouns reset.', room, userId); return; } @@ -1010,10 +945,10 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { .map((p) => (p.language ? `for ${p.language} "${p.summary}" was set` : p.summary)) .join(', '); - sendFeedback(`Global space pronouns set: ${feedbackString}`); + sendFeedback(`Global space pronouns set: ${feedbackString}`, room, userId); } catch (e: any) { if (e.errcode === 'M_FORBIDDEN') { - sendFeedback('Permission Denied. Could not update space pronouns.'); + sendFeedback('Permission Denied. Could not update space pronouns.', room, userId); } } }, @@ -1039,25 +974,15 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { '[Dev only] Send raw message event. Example: /rawmsg {"msgtype":"m.text", "body":"hello"}', exe: async (payload) => { const userId = mx.getSafeUserId(); - const sendFeedback = (msg: string) => { - const localNotice = new MatrixEvent({ - type: 'm.room.message', - content: { msgtype: 'm.notice', body: msg }, - event_id: `~rawmsg-${Date.now()}`, - room_id: room.roomId, - sender: userId, - }); - (room as any).addLiveEvents([localNotice], { duplicateStrategy: 'ignore' } as any); - }; if (!developerTools) { - sendFeedback('Command available in Developer Mode only.'); + sendFeedback('Command available in Developer Mode only.', room, userId); return; } try { const content = JSON.parse(payload); await mx.sendMessage(room.roomId, content); } catch (e: any) { - sendFeedback(`Invalid JSON: ${e.message}`); + sendFeedback(`Invalid JSON: ${e.message}`, room, userId); } }, }, @@ -1066,19 +991,9 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { description: '[Dev only] Send any raw event. Usage: /raw [-s stateKey]', exe: async (payload) => { const userId = mx.getSafeUserId(); - const sendFeedback = (msg: string) => { - const localNotice = new MatrixEvent({ - type: 'm.room.message', - content: { msgtype: 'm.notice', body: msg }, - event_id: `~rawevent-${Date.now()}`, - room_id: room.roomId, - sender: userId, - }); - room.addLiveEvents([localNotice], { duplicateStrategy: 'ignore' } as any); - }; if (!developerTools) { - sendFeedback('Command available in Developer Mode only.'); + sendFeedback('Command available in Developer Mode only.', room, userId); return; } @@ -1090,7 +1005,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { const jsonString = mainPayload.trim().substring(eventType.length).trim(); if (!eventType || !jsonString) { - sendFeedback('Usage: /rawevent [-s stateKey]'); + sendFeedback('Usage: /rawevent [-s stateKey]', room, userId); return; } @@ -1099,13 +1014,17 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { if (typeof stateKey === 'string') { await mx.sendStateEvent(room.roomId, eventType as any, content, stateKey); - sendFeedback(`State event "${eventType}" sent with state key "${stateKey}".`); + sendFeedback( + `State event "${eventType}" sent with state key "${stateKey}".`, + room, + userId + ); } else { await mx.sendEvent(room.roomId, eventType as any, content); - sendFeedback(`Event "${eventType}" sent.`); + sendFeedback(`Event "${eventType}" sent.`, room, userId); } } catch (e: any) { - sendFeedback(`Error: ${e.message}`); + sendFeedback(`Error: ${e.message}`, room, userId); } }, }, @@ -1114,26 +1033,16 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { description: '[Dev only] Merge global account data. Usage: /rawacc ', exe: async (payload) => { const userId = mx.getSafeUserId(); - const sendFeedback = (msg: string) => { - const localNotice = new MatrixEvent({ - type: 'm.room.message', - content: { msgtype: 'm.notice', body: msg }, - event_id: `~rawacc-${Date.now()}`, - room_id: room.roomId, - sender: userId, - }); - (room as any).addLiveEvents([localNotice], { duplicateStrategy: 'ignore' } as any); - }; if (!developerTools) { - sendFeedback('Command available in Developer Mode only.'); + sendFeedback('Command available in Developer Mode only.', room, userId); return; } const trimmed = payload.trim(); const firstSpaceIndex = trimmed.indexOf(' '); if (firstSpaceIndex === -1) { - sendFeedback('Usage: /rawacc '); + sendFeedback('Usage: /rawacc ', room, userId); return; } @@ -1149,9 +1058,9 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { const mergedContent = { ...existingContent, ...newContent }; await mx.setAccountData(type as any, mergedContent); - sendFeedback(`Account data "${type}" merged successfully.`); + sendFeedback(`Account data "${type}" merged successfully.`, room, userId); } catch (e: any) { - sendFeedback(`Error: ${e.message}`); + sendFeedback(`Error: ${e.message}`, room, userId); } }, }, @@ -1160,38 +1069,28 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { description: '[Dev Only] Remove a key from account data. Usage: /delacc ', exe: async (payload) => { const userId = mx.getSafeUserId(); - const sendFeedback = (msg: string) => { - const localNotice = new MatrixEvent({ - type: 'm.room.message', - content: { msgtype: 'm.notice', body: msg }, - event_id: `~removeacc-${Date.now()}`, - room_id: room.roomId, - sender: userId, - }); - room.addLiveEvents([localNotice], { duplicateStrategy: 'ignore' } as any); - }; const parts = payload.trim().split(/\s+/); if (parts.length < 2) { - sendFeedback('Usage: /delacc '); + sendFeedback('Usage: /delacc ', room, userId); return; } const [type, key] = parts; try { const existingEvent = mx.getAccountData(type as any); if (!existingEvent) { - sendFeedback(`No account data found for type "${type}".`); + sendFeedback(`No account data found for type "${type}".`, room, userId); return; } const content = { ...existingEvent.getContent() }; if (!(key in content)) { - sendFeedback(`Key "${key}" not found in "${type}".`); + sendFeedback(`Key "${key}" not found in "${type}".`, room, userId); return; } delete content[key]; await mx.setAccountData(type as any, content as any); - sendFeedback(`Key "${key}" removed from "${type}".`); + sendFeedback(`Key "${key}" removed from "${type}".`, room, userId); } catch (e: any) { - sendFeedback(`Error: ${e.message}`); + sendFeedback(`Error: ${e.message}`, room, userId); } }, }, @@ -1200,23 +1099,13 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { description: '[Dev Only] Set an extended profile property. Usage: /setext ', exe: async (payload) => { const userId = mx.getSafeUserId(); - const sendFeedback = (msg: string) => { - const localNotice = new MatrixEvent({ - type: 'm.room.message', - content: { msgtype: 'm.notice', body: msg }, - event_id: `~setext-${Date.now()}`, - room_id: room.roomId, - sender: userId, - }); - room.addLiveEvents([localNotice], { duplicateStrategy: 'ignore' } as any); - }; if (!developerTools) { - sendFeedback('Command available in Developer Mode only.'); + sendFeedback('Command available in Developer Mode only.', room, userId); return; } const parts = payload.trim().split(/\s+/); if (parts.length < 2) { - sendFeedback('Usage: /setext '); + sendFeedback('Usage: /setext ', room, userId); return; } const key = parts[0]; @@ -1228,12 +1117,16 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { try { if (typeof mx.setExtendedProfileProperty === 'function') { await mx.setExtendedProfileProperty(key, finalValue); - sendFeedback(`Extended profile property "${key}" set to: ${finalValue}`); + sendFeedback( + `Extended profile property "${key}" set to: ${finalValue}`, + room, + userId + ); } else { - sendFeedback('Error: setExtendedProfileProperty is not supported.'); + sendFeedback('Error: setExtendedProfileProperty is not supported.', room, userId); } } catch (e: any) { - sendFeedback(`Failed to set extended profile: ${e.message}`); + sendFeedback(`Failed to set extended profile: ${e.message}`, room, userId); } }, }, @@ -1244,36 +1137,25 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { const userId = mx.getSafeUserId(); const key = payload.trim(); - const sendFeedback = (msg: string) => { - const localNotice = new MatrixEvent({ - type: 'm.room.message', - content: { msgtype: 'm.notice', body: msg }, - event_id: `~removeext-${Date.now()}`, - room_id: room.roomId, - sender: userId, - }); - room.addLiveEvents([localNotice], { duplicateStrategy: 'ignore' } as any); - }; - if (!developerTools) { - sendFeedback('Command available in Developer Mode only.'); + sendFeedback('Command available in Developer Mode only.', room, userId); return; } if (!key) { - sendFeedback('Usage: /delext '); + sendFeedback('Usage: /delext ', room, userId); return; } try { if (typeof mx.deleteExtendedProfileProperty === 'function') { await mx.deleteExtendedProfileProperty(key); - sendFeedback(`Extended profile property "${key}" removed.`); + sendFeedback(`Extended profile property "${key}" removed.`, room, userId); } else { - sendFeedback('Error: setExtendedProfileProperty is not supported.'); + sendFeedback('Error: setExtendedProfileProperty is not supported.', room, userId); } } catch (e: any) { - sendFeedback(`Failed to remove property: ${e.message}`); + sendFeedback(`Failed to remove property: ${e.message}`, room, userId); } }, }, @@ -1282,28 +1164,57 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { description: 'Force discard the current outbound E2EE session in this room.', exe: async () => { const userId = mx.getSafeUserId(); - const sendFeedback = (msg: string) => { - const localNotice = new MatrixEvent({ - type: 'm.room.message', - content: { msgtype: 'm.notice', body: msg }, - event_id: `~discard-${Date.now()}`, - room_id: room.roomId, - sender: userId, - }); - room.addLiveEvents([localNotice], { duplicateStrategy: 'ignore' } as any); - }; try { const crypto = mx.getCrypto(); if (!crypto) { - sendFeedback('Encryption is not enabled on this client.'); + sendFeedback('Encryption is not enabled on this client.', room, userId); return; } await crypto.forceDiscardSession(room.roomId); - sendFeedback('Outbound encryption session discarded.'); + sendFeedback('Outbound encryption session discarded.', room, userId); } catch (e: any) { - sendFeedback(`Failed to discard session: ${e.message}`); + sendFeedback(`Failed to discard session: ${e.message}`, room, userId); + } + }, + }, + // Sharing E2EE History of a room with a user + [Command.ShareE2EEHistory]: { + name: Command.ShareE2EEHistory, + description: + 'Share E2EE history (MSC4268) of this room with a user. Example: /sharee2eehistory @user:example.org', + exe: async (payload) => { + const targetUserId = payload.trim(); + const { roomId } = room; + if (!enableMSC4268CMD) { + sendFeedback( + 'This command is disabled. Enable it under experimental settings to use it.', + room, + mx.getSafeUserId() + ); + return; } + if (!targetUserId) { + sendFeedback('Usage: /sharee2eehistory @user:example.org', room, mx.getSafeUserId()); + return; + } + const crypto = mx.getCrypto(); + if (!crypto) { + sendFeedback('Encryption is not enabled on this client.', room, mx.getSafeUserId()); + return; + } + crypto + .shareRoomHistoryWithUser(roomId, targetUserId) + .then(() => { + sendFeedback( + `E2EE history shared with ${targetUserId}. (Their client needs to support MSC4268)`, + room, + mx.getSafeUserId() + ); + }) + .catch((e) => { + sendFeedback(`Failed to share E2EE history: ${e.message}`, room, mx.getSafeUserId()); + }); }, }, // Cute Events @@ -1393,7 +1304,16 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { }, }, }), - [mx, navigateRoom, room, profile.displayName, profile.avatarUrl, developerTools, openBugReport] + [ + mx, + navigateRoom, + room, + profile.displayName, + profile.avatarUrl, + developerTools, + enableMSC4268CMD, + openBugReport, + ] ); return commands; diff --git a/src/app/hooks/useDebounce.test.tsx b/src/app/hooks/useDebounce.test.tsx new file mode 100644 index 000000000..5287f4b1a --- /dev/null +++ b/src/app/hooks/useDebounce.test.tsx @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useDebounce } from './useDebounce'; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('useDebounce', () => { + it('does not call callback before wait time elapses', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useDebounce(fn, { wait: 200 })); + + act(() => { + result.current('a'); + }); + + act(() => { + vi.advanceTimersByTime(199); + }); + + expect(fn).not.toHaveBeenCalled(); + }); + + it('calls callback after wait time elapses', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useDebounce(fn, { wait: 200 })); + + act(() => { + result.current('a'); + }); + + act(() => { + vi.advanceTimersByTime(200); + }); + + expect(fn).toHaveBeenCalledOnce(); + expect(fn).toHaveBeenCalledWith('a'); + }); + + it('resets the timer on each successive call', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useDebounce(fn, { wait: 200 })); + + act(() => { + result.current('first'); + }); + + act(() => { + vi.advanceTimersByTime(150); + result.current('second'); + }); + + // 150ms into the reset timer — should not have fired yet + act(() => { + vi.advanceTimersByTime(150); + }); + + expect(fn).not.toHaveBeenCalled(); + + // Complete the 200ms wait after the second call + act(() => { + vi.advanceTimersByTime(50); + }); + + expect(fn).toHaveBeenCalledOnce(); + expect(fn).toHaveBeenCalledWith('second'); + }); + + it('only fires once after rapid successive calls', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useDebounce(fn, { wait: 100 })); + + act(() => { + result.current(1); + result.current(2); + result.current(3); + vi.advanceTimersByTime(100); + }); + + expect(fn).toHaveBeenCalledOnce(); + expect(fn).toHaveBeenCalledWith(3); + }); + + it('fires immediately on first call when immediate option is set', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useDebounce(fn, { wait: 200, immediate: true })); + + act(() => { + result.current('go'); + }); + + expect(fn).toHaveBeenCalledOnce(); + expect(fn).toHaveBeenCalledWith('go'); + }); +}); diff --git a/src/app/hooks/useKeyBackup.ts b/src/app/hooks/useKeyBackup.ts index 1cc531eda..3714ec6be 100644 --- a/src/app/hooks/useKeyBackup.ts +++ b/src/app/hooks/useKeyBackup.ts @@ -6,6 +6,7 @@ import { KeyBackupInfo, } from '$types/matrix-sdk'; import { useCallback, useEffect, useState } from 'react'; +import * as Sentry from '@sentry/react'; import { useMatrixClient } from './useMatrixClient'; import { useAlive } from './useAlive'; @@ -92,6 +93,15 @@ export const useKeyBackupSync = (): [number, string | undefined] => { useKeyBackupFailedChange( useCallback((f) => { if (typeof f === 'string') { + Sentry.addBreadcrumb({ + category: 'crypto', + message: 'Key backup failed', + level: 'error', + data: { errcode: f }, + }); + Sentry.metrics.count('sable.crypto.key_backup_failures', 1, { + attributes: { errcode: f }, + }); setFailure(f); setRemaining(0); } diff --git a/src/app/hooks/usePreviousValue.test.tsx b/src/app/hooks/usePreviousValue.test.tsx new file mode 100644 index 000000000..9c3479750 --- /dev/null +++ b/src/app/hooks/usePreviousValue.test.tsx @@ -0,0 +1,51 @@ +// Integration tests: renders a real React component tree via renderHook. +import { describe, it, expect } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { usePreviousValue } from './usePreviousValue'; + +describe('usePreviousValue', () => { + it('returns the initial value on the first render', () => { + const { result } = renderHook(() => usePreviousValue('current', 'initial')); + expect(result.current).toBe('initial'); + }); + + it('returns the previous value after a prop update', () => { + const { result, rerender } = renderHook( + ({ value }: { value: string }) => usePreviousValue(value, 'initial'), + { initialProps: { value: 'first' } } + ); + + // Before any update: returns initial + expect(result.current).toBe('initial'); + + rerender({ value: 'second' }); + expect(result.current).toBe('first'); + + rerender({ value: 'third' }); + expect(result.current).toBe('second'); + }); + + it('works with numeric values', () => { + const { result, rerender } = renderHook(({ n }: { n: number }) => usePreviousValue(n, 0), { + initialProps: { n: 1 }, + }); + + expect(result.current).toBe(0); + rerender({ n: 42 }); + expect(result.current).toBe(1); + }); + + it('works with object values (reference equality)', () => { + const a = { x: 1 }; + const b = { x: 2 }; + + const { result, rerender } = renderHook( + ({ obj }: { obj: { x: number } }) => usePreviousValue(obj, a), + { initialProps: { obj: a } } + ); + + expect(result.current).toBe(a); + rerender({ obj: b }); + expect(result.current).toBe(a); + }); +}); diff --git a/src/app/hooks/useRoomWidgets.ts b/src/app/hooks/useRoomWidgets.ts index a424e2eca..65381ddd3 100644 --- a/src/app/hooks/useRoomWidgets.ts +++ b/src/app/hooks/useRoomWidgets.ts @@ -27,16 +27,16 @@ export const resolveWidgetUrl = ( const theme = document.body.classList.contains('dark-theme') ? 'dark' : 'light'; let resolved = url - .replace(/\$matrix_user_id/g, encodeURIComponent(userId)) - .replace(/\$matrix_room_id/g, encodeURIComponent(roomId)) - .replace(/\$matrix_display_name/g, encodeURIComponent(displayName)) - .replace(/\$matrix_avatar_url/g, encodeURIComponent(avatarUrl)) - .replace(/\$matrix_widget_id/g, encodeURIComponent(widgetId)) - .replace(/\$org\.matrix\.msc2873\.client_id/g, encodeURIComponent(clientId)) - .replace(/\$org\.matrix\.msc2873\.client_theme/g, encodeURIComponent(theme)) - .replace(/\$org\.matrix\.msc2873\.client_language/g, encodeURIComponent(lang)) - .replace(/\$org\.matrix\.msc3819\.matrix_device_id/g, encodeURIComponent(deviceId)) - .replace(/\$org\.matrix\.msc4039\.matrix_base_url/g, encodeURIComponent(baseUrl)); + .replaceAll('$matrix_user_id', encodeURIComponent(userId)) + .replaceAll('$matrix_room_id', encodeURIComponent(roomId)) + .replaceAll('$matrix_display_name', encodeURIComponent(displayName)) + .replaceAll('$matrix_avatar_url', encodeURIComponent(avatarUrl)) + .replaceAll('$matrix_widget_id', encodeURIComponent(widgetId)) + .replaceAll('$org.matrix.msc2873.client_id', encodeURIComponent(clientId)) + .replaceAll('$org.matrix.msc2873.client_theme', encodeURIComponent(theme)) + .replaceAll('$org.matrix.msc2873.client_language', encodeURIComponent(lang)) + .replaceAll('$org.matrix.msc3819.matrix_device_id', encodeURIComponent(deviceId)) + .replaceAll('$org.matrix.msc4039.matrix_base_url', encodeURIComponent(baseUrl)); try { const u = new URL(resolved); @@ -112,7 +112,7 @@ export const useRoomWidgets = (room: Room): RoomWidget[] => { return events.reduce((widgets, event) => { const content = event.getContent(); - if (!content || !content.url || Object.keys(content).length === 0) return widgets; + if (!content?.url || Object.keys(content).length === 0) return widgets; const stateKey = event.getStateKey(); if (!stateKey) return widgets; diff --git a/src/app/hooks/useThrottle.test.tsx b/src/app/hooks/useThrottle.test.tsx new file mode 100644 index 000000000..db7a15d3c --- /dev/null +++ b/src/app/hooks/useThrottle.test.tsx @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useThrottle } from './useThrottle'; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('useThrottle', () => { + it('fires once after the wait period even when called multiple times', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useThrottle(fn, { wait: 200 })); + + act(() => { + result.current('a'); + result.current('b'); + result.current('c'); + }); + + expect(fn).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(200); + }); + + expect(fn).toHaveBeenCalledOnce(); + }); + + it('fires with the latest args when called multiple times within the wait', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useThrottle(fn, { wait: 200 })); + + act(() => { + result.current('first'); + result.current('second'); + result.current('third'); + }); + + act(() => { + vi.advanceTimersByTime(200); + }); + + expect(fn).toHaveBeenCalledWith('third'); + }); + + it('does not fire before the wait period ends', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useThrottle(fn, { wait: 300 })); + + act(() => { + result.current('x'); + vi.advanceTimersByTime(299); + }); + + expect(fn).not.toHaveBeenCalled(); + }); + + it('allows a new invocation after the wait period resets', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useThrottle(fn, { wait: 100 })); + + act(() => { + result.current('first-burst'); + vi.advanceTimersByTime(100); + }); + + act(() => { + result.current('second-burst'); + vi.advanceTimersByTime(100); + }); + + expect(fn).toHaveBeenCalledTimes(2); + expect(fn).toHaveBeenNthCalledWith(1, 'first-burst'); + expect(fn).toHaveBeenNthCalledWith(2, 'second-burst'); + }); + + it('fires immediately on first call when immediate option is set', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useThrottle(fn, { wait: 200, immediate: true })); + + act(() => { + result.current('now'); + }); + + expect(fn).toHaveBeenCalledOnce(); + expect(fn).toHaveBeenCalledWith('now'); + }); +}); diff --git a/src/app/hooks/useTimeoutToggle.test.tsx b/src/app/hooks/useTimeoutToggle.test.tsx new file mode 100644 index 000000000..9fa3a6579 --- /dev/null +++ b/src/app/hooks/useTimeoutToggle.test.tsx @@ -0,0 +1,101 @@ +// Integration tests: uses fake timers to control setTimeout behaviour. +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useTimeoutToggle } from './useTimeoutToggle'; + +describe('useTimeoutToggle', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('starts with the default initial value of false', () => { + const { result } = renderHook(() => useTimeoutToggle()); + expect(result.current[0]).toBe(false); + }); + + it('becomes true after trigger() is called', () => { + const { result } = renderHook(() => useTimeoutToggle()); + act(() => { + result.current[1](); + }); + expect(result.current[0]).toBe(true); + }); + + it('resets to false after the default 1500ms duration', () => { + const { result } = renderHook(() => useTimeoutToggle()); + + act(() => { + result.current[1](); + }); + expect(result.current[0]).toBe(true); + + act(() => { + vi.advanceTimersByTime(1500); + }); + expect(result.current[0]).toBe(false); + }); + + it('does not reset before the duration has elapsed', () => { + const { result } = renderHook(() => useTimeoutToggle(500)); + + act(() => { + result.current[1](); + }); + + act(() => { + vi.advanceTimersByTime(499); + }); + expect(result.current[0]).toBe(true); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(result.current[0]).toBe(false); + }); + + it('re-triggering before timeout resets the countdown', () => { + const { result } = renderHook(() => useTimeoutToggle(1000)); + + act(() => { + result.current[1](); // t=0: trigger + }); + + act(() => { + vi.advanceTimersByTime(800); // t=800 + }); + + act(() => { + result.current[1](); // t=800: re-trigger, timer resets + }); + + act(() => { + vi.advanceTimersByTime(800); // t=1600 — only 800ms since re-trigger + }); + expect(result.current[0]).toBe(true); // still active + + act(() => { + vi.advanceTimersByTime(200); // t=1800 — 1000ms since re-trigger + }); + expect(result.current[0]).toBe(false); + }); + + it('supports a custom initial value of true (inverted toggle)', () => { + const { result } = renderHook(() => useTimeoutToggle(1500, true)); + + expect(result.current[0]).toBe(true); + + act(() => { + result.current[1](); // trigger → false + }); + expect(result.current[0]).toBe(false); + + act(() => { + vi.advanceTimersByTime(1500); // resets back to true + }); + expect(result.current[0]).toBe(true); + }); +}); diff --git a/src/app/hooks/useUserProfile.ts b/src/app/hooks/useUserProfile.ts index e5f1a013a..8de5da050 100644 --- a/src/app/hooks/useUserProfile.ts +++ b/src/app/hooks/useUserProfile.ts @@ -27,7 +27,7 @@ export type UserProfile = { }; const normalizeInfo = (info: any): UserProfile => { - const knownKeys = [ + const knownKeys = new Set([ 'avatar_url', 'displayname', 'io.fsky.nyx.pronouns', @@ -40,11 +40,11 @@ const normalizeInfo = (info: any): UserProfile => { 'moe.sable.app.name_color', 'kitty.meow.has_cats', 'kitty.meow.is_cat', - ]; + ]); const extended: Record = {}; Object.entries(info).forEach(([key, value]) => { - if (!knownKeys.includes(key)) { + if (!knownKeys.has(key)) { extended[key] = value; } }); @@ -68,10 +68,11 @@ const normalizeInfo = (info: any): UserProfile => { const isValidHex = (c: any): string | undefined => { if (typeof c !== 'string') return undefined; // silly tuwunel smh - const cleaned = c.replace(/["']/g, '').trim(); - return /^#([0-9A-F]{3,6})$/i.test(cleaned) ? cleaned : undefined; + const cleaned = c.replaceAll(/["']/g, '').trim(); + // Strictly allow only 3 or 6 digit hex codes, aka no opacity + return /^#([0-9A-F]{3}|[0-9A-F]{6})$/i.test(cleaned) ? cleaned : undefined; }; -const sanitizeFont = (f: string) => f.replace(/[;{}<>]/g, '').slice(0, 32); +const sanitizeFont = (f: string) => f.replaceAll(/[;{}<>]/g, '').slice(0, 32); export const useUserProfile = ( userId: string, diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx index 0408f38ea..3ed43c510 100644 --- a/src/app/pages/App.tsx +++ b/src/app/pages/App.tsx @@ -3,10 +3,11 @@ import { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProv import { RouterProvider } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; -import { ErrorBoundary } from 'react-error-boundary'; +import * as Sentry from '@sentry/react'; import { ClientConfigLoader } from '$components/ClientConfigLoader'; import { ClientConfigProvider } from '$hooks/useClientConfig'; +import { setMatrixToBase } from '$plugins/matrix-to'; import { ScreenSizeProvider, useScreenSize } from '$hooks/useScreenSize'; import { useCompositionEndTracking } from '$hooks/useComposingCheck'; import { ErrorPage } from '$components/DefaultErrorPage'; @@ -23,7 +24,14 @@ function App() { const portalContainer = document.getElementById('portalContainer') ?? undefined; return ( - + ( + + )} + > @@ -35,23 +43,26 @@ function App() { )} > - {(clientConfig) => ( - - - - - - - - - )} + {(clientConfig) => { + setMatrixToBase(clientConfig.matrixToBaseUrl); + return ( + + + + + + + + + ); + }} - + ); } diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index f14567f7d..d81890da1 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -6,8 +6,10 @@ import { createRoutesFromElements, redirect, } from 'react-router-dom'; +import * as Sentry from '@sentry/react'; import { ClientConfig } from '$hooks/useClientConfig'; +import { ErrorPage } from '$components/DefaultErrorPage'; import { Room } from '$features/room'; import { Lobby } from '$features/lobby'; import { PageRoot } from '$components/page'; @@ -117,10 +119,20 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) return null; }} element={ - <> - - - + ( + + )} + beforeCapture={(scope) => scope.setTag('section', 'auth')} + > + <> + + + + } > } /> @@ -142,60 +154,70 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) return null; }} element={ - - {/* HandleNotificationClick must live outside ClientRoot's loading gate so + ( + + )} + beforeCapture={(scope) => scope.setTag('section', 'client')} + > + + {/* HandleNotificationClick must live outside ClientRoot's loading gate so SW notification-click postMessages are never dropped during client reloads (e.g., account switches). It only needs navigate + Jotai atoms. */} - - - - - - - - - - - - } - > - - - - - - - - - - - - - {/* Screen reader live region — populated by announce() in utils/announce.ts */} -
- - - - - - - - + + + + + + + + + + + + } + > + + + + + + + + + + + + + {/* Screen reader live region — populated by announce() in utils/announce.ts */} +
+ + + + + + + + + } > Sable {isAddingAccount && ( - - Adding account - + + + Adding account + + window.location.assign(getHomePath())} + > + Cancel + + )} diff --git a/src/app/pages/auth/ServerPicker.tsx b/src/app/pages/auth/ServerPicker.tsx index 12dfa1757..e8a4984b2 100644 --- a/src/app/pages/auth/ServerPicker.tsx +++ b/src/app/pages/auth/ServerPicker.tsx @@ -1,5 +1,6 @@ import { ChangeEventHandler, + FocusEventHandler, KeyboardEventHandler, MouseEventHandler, useEffect, @@ -37,10 +38,16 @@ export function ServerPicker({ }) { const [serverMenuAnchor, setServerMenuAnchor] = useState(); const serverInputRef = useRef(null); + const inputFocusedRef = useRef(false); useEffect(() => { - // sync input with it outside server changes - if (serverInputRef.current && serverInputRef.current.value !== server) { + // sync input with outside server changes, but only when not focused + // (on iOS, syncing while focused causes the deleted text to reappear) + if ( + !inputFocusedRef.current && + serverInputRef.current && + serverInputRef.current.value !== server + ) { serverInputRef.current.value = server; } }, [server]); @@ -52,6 +59,18 @@ export function ServerPicker({ if (inputServer) debounceServerSelect(inputServer); }; + const handleInputFocus: FocusEventHandler = () => { + inputFocusedRef.current = true; + }; + + const handleInputBlur: FocusEventHandler = () => { + inputFocusedRef.current = false; + // restore the server value if the input was cleared without selecting a new one + if (serverInputRef.current && serverInputRef.current.value.trim() === '') { + serverInputRef.current.value = server; + } + }; + const handleKeyDown: KeyboardEventHandler = (evt) => { if (evt.key === 'ArrowDown') { evt.preventDefault(); @@ -86,6 +105,8 @@ export function ServerPicker({ defaultValue={server} onChange={handleServerChange} onKeyDown={handleKeyDown} + onFocus={handleInputFocus} + onBlur={handleInputBlur} size="500" readOnly={!allowCustomServer} onClick={allowCustomServer ? undefined : handleOpenServerMenu} diff --git a/src/app/pages/auth/login/loginUtil.ts b/src/app/pages/auth/login/loginUtil.ts index 1659e5474..f14641746 100644 --- a/src/app/pages/auth/login/loginUtil.ts +++ b/src/app/pages/auth/login/loginUtil.ts @@ -3,6 +3,7 @@ import { createClient, LoginRequest, LoginResponse, MatrixError } from '$types/m import { useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { useSetAtom } from 'jotai'; +import * as Sentry from '@sentry/react'; import { clientAllowedServer, ClientConfig } from '$hooks/useClientConfig'; import { deleteAfterLoginRedirectPath, @@ -11,10 +12,12 @@ import { import { getHomePath } from '$pages/pathUtils'; import { activeSessionIdAtom, sessionsAtom } from '$state/sessions'; import { createLogger } from '$utils/debug'; +import { createDebugLogger } from '$utils/debugLogger'; import { ErrorCode } from '../../../cs-errorcode'; import { autoDiscovery, specVersions } from '../../../cs-api'; const log = createLogger('loginUtil'); +const debugLog = createDebugLogger('loginUtil'); export enum GetBaseUrlError { NotAllow = 'NotAllow', @@ -75,39 +78,64 @@ export const login = async ( } const mx = createClient({ baseUrl: url }); - const [err, res] = await to(mx.loginRequest(data)); + debugLog.info('general', 'Attempting login', { baseUrl: url, loginType: data.type }); - if (err) { - if (err.httpStatus === 400) { - throw new MatrixError({ - errcode: LoginError.InvalidRequest, - }); - } - if (err.httpStatus === 429) { - throw new MatrixError({ - errcode: LoginError.RateLimited, - }); - } - if (err.errcode === ErrorCode.M_USER_DEACTIVATED) { - throw new MatrixError({ - errcode: LoginError.UserDeactivated, - }); - } + return Sentry.startSpan( + { name: 'auth.login', op: 'auth', attributes: { 'auth.method': data.type } }, + async (span) => { + const [err, res] = await to(mx.loginRequest(data)); + + if (err) { + span.setAttribute('auth.error', err.errcode ?? 'unknown'); + Sentry.metrics.count('sable.auth.login_failed', 1, { + attributes: { errcode: err.errcode ?? 'unknown' }, + }); + if (err.httpStatus === 400) { + debugLog.error('general', 'Login failed - invalid request', { httpStatus: 400 }); + throw new MatrixError({ + errcode: LoginError.InvalidRequest, + }); + } + if (err.httpStatus === 429) { + debugLog.error('general', 'Login failed - rate limited', { httpStatus: 429 }); + throw new MatrixError({ + errcode: LoginError.RateLimited, + }); + } + if (err.errcode === ErrorCode.M_USER_DEACTIVATED) { + debugLog.error('general', 'Login failed - user deactivated', { errcode: err.errcode }); + throw new MatrixError({ + errcode: LoginError.UserDeactivated, + }); + } + + if (err.httpStatus === 403) { + debugLog.error('general', 'Login failed - forbidden', { httpStatus: 403 }); + throw new MatrixError({ + errcode: LoginError.Forbidden, + }); + } - if (err.httpStatus === 403) { - throw new MatrixError({ - errcode: LoginError.Forbidden, + debugLog.error('general', 'Login failed - unknown error', { + error: err.message, + httpStatus: err.httpStatus, + }); + throw new MatrixError({ + errcode: LoginError.Unknown, + }); + } + + span.setAttribute('auth.success', true); + debugLog.info('general', 'Login successful', { + userId: res.user_id, + deviceId: res.device_id, }); + return { + baseUrl: url, + response: res, + }; } - - throw new MatrixError({ - errcode: LoginError.Unknown, - }); - } - return { - baseUrl: url, - response: res, - }; + ); }; export const useLoginComplete = (data?: CustomLoginResponse) => { diff --git a/src/app/pages/client/BackgroundNotifications.tsx b/src/app/pages/client/BackgroundNotifications.tsx index 9ca53b5cb..2d382fcc7 100644 --- a/src/app/pages/client/BackgroundNotifications.tsx +++ b/src/app/pages/client/BackgroundNotifications.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { ClientEvent, createClient, @@ -32,17 +32,20 @@ import { } from '$utils/room'; import { NotificationType, StateEvent } from '$types/matrix/room'; import { createLogger } from '$utils/debug'; +import { createDebugLogger } from '$utils/debugLogger'; import LogoSVG from '$public/res/svg/cinny.svg'; import { nicknamesAtom } from '$state/nicknames'; import { buildRoomMessageNotification, resolveNotificationPreviewText, } from '$utils/notificationStyle'; +import * as Sentry from '@sentry/react'; import { startClient, stopClient } from '$client/initMatrix'; import { useClientConfig } from '$hooks/useClientConfig'; import { mobileOrTablet } from '$utils/user-agent'; const log = createLogger('BackgroundNotifications'); +const debugLog = createDebugLogger('BackgroundNotifications'); const isClientReadyForNotifications = (state: SyncState | string | null): boolean => state === SyncState.Prepared || state === SyncState.Syncing || state === SyncState.Catchup; @@ -68,21 +71,29 @@ const startBackgroundClient = async ( /** * Wait for the background client to finish its initial sync so that * push rules and account data are available before processing events. + * Rejects after 30 seconds so callers can handle a stalled client instead + * of blocking indefinitely. */ const waitForSync = (mx: MatrixClient): Promise => - new Promise((resolve) => { + new Promise((resolve, reject) => { const state = mx.getSyncState(); if (isClientReadyForNotifications(state)) { resolve(); return; } + let syncTimer: ReturnType | undefined; const onSync = (newState: SyncState) => { if (isClientReadyForNotifications(newState)) { + if (syncTimer !== undefined) clearTimeout(syncTimer); mx.removeListener(ClientEvent.Sync, onSync); resolve(); } }; mx.on(ClientEvent.Sync, onSync); + syncTimer = setTimeout(() => { + mx.removeListener(ClientEvent.Sync, onSync); + reject(new Error('background client sync timed out')); + }, 30_000); }); export function BackgroundNotifications() { @@ -120,10 +131,18 @@ export function BackgroundNotifications() { setBackgroundUnreadsRef.current = setBackgroundUnreads; const setInAppBannerRef = useRef(setInAppBanner); setInAppBannerRef.current = setInAppBanner; + // Per-client listener teardown callbacks, so we can explicitly remove event + // listeners before stopping a background client. + const clientCleanupRef = useRef void>>(new Map()); - const inactiveSessions = sessions.filter( - (s) => s.userId !== (activeSessionId ?? sessions[0]?.userId) + const inactiveSessions = useMemo( + () => sessions.filter((s) => s.userId !== (activeSessionId ?? sessions[0]?.userId)), + [sessions, activeSessionId] ); + // Ref so retry setTimeout callbacks can access the current session list + // without stale closures. + const inactiveSessionsRef = useRef(inactiveSessions); + inactiveSessionsRef.current = inactiveSessions; interface NotifyOptions { /** Title shown in the notification banner. */ @@ -192,8 +211,11 @@ export function BackgroundNotifications() { current.forEach((mx, userId) => { if (!activeIds.has(userId)) { + clientCleanupRef.current.get(userId)?.(); + clientCleanupRef.current.delete(userId); stopClient(mx); current.delete(userId); + Sentry.metrics.gauge('sable.background.client_count', current.size); // Clear the background unread badge when this session is no longer a background account. setBackgroundUnreads((prev) => { const next = { ...prev }; @@ -203,12 +225,16 @@ export function BackgroundNotifications() { } }); - inactiveSessions.forEach((session) => { - const alreadyRunning = current.has(session.userId); - if (alreadyRunning) return; + // startSession handles init, listener teardown tracking, and retry-on-failure. + // Using a named function (vs. inline .then) lets the .catch() schedule a + // fresh retry referencing the latest session from inactiveSessionsRef. + const startSession = (session: Session, attempt = 0): void => { + let sessionMx: MatrixClient | undefined; startBackgroundClient(session, clientConfig.slidingSync) .then(async (mx) => { + sessionMx = mx; current.set(session.userId, mx); + Sentry.metrics.gauge('sable.background.client_count', current.size); await waitForSync(mx); @@ -303,6 +329,10 @@ export function BackgroundNotifications() { const notificationType = getNotificationType(mx, room.roomId); if (notificationType === NotificationType.Mute) { + debugLog.debug('notification', 'Room is muted - skipping notification', { + roomId: room.roomId, + eventId, + }); return; } @@ -329,9 +359,27 @@ export function BackgroundNotifications() { const shouldNotify = pushActions?.notify || shouldForceDMNotification; if (!shouldNotify) { + debugLog.debug('notification', 'Event filtered - no push action match', { + eventId, + roomId: room.roomId, + eventType, + isDM, + }); return; } + const loudByRule = Boolean(pushActions.tweaks?.sound); + const isHighlight = Boolean(pushActions.tweaks?.highlight); + + debugLog.info('notification', 'Processing notification event', { + eventId, + roomId: room.roomId, + eventType, + isDM, + isHighlight, + loud: loudByRule, + }); + const senderName = getMemberDisplayName(room, sender, nicknamesRef.current) ?? getMxIdLocalPart(sender) ?? @@ -343,10 +391,7 @@ export function BackgroundNotifications() { ? (mxcUrlToHttp(mx, avatarMxc, false, 96, 96, 'crop') ?? undefined) : LogoSVG; - const loudByRule = Boolean(pushActions.tweaks?.sound); - // Track background unread count for every notifiable event (loud or silent). - const isHighlight = Boolean(pushActions.tweaks?.highlight); setBackgroundUnreadsRef.current((prev) => { const cur = prev[session.userId] ?? { total: 0, highlight: 0 }; return { @@ -360,6 +405,10 @@ export function BackgroundNotifications() { // Silent-rule events: unread badge updated above; no OS notification or sound. if (!loudByRule && !isHighlight) { + debugLog.debug('notification', 'Silent notification - badge updated only', { + eventId, + roomId: room.roomId, + }); return; } @@ -410,6 +459,11 @@ export function BackgroundNotifications() { if (canShowInAppBanner) { // App is in the foreground on a different account — show the themed in-app banner. + debugLog.info('notification', 'Showing in-app banner', { + eventId, + roomId: room.roomId, + title: notificationPayload.title, + }); setInAppBannerRef.current({ id: dedupeId, title: notificationPayload.title, @@ -422,6 +476,12 @@ export function BackgroundNotifications() { } else if (loudByRule) { // App is backgrounded or in-app notifications disabled — fire an OS notification. // Only send for loud (sound-tweak) rules; highlight-only events are silently counted. + debugLog.info('notification', 'Sending OS notification', { + eventId, + roomId: room.roomId, + title: notificationPayload.title, + hasSound: !notificationPayload.options.silent, + }); sendNotification({ title: notificationPayload.title, icon: notificationPayload.options.icon, @@ -435,14 +495,59 @@ export function BackgroundNotifications() { }; mx.on(RoomEvent.Timeline, handleTimeline as unknown as (...args: unknown[]) => void); + + // Register teardown so these listeners are removed when this client is stopped. + clientCleanupRef.current.set(session.userId, () => { + mx.off(ClientEvent.AccountData as any, handleAccountData); + mx.off(RoomEvent.Timeline, handleTimeline as unknown as (...args: unknown[]) => void); + }); }) .catch((err) => { log.error('failed to start background client for', session.userId, err); + debugLog.error('notification', 'Failed to start background client', { + userId: session.userId, + error: err, + }); + Sentry.captureException(err, { tags: { component: 'BackgroundNotifications' } }); + + // Remove the stuck/failed client from current so future runs (or the + // retry below) can attempt a fresh start. + if (sessionMx && current.get(session.userId) === sessionMx) { + clientCleanupRef.current.get(session.userId)?.(); + clientCleanupRef.current.delete(session.userId); + current.delete(session.userId); + stopClient(sessionMx); + } + + // Retry with exponential backoff, up to 5 attempts (5s, 10s, 20s, 40s, 60s cap). + if (attempt < 5) { + const retryDelay = Math.min(5_000 * 2 ** attempt, 60_000); + setTimeout(() => { + const latestSession = inactiveSessionsRef.current.find( + (s) => s.userId === session.userId + ); + if (latestSession && !current.has(session.userId)) { + startSession(latestSession, attempt + 1); + } + }, retryDelay); + } }); + }; + + inactiveSessions.forEach((session) => { + if (!current.has(session.userId)) startSession(session); }); return () => { - current.forEach((mx) => stopClient(mx)); + // Reading ref.current in cleanup is intentional - we want cleanup functions + // that were registered during async startBackgroundClient operations + // eslint-disable-next-line react-hooks/exhaustive-deps + const cleanupMap = clientCleanupRef.current; + current.forEach((mx, userId) => { + cleanupMap.get(userId)?.(); + cleanupMap.delete(userId); + stopClient(mx); + }); current.clear(); }; }, [ diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 5ec5d8806..d8228ccdd 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -1,7 +1,9 @@ import { useAtomValue, useSetAtom } from 'jotai'; +import * as Sentry from '@sentry/react'; import { ReactNode, useCallback, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { + MatrixEvent, MatrixEventEvent, PushProcessor, RoomEvent, @@ -44,13 +46,19 @@ import { resolveNotificationPreviewText, } from '$utils/notificationStyle'; import { mobileOrTablet } from '$utils/user-agent'; +import { createDebugLogger } from '$utils/debugLogger'; import { useSlidingSyncActiveRoom } from '$hooks/useSlidingSyncActiveRoom'; import { getSlidingSyncManager } from '$client/initMatrix'; import { NotificationBanner } from '$components/notification-banner'; +import { TelemetryConsentBanner } from '$components/telemetry-consent'; import { useCallSignaling } from '$hooks/useCallSignaling'; +import { getBlobCacheStats } from '$hooks/useBlobCache'; +import { lastVisitedRoomIdAtom } from '$state/room/lastRoom'; import { getInboxInvitesPath } from '../pathUtils'; import { BackgroundNotifications } from './BackgroundNotifications'; +const pushRelayLog = createDebugLogger('push-relay'); + function clearMediaSessionQuickly(): void { if (!('mediaSession' in navigator)) return; // iOS registers the lock screen media player as a side-effect of @@ -91,6 +99,7 @@ function PageZoomFeature() { function FaviconUpdater() { const roomToUnread = useAtomValue(roomToUnreadAtom); const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications'); + const [faviconForMentionsOnly] = useSetting(settingsAtom, 'faviconForMentionsOnly'); const registration = useAtomValue(registrationAtom); useEffect(() => { @@ -111,8 +120,10 @@ function FaviconUpdater() { } }); - if (notification) { - setFavicon(highlight ? LogoHighlightSVG : LogoUnreadSVG); + if (highlight) { + setFavicon(LogoHighlightSVG); + } else if (!faviconForMentionsOnly && notification) { + setFavicon(LogoUnreadSVG); } else { setFavicon(LogoSVG); } @@ -145,7 +156,7 @@ function FaviconUpdater() { } catch { // Likely Firefox/Gecko-based and doesn't support badging API } - }, [roomToUnread, usePushNotifications, registration]); + }, [roomToUnread, usePushNotifications, registration, faviconForMentionsOnly]); return null; } @@ -263,6 +274,8 @@ function MessageNotifications() { // already checked focus when the encrypted event arrived, and want to use that // original state rather than re-checking after decryption completes). const skipFocusCheckEvents = new Set(); + // Tracks when each event first arrived so we can measure notification delivery latency + const notifyTimerMap = new Map(); const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = ( mEvent, @@ -274,6 +287,10 @@ function MessageNotifications() { if (mx.getSyncState() !== 'SYNCING') return; const eventId = mEvent.getId(); + // Record event arrival time once per eventId (re-entry via handleDecrypted must not reset it) + if (eventId && !notifyTimerMap.has(eventId)) { + notifyTimerMap.set(eventId, performance.now()); + } const shouldSkipFocusCheck = eventId && skipFocusCheckEvents.has(eventId); if (!shouldSkipFocusCheck) { if (document.hasFocus() && (selectedRoomId === room?.roomId || notificationSelected)) @@ -334,6 +351,17 @@ function MessageNotifications() { // Check if this is a DM using multiple signals for robustness const isDM = isDMRoom(room, mDirectsRef.current); + + // Measure total notification delivery latency (includes decryption wait for E2EE events) + const arrivalMs = notifyTimerMap.get(eventId); + if (arrivalMs !== undefined) { + Sentry.metrics.distribution( + 'sable.notification.delivery_ms', + performance.now() - arrivalMs, + { attributes: { encrypted: String(mEvent.isEncrypted()), dm: String(isDM) } } + ); + notifyTimerMap.delete(eventId); + } const pushActions = pushProcessor.actionsForEvent(mEvent); // For DMs with "All Messages" or "Default" notification settings: @@ -528,6 +556,30 @@ function PrivacyBlurFeature() { return null; } +// Periodically emits memory-health gauges so Sentry dashboards can surface +// unbounded growth (e.g. blob cache never evicted, stale inflight requests). +function HealthMonitor() { + useEffect(() => { + const id = window.setInterval(() => { + const { cacheSize, inflightCount } = getBlobCacheStats(); + Sentry.metrics.gauge('sable.media.blob_cache_size', cacheSize); + if (inflightCount > 0) { + Sentry.metrics.gauge('sable.media.inflight_requests', inflightCount); + if (inflightCount >= 10) { + Sentry.addBreadcrumb({ + category: 'media', + message: `High inflight request count: ${inflightCount}`, + level: 'warning', + data: { inflight_count: inflightCount }, + }); + } + } + }, 60_000); + return () => window.clearInterval(id); + }, []); + return null; +} + type ClientNonUIFeaturesProps = { children: ReactNode; }; @@ -619,6 +671,155 @@ function SlidingSyncActiveRoomSubscriber() { return null; } +/** + * Tracks the currently-viewed room and writes sanitised room metadata to the Sentry scope. + * This context appears on every subsequent error/transaction captured while the room is open, + * making room-specific bugs much easier to triage. + */ +function SentryRoomContextFeature() { + const mx = useMatrixClient(); + const mDirect = useAtomValue(mDirectAtom); + const roomId = useAtomValue(lastVisitedRoomIdAtom); + + useEffect(() => { + if (!roomId) { + Sentry.setContext('room', null); + Sentry.setTag('room_type', 'none'); + Sentry.setTag('room_encrypted', 'none'); + return; + } + const room = mx.getRoom(roomId); + if (!room) return; + + const isDm = mDirect.has(roomId); + const encrypted = mx.isRoomEncrypted(roomId); + const memberCount = room.getJoinedMemberCount(); + // Bucket member count so we can correlate issues with room scale + // without leaking precise membership numbers of private rooms. + let memberCountRange: string; + if (memberCount <= 2) memberCountRange = '1-2'; + else if (memberCount <= 10) memberCountRange = '3-10'; + else if (memberCount <= 50) memberCountRange = '11-50'; + else if (memberCount <= 200) memberCountRange = '51-200'; + else memberCountRange = '200+'; + + Sentry.setContext('room', { + type: isDm ? 'dm' : 'group', + encrypted, + member_count_range: memberCountRange, + }); + // Also set as tags so they can be used to filter events in Sentry + Sentry.setTag('room_type', isDm ? 'dm' : 'group'); + Sentry.setTag('room_encrypted', String(encrypted)); + }, [mx, mDirect, roomId]); + + return null; +} + +function SentryTagsFeature() { + const settings = useAtomValue(settingsAtom); + + useEffect(() => { + // Core rendering tags — indexed in Sentry for filtering/search + Sentry.setTag('message_layout', String(settings.messageLayout)); + Sentry.setTag('message_spacing', String(settings.messageSpacing)); + Sentry.setTag('twitter_emoji', String(settings.twitterEmoji)); + Sentry.setTag('is_markdown', String(settings.isMarkdown)); + Sentry.setTag('page_zoom', String(settings.pageZoom)); + if (settings.themeId) Sentry.setTag('theme_id', settings.themeId); + // Additional high-value tags for bug reproduction + Sentry.setTag('use_right_bubbles', String(settings.useRightBubbles)); + Sentry.setTag('reduced_motion', String(settings.reducedMotion)); + Sentry.setTag('send_presence', String(settings.sendPresence)); + Sentry.setTag('enter_for_newline', String(settings.enterForNewline)); + Sentry.setTag('media_auto_load', String(settings.mediaAutoLoad)); + Sentry.setTag('url_preview', String(settings.urlPreview)); + Sentry.setTag('use_system_theme', String(settings.useSystemTheme)); + Sentry.setTag('uniform_icons', String(settings.uniformIcons)); + Sentry.setTag('jumbo_emoji_size', String(settings.jumboEmojiSize)); + Sentry.setTag('caption_position', String(settings.captionPosition)); + Sentry.setTag('right_swipe_action', String(settings.rightSwipeAction)); + // Full settings snapshot as structured Additional Data on every event + Sentry.setContext('settings', { ...settings }); + }, [settings]); + + return null; +} + +/** + * Listens for decryptPushEvent messages from the service worker, decrypts the + * event using the local Olm/Megolm session, then replies with pushDecryptResult + * so the SW can show a notification with the real message content. + * Falls back gracefully (success: false) on any error or if keys are missing. + */ +function HandleDecryptPushEvent() { + const mx = useMatrixClient(); + + useEffect(() => { + if (!('serviceWorker' in navigator)) return undefined; + + const handleMessage = async (ev: MessageEvent) => { + const { data } = ev; + if (!data || data.type !== 'decryptPushEvent') return; + + const { rawEvent } = data as { rawEvent: Record }; + const eventId = rawEvent.event_id as string; + const roomId = rawEvent.room_id as string; + const decryptStart = performance.now(); + + try { + const mxEvent = new MatrixEvent(rawEvent as any); + await mx.decryptEventIfNeeded(mxEvent); + + const room = mx.getRoom(roomId); + const sender = mxEvent.getSender(); + let senderName = 'Someone'; + if (sender) { + senderName = getMxIdLocalPart(sender) ?? sender; + if (room) senderName = getMemberDisplayName(room, sender) ?? senderName; + } + + const decryptMs = Math.round(performance.now() - decryptStart); + const visible = document.visibilityState === 'visible'; + pushRelayLog.info('notification', 'Push relay decryption succeeded', { + eventType: mxEvent.getType(), + decryptMs, + appVisible: visible, + }); + + navigator.serviceWorker.controller?.postMessage({ + type: 'pushDecryptResult', + eventId, + success: true, + eventType: mxEvent.getType(), + content: mxEvent.getContent(), + sender_display_name: senderName, + room_name: room?.name ?? '', + visibilityState: document.visibilityState, + }); + } catch (err) { + console.warn('[app] HandleDecryptPushEvent: failed to decrypt push event', err); + pushRelayLog.error( + 'notification', + 'Push relay decryption failed', + err instanceof Error ? err : new Error(String(err)) + ); + navigator.serviceWorker.controller?.postMessage({ + type: 'pushDecryptResult', + eventId, + success: false, + visibilityState: document.visibilityState, + }); + } + }; + + navigator.serviceWorker.addEventListener('message', handleMessage); + return () => navigator.serviceWorker.removeEventListener('message', handleMessage); + }, [mx]); + + return null; +} + function PresenceFeature() { const mx = useMatrixClient(); const [sendPresence] = useSetting(settingsAtom, 'sendPresence'); @@ -646,9 +847,14 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + + + + + {children} ); diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 8a9f23052..1a653e950 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -16,6 +16,7 @@ import { import { HttpApiEvent, HttpApiEventHandlerMap, MatrixClient } from '$types/matrix-sdk'; import FocusTrap from 'focus-trap-react'; import { useRef, MouseEventHandler, ReactNode, useCallback, useEffect, useState } from 'react'; +import * as Sentry from '@sentry/react'; import { useNavigate } from 'react-router-dom'; import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { @@ -150,6 +151,11 @@ function ClientRootOptions({ mx, onLogout }: ClientRootOptionsProps) { const useLogoutListener = (mx?: MatrixClient) => { useEffect(() => { const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => { + Sentry.addBreadcrumb({ + category: 'auth', + message: 'Session forcibly logged out by server', + level: 'warning', + }); if (mx) stopClient(mx); await mx?.clearStores(); window.localStorage.clear(); @@ -180,6 +186,8 @@ export function ClientRoot({ children }: ClientRootProps) { const { baseUrl, userId } = activeSession ?? {}; const loadedUserIdRef = useRef(undefined); + const syncStartTimeRef = useRef(performance.now()); + const firstSyncReadyRef = useRef(false); const [loadState, loadMatrix, setLoadState] = useAsyncCallback( useCallback(async () => { @@ -281,11 +289,68 @@ export function ClientRoot({ children }: ClientRootProps) { mx, useCallback((state: string) => { if (isClientReady(state)) { + if (!firstSyncReadyRef.current) { + firstSyncReadyRef.current = true; + Sentry.metrics.distribution( + 'sable.sync.time_to_ready_ms', + performance.now() - syncStartTimeRef.current + ); + } setLoading(false); } }, []) ); + // Set matrix client context: homeserver and sync type (not PII) + useEffect(() => { + if (!activeSession?.baseUrl) return undefined; + Sentry.setContext('client', { + homeserver: activeSession.baseUrl, + sliding_sync: clientConfig.slidingSync, + }); + return () => { + Sentry.setContext('client', null); + }; + }, [activeSession?.baseUrl, clientConfig.slidingSync]); + + // Set a pseudonymous hashed user ID for error grouping — never sends raw Matrix ID + useEffect(() => { + if (!mx) return undefined; + const matrixUserId = mx.getUserId(); + if (!matrixUserId) return undefined; + (async () => { + const hashBuffer = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(matrixUserId) + ); + const hashHex = Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + .slice(0, 16); + // Include the homeserver domain as a custom attribute — it is not PII (it is the + // server domain, not a personal identifier) and helps segment issues by deployment. + const serverDomain = matrixUserId.split(':')[1] ?? 'unknown'; + Sentry.setUser({ id: hashHex, homeserver: serverDomain }); + })(); + return () => { + Sentry.setUser(null); + }; + }, [mx]); + + // Capture fatal client failures — useAsyncCallback swallows these into state so + // they never reach the React ErrorBoundary; explicit capture is required. + useEffect(() => { + if (loadState.status === AsyncStatus.Error) { + Sentry.captureException(loadState.error, { tags: { phase: 'load' } }); + } + }, [loadState]); + + useEffect(() => { + if (startState.status === AsyncStatus.Error) { + Sentry.captureException(startState.error, { tags: { phase: 'start' } }); + } + }, [startState]); + return ( diff --git a/src/app/pages/client/SyncStatus.tsx b/src/app/pages/client/SyncStatus.tsx index 818d7700a..f55fe5e59 100644 --- a/src/app/pages/client/SyncStatus.tsx +++ b/src/app/pages/client/SyncStatus.tsx @@ -1,6 +1,7 @@ import { MatrixClient, SyncState } from '$types/matrix-sdk'; import { useCallback, useState } from 'react'; import { Box, config, Line, Text } from 'folds'; +import * as Sentry from '@sentry/react'; import { useSyncState } from '$hooks/useSyncState'; import { ContainerColor } from '$styles/ContainerColor.css'; @@ -27,6 +28,18 @@ export function SyncStatus({ mx }: SyncStatusProps) { } return { current, previous }; }); + + if (current === SyncState.Reconnecting || current === SyncState.Error) { + Sentry.addBreadcrumb({ + category: 'sync', + message: `Sync state changed to ${current}`, + level: current === SyncState.Error ? 'error' : 'warning', + data: { previous }, + }); + Sentry.metrics.count('sable.sync.degraded', 1, { + attributes: { state: current }, + }); + } }, []) ); diff --git a/src/app/pages/client/explore/Server.tsx b/src/app/pages/client/explore/Server.tsx index 84fd8f36f..0ff06dd54 100644 --- a/src/app/pages/client/explore/Server.tsx +++ b/src/app/pages/client/explore/Server.tsx @@ -90,7 +90,7 @@ type SearchProps = { onSearch: (term: string) => void; onReset: () => void; }; -function Search({ active, loading, searchInputRef, onSearch, onReset }: SearchProps) { +function Search({ active, loading, searchInputRef, onSearch, onReset }: Readonly) { const handleSearchSubmit: FormEventHandler = (evt) => { evt.preventDefault(); const { searchInput } = evt.target as HTMLFormElement & { @@ -149,10 +149,10 @@ const DEFAULT_INSTANCE_NAME = 'Matrix'; function ThirdPartyProtocolsSelector({ instanceId, onChange, -}: { +}: Readonly<{ instanceId?: string; onChange: (instanceId?: string) => void; -}) { +}>) { const mx = useMatrixClient(); const [menuAnchor, setMenuAnchor] = useState(); @@ -251,7 +251,7 @@ type LimitButtonProps = { limit: number; onLimitChange: (limit: string) => void; }; -function LimitButton({ limit, onLimitChange }: LimitButtonProps) { +function LimitButton({ limit, onLimitChange }: Readonly) { const [menuAnchor, setMenuAnchor] = useState(); const handleLimitSubmit: FormEventHandler = (evt) => { @@ -360,7 +360,7 @@ export function PublicRooms() { const currentLimit: number = useMemo(() => { const limitParam = serverSearchParams.limit; if (!limitParam) return FALLBACK_ROOMS_LIMIT; - return parseInt(limitParam, 10) || FALLBACK_ROOMS_LIMIT; + return Number.parseInt(limitParam, 10) || FALLBACK_ROOMS_LIMIT; }, [serverSearchParams.limit]); const resetScroll = useCallback(() => { @@ -371,7 +371,7 @@ export function PublicRooms() { const fetchPublicRooms = useCallback(() => { const limit = typeof serverSearchParams.limit === 'string' - ? parseInt(serverSearchParams.limit, 10) + ? Number.parseInt(serverSearchParams.limit, 10) : FALLBACK_ROOMS_LIMIT; const roomType: string | null | undefined = serverSearchParams.type === 'null' ? null : serverSearchParams.type; @@ -387,7 +387,7 @@ export function PublicRooms() { since: serverSearchParams.since, filter: { generic_search_term: serverSearchParams.term, - room_types: roomType !== undefined ? [roomType] : undefined, + room_types: roomType === undefined ? undefined : [roomType], }, third_party_instance_id: serverSearchParams.instance, } @@ -574,7 +574,7 @@ export function PublicRooms() { {isLoading && ( - {[...Array(currentLimit).keys()].map((item) => ( + {[...new Array(currentLimit).keys()].map((item) => ( ))} diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx index adc79ed2b..7543720d0 100644 --- a/src/app/pages/client/inbox/Notifications.tsx +++ b/src/app/pages/client/inbox/Notifications.tsx @@ -121,7 +121,7 @@ const groupNotifications = ( const groupIndex = groups.length - 1; const lastAddedGroup: RoomNotificationsGroup | undefined = groups[groupIndex]; - if (lastAddedGroup && notification.room_id === lastAddedGroup.roomId) { + if (notification.room_id === lastAddedGroup?.roomId) { lastAddedGroup.notifications.push(notification); return; } @@ -223,7 +223,7 @@ function RoomNotificationsGroupComp({ legacyUsernameColor, hour24Clock, dateFormatString, -}: RoomNotificationsGroupProps) { +}: Readonly) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const unread = useRoomUnread(room.roomId, roomToUnreadAtom); @@ -623,7 +623,7 @@ export function Notifications() { loadTimeline(); }, [loadTimeline]); - const lastVItem = vItems[vItems.length - 1]; + const lastVItem = vItems.at(-1); const lastVItemIndex: number | undefined = lastVItem?.index; useEffect(() => { if ( @@ -671,7 +671,7 @@ export function Notifications() { setOnlyHighlighted(false)} - variant={!onlyHighlight ? 'Success' : 'Surface'} + variant={onlyHighlight ? 'Surface' : 'Success'} aria-pressed={!onlyHighlight} before={!onlyHighlight && } outlined @@ -758,7 +758,7 @@ export function Notifications() { {timelineState.status === AsyncStatus.Loading && ( - {[...Array(8).keys()].map((key) => ( + {[...new Array(8).keys()].map((key) => ( (); const [busyUserIds, setBusyUserIds] = useState>(new Set()); const [settingsOpen, setSettingsOpen] = useState(false); + const [confirmSignOutSession, setConfirmSignOutSession] = useState( + undefined + ); const activeSession = sessions.find((s) => s.userId === activeSessionId) ?? sessions[0]; @@ -246,6 +254,9 @@ export function AccountSwitcherTab() { }; const handleOpenSettings = () => { + debugLog.info('ui', 'Settings button clicked', { + userId: activeSession?.userId, + }); setMenuAnchor(undefined); setSettingsOpen(true); }; @@ -328,7 +339,10 @@ export function AccountSwitcherTab() { isBusy={busyUserIds.has(session.userId)} unread={!isActive ? backgroundUnreads[session.userId] : undefined} onSwitch={handleSwitch} - onSignOut={handleSignOut} + onSignOut={(pendingSession) => { + setMenuAnchor(undefined); + setConfirmSignOutSession(pendingSession); + }} /> ); })} @@ -360,6 +374,43 @@ export function AccountSwitcherTab() { setSettingsOpen(false)} /> )} + {confirmSignOutSession && ( + setConfirmSignOutSession(undefined)}> + +
+ + Sign out + +
+ + + Are you sure you want to sign out of {confirmSignOutSession.userId}? + + + + + + +
+
+ )} ); } diff --git a/src/app/pages/client/sidebar/DirectDMsList.tsx b/src/app/pages/client/sidebar/DirectDMsList.tsx index 81d9f26d3..0c01c57d7 100644 --- a/src/app/pages/client/sidebar/DirectDMsList.tsx +++ b/src/app/pages/client/sidebar/DirectDMsList.tsx @@ -1,12 +1,10 @@ -import { useMemo, useState, useCallback } from 'react'; +import { useMemo, useRef, useEffect } from 'react'; +import * as Sentry from '@sentry/react'; import { useNavigate } from 'react-router-dom'; import { Avatar, Text, Box, toRem } from 'folds'; import { useAtomValue } from 'jotai'; -import { Room, SyncState } from '$types/matrix-sdk'; -import { useDirects } from '$state/hooks/roomList'; +import { Room } from '$types/matrix-sdk'; import { useMatrixClient } from '$hooks/useMatrixClient'; -import { mDirectAtom } from '$state/mDirectList'; -import { allRoomsAtom } from '$state/room-list/roomList'; import { roomToUnreadAtom } from '$state/room/roomToUnread'; import { getDirectRoomPath } from '$pages/pathUtils'; import { @@ -21,14 +19,12 @@ import { UserAvatar } from '$components/user-avatar'; import { getDirectRoomAvatarUrl } from '$utils/room'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { nameInitials } from '$utils/common'; -import { factoryRoomIdByActivity } from '$utils/sort'; import { getCanonicalAliasOrRoomId, mxcUrlToHttp } from '$utils/matrix'; import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; import { useGroupDMMembers } from '$hooks/useGroupDMMembers'; -import { useSyncState } from '$hooks/useSyncState'; +import { useSidebarDirectRoomIds } from './useSidebarDirectRoomIds'; import * as css from './DirectDMsList.css'; -const MAX_DM_AVATARS = 3; const MAX_GROUP_MEMBERS = 3; type DMItemProps = { @@ -146,7 +142,7 @@ function DMItem({ room, selected }: DMItemProps) { 0} style={{ - left: unread.total > 0 ? toRem(-6) : toRem(-2), + left: unread.total > 0 ? toRem(-6) : toRem(-4), right: 'auto', }} > @@ -163,50 +159,29 @@ function DMItem({ room, selected }: DMItemProps) { export function DirectDMsList() { const mx = useMatrixClient(); - const mDirects = useAtomValue(mDirectAtom); - const directs = useDirects(mx, allRoomsAtom, mDirects); - const roomToUnread = useAtomValue(roomToUnreadAtom); const selectedRoomId = useSelectedRoom(); + const sidebarRoomIds = useSidebarDirectRoomIds(); + + const mountTimeRef = useRef(performance.now()); + const firstReadyRef = useRef(false); - // Track sync state to wait for initial sync completion - const [syncReady, setSyncReady] = useState(false); - - useSyncState( - mx, - useCallback((state, prevState) => { - // Consider ready after initial sync reaches Syncing state - // This ensures m.direct and unread counts are populated - if (state === SyncState.Syncing && prevState !== SyncState.Syncing) { - setSyncReady(true); - } - // Also set ready if we're already syncing (e.g., after a refresh while still online) - if (state === SyncState.Syncing || state === SyncState.Catchup) { - setSyncReady(true); - } - }, []) + const recentDMs = useMemo( + () => + sidebarRoomIds + .map((roomId) => mx.getRoom(roomId)) + .filter((room): room is Room => room !== null), + [sidebarRoomIds, mx] ); - // Get up to MAX_DM_AVATARS recent DMs that have unread messages - const recentDMs = useMemo(() => { - // Don't show DMs until initial sync completes - if (!syncReady) { - return []; + useEffect(() => { + if (recentDMs.length > 0 && !firstReadyRef.current) { + firstReadyRef.current = true; + Sentry.metrics.distribution( + 'sable.roomlist.time_to_ready_ms', + performance.now() - mountTimeRef.current + ); } - - // Filter to only DMs with unread messages - const withUnread = directs.filter((roomId) => { - const unread = roomToUnread.get(roomId); - return unread && (unread.total > 0 || unread.highlight > 0); - }); - - // Sort by activity - const sorted = withUnread.sort(factoryRoomIdByActivity(mx)); - - return sorted - .slice(0, MAX_DM_AVATARS) - .map((roomId) => mx.getRoom(roomId)) - .filter((room): room is Room => room !== null); - }, [directs, mx, roomToUnread, syncReady]); + }, [recentDMs]); if (recentDMs.length === 0) { return null; diff --git a/src/app/pages/client/sidebar/DirectTab.tsx b/src/app/pages/client/sidebar/DirectTab.tsx index dd2a7d6e8..a1ff6ff58 100644 --- a/src/app/pages/client/sidebar/DirectTab.tsx +++ b/src/app/pages/client/sidebar/DirectTab.tsx @@ -1,4 +1,4 @@ -import { MouseEventHandler, forwardRef, useState } from 'react'; +import { MouseEventHandler, forwardRef, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds'; import FocusTrap from 'focus-trap-react'; @@ -25,6 +25,7 @@ import { stopPropagation } from '$utils/keyboard'; import { settingsAtom } from '$state/settings'; import { useSetting } from '$state/hooks/settings'; import { useDirectRooms } from '$pages/client/direct/useDirectRooms'; +import { useSidebarDirectRoomIds } from './useSidebarDirectRoomIds'; type DirectMenuProps = { requestClose: () => void; @@ -68,7 +69,14 @@ export function DirectTab() { const mDirects = useAtomValue(mDirectAtom); const directs = useDirects(mx, allRoomsAtom, mDirects); - const directUnread = useRoomsUnread(directs, roomToUnreadAtom); + const sidebarRoomIds = useSidebarDirectRoomIds(); + // Only count unread for DMs not already shown as individual avatars in the + // sidebar — prevents double-badging (issue #235). + const overflowDirects = useMemo(() => { + const sidebarSet = new Set(sidebarRoomIds); + return directs.filter((id) => !sidebarSet.has(id)); + }, [directs, sidebarRoomIds]); + const directUnread = useRoomsUnread(overflowDirects, roomToUnreadAtom); const [menuAnchor, setMenuAnchor] = useState(); const directSelected = useDirectSelected(); @@ -110,7 +118,7 @@ export function DirectTab() { 0} style={{ - left: directUnread.total > 0 ? toRem(-6) : toRem(-2), + left: directUnread.total > 0 ? toRem(-6) : toRem(-4), right: 'auto', }} > diff --git a/src/app/pages/client/sidebar/HomeTab.tsx b/src/app/pages/client/sidebar/HomeTab.tsx index 611957f33..7401e4233 100644 --- a/src/app/pages/client/sidebar/HomeTab.tsx +++ b/src/app/pages/client/sidebar/HomeTab.tsx @@ -112,7 +112,7 @@ export function HomeTab() { 0} style={{ - left: homeUnread.total > 0 ? toRem(-6) : toRem(-2), + left: homeUnread.total > 0 ? toRem(-6) : toRem(-4), right: 'auto', }} > diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx index a758cc1f8..c751f00cb 100644 --- a/src/app/pages/client/sidebar/SpaceTabs.tsx +++ b/src/app/pages/client/sidebar/SpaceTabs.tsx @@ -244,9 +244,8 @@ const useDraggableItem = ( const target = targetRef.current; const dragHandle = dragHandleRef?.current ?? undefined; - return !target - ? undefined - : draggable({ + return target + ? draggable({ element: target, dragHandle, getInitialData: () => ({ item }), @@ -258,7 +257,8 @@ const useDraggableItem = ( setDragging(false); onDragging?.(undefined); }, - }); + }) + : undefined; }, [targetRef, dragHandleRef, item, onDragging]); return dragging; @@ -357,7 +357,7 @@ const useDnDMonitor = ( useEffect(() => { const scrollElement = scrollRef.current; if (!scrollElement) { - throw Error('Scroll element ref not configured'); + throw new Error('Scroll element ref not configured'); } return combine( @@ -399,7 +399,7 @@ function SpaceTab({ onDragging, disabled, onUnpin, -}: SpaceTabProps) { +}: Readonly) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const targetRef = useRef(null); @@ -468,7 +468,7 @@ function SpaceTab({ 0} style={{ - left: unread.total > 0 ? toRem(-6) : toRem(-2), + left: unread.total > 0 ? toRem(-6) : toRem(-4), right: 'auto', }} > @@ -515,7 +515,7 @@ type OpenedSpaceFolderProps = { onClose: MouseEventHandler; children?: ReactNode; }; -function OpenedSpaceFolder({ folder, onClose, children }: OpenedSpaceFolderProps) { +function OpenedSpaceFolder({ folder, onClose, children }: Readonly) { const aboveTargetRef = useRef(null); const belowTargetRef = useRef(null); @@ -555,7 +555,7 @@ function ClosedSpaceFolder({ onOpen, onDragging, disabled, -}: ClosedSpaceFolderProps) { +}: Readonly) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const handlerRef = useRef(null); @@ -609,7 +609,7 @@ function ClosedSpaceFolder({ 0} style={{ - left: unread.total > 0 ? toRem(-6) : toRem(-2), + left: unread.total > 0 ? toRem(-6) : toRem(-4), right: 'auto', }} > @@ -628,7 +628,7 @@ function ClosedSpaceFolder({ type SpaceTabsProps = { scrollRef: RefObject; }; -export function SpaceTabs({ scrollRef }: SpaceTabsProps) { +export function SpaceTabs({ scrollRef }: Readonly) { const navigate = useNavigate(); const mx = useMatrixClient(); const screenSize = useScreenSizeContext(); @@ -786,7 +786,7 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) { } const activePath = navToActivePath.get(targetSpaceId); - if (activePath && activePath.pathname.startsWith(spacePath)) { + if (activePath?.pathname.startsWith(spacePath)) { navigate(joinPathComponent(activePath)); return; } diff --git a/src/app/pages/client/sidebar/useSidebarDirectRoomIds.ts b/src/app/pages/client/sidebar/useSidebarDirectRoomIds.ts new file mode 100644 index 000000000..8c62d3af7 --- /dev/null +++ b/src/app/pages/client/sidebar/useSidebarDirectRoomIds.ts @@ -0,0 +1,54 @@ +import { useMemo, useState, useCallback } from 'react'; +import { useAtomValue } from 'jotai'; +import { SyncState } from '$types/matrix-sdk'; +import { useDirects } from '$state/hooks/roomList'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { mDirectAtom } from '$state/mDirectList'; +import { allRoomsAtom } from '$state/room-list/roomList'; +import { roomToUnreadAtom } from '$state/room/roomToUnread'; +import { factoryRoomIdByActivity } from '$utils/sort'; +import { useSyncState } from '$hooks/useSyncState'; + +/** Maximum number of individual DM avatars shown in the sidebar. */ +export const MAX_SIDEBAR_DMS = 3; + +/** + * Returns the room IDs of DMs currently displayed as individual avatars in the + * sidebar `DirectDMsList`. These are the first `MAX_SIDEBAR_DMS` unread DMs + * sorted by recent activity, available only after initial sync completes. + * + * Used by `DirectDMsList` to decide which rooms to render, and by `DirectTab` + * to exclude those rooms from its own badge count (prevents double-badging). + */ +export const useSidebarDirectRoomIds = (): string[] => { + const mx = useMatrixClient(); + const mDirects = useAtomValue(mDirectAtom); + const directs = useDirects(mx, allRoomsAtom, mDirects); + const roomToUnread = useAtomValue(roomToUnreadAtom); + + const [syncReady, setSyncReady] = useState(false); + + useSyncState( + mx, + useCallback((state, prevState) => { + if (state === SyncState.Syncing && prevState !== SyncState.Syncing) { + setSyncReady(true); + } + if (state === SyncState.Syncing || state === SyncState.Catchup) { + setSyncReady(true); + } + }, []) + ); + + return useMemo(() => { + if (!syncReady) return []; + + const withUnread = directs.filter((roomId) => { + const unread = roomToUnread.get(roomId); + return unread && (unread.total > 0 || unread.highlight > 0); + }); + + const sorted = withUnread.sort(factoryRoomIdByActivity(mx)); + return sorted.slice(0, MAX_SIDEBAR_DMS); + }, [directs, mx, roomToUnread, syncReady]); +}; diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index bc88b9af5..aae2f2d7b 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -72,6 +72,9 @@ import { mobileOrTablet } from '$utils/user-agent'; import { lastVisitedRoomIdAtom } from '$state/room/lastRoom'; import { SwipeableOverlayWrapper } from '$components/SwipeableOverlayWrapper'; import { useCallEmbed } from '$hooks/useCallEmbed'; +import { createDebugLogger } from '$utils/debugLogger'; + +const debugLog = createDebugLogger('Space'); type SpaceMenuProps = { room: Room; @@ -122,6 +125,7 @@ const SpaceMenu = forwardRef(({ room, requestClo }; const handleOpenTimeline = () => { + debugLog.info('ui', 'Space timeline opened', { roomId: room.roomId }); navigateRoom(room.roomId); requestClose(); }; diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index c370bfac5..734cf1626 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -9,9 +9,11 @@ import { } from 'matrix-js-sdk'; import { ClientWidgetApi, + type IWidgetApiRequest, IRoomEvent, IWidget, Widget, + WidgetApiFromWidgetAction, WidgetApiToWidgetAction, WidgetDriver, } from 'matrix-widget-api'; @@ -25,6 +27,9 @@ import { } from './types'; import { CallControl } from './CallControl'; import { CallControlState } from './CallControlState'; +import { createDebugLogger } from '../../utils/debugLogger'; + +const debugLog = createDebugLogger('CallEmbed'); export class CallEmbed { private mx: MatrixClient; @@ -127,6 +132,8 @@ export class CallEmbed { container: HTMLElement, initialControlState?: CallControlState ) { + debugLog.info('call', 'Initializing call embed', { roomId: room.roomId }); + const iframe = CallEmbed.getIframe( widget.getCompleteUrl({ currentUserId: mx.getSafeUserId() }) ); @@ -144,9 +151,24 @@ export class CallEmbed { const controlState = initialControlState ?? new CallControlState(true, false, true); this.control = new CallControl(controlState, call, iframe); + this.disposables.push( + this.listenAction(WidgetApiFromWidgetAction.UpdateAlwaysOnScreen, (evt) => { + evt.preventDefault(); + this.call.transport.reply(evt.detail as IWidgetApiRequest, { success: true }); + }) + ); + this.disposables.push( + this.listenAction(ElementWidgetActions.Close, (evt) => { + evt.preventDefault(); + this.call.transport.reply(evt.detail as IWidgetApiRequest, {}); + }) + ); + let initialMediaEvent = true; this.disposables.push( this.listenAction(ElementWidgetActions.DeviceMute, (evt) => { + evt.preventDefault(); + this.call.transport.reply(evt.detail as IWidgetApiRequest, {}); if (initialMediaEvent) { initialMediaEvent = false; this.control.applyState(); @@ -174,6 +196,7 @@ export class CallEmbed { } public hangup() { + debugLog.info('call', 'Hanging up call', { roomId: this.roomId }); return this.call.transport.send(ElementWidgetActions.HangupCall, {}); } @@ -194,6 +217,7 @@ export class CallEmbed { } private start() { + debugLog.info('call', 'Starting call widget', { roomId: this.roomId }); // Room widgets get locked to the room they were added in this.call.setViewedRoomId(this.roomId); this.disposables.push( @@ -211,11 +235,24 @@ export class CallEmbed { this.readUpToMap[room.roomId] = roomEvent.getId()!; }); - // Attach listeners for feeding events - the underlying widget classes handle permissions for us - this.mx.on(ClientEvent.Event, this.onEvent.bind(this)); - this.mx.on(MatrixEventEvent.Decrypted, this.onEventDecrypted.bind(this)); - this.mx.on(RoomStateEvent.Events, this.onStateUpdate.bind(this)); - this.mx.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent.bind(this)); + // Attach listeners for feeding events - the underlying widget classes handle permissions for us. + // Bind once and store via disposables so the same function reference is used for removal. + // Using .bind(this) at call-site would create a new function every time, making .off() a no-op + // and causing MaxListeners warnings when the embed is recreated during sync retries. + const boundOnEvent = this.onEvent.bind(this); + const boundOnEventDecrypted = this.onEventDecrypted.bind(this); + const boundOnStateUpdate = this.onStateUpdate.bind(this); + const boundOnToDeviceEvent = this.onToDeviceEvent.bind(this); + this.mx.on(ClientEvent.Event, boundOnEvent); + this.mx.on(MatrixEventEvent.Decrypted, boundOnEventDecrypted); + this.mx.on(RoomStateEvent.Events, boundOnStateUpdate); + this.mx.on(ClientEvent.ToDeviceEvent, boundOnToDeviceEvent); + this.disposables.push(() => { + this.mx.off(ClientEvent.Event, boundOnEvent); + this.mx.off(MatrixEventEvent.Decrypted, boundOnEventDecrypted); + this.mx.off(RoomStateEvent.Events, boundOnStateUpdate); + this.mx.off(ClientEvent.ToDeviceEvent, boundOnToDeviceEvent); + }); } /** @@ -224,6 +261,7 @@ export class CallEmbed { * @param opts */ public dispose(): void { + debugLog.info('call', 'Disposing call widget', { roomId: this.roomId }); this.disposables.forEach((disposable) => { disposable(); }); @@ -231,17 +269,16 @@ export class CallEmbed { this.container.removeChild(this.iframe); this.control.dispose(); - this.mx.off(ClientEvent.Event, this.onEvent.bind(this)); - this.mx.off(MatrixEventEvent.Decrypted, this.onEventDecrypted.bind(this)); - this.mx.off(RoomStateEvent.Events, this.onStateUpdate.bind(this)); - this.mx.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent.bind(this)); - + // Listener removal is handled by the disposables pushed in start(). // Clear internal state this.readUpToMap = {}; this.eventsToFeed = new WeakSet(); } - private onCallJoined(): void { + private onCallJoined(evt: CustomEvent): void { + evt.preventDefault(); + this.call.transport.reply(evt.detail as IWidgetApiRequest, {}); + debugLog.info('call', 'Call joined', { roomId: this.roomId }); this.joined = true; this.applyStyles(); this.control.startObserving(); diff --git a/src/app/plugins/call/CallWidgetDriver.ts b/src/app/plugins/call/CallWidgetDriver.ts index a2cbc8ddc..6d94891f8 100644 --- a/src/app/plugins/call/CallWidgetDriver.ts +++ b/src/app/plugins/call/CallWidgetDriver.ts @@ -8,7 +8,7 @@ import { type IWidgetApiErrorResponseDataDetails, type ISearchUserDirectoryResult, type IGetMediaConfigResult, - type UpdateDelayedEventAction, + UpdateDelayedEventAction, OpenIDRequestState, SimpleObservable, IOpenIDUpdate, @@ -26,6 +26,9 @@ import { } from 'matrix-js-sdk'; import { getCallCapabilities } from './utils'; import { downloadMedia, mxcUrlToHttp } from '../../utils/matrix'; +import { createDebugLogger } from '../../utils/debugLogger'; + +const debugLog = createDebugLogger('CallWidgetDriver'); export class CallWidgetDriver extends WidgetDriver { private allowedCapabilities: Set; @@ -40,8 +43,12 @@ export class CallWidgetDriver extends WidgetDriver { this.mx = mx; const deviceId = mx.getDeviceId(); - if (!deviceId) throw new Error('Failed to initialize CallWidgetDriver! Device ID not found.'); + if (!deviceId) { + debugLog.error('call', 'Failed to initialize CallWidgetDriver - no device ID'); + throw new Error('Failed to initialize CallWidgetDriver! Device ID not found.'); + } + debugLog.info('call', 'Initializing CallWidgetDriver', { roomId: inRoomId, deviceId }); this.allowedCapabilities = getCallCapabilities(inRoomId, mx.getSafeUserId(), deviceId); } @@ -59,7 +66,16 @@ export class CallWidgetDriver extends WidgetDriver { const client = this.mx; const roomId = targetRoomId || this.inRoomId; - if (!client || !roomId) throw new Error('Not in a room or not attached to a client'); + if (!client || !roomId) { + debugLog.error('call', 'Cannot send event - no client or room', { eventType, roomId }); + throw new Error('Not in a room or not attached to a client'); + } + + debugLog.info('call', 'Sending call event', { + eventType, + roomId, + hasStateKey: stateKey !== null, + }); let r: { event_id: string } | null; if (typeof stateKey === 'string') { @@ -80,6 +96,7 @@ export class CallWidgetDriver extends WidgetDriver { ); } + debugLog.info('call', 'Call event sent successfully', { eventId: r.event_id, eventType }); return { roomId, eventId: r.event_id }; } @@ -148,6 +165,18 @@ export class CallWidgetDriver extends WidgetDriver { await client._unstable_updateDelayedEvent(delayId, action); } + public async cancelScheduledDelayedEvent(delayId: string): Promise { + await this.updateDelayedEvent(delayId, UpdateDelayedEventAction.Cancel); + } + + public async restartScheduledDelayedEvent(delayId: string): Promise { + await this.updateDelayedEvent(delayId, UpdateDelayedEventAction.Restart); + } + + public async sendScheduledDelayedEvent(delayId: string): Promise { + await this.updateDelayedEvent(delayId, UpdateDelayedEventAction.Send); + } + public async sendToDevice( eventType: string, encrypted: boolean, diff --git a/src/app/plugins/call/utils.ts b/src/app/plugins/call/utils.ts index 0ea72b3c8..822b81295 100644 --- a/src/app/plugins/call/utils.ts +++ b/src/app/plugins/call/utils.ts @@ -18,6 +18,8 @@ export function getCallCapabilities( capabilities.add(MatrixCapabilities.MSC3846TurnServers); capabilities.add(MatrixCapabilities.MSC4157SendDelayedEvent); capabilities.add(MatrixCapabilities.MSC4157UpdateDelayedEvent); + capabilities.add('moe.sable.thumbnails'); + capabilities.add('moe.sable.media_proxy'); capabilities.add(`org.matrix.msc2762.timeline:${roomId}`); capabilities.add(`org.matrix.msc2762.state:${roomId}`); @@ -78,13 +80,6 @@ export function getCallCapabilities( WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomCreate).raw ); - capabilities.add( - WidgetEventCapability.forRoomEvent( - EventDirection.Receive, - 'org.matrix.msc4075.rtc.notification' - ).raw - ); - [ 'io.element.call.encryption_keys', 'org.matrix.rageshake_request', @@ -92,6 +87,9 @@ export function getCallCapabilities( EventType.RoomRedaction, 'io.element.call.reaction', 'org.matrix.msc4310.rtc.decline', + 'org.matrix.msc4075.call.notify', + 'org.matrix.msc4075.rtc.notification', + 'org.matrix.msc4143.rtc.member', ].forEach((type) => { capabilities.add(WidgetEventCapability.forRoomEvent(EventDirection.Send, type).raw); capabilities.add(WidgetEventCapability.forRoomEvent(EventDirection.Receive, type).raw); diff --git a/src/app/plugins/matrix-to.test.ts b/src/app/plugins/matrix-to.test.ts new file mode 100644 index 000000000..519ecd825 --- /dev/null +++ b/src/app/plugins/matrix-to.test.ts @@ -0,0 +1,231 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + getMatrixToRoom, + getMatrixToRoomEvent, + getMatrixToUser, + parseMatrixToRoom, + parseMatrixToRoomEvent, + parseMatrixToUser, + setMatrixToBase, + testMatrixTo, +} from './matrix-to'; + +// Reset to default after each test so state doesn't leak between tests. +afterEach(() => { + setMatrixToBase(undefined); +}); + +// --------------------------------------------------------------------------- +// Link generation +// --------------------------------------------------------------------------- + +describe('getMatrixToUser', () => { + it('generates a standard matrix.to user link', () => { + expect(getMatrixToUser('@alice:example.com')).toBe('https://matrix.to/#/@alice:example.com'); + }); + + it('uses custom base when configured', () => { + setMatrixToBase('https://matrix.example.org'); + expect(getMatrixToUser('@alice:example.com')).toBe( + 'https://matrix.example.org/#/@alice:example.com' + ); + }); + + it('strips trailing slash from custom base', () => { + setMatrixToBase('https://matrix.example.org/'); + expect(getMatrixToUser('@alice:example.com')).toBe( + 'https://matrix.example.org/#/@alice:example.com' + ); + }); +}); + +describe('getMatrixToRoom', () => { + it('generates a standard matrix.to room link', () => { + expect(getMatrixToRoom('!room:example.com')).toBe('https://matrix.to/#/!room:example.com'); + }); + + it('appends via servers', () => { + expect(getMatrixToRoom('!room:example.com', ['s1.org', 's2.org'])).toBe( + 'https://matrix.to/#/!room:example.com?via=s1.org&via=s2.org' + ); + }); + + it('uses custom base when configured', () => { + setMatrixToBase('https://matrix.example.org'); + expect(getMatrixToRoom('#general:example.com')).toBe( + 'https://matrix.example.org/#/#general:example.com' + ); + }); +}); + +describe('getMatrixToRoomEvent', () => { + it('generates a standard matrix.to event link', () => { + expect(getMatrixToRoomEvent('!room:example.com', '$event123')).toBe( + 'https://matrix.to/#/!room:example.com/$event123' + ); + }); + + it('appends via servers', () => { + expect(getMatrixToRoomEvent('!room:example.com', '$event123', ['s1.org'])).toBe( + 'https://matrix.to/#/!room:example.com/$event123?via=s1.org' + ); + }); + + it('uses custom base when configured', () => { + setMatrixToBase('https://matrix.example.org'); + expect(getMatrixToRoomEvent('!room:example.com', '$event123')).toBe( + 'https://matrix.example.org/#/!room:example.com/$event123' + ); + }); +}); + +// --------------------------------------------------------------------------- +// testMatrixTo +// --------------------------------------------------------------------------- + +describe('testMatrixTo', () => { + it('matches standard matrix.to URLs', () => { + expect(testMatrixTo('https://matrix.to/#/@alice:example.com')).toBe(true); + expect(testMatrixTo('https://matrix.to/#/!room:example.com')).toBe(true); + expect(testMatrixTo('https://matrix.to/#/!room:example.com/$event')).toBe(true); + expect(testMatrixTo('http://matrix.to/#/@alice:example.com')).toBe(true); + }); + + it('rejects non-matrix.to URLs', () => { + expect(testMatrixTo('https://example.com')).toBe(false); + expect(testMatrixTo('https://notmatrix.to/#/@alice:example.com')).toBe(false); + }); + + it('matches custom base URLs after setMatrixToBase', () => { + setMatrixToBase('https://matrix.example.org'); + expect(testMatrixTo('https://matrix.example.org/#/@alice:example.com')).toBe(true); + }); + + it('still matches standard matrix.to after setMatrixToBase (cross-client compat)', () => { + setMatrixToBase('https://matrix.example.org'); + expect(testMatrixTo('https://matrix.to/#/@alice:example.com')).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// parseMatrixToUser +// --------------------------------------------------------------------------- + +describe('parseMatrixToUser', () => { + it('parses a standard matrix.to user link', () => { + expect(parseMatrixToUser('https://matrix.to/#/@alice:example.com')).toBe('@alice:example.com'); + }); + + it('returns undefined for non-user links', () => { + expect(parseMatrixToUser('https://matrix.to/#/!room:example.com')).toBeUndefined(); + }); + + it('parses user links from custom base', () => { + setMatrixToBase('https://matrix.example.org'); + expect(parseMatrixToUser('https://matrix.example.org/#/@alice:example.com')).toBe( + '@alice:example.com' + ); + }); + + it('parses standard matrix.to user links even after custom base is set', () => { + setMatrixToBase('https://matrix.example.org'); + expect(parseMatrixToUser('https://matrix.to/#/@alice:example.com')).toBe('@alice:example.com'); + }); +}); + +// --------------------------------------------------------------------------- +// parseMatrixToRoom +// --------------------------------------------------------------------------- + +describe('parseMatrixToRoom', () => { + it('parses a room ID link', () => { + expect(parseMatrixToRoom('https://matrix.to/#/!room:example.com')).toEqual({ + roomIdOrAlias: '!room:example.com', + viaServers: undefined, + }); + }); + + it('parses a room alias link', () => { + expect(parseMatrixToRoom('https://matrix.to/#/#general:example.com')).toEqual({ + roomIdOrAlias: '#general:example.com', + viaServers: undefined, + }); + }); + + it('parses via servers', () => { + expect( + parseMatrixToRoom('https://matrix.to/#/!room:example.com?via=s1.org&via=s2.org') + ).toEqual({ + roomIdOrAlias: '!room:example.com', + viaServers: ['s1.org', 's2.org'], + }); + }); + + it('returns undefined for event links (too many segments)', () => { + expect(parseMatrixToRoom('https://matrix.to/#/!room:example.com/$event123')).toBeUndefined(); + }); + + it('parses room links from custom base', () => { + setMatrixToBase('https://matrix.example.org'); + expect(parseMatrixToRoom('https://matrix.example.org/#/!room:example.com')).toEqual({ + roomIdOrAlias: '!room:example.com', + viaServers: undefined, + }); + }); + + it('still parses standard matrix.to room links after custom base is set', () => { + setMatrixToBase('https://matrix.example.org'); + expect(parseMatrixToRoom('https://matrix.to/#/!room:example.com')).toEqual({ + roomIdOrAlias: '!room:example.com', + viaServers: undefined, + }); + }); +}); + +// --------------------------------------------------------------------------- +// parseMatrixToRoomEvent +// --------------------------------------------------------------------------- + +describe('parseMatrixToRoomEvent', () => { + it('parses a room event link', () => { + expect(parseMatrixToRoomEvent('https://matrix.to/#/!room:example.com/$event123')).toEqual({ + roomIdOrAlias: '!room:example.com', + eventId: '$event123', + viaServers: undefined, + }); + }); + + it('parses via servers', () => { + expect( + parseMatrixToRoomEvent('https://matrix.to/#/!room:example.com/$event123?via=s1.org') + ).toEqual({ + roomIdOrAlias: '!room:example.com', + eventId: '$event123', + viaServers: ['s1.org'], + }); + }); + + it('returns undefined for room-only links', () => { + expect(parseMatrixToRoomEvent('https://matrix.to/#/!room:example.com')).toBeUndefined(); + }); + + it('parses event links from custom base', () => { + setMatrixToBase('https://matrix.example.org'); + expect( + parseMatrixToRoomEvent('https://matrix.example.org/#/!room:example.com/$event123') + ).toEqual({ + roomIdOrAlias: '!room:example.com', + eventId: '$event123', + viaServers: undefined, + }); + }); + + it('still parses standard matrix.to event links after custom base is set', () => { + setMatrixToBase('https://matrix.example.org'); + expect(parseMatrixToRoomEvent('https://matrix.to/#/!room:example.com/$event123')).toEqual({ + roomIdOrAlias: '!room:example.com', + eventId: '$event123', + viaServers: undefined, + }); + }); +}); diff --git a/src/app/plugins/matrix-to.ts b/src/app/plugins/matrix-to.ts index 03a7d2c17..0397a8010 100644 --- a/src/app/plugins/matrix-to.ts +++ b/src/app/plugins/matrix-to.ts @@ -1,4 +1,12 @@ -const MATRIX_TO_BASE = 'https://matrix.to'; +let MATRIX_TO_BASE = 'https://matrix.to'; + +/** + * Override the default matrix.to base URL (configurable per deployment). + * Must be called before any getMatrixTo* functions are used. + */ +export const setMatrixToBase = (baseUrl?: string): void => { + MATRIX_TO_BASE = baseUrl ? baseUrl.replace(/\/$/, '') : 'https://matrix.to'; +}; export const getMatrixToUser = (userId: string): string => `${MATRIX_TO_BASE}/#/${userId}`; @@ -38,23 +46,49 @@ export type MatrixToRoomEvent = MatrixToRoom & { eventId: string; }; -const MATRIX_TO = /^https?:\/\/matrix\.to\S*$/; -export const testMatrixTo = (href: string): boolean => MATRIX_TO.test(href); +const escapeForRegex = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +// Lazily cached regex set; rebuilt if MATRIX_TO_BASE changes. +let cachedRegexBase = ''; +let cachedRegexes: { + any: RegExp; + user: RegExp; + room: RegExp; + event: RegExp; +} | null = null; + +/** + * Returns regexes that match BOTH https://matrix.to (for cross-client links + * received from standard clients) and the configured custom base URL (if any). + */ +const getMatchRegexes = () => { + if (cachedRegexBase === MATRIX_TO_BASE && cachedRegexes) return cachedRegexes; + cachedRegexBase = MATRIX_TO_BASE; + // Use https? so both http:// and https://matrix.to are accepted (original behaviour). + const standard = `https?://${escapeForRegex('matrix.to')}`; + const b = + MATRIX_TO_BASE !== 'https://matrix.to' + ? `(?:${standard}|${escapeForRegex(MATRIX_TO_BASE)})` + : standard; + cachedRegexes = { + any: new RegExp(`^${b}\\S*$`), + user: new RegExp(`^${b}/#/(@[^:\\s]+:[^?/\\s]+)\\/?$`), + room: new RegExp(`^${b}/#/([#!][^?/\\s]+)\\/?(\\?[\\S]*)?$`), + event: new RegExp(`^${b}/#/([#!][^?/\\s]+)/(\\$[^?/\\s]+)\\/?([?\\S]*)?$`), + }; + return cachedRegexes; +}; -const MATRIX_TO_USER = /^https?:\/\/matrix\.to\/#\/(@[^:\s]+:[^?/\s]+)\/?$/; -const MATRIX_TO_ROOM = /^https?:\/\/matrix\.to\/#\/([#!][^?/\s]+)\/?(\?[\S]*)?$/; -const MATRIX_TO_ROOM_EVENT = - /^https?:\/\/matrix\.to\/#\/([#!][^?/\s]+)\/(\$[^?/\s]+)\/?(\?[\S]*)?$/; +export const testMatrixTo = (href: string): boolean => getMatchRegexes().any.test(href); export const parseMatrixToUser = (href: string): string | undefined => { - const match = href.match(MATRIX_TO_USER); + const match = href.match(getMatchRegexes().user); if (!match) return undefined; - const userId = match[1]; - return userId; + return match[1]; }; export const parseMatrixToRoom = (href: string): MatrixToRoom | undefined => { - const match = href.match(MATRIX_TO_ROOM); + const match = href.match(getMatchRegexes().room); if (!match) return undefined; const roomIdOrAlias = match[1]; @@ -68,7 +102,7 @@ export const parseMatrixToRoom = (href: string): MatrixToRoom | undefined => { }; export const parseMatrixToRoomEvent = (href: string): MatrixToRoomEvent | undefined => { - const match = href.match(MATRIX_TO_ROOM_EVENT); + const match = href.match(getMatchRegexes().event); if (!match) return undefined; const roomIdOrAlias = match[1]; diff --git a/src/app/plugins/voice-recorder-kit/index.ts b/src/app/plugins/voice-recorder-kit/index.ts new file mode 100644 index 000000000..00e2e4d7e --- /dev/null +++ b/src/app/plugins/voice-recorder-kit/index.ts @@ -0,0 +1,7 @@ +export { useVoiceRecorder } from './useVoiceRecorder'; +export type { + UseVoiceRecorderOptions, + UseVoiceRecorderReturn, + RecorderState, + VoiceRecorderStopPayload, +} from './types'; diff --git a/src/app/plugins/voice-recorder-kit/supportedCodec.ts b/src/app/plugins/voice-recorder-kit/supportedCodec.ts new file mode 100644 index 000000000..445147cda --- /dev/null +++ b/src/app/plugins/voice-recorder-kit/supportedCodec.ts @@ -0,0 +1,79 @@ +const safariPreferredCodecs = [ + // Safari works best with MP4/AAC but fails when strict codecs are defined on iOS. + // Prioritize the plain container to avoid NotSupportedError during MediaRecorder initialization. + 'audio/mp4', + 'audio/mp4;codecs=mp4a.40.2', + 'audio/mp4;codecs=mp4a.40.5', + 'audio/mp4;codecs=aac', + 'audio/aac', + // Fallbacks + 'audio/wav;codecs=1', + 'audio/wav', + 'audio/mpeg', +]; + +const defaultPreferredCodecs = [ + // Chromium / Firefox stable path. + 'audio/webm;codecs=opus', + 'audio/webm', + // Firefox + 'audio/ogg;codecs=opus', + 'audio/ogg;codecs=vorbis', + 'audio/ogg', + // Fallbacks + 'audio/wav;codecs=1', + 'audio/wav', + 'audio/mpeg', + // Keep MP4/AAC as late fallback for non-Safari browsers. + 'audio/mp4;codecs=mp4a.40.2', + 'audio/mp4;codecs=mp4a.40.5', + 'audio/mp4;codecs=aac', + 'audio/mp4', + 'audio/aac', + 'audio/ogg;codecs=speex', + 'audio/webm;codecs=vorbis', +]; + +/** + * Checks for supported audio codecs in the current browser and returns the first supported codec. + * If no supported codec is found, it returns null. + */ +export function getSupportedAudioCodec(): string | null { + if (!('MediaRecorder' in globalThis) || !globalThis.MediaRecorder) { + return null; + } + + const userAgent = globalThis.navigator?.userAgent ?? ''; + const isIOS = + /iPad|iPhone|iPod/.test(userAgent) || + (globalThis.navigator?.platform === 'MacIntel' && globalThis.navigator?.maxTouchPoints > 1); + const isSafari = /^((?!chrome|android|crios|fxios|edgios).)*safari/i.test(userAgent) || isIOS; + + const preferredCodecs = isSafari ? safariPreferredCodecs : defaultPreferredCodecs; + const supportedCodec = preferredCodecs.find((codec) => MediaRecorder.isTypeSupported(codec)); + return supportedCodec || null; +} + +/** + * Returns the appropriate file extension for a given audio codec. + * This is used to ensure that the recorded audio file has the correct extension based on the codec used for recording. + */ +export function getSupportedAudioExtension(codec: string): string { + const baseType = codec.split(';')[0].trim(); + switch (baseType) { + case 'audio/ogg': + return 'ogg'; + case 'audio/webm': + return 'webm'; + case 'audio/mp4': + return 'm4a'; + case 'audio/mpeg': + return 'mp3'; + case 'audio/wav': + return 'wav'; + case 'audio/aac': + return 'aac'; + default: + return 'dat'; // default extension for unknown codecs + } +} diff --git a/src/app/plugins/voice-recorder-kit/types.ts b/src/app/plugins/voice-recorder-kit/types.ts new file mode 100644 index 000000000..609ebe9c1 --- /dev/null +++ b/src/app/plugins/voice-recorder-kit/types.ts @@ -0,0 +1,40 @@ +export type RecorderState = 'idle' | 'recording' | 'paused' | 'reviewing' | 'playing'; + +export type VoiceRecorderStopPayload = { + audioFile: Blob; + audioUrl: string; + waveform: number[]; + audioLength: number; + audioCodec: string; +}; + +export type UseVoiceRecorderOptions = { + autoStart?: boolean; + onStop?: (payload: VoiceRecorderStopPayload) => void; + onDelete?: () => void; +}; + +export type UseVoiceRecorderReturn = { + state: RecorderState; + isRecording: boolean; + isStopped: boolean; + isTemporaryStopped: boolean; + isPlaying: boolean; + isPaused: boolean; + seconds: number; + levels: number[]; + error: string | null; + audioUrl: string | null; + audioFile: File | null; + waveform: number[] | null; + start: () => void; + handlePause: () => void; + handleStopTemporary: () => void; + handleStop: () => void; + handleResume: () => void; + handlePreviewPlay: () => void; + handlePlay: () => void; + handleRestart: () => void; + handleDelete: () => void; + handleRecordAgain: () => void; +}; diff --git a/src/app/plugins/voice-recorder-kit/useVoiceRecorder.ts b/src/app/plugins/voice-recorder-kit/useVoiceRecorder.ts new file mode 100644 index 000000000..f73f7daf0 --- /dev/null +++ b/src/app/plugins/voice-recorder-kit/useVoiceRecorder.ts @@ -0,0 +1,910 @@ +// Based on https://github.com/mohamad-fallah/react-voice-recorder-kit by mohamad-fallah +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { + UseVoiceRecorderOptions, + UseVoiceRecorderReturn, + RecorderState, + VoiceRecorderStopPayload, +} from './types'; +import { getSupportedAudioCodec, getSupportedAudioExtension } from './supportedCodec'; + +const BAR_COUNT = 40; +const WAVEFORM_POINT_COUNT = 100; + +let sharedAudioContext: AudioContext | null = null; + +function getSharedAudioContext(): AudioContext { + if (!sharedAudioContext || sharedAudioContext.state === 'closed') { + sharedAudioContext = new AudioContext(); + } + return sharedAudioContext; +} + +// downsample an array of samples to a target count by averaging blocks of samples together +function downsampleWaveform(samples: number[], targetCount: number): number[] { + if (samples.length === 0) return Array.from({ length: targetCount }, () => 0.15); + if (samples.length <= targetCount) { + const step = (samples.length - 1) / (targetCount - 1); + return Array.from({ length: targetCount }, (_, i) => { + const position = i * step; + const lower = Math.floor(position); + const upper = Math.min(Math.ceil(position), samples.length - 1); + const fraction = position - lower; + if (lower === upper) { + return samples[lower] ?? 0.15; + } + return (samples[lower] ?? 0.15) * (1 - fraction) + (samples[upper] ?? 0.15) * fraction; + }); + } + const step = samples.length / targetCount; + return Array.from({ length: targetCount }, (_, i) => { + const start = Math.floor(i * step); + const end = Math.floor((i + 1) * step); + const slice = samples.slice(start, end); + return slice.length > 0 ? Math.max(...slice) : 0.15; + }); +} + +export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoiceRecorderReturn { + const { autoStart = true, onStop, onDelete } = options; + + /** + * The audio codec we will use + * we will choose depending on the browser support + */ + const audioCodec = getSupportedAudioCodec(); + + const [isRecording, setIsRecording] = useState(false); + const [isStopped, setIsStopped] = useState(false); + const [isTemporaryStopped, setIsTemporaryStopped] = useState(false); + const [isPlaying, setIsPlaying] = useState(false); + const [isPaused, setIsPaused] = useState(false); + const [seconds, setSeconds] = useState(0); + const [levels, setLevels] = useState(() => + Array.from({ length: BAR_COUNT }, () => 0.15) + ); + const [error, setError] = useState(null); + const [audioUrl, setAudioUrl] = useState(null); + const [audioFile, setAudioFile] = useState(null); + const [waveform, setWaveform] = useState(null); + + const mediaRecorderRef = useRef(null); + const chunksRef = useRef([]); + const streamRef = useRef(null); + const audioContextRef = useRef(null); + const analyserRef = useRef(null); + const dataArrayRef = useRef(null); + const animationFrameIdRef = useRef(null); + const frameCountRef = useRef(0); + const timerRef = useRef(null); + const startTimeRef = useRef(null); + const pausedTimeRef = useRef(0); + const secondsRef = useRef(0); + const lastUrlRef = useRef(null); + const audioRef = useRef(null); + const previousChunksRef = useRef([]); + const isResumingRef = useRef(false); + const isRestartingRef = useRef(false); + const isTemporaryStopRef = useRef(false); + const temporaryPreviewUrlRef = useRef(null); + /** + * waveform samples collected during recording, used to generate waveform on stop. + * We collect all samples and downsample at the end to get a more accurate waveform, especially for short recordings. + * We use a ref to avoid causing re-renders on every sample. + */ + const waveformSamplesRef = useRef([]); + /** + * Flag to indicate whether we should be collecting waveform samples. + * We need this because there can be a short delay between starting recording + * and the audio graph being set up, during which we might get some samples that we don't want to include in the waveform. + */ + const isCollectingWaveformRef = useRef(false); + + const cleanupStream = useCallback(() => { + if (streamRef.current) { + streamRef.current.getTracks().forEach((track: MediaStreamTrack) => track.stop()); + streamRef.current = null; + } + }, []); + + const cleanupAudioContext = useCallback(() => { + if (animationFrameIdRef.current !== null) { + cancelAnimationFrame(animationFrameIdRef.current); + animationFrameIdRef.current = null; + } + frameCountRef.current = 0; + if (audioContextRef.current) { + if (audioContextRef.current.state !== 'closed') { + audioContextRef.current.suspend().catch(() => {}); + } + audioContextRef.current = null; + } + analyserRef.current = null; + dataArrayRef.current = null; + }, []); + + const stopTimer = useCallback(() => { + if (timerRef.current !== null) { + window.clearInterval(timerRef.current); + timerRef.current = null; + } + }, []); + + const startRecordingTimer = useCallback(() => { + startTimeRef.current = Date.now() - pausedTimeRef.current * 1000; + stopTimer(); + timerRef.current = window.setInterval(() => { + if (startTimeRef.current === null) return; + const diffMs = Date.now() - startTimeRef.current; + setSeconds(Math.floor(diffMs / 1000)); + }, 1000); + }, [stopTimer]); + + const startPlaybackTimer = useCallback( + (audio: HTMLAudioElement) => { + setSeconds(0); + stopTimer(); + timerRef.current = window.setInterval(() => { + setSeconds(Math.floor(audio.currentTime)); + }, 250); + }, + [stopTimer] + ); + + useEffect(() => { + // Keep a ref copy of seconds for use in callbacks to avoid stale closures + secondsRef.current = seconds; + }, [seconds]); + + const getAudioLength = useCallback(() => { + if (startTimeRef.current === null) { + return secondsRef.current; + } + const elapsedSeconds = Math.floor((Date.now() - startTimeRef.current) / 1000); + return Math.max(secondsRef.current, elapsedSeconds); + }, []); + + const emitStopPayload = useCallback( + (file: File, url: string, waveformData: number[], audioLength: number) => { + if (!onStop) return; + const payload: VoiceRecorderStopPayload = { + audioFile: file, + audioUrl: url, + waveform: waveformData, + audioLength, + audioCodec: file.type, + }; + onStop(payload); + }, + [onStop] + ); + + const animateLevels = useCallback(() => { + const analyser = analyserRef.current; + const storedArray = dataArrayRef.current; + if (!analyser || !storedArray) return; + + const dataArray = new Uint8Array(storedArray); + + const draw = () => { + analyser.getByteFrequencyData(dataArray); + const bufferLength = dataArray.length; + let sum = 0; + for (let i = 0; i < bufferLength; i += 1) { + sum += dataArray[i]; + } + const avg = sum / bufferLength; + let normalized = (avg / 255) * 3.5; + const minLevel = 0.05; + if (normalized < minLevel) normalized = minLevel; + if (normalized > 1) normalized = 1; + + frameCountRef.current += 1; + if (frameCountRef.current >= 5) { + setLevels((prev: number[]) => { + const next: number[] = prev.slice(1); + next.push(normalized); + return next; + }); + if (isCollectingWaveformRef.current) { + waveformSamplesRef.current.push(normalized); + } + frameCountRef.current = 0; + } + + animationFrameIdRef.current = requestAnimationFrame(draw); + }; + draw(); + }, []); + + const setupAudioGraph = useCallback( + (stream: MediaStream): MediaStream => { + const audioContext = getSharedAudioContext(); + audioContextRef.current = audioContext; + const source = audioContext.createMediaStreamSource(stream); + const analyser = audioContext.createAnalyser(); + analyser.fftSize = 512; + analyser.smoothingTimeConstant = 0.6; + const bufferLength = analyser.frequencyBinCount; + const dataArray = new Uint8Array(bufferLength); + analyserRef.current = analyser; + dataArrayRef.current = dataArray; + + // Fix for iOS Safari: routing the stream through a MediaStreamDestination + // prevents the AudioContext from "stealing" the track from the MediaRecorder + const destination = audioContext.createMediaStreamDestination(); + source.connect(analyser); + analyser.connect(destination); + + if (audioContext.state === 'suspended') { + audioContext.resume().catch(() => {}); + } + animateLevels(); + + return destination.stream; + }, + [animateLevels] + ); + + const setupPlaybackGraph = useCallback( + (audio: HTMLAudioElement) => { + const audioContext = getSharedAudioContext(); + audioContextRef.current = audioContext; + const source = audioContext.createMediaElementSource(audio); + const analyser = audioContext.createAnalyser(); + analyser.fftSize = 512; + analyser.smoothingTimeConstant = 0.6; + const bufferLength = analyser.frequencyBinCount; + const dataArray = new Uint8Array(bufferLength); + analyserRef.current = analyser; + dataArrayRef.current = dataArray; + source.connect(analyser); + analyser.connect(audioContext.destination); + if (audioContext.state === 'suspended') { + audioContext.resume().catch(() => {}); + } + animateLevels(); + }, + [animateLevels] + ); + + const internalStartRecording = useCallback(async () => { + if (typeof window === 'undefined') return; + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + setError('Browser does not support audio recording.'); + return; + } + + setError(null); + isResumingRef.current = false; + + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const codec = getSupportedAudioCodec(); + if (!codec) { + setError('No supported audio codec found for recording.'); + cleanupStream(); + return; + } + streamRef.current = stream; + chunksRef.current = []; + previousChunksRef.current = []; + waveformSamplesRef.current = []; + isCollectingWaveformRef.current = true; + const recordedStream = setupAudioGraph(stream); + startRecordingTimer(); + + const mediaRecorder = new MediaRecorder(recordedStream, { mimeType: codec }); + mediaRecorderRef.current = mediaRecorder; + + mediaRecorder.ondataavailable = (event: BlobEvent) => { + if (event.data && event.data.size > 0) { + chunksRef.current.push(event.data); + } + }; + + mediaRecorder.onstop = () => { + cleanupAudioContext(); + cleanupStream(); + stopTimer(); + setIsRecording(false); + setIsPaused(false); + const audioLength = getAudioLength(); + pausedTimeRef.current = 0; + startTimeRef.current = null; + + isCollectingWaveformRef.current = false; + + if (isResumingRef.current) { + isResumingRef.current = false; + return; + } + + if (isRestartingRef.current) { + isRestartingRef.current = false; + return; + } + + if (chunksRef.current.length === 0) { + if (isTemporaryStopRef.current) { + setIsTemporaryStopped(true); + setIsStopped(true); + isTemporaryStopRef.current = false; + } else { + setIsStopped(true); + setIsTemporaryStopped(false); + } + return; + } + + const actualType = chunksRef.current[0]?.type || codec || 'audio/webm'; + const blob = new Blob(chunksRef.current, { type: actualType }); + if (lastUrlRef.current) { + URL.revokeObjectURL(lastUrlRef.current); + } + const url = URL.createObjectURL(blob); + lastUrlRef.current = url; + setAudioUrl(url); + + const file = new File( + [blob], + `voice-${Date.now()}.${getSupportedAudioExtension(actualType)}`, + { + type: actualType, + } + ); + setAudioFile(file); + + const waveformData = downsampleWaveform(waveformSamplesRef.current, WAVEFORM_POINT_COUNT); + setWaveform(waveformData); + + if (isTemporaryStopRef.current) { + setIsTemporaryStopped(true); + setIsStopped(true); + isTemporaryStopRef.current = false; + } else { + setIsStopped(true); + setIsTemporaryStopped(false); + emitStopPayload(file, url, waveformData, audioLength); + } + }; + + // Pass a timeslice to ensure Safari iOS periodically flushes chunks + // Otherwise Safari might fail to emit any chunks when stopped abruptly + mediaRecorder.start(1000); + setIsRecording(true); + setIsPaused(false); + setIsStopped(false); + pausedTimeRef.current = 0; + } catch { + setError('Microphone access denied or an error occurred.'); + cleanupAudioContext(); + cleanupStream(); + stopTimer(); + setIsRecording(false); + } + }, [ + cleanupAudioContext, + cleanupStream, + emitStopPayload, + getAudioLength, + setupAudioGraph, + startRecordingTimer, + stopTimer, + ]); + + const start = useCallback(() => { + internalStartRecording(); + }, [internalStartRecording]); + + const handlePause = useCallback(() => { + const mediaRecorder = mediaRecorderRef.current; + if (!mediaRecorder || mediaRecorder.state !== 'recording') return; + + try { + mediaRecorder.requestData(); + mediaRecorder.pause(); + stopTimer(); + pausedTimeRef.current = seconds; + setIsPaused(true); + + if (animationFrameIdRef.current !== null) { + cancelAnimationFrame(animationFrameIdRef.current); + animationFrameIdRef.current = null; + } + + if (audioContextRef.current && audioContextRef.current.state !== 'closed') { + audioContextRef.current.suspend().catch(() => {}); + } + + setLevels(Array.from({ length: BAR_COUNT }, () => 0.15)); + } catch { + setError('Error pausing recording'); + } + }, [seconds, stopTimer]); + + const handleStopTemporary = useCallback(() => { + const mediaRecorder = mediaRecorderRef.current; + + if (mediaRecorder && mediaRecorder.state !== 'inactive') { + previousChunksRef.current = [...chunksRef.current]; + isTemporaryStopRef.current = false; + + if (mediaRecorder.state === 'recording') { + try { + mediaRecorder.requestData(); + } catch { + // ignore + } + } + + try { + mediaRecorder.stop(); + } catch { + // ignore + } + + // Let cleanupStream() be handled by mediaRecorder.onstop + // Calling it synchronously here can kill the stream before Safari finishes emitting data + setIsStopped(true); + setIsTemporaryStopped(false); + setIsPaused(false); + pausedTimeRef.current = 0; + } else { + if (audioUrl && audioFile) { + const waveformData = + waveform ?? downsampleWaveform(waveformSamplesRef.current, WAVEFORM_POINT_COUNT); + emitStopPayload(audioFile, audioUrl, waveformData, secondsRef.current); + } + cleanupAudioContext(); + cleanupStream(); + stopTimer(); + setIsRecording(false); + setIsStopped(true); + setIsTemporaryStopped(false); + setIsPaused(false); + pausedTimeRef.current = 0; + startTimeRef.current = null; + } + }, [ + audioFile, + audioUrl, + cleanupAudioContext, + cleanupStream, + emitStopPayload, + stopTimer, + waveform, + ]); + + const handleStop = useCallback(() => { + const mediaRecorder = mediaRecorderRef.current; + + if (mediaRecorder && mediaRecorder.state !== 'inactive') { + previousChunksRef.current = [...chunksRef.current]; + isTemporaryStopRef.current = false; + + if (mediaRecorder.state === 'recording') { + try { + mediaRecorder.requestData(); + } catch { + // ignore + } + } + + try { + mediaRecorder.stop(); + } catch { + // ignore + } + + // Let cleanupStream() be handled by mediaRecorder.onstop + // Calling it synchronously here can kill the stream before Safari finishes emitting data + setIsStopped(true); + setIsTemporaryStopped(false); + setIsPaused(false); + pausedTimeRef.current = 0; + } else { + if (audioUrl && audioFile) { + const waveformData = + waveform ?? downsampleWaveform(waveformSamplesRef.current, WAVEFORM_POINT_COUNT); + emitStopPayload(audioFile, audioUrl, waveformData, secondsRef.current); + } + cleanupAudioContext(); + cleanupStream(); + stopTimer(); + setIsRecording(false); + setIsStopped(true); + setIsTemporaryStopped(false); + setIsPaused(false); + pausedTimeRef.current = 0; + startTimeRef.current = null; + } + }, [ + audioFile, + audioUrl, + cleanupAudioContext, + cleanupStream, + emitStopPayload, + stopTimer, + waveform, + ]); + + const handlePreviewPlay = useCallback(() => { + let urlToPlay = audioUrl; + + if (!urlToPlay && isPaused) { + if (temporaryPreviewUrlRef.current) { + URL.revokeObjectURL(temporaryPreviewUrlRef.current); + } + + const allChunks = + chunksRef.current.length > 0 ? chunksRef.current : previousChunksRef.current; + + if (allChunks.length > 0) { + const actualType = allChunks[0]?.type || audioCodec || 'audio/webm'; + const blob = new Blob(allChunks, { type: actualType }); + urlToPlay = URL.createObjectURL(blob); + temporaryPreviewUrlRef.current = urlToPlay; + } + } + + if (!urlToPlay) return; + + if (temporaryPreviewUrlRef.current && temporaryPreviewUrlRef.current !== urlToPlay) { + URL.revokeObjectURL(temporaryPreviewUrlRef.current); + temporaryPreviewUrlRef.current = null; + } + + if (!audioRef.current || audioRef.current.src !== urlToPlay) { + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current = null; + } + const audio = new Audio(urlToPlay); + audioRef.current = audio; + + audio.onended = () => { + setIsPlaying(false); + stopTimer(); + cleanupAudioContext(); + audio.currentTime = 0; + setSeconds(pausedTimeRef.current); // Reset to total recorded time + }; + audio.onpause = () => { + setIsPlaying(false); + stopTimer(); + cleanupAudioContext(); + }; + audio.onplay = () => { + setIsPlaying(true); + cleanupAudioContext(); + setupPlaybackGraph(audio); + startPlaybackTimer(audio); + }; + } + + const audio = audioRef.current; + if (!audio) return; + + if (isPlaying) { + audio.pause(); + } else { + if (audio.ended || (audio.duration && audio.currentTime >= audio.duration - 0.01)) { + audio.currentTime = 0; + setSeconds(0); + } + audio.play().catch(() => {}); + } + }, [ + audioUrl, + isPaused, + isPlaying, + audioCodec, + stopTimer, + cleanupAudioContext, + setupPlaybackGraph, + startPlaybackTimer, + ]); + + const handlePlay = useCallback(() => { + if (!audioUrl) return; + + if (!audioRef.current) { + const audio = new Audio(audioUrl); + audioRef.current = audio; + + audio.onended = () => { + setIsPlaying(false); + stopTimer(); + cleanupAudioContext(); + audio.currentTime = 0; + setSeconds(0); + }; + audio.onpause = () => { + setIsPlaying(false); + stopTimer(); + cleanupAudioContext(); + }; + audio.onplay = () => { + setIsPlaying(true); + cleanupAudioContext(); + setupPlaybackGraph(audio); + startPlaybackTimer(audio); + }; + } + + const audio = audioRef.current; + if (!audio) return; + + if (isPlaying) { + audio.pause(); + } else { + if (audio.ended || (audio.duration && audio.currentTime >= audio.duration - 0.01)) { + audio.currentTime = 0; + setSeconds(0); + } + audio.play().catch(() => {}); + } + }, [audioUrl, cleanupAudioContext, isPlaying, setupPlaybackGraph, startPlaybackTimer, stopTimer]); + + const handleResume = useCallback(async () => { + if (typeof window === 'undefined') return; + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + setError('Browser does not support audio recording.'); + return; + } + + setError(null); + isResumingRef.current = true; + + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + streamRef.current = stream; + const recordedStream = setupAudioGraph(stream); + + // Force update seconds to the correct total time before starting timer + setSeconds(pausedTimeRef.current); + startRecordingTimer(); + + const codec = getSupportedAudioCodec() || audioCodec; + const mediaRecorder = codec + ? new MediaRecorder(recordedStream, { mimeType: codec }) + : new MediaRecorder(recordedStream); + mediaRecorderRef.current = mediaRecorder; + + mediaRecorder.ondataavailable = (event: BlobEvent) => { + if (event.data && event.data.size > 0) { + chunksRef.current.push(event.data); + } + }; + + mediaRecorder.onstop = () => { + cleanupAudioContext(); + cleanupStream(); + stopTimer(); + setIsRecording(false); + setIsPaused(false); + const audioLength = getAudioLength(); + pausedTimeRef.current = 0; + startTimeRef.current = null; + + isCollectingWaveformRef.current = false; + + if (chunksRef.current.length === 0) { + setIsStopped(true); + return; + } + + const actualType = chunksRef.current[0]?.type || audioCodec || 'audio/webm'; + const blob = new Blob(chunksRef.current, { type: actualType }); + if (lastUrlRef.current) { + URL.revokeObjectURL(lastUrlRef.current); + } + const url = URL.createObjectURL(blob); + lastUrlRef.current = url; + setAudioUrl(url); + + const file = new File( + [blob], + `voice-${Date.now()}.${getSupportedAudioExtension(blob.type)}`, + { type: blob.type } + ); + setAudioFile(file); + + const waveformData = downsampleWaveform(waveformSamplesRef.current, WAVEFORM_POINT_COUNT); + setWaveform(waveformData); + + emitStopPayload(file, url, waveformData, audioLength); + }; + + // Pass a timeslice to ensure Safari iOS periodically flushes chunks + // Otherwise Safari might fail to emit any chunks when stopped abruptly + mediaRecorder.start(1000); + setIsRecording(true); + setIsPaused(false); + setIsStopped(false); + setIsTemporaryStopped(false); + setIsPlaying(false); + + isCollectingWaveformRef.current = true; + + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current = null; + } + if (temporaryPreviewUrlRef.current) { + URL.revokeObjectURL(temporaryPreviewUrlRef.current); + temporaryPreviewUrlRef.current = null; + } + + // We removed: pausedTimeRef.current = seconds + // So it keeps the correct total time from previous Pause + startTimeRef.current = Date.now() - pausedTimeRef.current * 1000; + } catch { + setError('Microphone access denied or an error occurred.'); + cleanupAudioContext(); + cleanupStream(); + stopTimer(); + setIsRecording(false); + isResumingRef.current = false; + } + }, [ + audioCodec, + cleanupAudioContext, + cleanupStream, + emitStopPayload, + getAudioLength, + setupAudioGraph, + startRecordingTimer, + stopTimer, + ]); + + const handleDelete = useCallback(() => { + const mediaRecorder = mediaRecorderRef.current; + if (mediaRecorder && mediaRecorder.state !== 'inactive') { + mediaRecorder.stop(); + } + + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current = null; + } + + cleanupAudioContext(); + cleanupStream(); + stopTimer(); + setIsPlaying(false); + setIsStopped(true); + setIsRecording(false); + setIsPaused(false); + setSeconds(0); + pausedTimeRef.current = 0; + startTimeRef.current = null; + setLevels(Array.from({ length: BAR_COUNT }, () => 0.15)); + previousChunksRef.current = []; + chunksRef.current = []; + waveformSamplesRef.current = []; + isCollectingWaveformRef.current = false; + setWaveform(null); + + if (temporaryPreviewUrlRef.current) { + URL.revokeObjectURL(temporaryPreviewUrlRef.current); + temporaryPreviewUrlRef.current = null; + } + + if (onDelete) { + onDelete(); + } + }, [cleanupAudioContext, cleanupStream, onDelete, stopTimer]); + + const handleRestart = useCallback(() => { + isRestartingRef.current = true; + const mediaRecorder = mediaRecorderRef.current; + + if (mediaRecorder && mediaRecorder.state !== 'inactive') { + mediaRecorder.stop(); + } + + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current = null; + } + + cleanupAudioContext(); + cleanupStream(); + stopTimer(); + setIsRecording(false); + setIsStopped(false); + setIsTemporaryStopped(false); + setIsPaused(false); + setIsPlaying(false); + setSeconds(0); + pausedTimeRef.current = 0; + startTimeRef.current = null; + setLevels(Array.from({ length: BAR_COUNT }, () => 0.15)); + previousChunksRef.current = []; + chunksRef.current = []; + isResumingRef.current = false; + waveformSamplesRef.current = []; + isCollectingWaveformRef.current = false; + setWaveform(null); + + if (lastUrlRef.current) { + URL.revokeObjectURL(lastUrlRef.current); + lastUrlRef.current = null; + } + + if (temporaryPreviewUrlRef.current) { + URL.revokeObjectURL(temporaryPreviewUrlRef.current); + temporaryPreviewUrlRef.current = null; + } + + setAudioUrl(null); + setAudioFile(null); + internalStartRecording(); + }, [cleanupAudioContext, cleanupStream, internalStartRecording, stopTimer]); + + useEffect(() => { + if (autoStart) { + internalStartRecording(); + } + return () => { + const mediaRecorder = mediaRecorderRef.current; + if (mediaRecorder && mediaRecorder.state !== 'inactive') { + mediaRecorder.stop(); + } + cleanupAudioContext(); + cleanupStream(); + stopTimer(); + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current = null; + } + if (lastUrlRef.current) { + URL.revokeObjectURL(lastUrlRef.current); + lastUrlRef.current = null; + } + if (temporaryPreviewUrlRef.current) { + URL.revokeObjectURL(temporaryPreviewUrlRef.current); + temporaryPreviewUrlRef.current = null; + } + }; + }, [autoStart, cleanupAudioContext, cleanupStream, internalStartRecording, stopTimer]); + + const getState = (): RecorderState => { + if (isPlaying) return 'playing'; + if (isStopped && !isTemporaryStopped && audioUrl) return 'reviewing'; + if (isRecording && isPaused) return 'paused'; + if (isRecording) return 'recording'; + return 'idle'; + }; + + const handleRecordAgain = useCallback(() => { + handleRestart(); + }, [handleRestart]); + + return { + state: getState(), + isRecording, + isStopped, + isTemporaryStopped, + isPlaying, + isPaused, + seconds, + levels, + error, + audioUrl, + audioFile, + waveform, + start, + handlePause, + handleStopTemporary, + handleStop, + handleResume, + handlePreviewPlay, + handlePlay, + handleRestart, + handleDelete, + handleRecordAgain, + }; +} diff --git a/src/app/state/callEmbed.ts b/src/app/state/callEmbed.ts index 1452fb971..b05055c43 100644 --- a/src/app/state/callEmbed.ts +++ b/src/app/state/callEmbed.ts @@ -1,8 +1,12 @@ import { atom } from 'jotai'; +import * as Sentry from '@sentry/react'; import { CallEmbed } from '../plugins/call'; const baseCallEmbedAtom = atom(undefined); +// Tracks when the active call embed was created, for lifetime measurement. +let embedCreatedAt: number | null = null; + export const callEmbedAtom = atom( (get) => get(baseCallEmbedAtom), (get, set, callEmbed) => { @@ -10,9 +14,21 @@ export const callEmbedAtom = atom debugLogger.getLogs()); + +/** + * Atom for enabling/disabling debug logging + */ +export const debugLoggerEnabledAtom = atom( + debugLogger.isEnabled(), + (get, set, enabled: boolean) => { + debugLogger.setEnabled(enabled); + set(debugLoggerEnabledAtom, enabled); + set(debugLogsAtom); + } +); + +/** + * Atom for filtered logs + */ +export const filteredDebugLogsAtom = atom((get) => get(debugLogsAtom)); + +/** + * Action to clear all debug logs + */ +export const clearDebugLogsAtom = atom(null, (_, set) => { + debugLogger.clear(); + set(debugLogsAtom); +}); + +/** + * Action to export debug logs + */ +export const exportDebugLogsAtom = atom(null, () => debugLogger.exportLogs()); diff --git a/src/app/state/hooks/callPreferences.ts b/src/app/state/hooks/callPreferences.ts index 829ed4b43..d02700f05 100644 --- a/src/app/state/hooks/callPreferences.ts +++ b/src/app/state/hooks/callPreferences.ts @@ -18,6 +18,7 @@ export const useCallPreferences = (): CallPreferences & { toggleMicrophone: () => void; toggleVideo: () => void; toggleSound: () => void; + setPreferences: (prefs: CallPreferences) => void; } => { const callPrefAtom = useCallPreferencesAtom(); const [pref, setPref] = useAtom(callPrefAtom); @@ -57,5 +58,6 @@ export const useCallPreferences = (): CallPreferences & { toggleMicrophone, toggleVideo, toggleSound, + setPreferences: setPref, }; }; diff --git a/src/app/state/room/roomInputDrafts.ts b/src/app/state/room/roomInputDrafts.ts index 51c3cfaf5..4b167f220 100644 --- a/src/app/state/room/roomInputDrafts.ts +++ b/src/app/state/room/roomInputDrafts.ts @@ -9,6 +9,8 @@ import { createListAtom } from '$state/list'; export type TUploadMetadata = { markedAsSpoiler: boolean; + waveform?: number[]; + audioDuration?: number; }; export type TUploadItem = { diff --git a/src/app/state/room/roomToOpenThread.ts b/src/app/state/room/roomToOpenThread.ts new file mode 100644 index 000000000..0a60fa4a7 --- /dev/null +++ b/src/app/state/room/roomToOpenThread.ts @@ -0,0 +1,14 @@ +import { atom } from 'jotai'; +import { atomFamily } from 'jotai/utils'; + +const createOpenThreadAtom = () => atom(undefined); +export type TOpenThreadAtom = ReturnType; + +/** + * Tracks the currently-open thread root event ID per room. + * Key: roomId + * Value: eventId of the thread root, or undefined if no thread is open. + */ +export const roomIdToOpenThreadAtomFamily = atomFamily(() => + createOpenThreadAtom() +); diff --git a/src/app/state/room/roomToThreadBrowser.ts b/src/app/state/room/roomToThreadBrowser.ts new file mode 100644 index 000000000..3d8963165 --- /dev/null +++ b/src/app/state/room/roomToThreadBrowser.ts @@ -0,0 +1,13 @@ +import { atom } from 'jotai'; +import { atomFamily } from 'jotai/utils'; + +const createThreadBrowserAtom = () => atom(false); +export type TThreadBrowserAtom = ReturnType; + +/** + * Tracks whether the thread browser panel is open per room. + * Key: roomId + */ +export const roomIdToThreadBrowserAtomFamily = atomFamily(() => + createThreadBrowserAtom() +); diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 96cae3872..0a8e47fd3 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -50,7 +50,6 @@ export interface Settings { showHiddenEvents: boolean; showTombstoneEvents: boolean; legacyUsernameColor: boolean; - allowPipVideos: boolean; usePushNotifications: boolean; useInAppNotifications: boolean; @@ -64,6 +63,7 @@ export interface Settings { dateFormatString: string; developerTools: boolean; + enableMSC4268CMD: boolean; // Cosmetics! jumboEmojiSize: JumboEmojiSize; @@ -71,6 +71,7 @@ export interface Settings { privacyBlurAvatars: boolean; privacyBlurEmotes: boolean; showPronouns: boolean; + parsePronouns: boolean; renderGlobalNameColors: boolean; filterPronounsBasedOnLanguage?: boolean; filterPronounsLanguages?: string[]; @@ -94,6 +95,10 @@ export interface Settings { autoplayGifs: boolean; autoplayStickers: boolean; autoplayEmojis: boolean; + saveStickerEmojiBandwidth: boolean; + alwaysShowCallButton: boolean; + faviconForMentionsOnly: boolean; + highlightMentions: boolean; // furry stuff renderAnimals: boolean; @@ -126,7 +131,8 @@ const defaultSettings: Settings = { showHiddenEvents: false, showTombstoneEvents: false, legacyUsernameColor: false, - allowPipVideos: false, + + enableMSC4268CMD: false, // Push notifications (SW/Sygnal): default on for mobile, opt-in on desktop. // In-app pill banner: default on for mobile (primary foreground alert), opt-in on desktop. @@ -150,6 +156,7 @@ const defaultSettings: Settings = { privacyBlurAvatars: false, privacyBlurEmotes: false, showPronouns: true, + parsePronouns: true, renderGlobalNameColors: true, renderRoomColors: true, renderRoomFonts: true, @@ -171,6 +178,10 @@ const defaultSettings: Settings = { autoplayGifs: true, autoplayStickers: true, autoplayEmojis: true, + saveStickerEmojiBandwidth: false, + alwaysShowCallButton: false, + faviconForMentionsOnly: false, + highlightMentions: true, // furry stuff renderAnimals: true, diff --git a/src/app/utils/ASCIILexicalTable.ts b/src/app/utils/ASCIILexicalTable.ts index 3dcd43d05..46c35c393 100644 --- a/src/app/utils/ASCIILexicalTable.ts +++ b/src/app/utils/ASCIILexicalTable.ts @@ -61,13 +61,13 @@ export class ASCIILexicalTable { } first(): string { - return String.fromCharCode(this.startCode); + return String.fromCodePoint(this.startCode); } last(): string { let str = ''; for (let i = 0; i < this.maxStrWidth; i += 1) { - str += String.fromCharCode(this.endCode); + str += String.fromCodePoint(this.endCode); } return str; } @@ -164,7 +164,7 @@ export class ASCIILexicalTable { prev += String.fromCharCode(lastCode - 1); while (prev.length < this.maxStrWidth) { - prev += String.fromCharCode(this.endCode); + prev += String.fromCodePoint(this.endCode); } return prev; } diff --git a/src/app/utils/AsyncSearch.ts b/src/app/utils/AsyncSearch.ts index 26dd92047..c799c60bb 100644 --- a/src/app/utils/AsyncSearch.ts +++ b/src/app/utils/AsyncSearch.ts @@ -27,12 +27,12 @@ export type TerminateAsyncSearch = () => void; export const normalize = (str: string, options?: NormalizeOption) => { let nStr = str.normalize((options?.normalizeUnicode ?? true) ? 'NFKC' : 'NFC'); if (!options?.caseSensitive) nStr = nStr.toLocaleLowerCase(); - if (options?.ignoreWhitespace ?? true) nStr = nStr.replace(/\s/g, ''); + if (options?.ignoreWhitespace ?? true) nStr = nStr.replaceAll(/\s/g, ''); return nStr; }; export const matchQuery = (item: string, query: string, options?: MatchQueryOption): boolean => { - if (options?.contain) return item.indexOf(query) !== -1; + if (options?.contain) return item.includes(query); return item.startsWith(query); }; @@ -46,7 +46,7 @@ export const AsyncSearch = ( let searchIndex = 0; let sessionStartTimestamp = 0; - let sessionScheduleId: number | undefined; + let sessionScheduleId: ReturnType | undefined; const terminateSearch: TerminateAsyncSearch = () => { resultList = []; @@ -62,7 +62,7 @@ export const AsyncSearch = ( // return if find session got reset if (sessionTimestamp !== sessionStartTimestamp) return; - sessionStartTimestamp = window.performance.now(); + sessionStartTimestamp = globalThis.performance.now(); for (; searchIndex < list.length; searchIndex += 1) { if (match(list[searchIndex], query)) { resultList.push(list[searchIndex]); @@ -71,14 +71,14 @@ export const AsyncSearch = ( } } - const matchFinishTime = window.performance.now(); + const matchFinishTime = globalThis.performance.now(); if (matchFinishTime - sessionStartTimestamp > 8) { const currentFindingCount = resultList.length; const thisSessionTimestamp = sessionStartTimestamp; if (findingCount !== currentFindingCount) onResult(resultList, query); searchIndex += 1; - sessionScheduleId = window.setTimeout(() => find(query, thisSessionTimestamp), 1); + sessionScheduleId = globalThis.setTimeout(() => find(query, thisSessionTimestamp), 1); return; } } diff --git a/src/app/utils/MegolmExportEncryption.ts b/src/app/utils/MegolmExportEncryption.ts index 896a8d9d4..96250e5bd 100644 --- a/src/app/utils/MegolmExportEncryption.ts +++ b/src/app/utils/MegolmExportEncryption.ts @@ -5,7 +5,7 @@ import { createLogger } from './debug'; const logger = createLogger('MegolmExportEncryption'); -const subtleCrypto = window.crypto.subtle; +const subtleCrypto = globalThis.crypto.subtle; export type FriendlyError = { message: string; @@ -43,9 +43,9 @@ function toArrayBufferView(data: Uint8Array): Uint8Array { function encodeBase64(uint8Array: Uint8Array): string { // Misinterpt the Uint8Array as Latin-1. // window.btoa expects a unicode string with codepoints in the range 0-255. - const latin1String = String.fromCharCode.apply(null, Array.from(uint8Array)); + const latin1String = String.fromCodePoint.apply(null, Array.from(uint8Array)); // Use the builtin base64 encoder. - return window.btoa(latin1String); + return globalThis.btoa(latin1String); } /** @@ -55,7 +55,7 @@ function encodeBase64(uint8Array: Uint8Array): string { */ function decodeBase64(base64: string): Uint8Array { // window.atob returns a unicode string with codepoints in the range 0-255. - const latin1String = window.atob(base64); + const latin1String = globalThis.atob(base64); // Encode the string as a Uint8Array const uint8Array = new Uint8Array(new ArrayBuffer(latin1String.length)); for (let i = 0; i < latin1String.length; i += 1) { @@ -303,10 +303,10 @@ export async function encryptMegolmKeyFile( const kdfRounds = normalizedOptions.kdf_rounds || 500000; const salt = new Uint8Array(new ArrayBuffer(16)); - window.crypto.getRandomValues(salt); + globalThis.crypto.getRandomValues(salt); const iv = new Uint8Array(new ArrayBuffer(16)); - window.crypto.getRandomValues(iv); + globalThis.crypto.getRandomValues(iv); // clear bit 63 of the IV to stop us hitting the 64-bit counter boundary // (which would mean we wouldn't be able to decrypt on Android). The loss diff --git a/src/app/utils/colorMXID.test.ts b/src/app/utils/colorMXID.test.ts new file mode 100644 index 000000000..c73e04fa5 --- /dev/null +++ b/src/app/utils/colorMXID.test.ts @@ -0,0 +1,36 @@ +// Example: testing pure utility functions. +// These are the simplest tests to write — no mocking or DOM needed. +import { describe, it, expect } from 'vitest'; +import colorMXID, { cssColorMXID } from './colorMXID'; + +describe('colorMXID', () => { + it('returns a valid hsl() string', () => { + expect(colorMXID('@alice:example.com')).toMatch(/^hsl\(\d+, 65%, 80%\)$/); + }); + + it('is deterministic', () => { + expect(colorMXID('@alice:example.com')).toBe(colorMXID('@alice:example.com')); + }); + + it('produces different colors for different users', () => { + expect(colorMXID('@alice:example.com')).not.toBe(colorMXID('@bob:example.com')); + }); + + it('handles undefined without throwing', () => { + expect(colorMXID(undefined)).toBe('hsl(0, 65%, 80%)'); + }); +}); + +describe('cssColorMXID', () => { + it('returns a CSS variable in the --mx-uc-1 to --mx-uc-8 range', () => { + // Run many users through it so we cover the full 0-7 modulo range + const results = ['@a', '@b', '@c', '@d', '@e', '@f', '@g', '@h'].map(cssColorMXID); + results.forEach((v) => { + expect(v).toMatch(/^--mx-uc-[1-8]$/); + }); + }); + + it('handles undefined without throwing', () => { + expect(cssColorMXID(undefined)).toBe('--mx-uc-1'); + }); +}); diff --git a/src/app/utils/colorMXID.ts b/src/app/utils/colorMXID.ts index 6b40b9596..c94a19d48 100644 --- a/src/app/utils/colorMXID.ts +++ b/src/app/utils/colorMXID.ts @@ -6,11 +6,11 @@ function hashCode(str?: string): number { return hash; } for (let i = 0; i < str.length; i += 1) { - const chr = str.charCodeAt(i); + const chr = str.codePointAt(i) ?? 0; // eslint-disable-next-line no-bitwise hash = (hash << 5) - hash + chr; // eslint-disable-next-line no-bitwise - hash |= 0; + hash = Math.trunc(hash); } return Math.abs(hash); } diff --git a/src/app/utils/common.test.ts b/src/app/utils/common.test.ts new file mode 100644 index 000000000..073181a16 --- /dev/null +++ b/src/app/utils/common.test.ts @@ -0,0 +1,173 @@ +// Example: testing a utility file with multiple related exports. +// Uses it.each for table-driven tests — good for exhaustive format coverage. +import { describe, it, expect } from 'vitest'; +import { + binarySearch, + bytesToSize, + millisecondsToMinutesAndSeconds, + nameInitials, + parseGeoUri, + secondsToMinutesAndSeconds, + splitWithSpace, + suffixRename, + trimLeadingSlash, + trimSlash, + trimTrailingSlash, +} from './common'; + +describe('bytesToSize', () => { + it.each([ + [0, '0KB'], + [1_500, '1.5 KB'], + [2_500_000, '2.5 MB'], + [3_200_000_000, '3.2 GB'], + ])('bytesToSize(%i) → %s', (input, expected) => { + expect(bytesToSize(input)).toBe(expected); + }); +}); + +describe('millisecondsToMinutesAndSeconds', () => { + it.each([ + [0, '0:00'], + [5_000, '0:05'], + [60_000, '1:00'], + [90_000, '1:30'], + [3_661_000, '61:01'], + ])('%ims → %s', (ms, expected) => { + expect(millisecondsToMinutesAndSeconds(ms)).toBe(expected); + }); +}); + +describe('secondsToMinutesAndSeconds', () => { + it.each([ + [0, '0:00'], + [9, '0:09'], + [125, '2:05'], + ])('%is → %s', (s, expected) => { + expect(secondsToMinutesAndSeconds(s)).toBe(expected); + }); +}); + +// binarySearch: match fn returns 0=found, 1=go left (item too large), -1=go right (item too small) +describe('binarySearch', () => { + const nums = [1, 3, 5, 7, 9, 11, 13]; + const matcherFor = + (target: number) => + (n: number): -1 | 0 | 1 => { + if (n === target) return 0; + if (n > target) return 1; + return -1; + }; + + it('finds a value in the middle', () => { + expect(binarySearch(nums, matcherFor(7))).toBe(7); + }); + + it('finds the first element', () => { + expect(binarySearch(nums, matcherFor(1))).toBe(1); + }); + + it('finds the last element', () => { + expect(binarySearch(nums, matcherFor(13))).toBe(13); + }); + + it('returns undefined when value is not present', () => { + expect(binarySearch(nums, matcherFor(6))).toBeUndefined(); + }); + + it('returns undefined for an empty array', () => { + expect(binarySearch([], matcherFor(1))).toBeUndefined(); + }); +}); + +describe('parseGeoUri', () => { + it('parses a basic geo URI', () => { + expect(parseGeoUri('geo:51.5074,-0.1278')).toEqual({ + latitude: '51.5074', + longitude: '-0.1278', + }); + }); + + it('ignores the uncertainty parameter after the semicolon', () => { + expect(parseGeoUri('geo:48.8566,2.3522;u=20')).toEqual({ + latitude: '48.8566', + longitude: '2.3522', + }); + }); + + it('returns undefined for an empty string', () => { + expect(parseGeoUri('')).toBeUndefined(); + }); + + it('returns undefined when there is no colon separator', () => { + expect(parseGeoUri('no-colon-here')).toBeUndefined(); + }); + + it('returns undefined when coordinates are missing', () => { + expect(parseGeoUri('geo:')).toBeUndefined(); + }); +}); + +describe('nameInitials', () => { + it.each<[string | null | undefined, number, string]>([ + ['Alice', 1, 'A'], + ['Bob Smith', 2, 'Bo'], + ['', 1, ''], + [null, 1, ''], + [undefined, 1, ''], + ['😀Emoji', 1, '😀'], + ])('nameInitials(%s, %i) → %s', (str, len, expected) => { + expect(nameInitials(str, len)).toBe(expected); + }); +}); + +describe('suffixRename', () => { + it('appends suffix 1 when the name is immediately valid', () => { + expect(suffixRename('room', () => false)).toBe('room1'); + }); + + it('increments the suffix until the validator returns false', () => { + const taken = new Set(['room1', 'room2', 'room3']); + expect(suffixRename('room', (n) => taken.has(n))).toBe('room4'); + }); +}); + +describe('splitWithSpace', () => { + it.each([ + ['hello world', ['hello', 'world']], + [' leading', ['leading']], + ['trailing ', ['trailing']], + ['', []], + [' ', []], + ['one', ['one']], + ])('splitWithSpace(%s)', (input, expected) => { + expect(splitWithSpace(input)).toEqual(expected); + }); +}); + +describe('trimLeadingSlash / trimTrailingSlash / trimSlash', () => { + it.each([ + ['///foo/bar', 'foo/bar'], + ['foo/bar', 'foo/bar'], + ['/', ''], + ])('trimLeadingSlash(%s) → %s', (input, expected) => { + expect(trimLeadingSlash(input)).toBe(expected); + }); + + it.each([ + ['foo/bar///', 'foo/bar'], + ['foo/bar', 'foo/bar'], + ['/', ''], + ])('trimTrailingSlash(%s) → %s', (input, expected) => { + expect(trimTrailingSlash(input)).toBe(expected); + }); + + it.each([ + ['///foo/bar///', 'foo/bar'], + ['/a/', 'a'], + ['', ''], + ['/', ''], + ])('trimSlash(%s) → %s', (input, expected) => { + expect(trimSlash(input)).toBe(expected); + }); +}); diff --git a/src/app/utils/debug.ts b/src/app/utils/debug.ts index 6f7f2b367..916cc1e7f 100644 --- a/src/app/utils/debug.ts +++ b/src/app/utils/debug.ts @@ -8,7 +8,8 @@ * localStorage.removeItem('sable_debug'); location.reload(); */ -const isDebug = (): boolean => localStorage.getItem('sable_debug') === '1'; +export const isDebug = (): boolean => + import.meta.env.DEV || localStorage.getItem('sable_debug') === '1'; type LogLevel = 'log' | 'warn' | 'error'; diff --git a/src/app/utils/debugLogger.ts b/src/app/utils/debugLogger.ts new file mode 100644 index 000000000..1e1c7e74b --- /dev/null +++ b/src/app/utils/debugLogger.ts @@ -0,0 +1,364 @@ +/** + * Enhanced debug logger for Sable with circular buffer storage and categorization. + * + * Enable via Developer Tools UI or with: + * localStorage.setItem('sable_internal_debug', '1'); location.reload(); + */ + +import * as Sentry from '@sentry/react'; + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +export type LogCategory = + | 'sync' + | 'network' + | 'notification' + | 'message' + | 'call' + | 'ui' + | 'timeline' + | 'error' + | 'general'; + +export interface LogEntry { + timestamp: number; + level: LogLevel; + category: LogCategory; + namespace: string; + message: string; + data?: unknown; +} + +type LogListener = (entry: LogEntry) => void; + +const BREADCRUMB_DISABLED_KEY = 'sable_sentry_breadcrumb_disabled'; + +class DebugLoggerService { + private logs: LogEntry[] = []; + + private maxLogs = 1000; // Circular buffer size + + private enabled = false; + + private listeners: Set = new Set(); + + private disabledBreadcrumbCategories: Set; + + private sentryStats = { errors: 0, warnings: 0 }; + + constructor() { + // Check if debug logging is enabled from localStorage + this.enabled = localStorage.getItem('sable_internal_debug') === '1'; + // Load disabled breadcrumb categories + try { + const stored = localStorage.getItem(BREADCRUMB_DISABLED_KEY); + this.disabledBreadcrumbCategories = new Set( + stored ? (JSON.parse(stored) as LogCategory[]) : [] + ); + } catch { + this.disabledBreadcrumbCategories = new Set(); + } + } + + public isEnabled(): boolean { + return this.enabled; + } + + public setEnabled(enabled: boolean): void { + this.enabled = enabled; + if (enabled) { + localStorage.setItem('sable_internal_debug', '1'); + } else { + localStorage.removeItem('sable_internal_debug'); + } + } + + public addListener(listener: LogListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private notifyListeners(entry: LogEntry): void { + this.listeners.forEach((listener) => { + try { + listener(entry); + } catch (error) { + // Silently catch listener errors to prevent debug logging from breaking the app + console.error('[DebugLogger] Listener error:', error); + } + }); + } + + public log( + level: LogLevel, + category: LogCategory, + namespace: string, + message: string, + data?: unknown + ): void { + if (!this.enabled && level !== 'error') return; + + const entry: LogEntry = { + timestamp: Date.now(), + level, + category, + namespace, + message, + data, + }; + + // Add to circular buffer + if (this.logs.length >= this.maxLogs) { + this.logs.shift(); // Remove oldest entry + } + this.logs.push(entry); + + // Notify listeners + this.notifyListeners(entry); + + // Send to Sentry + this.sendToSentry(entry); + + // Also log to console for developer convenience + const prefix = `[sable:${category}:${namespace}]`; + const consoleLevel = level === 'debug' ? 'log' : level; + // eslint-disable-next-line no-console + console[consoleLevel](prefix, message, data !== undefined ? data : ''); + } + + public getBreadcrumbCategoryEnabled(category: LogCategory): boolean { + return !this.disabledBreadcrumbCategories.has(category); + } + + public setBreadcrumbCategoryEnabled(category: LogCategory, enabled: boolean): void { + if (enabled) { + this.disabledBreadcrumbCategories.delete(category); + } else { + this.disabledBreadcrumbCategories.add(category); + } + const disabledArray = Array.from(this.disabledBreadcrumbCategories); + if (disabledArray.length > 0) { + localStorage.setItem(BREADCRUMB_DISABLED_KEY, JSON.stringify(disabledArray)); + } else { + localStorage.removeItem(BREADCRUMB_DISABLED_KEY); + } + } + + public getSentryStats(): { errors: number; warnings: number } { + return { ...this.sentryStats }; + } + + /** + * Send log entries to Sentry for error tracking and breadcrumbs + */ + private sendToSentry(entry: LogEntry): void { + // Map log levels to Sentry severity + const sentryLevelMap: Record = { + debug: 'debug', + info: 'info', + warn: 'warning', + error: 'error', + }; + const sentryLevel: Sentry.SeverityLevel = sentryLevelMap[entry.level] ?? 'error'; + + // Add breadcrumb for all logs (helps with debugging in Sentry), unless category is disabled + if (!this.disabledBreadcrumbCategories.has(entry.category)) + Sentry.addBreadcrumb({ + category: `${entry.category}.${entry.namespace}`, + message: entry.message, + level: sentryLevel, + data: entry.data ? { data: entry.data } : undefined, + timestamp: entry.timestamp / 1000, // Sentry expects seconds + }); + + // Send as structured log to the Sentry Logs product (requires enableLogs: true) + const logMsg = `[${entry.category}:${entry.namespace}] ${entry.message}`; + // Flatten primitive values from entry.data so they become searchable attributes in Sentry Logs + const logDataAttrs: Record = {}; + if (entry.data && typeof entry.data === 'object' && !(entry.data instanceof Error)) { + Object.entries(entry.data).forEach(([k, v]) => { + if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') { + logDataAttrs[k] = v; + } + }); + } + const logAttrs = { category: entry.category, namespace: entry.namespace, ...logDataAttrs }; + if (entry.level === 'debug') Sentry.logger.debug(logMsg, logAttrs); + else if (entry.level === 'info') Sentry.logger.info(logMsg, logAttrs); + else if (entry.level === 'warn') Sentry.logger.warn(logMsg, logAttrs); + else Sentry.logger.error(logMsg, logAttrs); + + // Track error/warn rates as metrics, tagged by category for filtering in Sentry dashboards + if (entry.level === 'error' || entry.level === 'warn') { + Sentry.metrics.count(`sable.${entry.level}s`, 1, { + attributes: { category: entry.category, namespace: entry.namespace }, + }); + } + + // Capture errors and warnings as Sentry events + if (entry.level === 'error') { + this.sentryStats.errors += 1; + // If data is an Error object, capture it as an exception + if (entry.data instanceof Error) { + Sentry.captureException(entry.data, { + level: 'error', + tags: { + category: entry.category, + namespace: entry.namespace, + }, + contexts: { + debugLog: { + message: entry.message, + timestamp: new Date(entry.timestamp).toISOString(), + }, + }, + }); + } else { + // Otherwise capture as a message + Sentry.captureMessage(`[${entry.category}:${entry.namespace}] ${entry.message}`, { + level: 'error', + tags: { + category: entry.category, + namespace: entry.namespace, + }, + contexts: { + debugLog: { + data: entry.data, + timestamp: new Date(entry.timestamp).toISOString(), + }, + }, + }); + } + } else if (entry.level === 'warn' && Math.random() < 0.1) { + // Capture 10% of warnings to avoid overwhelming Sentry + this.sentryStats.warnings += 1; + Sentry.captureMessage(`[${entry.category}:${entry.namespace}] ${entry.message}`, { + level: 'warning', + tags: { + category: entry.category, + namespace: entry.namespace, + }, + contexts: { + debugLog: { + data: entry.data, + timestamp: new Date(entry.timestamp).toISOString(), + }, + }, + }); + } + } + + public getLogs(): LogEntry[] { + return [...this.logs]; + } + + public getFilteredLogs(filters?: { + level?: LogLevel; + category?: LogCategory; + since?: number; + }): LogEntry[] { + let filtered = [...this.logs]; + + if (filters?.level) { + filtered = filtered.filter((log) => log.level === filters.level); + } + + if (filters?.category) { + filtered = filtered.filter((log) => log.category === filters.category); + } + + if (filters?.since) { + const { since } = filters; + filtered = filtered.filter((log) => log.timestamp >= since); + } + + return filtered; + } + + public clear(): void { + this.logs = []; + } + + public exportLogs(): string { + return JSON.stringify( + { + exportedAt: new Date().toISOString(), + build: `v${APP_VERSION}${BUILD_HASH ? ` (${BUILD_HASH})` : ''}`, + logsCount: this.logs.length, + logs: this.logs.map((log) => ({ + ...log, + timestamp: new Date(log.timestamp).toISOString(), + })), + }, + null, + 2 + ); + } + + /** + * Export logs in a format suitable for attaching to Sentry reports + */ + public exportLogsForSentry(): Record[] { + return this.logs.map((log) => ({ + timestamp: new Date(log.timestamp).toISOString(), + level: log.level, + category: log.category, + namespace: log.namespace, + message: log.message, + data: log.data, + })); + } + + /** + * Attach recent logs to the next Sentry event + * Useful for bug reports to include context + */ + public attachLogsToSentry(limit = 100): void { + const recentLogs = this.logs.slice(-limit); + const logsData = recentLogs.map((log) => ({ + time: new Date(log.timestamp).toISOString(), + level: log.level, + category: log.category, + namespace: log.namespace, + message: log.message, + // Only include data for errors/warnings to avoid excessive payload + ...(log.level === 'error' || log.level === 'warn' ? { data: log.data } : {}), + })); + + // Add to context + Sentry.setContext('recentLogs', { + count: recentLogs.length, + logs: logsData, + }); + + // Also add as extra data for better visibility in Sentry UI + Sentry.getCurrentScope().setExtra('debugLogs', logsData); + + // Add as attachment for download + const logsText = JSON.stringify(logsData, null, 2); + Sentry.getCurrentScope().addAttachment({ + filename: 'debug-logs.json', + data: logsText, + contentType: 'application/json', + }); + } +} + +// Singleton instance +const debugLoggerService = new DebugLoggerService(); + +export const getDebugLogger = (): DebugLoggerService => debugLoggerService; + +/** + * Creates a logger for a specific namespace + */ +export const createDebugLogger = (namespace: string) => ({ + debug: (category: LogCategory, message: string, data?: unknown) => + debugLoggerService.log('debug', category, namespace, message, data), + info: (category: LogCategory, message: string, data?: unknown) => + debugLoggerService.log('info', category, namespace, message, data), + warn: (category: LogCategory, message: string, data?: unknown) => + debugLoggerService.log('warn', category, namespace, message, data), + error: (category: LogCategory, message: string, data?: unknown) => + debugLoggerService.log('error', category, namespace, message, data), +}); diff --git a/src/app/utils/findAndReplace.test.ts b/src/app/utils/findAndReplace.test.ts new file mode 100644 index 000000000..6eda8e373 --- /dev/null +++ b/src/app/utils/findAndReplace.test.ts @@ -0,0 +1,59 @@ +// Example: testing an algorithmic utility with multiple interaction scenarios. +// Shows how to test functions that return arrays, and how to test edge cases +// like empty input, no matches, and back-to-back matches. +import { describe, it, expect } from 'vitest'; +import { findAndReplace } from './findAndReplace'; + +// Helpers that mirror what a real caller would pass +const asText = (text: string) => ({ type: 'text', text }) as const; +const asMatch = (match: string) => ({ type: 'match', match }) as const; + +describe('findAndReplace', () => { + it('returns the original text when there are no matches', () => { + const result = findAndReplace('hello world', /xyz/g, (m) => asMatch(m[0]), asText); + expect(result).toEqual([asText('hello world')]); + }); + + it('splits text around a single match', () => { + const result = findAndReplace('say hello there', /hello/g, (m) => asMatch(m[0]), asText); + expect(result).toEqual([asText('say '), asMatch('hello'), asText(' there')]); + }); + + it('handles multiple matches in sequence', () => { + const result = findAndReplace('a b a', /a/g, (m) => asMatch(m[0]), asText); + expect(result).toEqual([asText(''), asMatch('a'), asText(' b '), asMatch('a'), asText('')]); + }); + + it('handles a match at the very start', () => { + const result = findAndReplace('helloworld', /hello/g, (m) => asMatch(m[0]), asText); + expect(result).toEqual([asText(''), asMatch('hello'), asText('world')]); + }); + + it('handles a match at the very end', () => { + const result = findAndReplace('worldhello', /hello/g, (m) => asMatch(m[0]), asText); + expect(result).toEqual([asText('world'), asMatch('hello'), asText('')]); + }); + + it('handles an empty input string', () => { + const result = findAndReplace('', /hello/g, (m) => asMatch(m[0]), asText); + expect(result).toEqual([asText('')]); + }); + + it('passes the correct pushIndex to callbacks', () => { + const indices: number[] = []; + findAndReplace( + 'a b', + /[ab]/g, + (m, i) => { + indices.push(i); + return asMatch(m[0]); + }, + (t, i) => { + indices.push(i); + return asText(t); + } + ); + // indices are assigned in push order: text(''), match('a'), text(' '), match('b'), text('') + expect(indices).toEqual([0, 1, 2, 3, 4]); + }); +}); diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index ea1a85147..f04f71d90 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -5,6 +5,7 @@ import { } from 'browser-encrypt-attachment'; import { EventTimeline, + EventTimelineSet, MatrixClient, MatrixError, MatrixEvent, @@ -17,6 +18,7 @@ import to from 'await-to-js'; import { IImageInfo, IThumbnailContent, IVideoInfo } from '$types/matrix/common'; import { AccountDataEvent } from '$types/matrix/accountData'; import { Membership, MessageEvent, StateEvent } from '$types/matrix/room'; +import * as Sentry from '@sentry/react'; import { getEventReactions, getReactionContent, getStateEvent } from './room'; const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/; @@ -162,6 +164,7 @@ export const uploadContent = async ( ) => { const { name, fileType, hideFilename, onProgress, onPromise, onSuccess, onError } = options; + const uploadStart = performance.now(); const uploadPromise = mx.uploadContent(file, { name, type: fileType, @@ -172,9 +175,25 @@ export const uploadContent = async ( try { const data = await uploadPromise; const mxc = data.content_uri; - if (mxc) onSuccess(mxc); - else onError(new MatrixError(data)); + if (mxc) { + const mediaType = file.type.split('/')[0] || 'unknown'; + Sentry.metrics.distribution( + 'sable.media.upload_latency_ms', + performance.now() - uploadStart, + { + attributes: { type: mediaType }, + } + ); + Sentry.metrics.distribution('sable.media.upload_bytes', file.size, { + attributes: { type: mediaType }, + }); + onSuccess(mxc); + } else { + Sentry.metrics.count('sable.media.upload_error', 1, { attributes: { reason: 'no_uri' } }); + onError(new MatrixError(data)); + } } catch (e: any) { + Sentry.metrics.count('sable.media.upload_error', 1, { attributes: { reason: 'exception' } }); const error = typeof e?.message === 'string' ? e.message : undefined; const errcode = typeof e?.name === 'string' ? e.message : undefined; onError(new MatrixError({ error, errcode })); @@ -387,9 +406,13 @@ export const toggleReaction = ( room: Room, targetEventId: string, key: string, - shortcode?: string + shortcode?: string, + timelineSet?: EventTimelineSet ) => { - const relations = getEventReactions(room.getUnfilteredTimelineSet(), targetEventId); + const relations = getEventReactions( + timelineSet ?? room.getUnfilteredTimelineSet(), + targetEventId + ); const allReactions = relations?.getSortedAnnotationsByKey() ?? []; const [, reactionsSet] = allReactions.find(([k]: [string, any]) => k === key) ?? []; const reactions: MatrixEvent[] = reactionsSet ? Array.from(reactionsSet) : []; diff --git a/src/app/utils/mimeTypes.test.ts b/src/app/utils/mimeTypes.test.ts new file mode 100644 index 000000000..0feaf8391 --- /dev/null +++ b/src/app/utils/mimeTypes.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest'; +import { + getBlobSafeMimeType, + mimeTypeToExt, + getFileNameExt, + getFileNameWithoutExt, + FALLBACK_MIMETYPE, +} from './mimeTypes'; + +describe('getBlobSafeMimeType', () => { + it('passes through known image types', () => { + expect(getBlobSafeMimeType('image/jpeg')).toBe('image/jpeg'); + expect(getBlobSafeMimeType('image/png')).toBe('image/png'); + expect(getBlobSafeMimeType('image/webp')).toBe('image/webp'); + }); + + it('passes through known video and audio types', () => { + expect(getBlobSafeMimeType('video/mp4')).toBe('video/mp4'); + expect(getBlobSafeMimeType('audio/mpeg')).toBe('audio/mpeg'); + }); + + it('converts video/quicktime to video/mp4', () => { + expect(getBlobSafeMimeType('video/quicktime')).toBe('video/mp4'); + }); + + it('returns fallback for unknown mime types', () => { + expect(getBlobSafeMimeType('application/x-unknown')).toBe(FALLBACK_MIMETYPE); + expect(getBlobSafeMimeType('image/bmp')).toBe(FALLBACK_MIMETYPE); + }); + + it('strips charset parameter before checking allowlist', () => { + expect(getBlobSafeMimeType('text/plain; charset=utf-8')).toBe('text/plain'); + }); + + it('returns fallback for non-string input', () => { + // @ts-expect-error — testing runtime safety for external data + expect(getBlobSafeMimeType(null)).toBe(FALLBACK_MIMETYPE); + // @ts-expect-error + expect(getBlobSafeMimeType(42)).toBe(FALLBACK_MIMETYPE); + }); +}); + +describe('mimeTypeToExt', () => { + it.each([ + ['image/jpeg', 'jpeg'], + ['image/png', 'png'], + ['video/mp4', 'mp4'], + ['audio/ogg', 'ogg'], + ['application/pdf', 'pdf'], + ['text/plain', 'plain'], + ])('%s → %s', (mimeType, expected) => { + expect(mimeTypeToExt(mimeType)).toBe(expected); + }); +}); + +describe('getFileNameExt', () => { + it.each([ + ['photo.jpg', 'jpg'], + ['archive.tar.gz', 'gz'], + ['readme.MD', 'MD'], + // No dot: lastIndexOf returns -1, slice(0) returns the full string + ['noextension', 'noextension'], + ])('%s → "%s"', (filename, expected) => { + expect(getFileNameExt(filename)).toBe(expected); + }); +}); + +describe('getFileNameWithoutExt', () => { + it.each([ + ['photo.jpg', 'photo'], + ['archive.tar.gz', 'archive.tar'], + ['noextension', 'noextension'], + ['.gitignore', '.gitignore'], + ['.hidden.txt', '.hidden'], + ])('%s → "%s"', (filename, expected) => { + expect(getFileNameWithoutExt(filename)).toBe(expected); + }); +}); diff --git a/src/app/utils/pronouns.test.ts b/src/app/utils/pronouns.test.ts new file mode 100644 index 000000000..e0471ce64 --- /dev/null +++ b/src/app/utils/pronouns.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest'; +import { parsePronounsInput, filterPronounsByLanguage } from './pronouns'; + +describe('parsePronounsInput', () => { + it('parses a single pronoun without a language prefix', () => { + expect(parsePronounsInput('he/him')).toEqual([{ summary: 'he/him', language: 'en' }]); + }); + + it('parses multiple comma-separated pronouns', () => { + expect(parsePronounsInput('he/him,she/her')).toEqual([ + { summary: 'he/him', language: 'en' }, + { summary: 'she/her', language: 'en' }, + ]); + }); + + it('parses a pronoun with a language prefix', () => { + expect(parsePronounsInput('de:er/ihm')).toEqual([{ language: 'de', summary: 'er/ihm' }]); + }); + + it('trims whitespace around entries', () => { + expect(parsePronounsInput(' he/him , she/her ')).toEqual([ + { summary: 'he/him', language: 'en' }, + { summary: 'she/her', language: 'en' }, + ]); + }); + + it('truncates summary to 16 characters', () => { + const longSummary = 'this/is/way/too/long'; + const result = parsePronounsInput(longSummary); + expect(result[0]?.summary).toHaveLength(16); + expect(result[0]?.summary).toBe('this/is/way/too/'); + }); + + it('falls back to "en" when language prefix is empty', () => { + expect(parsePronounsInput(':he/him')).toEqual([{ language: 'en', summary: 'he/him' }]); + }); + + it('returns empty array for empty string', () => { + expect(parsePronounsInput('')).toEqual([]); + }); + + it.each([null, undefined, 42 as unknown as string])( + 'returns empty array for non-string input: %s', + (input) => { + expect(parsePronounsInput(input as string)).toEqual([]); + } + ); +}); + +describe('filterPronounsByLanguage', () => { + const pronouns = [ + { summary: 'he/him', language: 'en' }, + { summary: 'er/ihm', language: 'de' }, + { summary: 'il/lui', language: 'fr' }, + ]; + + it('returns all pronouns when filtering is disabled', () => { + const result = filterPronounsByLanguage(pronouns, false, ['en']); + expect(result).toHaveLength(3); + }); + + it('filters to matching language when enabled', () => { + const result = filterPronounsByLanguage(pronouns, true, ['de']); + expect(result).toHaveLength(1); + expect(result[0]?.language).toBe('de'); + }); + + it('returns all pronouns when no entries match (fallthrough)', () => { + const result = filterPronounsByLanguage(pronouns, true, ['ja']); + expect(result).toHaveLength(3); + }); + + it('matches multiple languages', () => { + const result = filterPronounsByLanguage(pronouns, true, ['en', 'fr']); + expect(result).toHaveLength(2); + expect(result.map((p) => p.language)).toEqual(['en', 'fr']); + }); + + it('is case-insensitive for language matching', () => { + const result = filterPronounsByLanguage(pronouns, true, ['EN']); + expect(result).toHaveLength(1); + expect(result[0]?.language).toBe('en'); + }); + + it('returns empty array for non-array input', () => { + // @ts-expect-error — testing runtime safety + expect(filterPronounsByLanguage(null, true, ['en'])).toEqual([]); + }); +}); diff --git a/src/app/utils/pronouns.ts b/src/app/utils/pronouns.ts index c653d9fdc..6d111a2a1 100644 --- a/src/app/utils/pronouns.ts +++ b/src/app/utils/pronouns.ts @@ -63,3 +63,45 @@ export function filterPronounsByLanguage( return filteredPronouns; } + +const pronounParseCache = new Map< + string, + { cleanedDisplayName: string; inlinePronoun: string | null } +>(); + +export function getParsedPronouns(rawName: string, parseSetting: boolean) { + if (!parseSetting || !rawName) { + return { cleanedDisplayName: rawName, inlinePronoun: null }; + } + + if (pronounParseCache.has(rawName)) { + return pronounParseCache.get(rawName)!; + } + + // match text like (she/her) or [he/they/them] but not (hi) + const regex = /[([]?([a-zA-Z]+\/[a-zA-Z]+(?:\/[a-zA-Z]+)?)[)\]]?/; + const match = rawName.match(regex); + + let result: { cleanedDisplayName: string; inlinePronoun: string | null } = { + cleanedDisplayName: rawName, + inlinePronoun: null, + }; + + if (match) { + let strippedName = rawName.replace(match[0], '').trim(); + if (!strippedName || strippedName === '') { + strippedName = rawName; + } + result = { + cleanedDisplayName: strippedName, + inlinePronoun: match[1].toLowerCase().slice(0, 16), + }; + } + + if (pronounParseCache.size > 1000) { + pronounParseCache.clear(); + } + + pronounParseCache.set(rawName, result); + return result; +} diff --git a/src/app/utils/regex.test.ts b/src/app/utils/regex.test.ts new file mode 100644 index 000000000..80c65876d --- /dev/null +++ b/src/app/utils/regex.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest'; +import { sanitizeForRegex, EMAIL_REGEX, URL_REG } from './regex'; + +describe('sanitizeForRegex', () => { + it('returns normal alphanumeric strings unchanged', () => { + expect(sanitizeForRegex('hello123')).toBe('hello123'); + }); + + it.each([ + ['|', '\\|'], + ['\\', '\\\\'], + ['{', '\\{'], + ['}', '\\}'], + ['(', '\\('], + [')', '\\)'], + ['[', '\\['], + [']', '\\]'], + ['^', '\\^'], + ['$', '\\$'], + ['+', '\\+'], + ['*', '\\*'], + ['?', '\\?'], + ['.', '\\.'], + ['-', '\\x2d'], + ])('escapes special char %s', (input, expected) => { + expect(sanitizeForRegex(input)).toBe(expected); + }); + + it('escapes all special chars in a complex string', () => { + const result = sanitizeForRegex('a.b+c?d'); + expect(result).toBe('a\\.b\\+c\\?d'); + }); + + it('produces a valid regex that matches the original string literally', () => { + const input = 'foo.bar (baz)+'; + const safePattern = sanitizeForRegex(input); + const reg = new RegExp(safePattern); + expect(reg.test(input)).toBe(true); + // Without escaping, `.` would match any char — make sure it's literal + expect(reg.test('fooXbar (baz)+')).toBe(false); + }); + + it('handles empty string', () => { + expect(sanitizeForRegex('')).toBe(''); + }); +}); + +describe('EMAIL_REGEX', () => { + it.each([ + 'user@example.com', + 'user.name+tag@subdomain.example.org', + 'x@y.io', + 'user123@domain.co.uk', + ])('matches valid email: %s', (email) => { + expect(EMAIL_REGEX.test(email)).toBe(true); + }); + + it.each(['notanemail', '@nodomain.com', 'missing-at-sign.com', 'two@@at.com'])( + 'rejects invalid email: %s', + (email) => { + expect(EMAIL_REGEX.test(email)).toBe(false); + } + ); +}); + +describe('URL_REG', () => { + it('matches a simple http URL', () => { + const matches = 'visit http://example.com today'.match(URL_REG); + expect(matches).not.toBeNull(); + expect(matches?.[0]).toBe('http://example.com'); + }); + + it('matches a simple https URL', () => { + const matches = 'go to https://example.com/path?q=1'.match(URL_REG); + expect(matches?.[0]).toBe('https://example.com/path?q=1'); + }); + + it('finds multiple URLs in a string', () => { + const matches = 'https://one.com and https://two.org'.match(URL_REG); + expect(matches).toHaveLength(2); + expect(matches?.[0]).toBe('https://one.com'); + expect(matches?.[1]).toBe('https://two.org'); + }); + + it('does not match plain text without a scheme', () => { + const matches = 'just some text without a link'.match(URL_REG); + expect(matches).toBeNull(); + }); + + it('strips trailing punctuation from matched URL', () => { + // The pattern uses a negative lookbehind to exclude trailing punctuation + const matches = 'see https://example.com.'.match(URL_REG); + expect(matches?.[0]).not.toMatch(/\.$/); + }); +}); diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index 21fa6e290..34fb527d8 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -11,25 +11,26 @@ import { JoinRule, MatrixClient, MatrixEvent, - MsgType, NotificationCountType, PushProcessor, RelationType, Room, RoomMember, CryptoBackend, + MsgType, } from '$types/matrix-sdk'; import { AccountDataEvent } from '$types/matrix/accountData'; import { IRoomCreateContent, Membership, - MessageEvent, NotificationType, RoomToParents, RoomType, + MessageEvent, StateEvent, UnreadInfo, } from '$types/matrix/room'; +import * as Sentry from '@sentry/react'; export const getStateEvent = ( room: Room, @@ -557,7 +558,22 @@ export const decryptAllTimelineEvent = async (mx: MatrixClient, timeline: EventT .filter((event) => event.isEncrypted()) .reverse() .map((event) => event.attemptDecryption(crypto as CryptoBackend, { isRetry: true })); - await Promise.allSettled(decryptionPromises); + const decryptStart = performance.now(); + await Sentry.startSpan( + { + name: 'decrypt.bulk', + op: 'matrix.crypto', + attributes: { event_count: decryptionPromises.length }, + }, + () => Promise.allSettled(decryptionPromises) + ); + if (decryptionPromises.length > 0) { + Sentry.metrics.distribution( + 'sable.decryption.bulk_latency_ms', + performance.now() - decryptStart, + { attributes: { event_count: String(decryptionPromises.length) } } + ); + } }; export const getReactionContent = (eventId: string, key: string, shortcode?: string) => ({ @@ -602,11 +618,15 @@ export const canEditEvent = (mx: MatrixClient, mEvent: MatrixEvent) => { const relationType = content['m.relates_to']?.rel_type; return ( mEvent.getSender() === mx.getUserId() && - (!relationType || relationType === RelationType.Thread) && mEvent.getType() === MessageEvent.RoomMessage && + (!relationType || relationType === RelationType.Thread) && (content.msgtype === MsgType.Text || content.msgtype === MsgType.Emote || - content.msgtype === MsgType.Notice) + content.msgtype === MsgType.Notice || + content.msgtype === MsgType.Image || + content.msgtype === MsgType.Video || + content.msgtype === MsgType.Audio || + content.msgtype === MsgType.File) ); }; diff --git a/src/app/utils/sanitize.test.ts b/src/app/utils/sanitize.test.ts new file mode 100644 index 000000000..52c668e6b --- /dev/null +++ b/src/app/utils/sanitize.test.ts @@ -0,0 +1,124 @@ +// Tests for sanitizeCustomHtml — security-critical: strips dangerous content from +// user-supplied Matrix message HTML before rendering. +import { describe, it, expect } from 'vitest'; +import { sanitizeCustomHtml } from './sanitize'; + +describe('sanitizeCustomHtml – tag allowlist', () => { + it('passes through permitted tags', () => { + expect(sanitizeCustomHtml('bold')).toBe('bold'); + expect(sanitizeCustomHtml('italic')).toBe('italic'); + expect(sanitizeCustomHtml('snippet')).toBe('snippet'); + }); + + it('strips disallowed tags but keeps their text content', () => { + const result = sanitizeCustomHtml('text'); + expect(result).not.toContain(' and its content entirely', () => { + const result = sanitizeCustomHtml('quoted messageremaining'); + expect(result).not.toContain('quoted message'); + expect(result).toContain('remaining'); + }); +}); + +describe('sanitizeCustomHtml – XSS prevention', () => { + it('strips "); + expect(result).not.toContain(' { + const result = sanitizeCustomHtml('click me'); + expect(result).not.toContain('onclick'); + expect(result).toContain('click me'); + }); + + it('strips javascript: href on anchor tags', () => { + const result = sanitizeCustomHtml('
link'); + expect(result).not.toMatch(/javascript:/); + }); + + it('strips data: href on anchor tags', () => { + const result = sanitizeCustomHtml( + 'link' + ); + expect(result).not.toContain('data:'); + }); + + it('strips vbscript: href', () => { + const result = sanitizeCustomHtml('link'); + expect(result).not.toContain('vbscript:'); + }); +}); + +describe('sanitizeCustomHtml – link transformer', () => { + it('adds rel and target to http links', () => { + const result = sanitizeCustomHtml('link'); + expect(result).toContain('rel="noreferrer noopener"'); + expect(result).toContain('target="_blank"'); + }); + + it('passes through existing href for http links', () => { + const result = sanitizeCustomHtml('link'); + expect(result).toContain('href="https://example.com"'); + }); +}); + +describe('sanitizeCustomHtml – image transformer', () => { + it('keeps tags with mxc:// src', () => { + const result = sanitizeCustomHtml('img'); + expect(result).toContain(' with https:// src to a safe link', () => { + const result = sanitizeCustomHtml('photo'); + expect(result).not.toContain(' { + // The span transformer unconditionally overwrites the style attribute with + // values derived from data-mx-color / data-mx-bg-color. Inline CSS is always + // discarded; colors must come from the data-mx-* attributes. + it('converts data-mx-color to a CSS color style on span', () => { + const result = sanitizeCustomHtml('text'); + // sanitize-html may normalise whitespace around the colon + expect(result).toMatch(/color:\s*#ff0000/); + }); + + it('discards plain inline style on span (use data-mx-color instead)', () => { + const result = sanitizeCustomHtml('text'); + // The transformer replaces style with data-mx-* values; no data-mx-color + // present here, so style ends up stripped by the allowedStyles check. + expect(result).not.toContain('color: #ff0000'); + }); + + it('strips non-hex values from data-mx-color', () => { + const result = sanitizeCustomHtml('text'); + expect(result).not.toContain('color: red'); + }); + + it('strips disallowed CSS properties', () => { + const result = sanitizeCustomHtml('text'); + expect(result).not.toContain('position'); + }); +}); + +describe('sanitizeCustomHtml – code block class handling', () => { + it('preserves language class on code blocks', () => { + const result = sanitizeCustomHtml('const x = 1;'); + expect(result).toContain('class="language-typescript"'); + }); + + it('strips arbitrary classes not matching language-*', () => { + const result = sanitizeCustomHtml('code'); + expect(result).not.toContain('evil-class'); + }); +}); diff --git a/src/app/utils/sendFeedbackToUser.ts b/src/app/utils/sendFeedbackToUser.ts new file mode 100644 index 000000000..c1fa7ab42 --- /dev/null +++ b/src/app/utils/sendFeedbackToUser.ts @@ -0,0 +1,12 @@ +import { MatrixEvent, Room } from 'matrix-js-sdk'; + +export function sendFeedback(msg: string, room: Room, userId: string) { + const localNotice = new MatrixEvent({ + type: 'm.room.message', + content: { msgtype: 'm.notice', body: msg }, + event_id: `~sable-feedback-${Date.now()}`, + room_id: room.roomId, + sender: userId, + }); + room.addLiveEvents([localNotice], { duplicateStrategy: 'ignore' } as any); +} diff --git a/src/app/utils/sentryScrubbers.test.ts b/src/app/utils/sentryScrubbers.test.ts new file mode 100644 index 000000000..9518705c9 --- /dev/null +++ b/src/app/utils/sentryScrubbers.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect } from 'vitest'; +import { scrubMatrixIds, scrubDataObject, scrubMatrixUrl } from './sentryScrubbers'; + +// ─── scrubMatrixIds ─────────────────────────────────────────────────────────── + +describe('scrubMatrixIds – credential tokens', () => { + it('redacts access_token in query-string form', () => { + expect(scrubMatrixIds('GET /?access_token=abc123xyz')).toBe('GET /?access_token=[REDACTED]'); + }); + + it('redacts password in key=value form', () => { + expect(scrubMatrixIds('password=hunter2')).toBe('password=[REDACTED]'); + }); + + it('redacts refresh_token', () => { + expect(scrubMatrixIds('refresh_token=tok_refresh_xyz')).toBe('refresh_token=[REDACTED]'); + }); + + it('redacts sync_token and next_batch', () => { + expect(scrubMatrixIds('sync_token=s1234_5678')).toBe('sync_token=[REDACTED]'); + expect(scrubMatrixIds('next_batch=s1234_5678')).toBe('next_batch=[REDACTED]'); + }); + + it('is case-insensitive for token names', () => { + expect(scrubMatrixIds('Access_Token=abc')).toBe('Access_Token=[REDACTED]'); + }); + + it('leaves unrelated query params untouched', () => { + expect(scrubMatrixIds('format=json&limit=50')).toBe('format=json&limit=50'); + }); +}); + +describe('scrubMatrixIds – Matrix entity IDs', () => { + it('replaces user IDs', () => { + expect(scrubMatrixIds('@alice:example.com')).toBe('@[USER_ID]'); + expect(scrubMatrixIds('@bob:matrix.org')).toBe('@[USER_ID]'); + }); + + it('replaces room IDs', () => { + expect(scrubMatrixIds('!roomid:example.com')).toBe('![ROOM_ID]'); + }); + + it('replaces room aliases', () => { + expect(scrubMatrixIds('#general:example.com')).toBe('#[ROOM_ALIAS]'); + }); + + it('replaces event IDs (10+ base64 chars)', () => { + expect(scrubMatrixIds('$abcdefghij')).toBe('$[EVENT_ID]'); + expect(scrubMatrixIds('$1234567890abcdef')).toBe('$[EVENT_ID]'); + }); + + it('leaves short dollar strings untouched (< 10 chars)', () => { + expect(scrubMatrixIds('$short')).toBe('$short'); + }); + + it('scrubs multiple IDs in one string', () => { + const input = 'User @alice:example.com joined !abc:example.com'; + const result = scrubMatrixIds(input); + expect(result).toContain('@[USER_ID]'); + expect(result).toContain('![ROOM_ID]'); + expect(result).not.toContain('@alice'); + expect(result).not.toContain('!abc'); + }); + + it('passes through plain strings with no sensitive content', () => { + const safe = 'Something went wrong loading the timeline'; + expect(scrubMatrixIds(safe)).toBe(safe); + }); +}); + +// ─── scrubDataObject ────────────────────────────────────────────────────────── + +describe('scrubDataObject', () => { + it('scrubs a top-level string', () => { + expect(scrubDataObject('@alice:example.com')).toBe('@[USER_ID]'); + }); + + it('scrubs string values inside a plain object', () => { + const result = scrubDataObject({ userId: '@alice:example.com', count: 3 }) as Record< + string, + unknown + >; + expect(result.userId).toBe('@[USER_ID]'); + expect(result.count).toBe(3); // non-strings are preserved + }); + + it('scrubs string values inside a nested object', () => { + const result = scrubDataObject({ + context: { roomId: '!room:example.com' }, + }) as { context: Record }; + expect(result.context.roomId).toBe('![ROOM_ID]'); + }); + + it('scrubs string values inside an array', () => { + const result = scrubDataObject(['@alice:example.com', '!room:example.com', 42]) as unknown[]; + expect(result[0]).toBe('@[USER_ID]'); + expect(result[1]).toBe('![ROOM_ID]'); + expect(result[2]).toBe(42); + }); + + it('passes through null unchanged', () => { + expect(scrubDataObject(null)).toBeNull(); + }); + + it('passes through numbers and booleans unchanged', () => { + expect(scrubDataObject(42)).toBe(42); + expect(scrubDataObject(true)).toBe(true); + }); + + it('handles an empty object', () => { + expect(scrubDataObject({})).toEqual({}); + }); +}); + +// ─── scrubMatrixUrl ─────────────────────────────────────────────────────────── + +describe('scrubMatrixUrl – Matrix C-S API paths', () => { + it('scrubs room ID in /rooms/ path', () => { + expect(scrubMatrixUrl('/_matrix/client/v3/rooms/!abc:example.com/messages')).toBe( + '/_matrix/client/v3/rooms/![ROOM_ID]/messages' + ); + }); + + it('scrubs event ID in /event/ path', () => { + expect(scrubMatrixUrl('/rooms/!abc:example.com/event/$eventIdHere')).toContain( + '/event/$[EVENT_ID]' + ); + }); + + it('scrubs event ID in /relations/ path', () => { + expect(scrubMatrixUrl('/rooms/!abc:example.com/relations/$eventIdHere')).toContain( + '/relations/$[EVENT_ID]' + ); + }); + + it('scrubs user ID in /profile/ path', () => { + expect(scrubMatrixUrl('/_matrix/client/v3/profile/@alice:example.com')).toBe( + '/_matrix/client/v3/profile/[USER_ID]' + ); + }); + + it('scrubs percent-encoded user ID in /profile/ path', () => { + expect(scrubMatrixUrl('/profile/%40alice%3Aexample.com')).toBe('/profile/[USER_ID]'); + }); + + it('scrubs user ID in /user/ path', () => { + expect(scrubMatrixUrl('/_matrix/client/v3/user/@alice:example.com/filter')).toBe( + '/_matrix/client/v3/user/[USER_ID]/filter' + ); + }); + + it('scrubs user ID in /presence/ path', () => { + expect(scrubMatrixUrl('/_matrix/client/v3/presence/@alice:example.com/status')).toBe( + '/_matrix/client/v3/presence/[USER_ID]/status' + ); + }); + + it('scrubs the version segment in /room_keys/keys/ paths', () => { + // The regex scrubs up to the first '/' — the version segment is redacted. + // Sub-paths (roomId, sessionId) are handled by subsequent URL patterns. + expect(scrubMatrixUrl('/_matrix/client/v3/room_keys/keys/latest')).toBe( + '/_matrix/client/v3/room_keys/keys/[REDACTED]' + ); + }); + + it('scrubs /sendToDevice/ transaction IDs', () => { + expect(scrubMatrixUrl('/sendToDevice/m.room.encrypted/txnId123')).toBe( + '/sendToDevice/m.room.encrypted/[TXN_ID]' + ); + }); + + it('scrubs MSC3916 media download path', () => { + expect(scrubMatrixUrl('/_matrix/client/v1/media/download/matrix.org/someMediaId')).toBe( + '/_matrix/client/v1/media/download/[SERVER]/[MEDIA_ID]' + ); + }); + + it('scrubs legacy /media/v3/ download path', () => { + expect(scrubMatrixUrl('/_matrix/media/v3/download/matrix.org/someMediaId')).toBe( + '/_matrix/media/v3/download/[SERVER]/[MEDIA_ID]' + ); + }); +}); + +describe('scrubMatrixUrl – app route path segments', () => { + it('scrubs bare room ID in app route', () => { + expect(scrubMatrixUrl('/home/!roomid:example.com/timeline')).toBe('/home/![ROOM_ID]/timeline'); + }); + + it('scrubs hybrid room ID (decoded sigil, encoded colon)', () => { + expect(scrubMatrixUrl('/home/!roomid%3Aexample.com/timeline')).toBe( + '/home/![ROOM_ID]/timeline' + ); + }); + + it('scrubs bare user ID in app route', () => { + expect(scrubMatrixUrl('/dm/@alice:example.com')).toBe('/dm/@[USER_ID]'); + }); + + it('scrubs bare room alias in app route', () => { + expect(scrubMatrixUrl('/home/#general:example.com')).toBe('/home/[ROOM_ALIAS]'); + }); +}); + +describe('scrubMatrixUrl – deep-link (percent-encoded) forms', () => { + it('scrubs %40-encoded user ID', () => { + expect(scrubMatrixUrl('/open/%40alice%3Aexample.com')).toBe('/open/[USER_ID]'); + }); + + it('scrubs %21-encoded room ID', () => { + expect(scrubMatrixUrl('/open/%21room%3Aexample.com')).toBe('/open/![ROOM_ID]'); + }); + + it('scrubs %23-encoded room alias', () => { + expect(scrubMatrixUrl('/open/%23general%3Aexample.com')).toBe('/open/[ROOM_ALIAS]'); + }); + + it('scrubs %24-encoded event ID', () => { + expect(scrubMatrixUrl('/open/%24eventIdLongEnough')).toBe('/open/[EVENT_ID]'); + }); +}); + +describe('scrubMatrixUrl – preview_url', () => { + it('strips query string from preview_url endpoint', () => { + expect(scrubMatrixUrl('/_matrix/media/v3/preview_url?url=https://example.com&ts=1234')).toBe( + '/_matrix/media/v3/preview_url' + ); + }); + + it('leaves the path intact and only removes query string', () => { + const result = scrubMatrixUrl('/preview_url?url=https://evil.example.com'); + expect(result).toBe('/preview_url'); + }); +}); + +describe('scrubMatrixUrl – safe inputs', () => { + it('passes through a plain path with no Matrix IDs', () => { + const safe = '/home/timeline'; + expect(scrubMatrixUrl(safe)).toBe(safe); + }); + + it('passes through an empty string', () => { + expect(scrubMatrixUrl('')).toBe(''); + }); +}); diff --git a/src/app/utils/sentryScrubbers.ts b/src/app/utils/sentryScrubbers.ts new file mode 100644 index 000000000..e04df6730 --- /dev/null +++ b/src/app/utils/sentryScrubbers.ts @@ -0,0 +1,100 @@ +/** + * Pure scrubbing helpers shared by the Sentry instrumentation layer. + * + * Extracted from src/instrument.ts so they can be unit-tested independently + * of the Sentry initialisation side-effects. + */ + +/** + * Scrub Matrix entity IDs and credential tokens from a plain string value. + * Handles the sigil-prefixed forms: !roomId:server, @userId:server, $eventId, + * #alias:server, and common credential token query-string / JSON patterns. + * Used for structured log attribute values and breadcrumb data fields. + */ +export function scrubMatrixIds(value: string): string { + return value + .replace( + /(access_token|password|token|refresh_token|session_id|sync_token|next_batch)([=:\s]+)([^\s&]+)/gi, + '$1$2[REDACTED]' + ) + .replace(/@[^\s:@]+:[^\s,'"(){}[\]]+/g, '@[USER_ID]') + .replace(/![^\s:]+:[^\s,'"(){}[\]]+/g, '![ROOM_ID]') + .replace(/#[^\s:@]+:[^\s,'"(){}[\]]+/g, '#[ROOM_ALIAS]') + .replace(/\$[A-Za-z0-9_+/-]{10,}/g, '$[EVENT_ID]'); +} + +/** + * Recursively scrub Matrix entity IDs from all string values in a plain object. + * Handles one level of nesting (objects and arrays of primitives). + */ +export function scrubDataObject(data: unknown): unknown { + if (typeof data === 'string') return scrubMatrixIds(data); + if (Array.isArray(data)) return data.map(scrubDataObject); + if (data !== null && typeof data === 'object') { + return Object.fromEntries( + Object.entries(data as Record).map(([k, v]) => [k, scrubDataObject(v)]) + ); + } + return data; +} + +/** + * Scrub Matrix-specific identifiers from URLs that appear in Sentry spans, breadcrumbs, + * transaction names, and page URLs. Covers both Matrix API paths and client-side app routes. + * Room IDs, user IDs, event IDs, media paths, and deep-link parameters are replaced with + * safe placeholders so no PII leaks into Sentry. + */ +export function scrubMatrixUrl(url: string): string { + return ( + url + // ── Matrix Client-Server API paths ────────────────────────────────────────────── + // /rooms/!roomId:server/... + .replace(/\/rooms\/![^/?#\s]*/g, '/rooms/![ROOM_ID]') + // /event/$eventId and /relations/$eventId + .replace(/\/event\/(?:\$|%24)[^/?#\s]*/g, '/event/$[EVENT_ID]') + .replace(/\/relations\/(?:\$|%24)[^/?#\s]*/g, '/relations/$[EVENT_ID]') + // /profile/@user:server or /profile/%40user%3Aserver + .replace(/\/profile\/(?:%40|@)[^/?#\s]*/gi, '/profile/[USER_ID]') + // /user/@user:server/... and /presence/@user:server/status + .replace(/\/(user|presence)\/(?:%40|@)[^/?#\s]*/gi, '/$1/[USER_ID]') + // /room_keys/keys/{version}/{roomId}/{sessionId} + .replace(/\/room_keys\/keys\/[^/?#\s]*/gi, '/room_keys/keys/[REDACTED]') + // /sendToDevice/{eventType}/{txnId} + .replace(/\/sendToDevice\/([^/?#\s]+)\/[^/?#\s]+/gi, '/sendToDevice/$1/[TXN_ID]') + // Media – MSC3916 (/media/thumbnail|download/{server}/{mediaId}) and legacy (v1/v3) + .replace( + /(\/media\/(?:thumbnail|download)\/)(?:[^/?#\s]+)\/(?:[^/?#\s]+)/gi, + '$1[SERVER]/[MEDIA_ID]' + ) + .replace( + /(\/media\/v\d+\/(?:thumbnail|download)\/)(?:[^/?#\s]+)\/(?:[^/?#\s]+)/gi, + '$1[SERVER]/[MEDIA_ID]' + ) + // ── App route path segments ───────────────────────────────────────────────────── + // Bare/partially-decoded Matrix IDs in URL path segments. + // Browsers decode %21→! and %40→@ for display but often keep %3A encoded, + // so we see hybrid forms like /!localpart%3Aserver/ or /!localpart:server/. + // Each pattern accepts either a literal colon or the %3A encoding. + // Bare room IDs: /!localpart:server/ or /!localpart%3Aserver/ + .replace(/\/![^/?#\s:%]+(?:%3A|:)[^/?#\s]*/gi, '/![ROOM_ID]') + // Bare user IDs: /@user:server/ or /@user%3Aserver/ + .replace(/\/@[^/?#\s:%]+(?:%3A|:)[^/?#\s]*/gi, '/@[USER_ID]') + // Bare room aliases: /#alias:server/ or /#alias%3Aserver/ + .replace(/\/#[^/?#\s:%]+(?:%3A|:)[^/?#\s]*/gi, '/[ROOM_ALIAS]') + // ── Deep-link / app-route URLs (percent-encoded via encodeURIComponent) ───────── + // URL-encoded user IDs: /%40user%3Aserver (%40 = @) + .replace(/\/%40[^/?#\s]*/gi, '/[USER_ID]') + // URL-encoded room IDs: /%21room%3Aserver (%21 = !) + .replace(/\/%21[^/?#\s]*/gi, '/![ROOM_ID]') + // URL-encoded room aliases: /%23alias%3Aserver (%23 = #) + // App routes like /:spaceIdOrAlias/ use encodeURIComponent() so #alias:server + // appears as %23alias%3Aserver in the URL path / Sentry transaction name. + .replace(/\/%23[^/?#\s]*/gi, '/[ROOM_ALIAS]') + // URL-encoded event IDs as bare path segments: /%24eventId (%24 = $) + .replace(/\/%24[^/?#\s]*/gi, '/[EVENT_ID]') + // ── Preview URL endpoint ──────────────────────────────────────────────────────── + // The ?url= query parameter on preview_url contains the full external URL being + // previewed — strip the entire query string so browsing habits cannot be inferred. + .replace(/(\/preview_url)\?[^#\s]*/gi, '$1') + ); +} diff --git a/src/app/utils/sort.test.ts b/src/app/utils/sort.test.ts new file mode 100644 index 000000000..bda66e233 --- /dev/null +++ b/src/app/utils/sort.test.ts @@ -0,0 +1,99 @@ +// Example: testing factory functions (functions that return functions). +// Shows how to build lightweight fakes/stubs instead of using a full mock library — +// for factoryRoomIdByActivity and factoryRoomIdByAtoZ the MatrixClient is stubbed +// with a plain object, keeping tests readable without heavy setup. +import { describe, it, expect } from 'vitest'; +import type { MatrixClient } from '$types/matrix-sdk'; +import { + byOrderKey, + byTsOldToNew, + factoryRoomIdByActivity, + factoryRoomIdByAtoZ, + factoryRoomIdByUnreadCount, +} from './sort'; + +// Minimal stub that satisfies the MatrixClient shape needed by these sort functions. +function makeClient(rooms: Record): MatrixClient { + return { + getRoom: (id: string) => { + const r = rooms[id]; + if (!r) return null; + return { name: r.name, getLastActiveTimestamp: () => r.ts } as any; + }, + } as unknown as MatrixClient; +} + +describe('byTsOldToNew', () => { + it('sorts ascending by timestamp', () => { + expect([300, 100, 200].sort(byTsOldToNew)).toEqual([100, 200, 300]); + }); +}); + +describe('byOrderKey', () => { + it('sorts defined keys lexicographically', () => { + expect(['c', 'a', 'b'].sort(byOrderKey)).toEqual(['a', 'b', 'c']); + }); + + it('puts undefined keys after defined keys', () => { + expect([undefined, 'a', undefined, 'b'].sort(byOrderKey)).toEqual([ + 'a', + 'b', + undefined, + undefined, + ]); + }); +}); + +describe('factoryRoomIdByActivity', () => { + it('sorts rooms most-recently-active first', () => { + const mx = makeClient({ + '!old:h': { name: 'Old', ts: 1000 }, + '!new:h': { name: 'New', ts: 9000 }, + '!mid:h': { name: 'Mid', ts: 5000 }, + }); + const sort = factoryRoomIdByActivity(mx); + expect(['!old:h', '!new:h', '!mid:h'].sort(sort)).toEqual(['!new:h', '!mid:h', '!old:h']); + }); + + it('places unknown room IDs last', () => { + const mx = makeClient({ '!known:h': { name: 'Known', ts: 1000 } }); + const sort = factoryRoomIdByActivity(mx); + expect(['!unknown:h', '!known:h'].sort(sort)).toEqual(['!known:h', '!unknown:h']); + }); +}); + +describe('factoryRoomIdByAtoZ', () => { + it('sorts room names case-insensitively A→Z', () => { + const mx = makeClient({ + '!c:h': { name: 'Charlie', ts: 0 }, + '!a:h': { name: 'Alice', ts: 0 }, + '!b:h': { name: 'bob', ts: 0 }, + }); + const sort = factoryRoomIdByAtoZ(mx); + expect(['!c:h', '!a:h', '!b:h'].sort(sort)).toEqual(['!a:h', '!b:h', '!c:h']); + }); + + it('strips leading # before comparing', () => { + const mx = makeClient({ + '!hash:h': { name: '#alpha', ts: 0 }, + '!plain:h': { name: 'beta', ts: 0 }, + }); + const sort = factoryRoomIdByAtoZ(mx); + // #alpha → "alpha" sorts before "beta" + expect(['!plain:h', '!hash:h'].sort(sort)).toEqual(['!hash:h', '!plain:h']); + }); +}); + +describe('factoryRoomIdByUnreadCount', () => { + it('sorts rooms with more unreads first', () => { + const counts: Record = { '!a:h': 5, '!b:h': 20, '!c:h': 1 }; + const sort = factoryRoomIdByUnreadCount((id) => counts[id] ?? 0); + expect(['!a:h', '!b:h', '!c:h'].sort(sort)).toEqual(['!b:h', '!a:h', '!c:h']); + }); + + it('treats missing counts as 0', () => { + const sort = factoryRoomIdByUnreadCount(() => 0); + const result = ['!a:h', '!b:h'].sort(sort); + expect(result).toHaveLength(2); + }); +}); diff --git a/src/app/utils/time.test.ts b/src/app/utils/time.test.ts new file mode 100644 index 000000000..2f2418bfb --- /dev/null +++ b/src/app/utils/time.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest'; +import { + daysToMs, + hour12to24, + hour24to12, + hoursToMs, + inSameDay, + minuteDifference, + minutesToMs, + secondsToMs, +} from './time'; + +describe('hour24to12', () => { + it.each([ + [0, 12], // midnight → 12 AM + [1, 1], + [11, 11], + [12, 12], // noon → 12 PM + [13, 1], + [23, 11], + ])('hour24to12(%i) → %i', (input, expected) => { + expect(hour24to12(input)).toBe(expected); + }); +}); + +describe('hour12to24', () => { + it.each([ + [12, true, 12], // 12 PM → 12 + [12, false, 0], // 12 AM → 0 (midnight) + [1, true, 13], // 1 PM → 13 + [1, false, 1], // 1 AM → 1 + [11, true, 23], // 11 PM → 23 + [11, false, 11], // 11 AM → 11 + ])('hour12to24(%i, pm=%s) → %i', (hour, pm, expected) => { + expect(hour12to24(hour, pm)).toBe(expected); + }); +}); + +describe('inSameDay', () => { + // Use noon UTC for all timestamps so the local calendar date is unambiguous + // in any timezone (avoids midnight UTC being the previous day locally). + const base = new Date('2024-01-15T12:00:00Z').getTime(); + const sameDay = new Date('2024-01-15T14:00:00Z').getTime(); + const nextDay = new Date('2024-01-16T12:00:00Z').getTime(); + + it('returns true for two timestamps on the same day', () => { + expect(inSameDay(base, sameDay)).toBe(true); + }); + + it('returns false for timestamps on different days', () => { + expect(inSameDay(base, nextDay)).toBe(false); + }); + + it('returns true when both timestamps are identical', () => { + expect(inSameDay(base, base)).toBe(true); + }); +}); + +describe('minuteDifference', () => { + it.each([ + [0, 60_000, 1], // 1 minute + [0, 3_600_000, 60], // 1 hour = 60 minutes + [0, 90_000, 2], // 1.5 minutes rounds to 2 + [5_000, 0, 0], // less than a minute → 0 + [0, 0, 0], // same timestamp + ])('minuteDifference(%i, %i) → %i', (ts1, ts2, expected) => { + expect(minuteDifference(ts1, ts2)).toBe(expected); + }); + + it('is symmetric (absolute difference)', () => { + expect(minuteDifference(3_600_000, 0)).toBe(minuteDifference(0, 3_600_000)); + }); +}); + +describe('unit conversion helpers', () => { + it('secondsToMs converts seconds to milliseconds', () => { + expect(secondsToMs(1)).toBe(1_000); + expect(secondsToMs(60)).toBe(60_000); + }); + + it('minutesToMs converts minutes to milliseconds', () => { + expect(minutesToMs(1)).toBe(60_000); + expect(minutesToMs(60)).toBe(3_600_000); + }); + + it('hoursToMs converts hours to milliseconds', () => { + expect(hoursToMs(1)).toBe(3_600_000); + expect(hoursToMs(24)).toBe(86_400_000); + }); + + it('daysToMs converts days to milliseconds', () => { + expect(daysToMs(1)).toBe(86_400_000); + expect(daysToMs(7)).toBe(604_800_000); + }); + + it('conversion chain is consistent: daysToMs(1) === hoursToMs(24)', () => { + expect(daysToMs(1)).toBe(hoursToMs(24)); + }); +}); diff --git a/src/app/utils/user-agent.ts b/src/app/utils/user-agent.ts index 0e82daf0e..e1a2e0a61 100644 --- a/src/app/utils/user-agent.ts +++ b/src/app/utils/user-agent.ts @@ -9,6 +9,12 @@ const isMobileOrTablet = (() => { return false; })(); +const normalizeMacName = (os?: string) => { + if (!os) return os; + if (os === 'Mac OS') return 'macOS'; + return os; +}; + const isMac = result.os.name === 'Mac OS'; export const ua = () => result; @@ -17,7 +23,7 @@ export const mobileOrTablet = () => isMobileOrTablet; export const deviceDisplayName = (): string => { const browser = result.browser.name; - const os = result.os.name; + const os = normalizeMacName(result.os.name); if (!browser || !os) return 'Sable Web'; return `Sable on ${browser} for ${os}`; }; diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 47c8ba29d..18809c5d2 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -4,6 +4,8 @@ import { MatrixClient, IndexedDBStore, IndexedDBCryptoStore, + SyncState, + ISyncStateData, } from '$types/matrix-sdk'; import { clearNavToActivePathStore } from '$state/navToActivePath'; @@ -16,12 +18,19 @@ import { } from '$state/sessions'; import { getLocalStorageItem } from '$state/utils/atomWithLocalStorage'; import { createLogger } from '$utils/debug'; +import { createDebugLogger } from '$utils/debugLogger'; +import * as Sentry from '@sentry/react'; import { pushSessionToSW } from '../sw-session'; import { cryptoCallbacks } from './secretStorageKeys'; import { SlidingSyncConfig, SlidingSyncDiagnostics, SlidingSyncManager } from './slidingSync'; const log = createLogger('initMatrix'); +const debugLog = createDebugLogger('initMatrix'); const slidingSyncByClient = new WeakMap(); +const classicSyncObserverByClient = new WeakMap< + MatrixClient, + (state: SyncState, prevState: SyncState | null, data?: ISyncStateData) => void +>(); const FAST_SYNC_POLL_TIMEOUT_MS = 10000; const SLIDING_SYNC_POLL_TIMEOUT_MS = 20000; type SyncTransport = 'classic' | 'sliding'; @@ -142,14 +151,23 @@ const isClientReadyForUi = (syncState: string | null): boolean => const waitForClientReady = (mx: MatrixClient, timeoutMs: number): Promise => new Promise((resolve) => { + const waitStart = performance.now(); if (isClientReadyForUi(mx.getSyncState())) { + Sentry.metrics.distribution('sable.sync.client_ready_ms', 0, { + attributes: { timed_out: 'false' }, + }); resolve(); return; } let timer = 0; + let timedOut = false; let finish = () => {}; const onSync = (state: string) => { + debugLog.info('sync', `Sync state changed: ${state}`, { + state, + ready: isClientReadyForUi(state), + }); if (isClientReadyForUi(state)) finish(); }; @@ -159,10 +177,25 @@ const waitForClientReady = (mx: MatrixClient, timeoutMs: number): Promise settled = true; mx.removeListener(ClientEvent.Sync, onSync); clearTimeout(timer); + const waitMs = performance.now() - waitStart; + Sentry.metrics.distribution('sable.sync.client_ready_ms', waitMs, { + attributes: { timed_out: String(timedOut) }, + }); + if (timedOut) { + Sentry.addBreadcrumb({ + category: 'sync', + message: 'waitForClientReady timed out — client may be stuck', + level: 'warning', + data: { timeout_ms: timeoutMs }, + }); + } resolve(); }; - timer = window.setTimeout(finish, timeoutMs); + timer = window.setTimeout(() => { + timedOut = true; + finish(); + }, timeoutMs); mx.on(ClientEvent.Sync, onSync); }); @@ -263,7 +296,10 @@ const buildClient = async (session: Session): Promise => { export const initClient = async (session: Session): Promise => { const storeName = getSessionStoreName(session); - log.log('initClient', { userId: session.userId, baseUrl: session.baseUrl, storeName }); + debugLog.info('sync', 'Initializing Matrix client', { + userId: session.userId, + baseUrl: session.baseUrl, + }); const isMismatch = (err: unknown): boolean => { const msg = err instanceof Error ? err.message : String(err); @@ -277,6 +313,13 @@ export const initClient = async (session: Session): Promise => { const wipeAllStores = async () => { log.warn('initClient: wiping all stores for', session.userId); + debugLog.warn('sync', 'Wiping all stores due to mismatch', { userId: session.userId }); + Sentry.addBreadcrumb({ + category: 'crypto', + message: 'Crypto store mismatch — wiping local stores and retrying', + level: 'warning', + }); + Sentry.metrics.count('sable.crypto.store_wipe', 1); await deleteSessionStores(storeName); try { const allDbs = await window.indexedDB.databases(); @@ -297,8 +340,12 @@ export const initClient = async (session: Session): Promise => { try { mx = await buildClient(session); } catch (err) { - if (!isMismatch(err)) throw err; + if (!isMismatch(err)) { + debugLog.error('sync', 'Failed to build client', { error: err }); + throw err; + } log.warn('initClient: mismatch on buildClient — wiping and retrying:', err); + debugLog.warn('sync', 'Client build mismatch - wiping stores and retrying', { error: err }); await wipeAllStores(); mx = await buildClient(session); } @@ -306,8 +353,12 @@ export const initClient = async (session: Session): Promise => { try { await mx.initRustCrypto({ cryptoDatabasePrefix: storeName.rustCryptoPrefix }); } catch (err) { - if (!isMismatch(err)) throw err; + if (!isMismatch(err)) { + debugLog.error('sync', 'Failed to initialize crypto', { error: err }); + throw err; + } log.warn('initClient: mismatch on initRustCrypto — wiping and retrying:', err); + debugLog.warn('sync', 'Crypto init mismatch - wiping stores and retrying', { error: err }); mx.stopClient(); await wipeAllStores(); mx = await buildClient(session); @@ -336,16 +387,11 @@ const disposeSlidingSync = (mx: MatrixClient): void => { slidingSyncByClient.delete(mx); }; -export const stopClient = (mx: MatrixClient): void => { - disposeSlidingSync(mx); - mx.stopClient(); -}; - export const getSlidingSyncManager = (mx: MatrixClient): SlidingSyncManager | undefined => slidingSyncByClient.get(mx); -export const startClient = async (mx: MatrixClient, config?: StartClientConfig) => { - log.log('startClient', mx.getUserId()); +export const startClient = async (mx: MatrixClient, config?: StartClientConfig): Promise => { + debugLog.info('sync', 'Starting Matrix client', { userId: mx.getUserId() }); disposeSlidingSync(mx); const slidingConfig = config?.slidingSync; const slidingEnabledOnServer = resolveSlidingEnabled(slidingConfig?.enabled); @@ -361,6 +407,11 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig) proxyBaseUrl, hasSlidingProxy, }); + debugLog.info('sync', 'Sliding sync configuration', { + enabledOnServer: slidingEnabledOnServer, + requested: slidingRequested, + hasProxy: hasSlidingProxy, + }); const startClassicSync = async (fallbackFromSliding: boolean, reason: SyncTransportReason) => { syncTransportByClient.set(mx, { @@ -372,10 +423,66 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig) fallbackFromSliding, reason, }); + Sentry.metrics.count('sable.sync.transport', 1, { + attributes: { transport: 'classic', reason, fallback: String(fallbackFromSliding) }, + }); await mx.startClient({ lazyLoadMembers: true, pollTimeout: FAST_SYNC_POLL_TIMEOUT_MS, }); + // Attach an ongoing classic-sync observer — equivalent to SlidingSyncManager's + // onLifecycle listener. Tracks state transitions, initial-sync timing, and errors. + let classicSyncCount = 0; + const classicSyncStartMs = performance.now(); + let classicInitialSyncDone = false; + const classicSyncListener = ( + state: SyncState, + prevState: SyncState | null, + data?: ISyncStateData + ) => { + classicSyncCount += 1; + Sentry.metrics.count('sable.sync.cycle', 1, { attributes: { transport: 'classic', state } }); + debugLog.info('sync', `Classic sync state: ${state}`, { + state, + prevState: prevState ?? 'null', + syncNumber: classicSyncCount, + error: data?.error?.message, + }); + if (state === SyncState.Error || state === SyncState.Reconnecting) { + debugLog.warn('sync', `Classic sync problem: ${state}`, { + state, + prevState: prevState ?? 'null', + errorMessage: data?.error?.message, + syncNumber: classicSyncCount, + }); + Sentry.metrics.count('sable.sync.error', 1, { + attributes: { transport: 'classic', state }, + }); + Sentry.addBreadcrumb({ + category: 'sync.classic', + message: `Classic sync problem: ${state}`, + level: 'warning', + data: { state, prevState, error: data?.error?.message, syncNumber: classicSyncCount }, + }); + } + if ( + !classicInitialSyncDone && + (state === SyncState.Syncing || state === SyncState.Prepared) + ) { + classicInitialSyncDone = true; + const elapsed = performance.now() - classicSyncStartMs; + debugLog.info('sync', 'Classic sync initial ready', { + state, + syncNumber: classicSyncCount, + elapsed: `${elapsed.toFixed(0)}ms`, + }); + Sentry.metrics.distribution('sable.sync.initial_ms', elapsed, { + attributes: { transport: 'classic' }, + }); + } + }; + classicSyncObserverByClient.set(mx, classicSyncListener); + mx.on(ClientEvent.Sync, classicSyncListener); }; const shouldBootstrapClassicOnColdCache = async (): Promise => { @@ -416,8 +523,12 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig) if (await shouldBootstrapClassicOnColdCache()) { log.log('startClient cold-cache bootstrap: using classic sync for this run', mx.getUserId()); await startClassicSync(false, 'cold_cache_bootstrap'); - waitForClientReady(mx, COLD_CACHE_BOOTSTRAP_TIMEOUT_MS).catch(() => { - /* ignore */ + waitForClientReady(mx, COLD_CACHE_BOOTSTRAP_TIMEOUT_MS).catch((err) => { + debugLog.warn('network', 'Cold cache bootstrap timed out', { + userId: mx.getUserId(), + timeout: `${COLD_CACHE_BOOTSTRAP_TIMEOUT_MS}ms`, + error: err instanceof Error ? err.message : String(err), + }); }); return; } @@ -437,6 +548,11 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig) }); if (!supported) { log.warn('Sliding Sync unavailable, falling back to classic sync for', mx.getUserId()); + debugLog.warn('network', 'Sliding Sync probe failed, falling back to classic sync', { + userId: mx.getUserId(), + proxyBaseUrl: resolvedProxyBaseUrl, + probeTimeout: `${probeTimeoutMs}ms`, + }); await startClassicSync(true, 'probe_failed_fallback'); return; } @@ -460,6 +576,9 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig) fallbackFromSliding: false, reason: 'sliding_active', }); + Sentry.metrics.count('sable.sync.transport', 1, { + attributes: { transport: 'sliding', reason: 'sliding_active', fallback: 'false' }, + }); try { await mx.startClient({ @@ -467,11 +586,30 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig) slidingSync: manager.slidingSync, }); } catch (err) { + debugLog.error('network', 'Failed to start client with sliding sync', { + error: err instanceof Error ? err.message : String(err), + userId: mx.getUserId(), + proxyBaseUrl: resolvedProxyBaseUrl, + stack: err instanceof Error ? err.stack : undefined, + }); disposeSlidingSync(mx); throw err; } }; +export const stopClient = (mx: MatrixClient): void => { + log.log('stopClient', mx.getUserId()); + debugLog.info('sync', 'Stopping client', { userId: mx.getUserId() }); + disposeSlidingSync(mx); + const classicSyncListener = classicSyncObserverByClient.get(mx); + if (classicSyncListener) { + mx.removeListener(ClientEvent.Sync, classicSyncListener); + classicSyncObserverByClient.delete(mx); + } + mx.stopClient(); + syncTransportByClient.delete(mx); +}; + export const clearCacheAndReload = async (mx: MatrixClient) => { log.log('clearCacheAndReload', mx.getUserId()); stopClient(mx); @@ -504,10 +642,12 @@ export const getClientSyncDiagnostics = (mx: MatrixClient): ClientSyncDiagnostic */ export const logoutClient = async (mx: MatrixClient, session?: Session) => { log.log('logoutClient', { userId: mx.getUserId(), sessionUserId: session?.userId }); + debugLog.info('general', 'Logging out client', { userId: mx.getUserId() }); pushSessionToSW(); stopClient(mx); try { await mx.logout(); + debugLog.info('general', 'Logout successful', { userId: mx.getUserId() }); } catch { // ignore } @@ -525,6 +665,7 @@ export const logoutClient = async (mx: MatrixClient, session?: Session) => { }; export const clearLoginData = async () => { + debugLog.info('general', 'Clearing all login data and reloading'); const dbs = await window.indexedDB.databases(); dbs.forEach((idbInfo) => { const { name } = idbInfo; diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index eacb7a472..a644bc2d9 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -5,6 +5,7 @@ import { ExtensionState, MatrixClient, MSC3575List, + MSC3575RoomData, MSC3575RoomSubscription, MSC3575_WILDCARD, SlidingSync, @@ -16,8 +17,11 @@ import { User, } from '$types/matrix-sdk'; import { createLogger } from '$utils/debug'; +import { createDebugLogger } from '$utils/debugLogger'; +import * as Sentry from '@sentry/react'; const log = createLogger('slidingSync'); +const debugLog = createDebugLogger('slidingSync'); export const LIST_JOINED = 'joined'; export const LIST_INVITES = 'invites'; @@ -316,6 +320,28 @@ export class SlidingSyncManager { private listsFullyLoaded = false; + private initialSyncCompleted = false; + + private syncCount = 0; + + private previousListCounts: Map = new Map(); + + /** + * One-shot RoomData listeners keyed by roomId, used to measure the latency + * between subscribeToRoom() and the first data arriving for that room. + * Cleaned up automatically after first fire or on unsubscribe/dispose. + */ + private readonly pendingRoomDataListeners = new Map< + string, + (roomId: string, data: MSC3575RoomData) => void + >(); + + /** Wall-clock time recorded in attach() — used to compute true initial-sync latency. */ + private attachTime: number | null = null; + + /** Span covering the period from attach() to the first successful complete cycle. */ + private initialSyncSpan: ReturnType | null = null; + public readonly slidingSync: SlidingSync; public readonly probeTimeoutMs: number; @@ -359,17 +385,145 @@ export class SlidingSyncManager { ); this.onLifecycle = (state, resp, err) => { - if (this.disposed || err || !resp || state !== SlidingSyncState.Complete) return; + const syncStartTime = performance.now(); + this.syncCount += 1; + Sentry.metrics.count('sable.sync.cycle', 1, { + attributes: { transport: 'sliding', state }, + }); + + debugLog.info('sync', `Sliding sync lifecycle: ${state} (cycle #${this.syncCount})`, { + state, + hasError: !!err, + syncNumber: this.syncCount, + isInitialSync: !this.initialSyncCompleted, + }); + + if (err) { + debugLog.error('sync', 'Sliding sync error', { + error: err, + errorMessage: err.message, + syncNumber: this.syncCount, + state, + }); + Sentry.metrics.count('sable.sync.error', 1, { + attributes: { transport: 'sliding', state }, + }); + } + + if (this.disposed) { + debugLog.warn('sync', 'Sync lifecycle called after disposal', { state }); + return; + } + + if (err || !resp || state !== SlidingSyncState.Complete) return; + + // Track what changed in this sync cycle + const changes: Record = {}; + let totalRoomCount = 0; + let hasChanges = false; + + this.listKeys.forEach((key) => { + const listData = this.slidingSync.getListData(key); + const currentCount = listData?.joinedCount ?? 0; + const previousCount = this.previousListCounts.get(key) ?? 0; + + totalRoomCount += currentCount; + + if (currentCount !== previousCount) { + changes[key] = { + previous: previousCount, + current: currentCount, + delta: currentCount - previousCount, + }; + this.previousListCounts.set(key, currentCount); + hasChanges = true; + } + }); + + if (hasChanges || !this.initialSyncCompleted) { + debugLog.info('sync', 'Room counts changed in sync cycle', { + syncNumber: this.syncCount, + changes, + totalRoomCount, + isInitialSync: !this.initialSyncCompleted, + }); + } + + const syncDuration = performance.now() - syncStartTime; + + // Mark initial sync as complete after first successful cycle + if (!this.initialSyncCompleted) { + this.initialSyncCompleted = true; + // Wall-clock ms from attach() — the actual user-perceived wait for first data. + const initialElapsed = + this.attachTime != null ? performance.now() - this.attachTime : syncDuration; + debugLog.info('sync', 'Initial sync completed', { + syncNumber: this.syncCount, + totalRoomCount, + listCounts: Object.fromEntries( + this.listKeys.map((key) => [key, this.slidingSync.getListData(key)?.joinedCount ?? 0]) + ), + timeElapsed: `${initialElapsed.toFixed(2)}ms`, + }); + Sentry.metrics.distribution('sable.sync.initial_ms', initialElapsed, { + attributes: { transport: 'sliding' }, + }); + this.initialSyncSpan?.setAttributes({ + 'sync.cycles_to_ready': this.syncCount, + 'sync.rooms_at_ready': totalRoomCount, + }); + this.initialSyncSpan?.end(); + this.initialSyncSpan = null; + } + this.expandListsToKnownCount(); + + Sentry.metrics.distribution('sable.sync.processing_ms', syncDuration, { + attributes: { transport: 'sliding' }, + }); + if (syncDuration > 1000) { + debugLog.warn('sync', 'Slow sync cycle detected', { + syncNumber: this.syncCount, + duration: `${syncDuration.toFixed(2)}ms`, + totalRoomCount, + }); + } }; this.onConnectionChange = () => { + const isOnline = navigator.onLine; + const connectionInfo = + typeof navigator !== 'undefined' ? (navigator as any).connection : undefined; + const effectiveType = connectionInfo?.effectiveType; + const downlink = connectionInfo?.downlink; + + debugLog.info('network', `Network connectivity changed: ${isOnline ? 'online' : 'offline'}`, { + online: isOnline, + effectiveType, + downlink: downlink ? `${downlink} Mbps` : undefined, + }); + + if (!isOnline) { + debugLog.warn('network', 'Device went offline - sync paused', { + syncNumber: this.syncCount, + }); + } else { + debugLog.info('network', 'Device back online - sync will resume', { + syncNumber: this.syncCount, + }); + } + if (this.disposed || !this.adaptiveTimeline) return; const nextLimit = resolveAdaptiveRoomTimelineLimit( this.configuredTimelineLimit, readAdaptiveSignals() ); if (nextLimit === this.roomTimelineLimit) return; + debugLog.info('sync', `Adaptive timeline limit updated to ${nextLimit}`, { + limit: nextLimit, + previousLimit: this.roomTimelineLimit, + reason: 'connection change', + }); this.roomTimelineLimit = nextLimit; this.applyRoomTimelineLimit(nextLimit); log.log( @@ -379,6 +533,22 @@ export class SlidingSyncManager { } public attach(): void { + debugLog.info('sync', 'Attaching sliding sync listeners', { + proxyBaseUrl: this.proxyBaseUrl, + listPageSize: this.listPageSize, + roomTimelineLimit: this.roomTimelineLimit, + adaptiveTimeline: this.adaptiveTimeline, + maxRooms: this.maxRooms, + lists: this.listKeys, + }); + + this.attachTime = performance.now(); + this.initialSyncSpan = Sentry.startInactiveSpan({ + name: 'sync.initial', + op: 'matrix.sync', + attributes: { 'sync.transport': 'sliding', 'sync.proxy': this.proxyBaseUrl }, + }); + this.slidingSync.on(SlidingSyncEvent.Lifecycle, this.onLifecycle); const connection = ( typeof navigator !== 'undefined' ? (navigator as any).connection : undefined @@ -395,10 +565,25 @@ export class SlidingSyncManager { window.addEventListener('online', this.onConnectionChange); window.addEventListener('offline', this.onConnectionChange); } + + debugLog.info('sync', 'Sliding sync listeners attached successfully', { + hasConnectionAPI: !!connection, + hasWindowEvents: typeof window !== 'undefined', + }); } public dispose(): void { if (this.disposed) return; + + debugLog.info('sync', 'Disposing sliding sync', { + syncCount: this.syncCount, + initialSyncCompleted: this.initialSyncCompleted, + }); + + // Clean up pending room-data latency listeners before marking disposed. + // SlidingSync.stop() will removeAllListeners anyway, but this keeps the Map tidy. + this.pendingRoomDataListeners.clear(); + this.disposed = true; this.slidingSync.removeListener(SlidingSyncEvent.Lifecycle, this.onLifecycle); const connection = ( @@ -416,6 +601,10 @@ export class SlidingSyncManager { window.removeEventListener('online', this.onConnectionChange); window.removeEventListener('offline', this.onConnectionChange); } + + debugLog.info('sync', 'Sliding sync disposed successfully', { + totalSyncCycles: this.syncCount, + }); } private applyRoomTimelineLimit(timelineLimit: number): void { @@ -455,10 +644,16 @@ export class SlidingSyncManager { let allListsComplete = true; let expandedAny = false; + const expansionStartTime = performance.now(); + const expansionDetails: Record = {}; + this.listKeys.forEach((key) => { const listData = this.slidingSync.getListData(key); const knownCount = listData?.joinedCount ?? 0; - if (knownCount <= 0) return; + if (knownCount <= 0) { + expansionDetails[key] = { status: 'empty', knownCount: 0 }; + return; + } const existing = this.slidingSync.getListParams(key); const currentEnd = getListEndIndex(existing); @@ -468,6 +663,7 @@ export class SlidingSyncManager { if (currentEnd >= maxEnd) { // This list is fully loaded + expansionDetails[key] = { status: 'complete', knownCount, currentEnd }; return; } @@ -480,23 +676,87 @@ export class SlidingSyncManager { const chunkSize = 100; const desiredEnd = Math.min(currentEnd + chunkSize, maxEnd); + if (desiredEnd === currentEnd) { + expansionDetails[key] = { + status: 'complete', + knownCount, + currentEnd, + desiredEnd, + }; + return; + } + this.slidingSync.setListRanges(key, [[0, desiredEnd]]); expandedAny = true; + expansionDetails[key] = { + status: 'expanding', + knownCount, + previousEnd: currentEnd, + newEnd: desiredEnd, + roomsToLoad: desiredEnd - currentEnd, + }; + + debugLog.info('sync', `Expanding list "${key}" to full range`, { + list: key, + knownCount, + previousEnd: currentEnd, + newEnd: desiredEnd, + roomsToLoad: desiredEnd - currentEnd, + }); + if (knownCount > this.maxRooms) { log.warn( `Sliding Sync list "${key}" capped at ${this.maxRooms}/${knownCount} rooms for ${this.mx.getUserId()}` ); + debugLog.warn('sync', `List "${key}" exceeds maxRooms limit`, { + list: key, + knownCount, + maxRooms: this.maxRooms, + cappedCount: this.maxRooms, + }); } }); + const expansionDuration = performance.now() - expansionStartTime; + const hasExpansions = Object.values(expansionDetails).some((d) => d.status === 'expanding'); + // Mark as fully loaded once all lists are complete if (allListsComplete) { this.listsFullyLoaded = true; log.log(`Sliding Sync all lists fully loaded for ${this.mx.getUserId()}`); + const totalRooms = this.listKeys.reduce( + (sum, key) => sum + (this.slidingSync.getListData(key)?.joinedCount ?? 0), + 0 + ); + const listsLoadedMs = + this.attachTime != null ? Math.round(performance.now() - this.attachTime) : 0; + Sentry.metrics.distribution('sable.sync.lists_loaded_ms', listsLoadedMs, { + attributes: { transport: 'sliding' }, + }); + Sentry.metrics.gauge('sable.sync.total_rooms', totalRooms, { + attributes: { transport: 'sliding' }, + }); } else if (expandedAny) { log.log(`Sliding Sync lists expanding... for ${this.mx.getUserId()}`); } + + if (hasExpansions) { + debugLog.info('sync', 'List expansion completed', { + syncNumber: this.syncCount, + lists: expansionDetails, + timeElapsed: `${expansionDuration.toFixed(2)}ms`, + }); + } + + if (expansionDuration > 500) { + debugLog.warn('sync', 'Slow list expansion detected', { + duration: `${expansionDuration.toFixed(2)}ms`, + expandedLists: Object.keys(expansionDetails).filter( + (key) => expansionDetails[key].status === 'expanding' + ), + }); + } } /** @@ -530,8 +790,13 @@ export class SlidingSyncManager { } else { this.slidingSync.setList(listKey, list); } - } catch { + } catch (error) { // ignore — the list will be re-sent on the next sync cycle + debugLog.warn('sync', `Failed to update list "${listKey}"`, { + list: listKey, + error: error instanceof Error ? error.message : String(error), + updateType: updateArgs.ranges && Object.keys(updateArgs).length === 1 ? 'ranges' : 'full', + }); } return this.slidingSync.getListParams(listKey) ?? list; } @@ -561,52 +826,64 @@ export class SlidingSyncManager { let endIndex = batchSize - 1; let hasMore = true; let firstTime = true; - - const spideringRequiredState: MSC3575List['required_state'] = [ - [EventType.RoomJoinRules, ''], - [EventType.RoomAvatar, ''], - [EventType.RoomTombstone, ''], - [EventType.RoomEncryption, ''], - [EventType.RoomCreate, ''], - [EventType.RoomTopic, ''], - [EventType.RoomCanonicalAlias, ''], - [EventType.RoomMember, MSC3575_STATE_KEY_ME], - ['m.space.child', MSC3575_WILDCARD], - ['im.ponies.room_emotes', MSC3575_WILDCARD], - ]; - - while (hasMore) { - if (this.disposed) return; - const ranges: [number, number][] = [[0, endIndex]]; - try { - if (firstTime) { - // Full setList on first call to register the list with all params. - this.slidingSync.setList(LIST_SEARCH, { - ranges, - sort: ['by_recency'], - timeline_limit: 0, - required_state: spideringRequiredState, - }); - } else { - // Cheaper range-only update for subsequent pages; sticky params are preserved. - this.slidingSync.setListRanges(LIST_SEARCH, ranges); + let batchCount = 0; + + await Sentry.startSpan( + { name: 'sync.spidering', op: 'matrix.sync', attributes: { 'sync.transport': 'sliding' } }, + async (span) => { + const spideringRequiredState: MSC3575List['required_state'] = [ + [EventType.RoomJoinRules, ''], + [EventType.RoomAvatar, ''], + [EventType.RoomTombstone, ''], + [EventType.RoomEncryption, ''], + [EventType.RoomCreate, ''], + [EventType.RoomTopic, ''], + [EventType.RoomCanonicalAlias, ''], + [EventType.RoomMember, MSC3575_STATE_KEY_ME], + ['m.space.child', MSC3575_WILDCARD], + ['im.ponies.room_emotes', MSC3575_WILDCARD], + ]; + + while (hasMore) { + if (this.disposed) return; + batchCount += 1; + const ranges: [number, number][] = [[0, endIndex]]; + try { + if (firstTime) { + // Full setList on first call to register the list with all params. + this.slidingSync.setList(LIST_SEARCH, { + ranges, + sort: ['by_recency'], + timeline_limit: 0, + required_state: spideringRequiredState, + }); + } else { + // Cheaper range-only update for subsequent pages; sticky params are preserved. + this.slidingSync.setListRanges(LIST_SEARCH, ranges); + } + } catch { + // Swallow errors — the next iteration will retry with updated ranges. + } finally { + // eslint-disable-next-line no-await-in-loop + await new Promise((res) => { + setTimeout(res, gapBetweenRequestsMs); + }); + } + + if (this.disposed) return; + const listData = this.slidingSync.getListData(LIST_SEARCH); + hasMore = endIndex + 1 < (listData?.joinedCount ?? 0); + endIndex += batchSize; + firstTime = false; } - } catch { - // Swallow errors — the next iteration will retry with updated ranges. - } finally { - // eslint-disable-next-line no-await-in-loop - await new Promise((res) => { - setTimeout(res, gapBetweenRequestsMs); + const finalCount = this.slidingSync.getListData(LIST_SEARCH)?.joinedCount ?? 0; + span.setAttributes({ + 'spidering.batches': batchCount, + 'spidering.total_rooms': finalCount, }); + log.log(`Sliding Sync spidering complete for ${this.mx.getUserId()}`); } - - if (this.disposed) return; - const listData = this.slidingSync.getListData(LIST_SEARCH); - hasMore = endIndex + 1 < (listData?.joinedCount ?? 0); - endIndex += batchSize; - firstTime = false; - } - log.log(`Sliding Sync spidering complete for ${this.mx.getUserId()}`); + ); } /** @@ -663,7 +940,8 @@ export class SlidingSyncManager { public subscribeToRoom(roomId: string): void { if (this.disposed) return; const room = this.mx.getRoom(roomId); - if (room && !this.mx.isRoomEncrypted(roomId)) { + const isEncrypted = this.mx.isRoomEncrypted(roomId); + if (room && !isEncrypted) { // Only use the unencrypted (lazy-load) subscription when we are certain // the room is unencrypted. Unknown rooms fall through to the safer // encrypted default. @@ -671,7 +949,58 @@ export class SlidingSyncManager { } this.activeRoomSubscriptions.add(roomId); this.slidingSync.modifyRoomSubscriptions(new Set(this.activeRoomSubscriptions)); + Sentry.metrics.gauge('sable.sync.active_subscriptions', this.activeRoomSubscriptions.size, { + attributes: { transport: 'sliding' }, + }); log.log(`Sliding Sync active room subscription added: ${roomId}`); + debugLog.info('sync', 'Room subscription requested (sliding)', { + encrypted: isEncrypted, + unknownRoom: !room, + activeSubscriptions: this.activeRoomSubscriptions.size, + syncCycle: this.syncCount, + }); + Sentry.addBreadcrumb({ + category: 'sync.sliding', + message: 'Subscribed to room (active)', + level: 'info', + data: { encrypted: isEncrypted, activeSubscriptions: this.activeRoomSubscriptions.size }, + }); + // One-shot listener: measure latency from subscription request to first room data. + // Clean up any stale listener for the same roomId first. + const existingListener = this.pendingRoomDataListeners.get(roomId); + if (existingListener) { + this.slidingSync.removeListener(SlidingSyncEvent.RoomData, existingListener); + } + const subscribeMs = performance.now(); + const onFirstRoomData = (dataRoomId: string) => { + if (dataRoomId !== roomId) return; + const latencyMs = Math.round(performance.now() - subscribeMs); + // Measure how many events landed on the live timeline as part of this + // subscription activation — this is the "page" the timeline has to absorb. + const subscribedRoom = this.mx.getRoom(roomId); + const eventCount = subscribedRoom?.getLiveTimeline().getEvents().length ?? 0; + debugLog.info('sync', 'Room subscription: first data received (sliding)', { + latencyMs, + syncCycle: this.syncCount, + eventCount, + }); + Sentry.metrics.distribution('sable.sync.room_sub_latency_ms', latencyMs, { + attributes: { transport: 'sliding' }, + }); + Sentry.metrics.distribution('sable.sync.room_sub_event_count', eventCount, { + attributes: { transport: 'sliding' }, + }); + Sentry.addBreadcrumb({ + category: 'sync.sliding', + message: `Room subscription data arrived (${eventCount} events, ${latencyMs}ms)`, + level: 'info', + data: { latencyMs, eventCount, syncCycle: this.syncCount }, + }); + this.slidingSync.removeListener(SlidingSyncEvent.RoomData, onFirstRoomData); + this.pendingRoomDataListeners.delete(roomId); + }; + this.pendingRoomDataListeners.set(roomId, onFirstRoomData); + this.slidingSync.on(SlidingSyncEvent.RoomData, onFirstRoomData); } /** @@ -681,9 +1010,22 @@ export class SlidingSyncManager { */ public unsubscribeFromRoom(roomId: string): void { if (this.disposed) return; + // Clean up any pending first-data latency listener for this room. + const pendingListener = this.pendingRoomDataListeners.get(roomId); + if (pendingListener) { + this.slidingSync.removeListener(SlidingSyncEvent.RoomData, pendingListener); + this.pendingRoomDataListeners.delete(roomId); + } this.activeRoomSubscriptions.delete(roomId); this.slidingSync.modifyRoomSubscriptions(new Set(this.activeRoomSubscriptions)); + Sentry.metrics.gauge('sable.sync.active_subscriptions', this.activeRoomSubscriptions.size, { + attributes: { transport: 'sliding' }, + }); log.log(`Sliding Sync active room subscription removed: ${roomId}`); + debugLog.info('sync', 'Room subscription removed (sliding)', { + remainingSubscriptions: this.activeRoomSubscriptions.size, + syncCycle: this.syncCount, + }); } public static async probe( @@ -691,25 +1033,33 @@ export class SlidingSyncManager { proxyBaseUrl: string, probeTimeoutMs: number ): Promise { - try { - const response = await mx.slidingSync( - { - lists: { - probe: { - ranges: [[0, 0]], - timeline_limit: 1, - required_state: [], + return Sentry.startSpan( + { name: 'sync.probe', op: 'matrix.sync', attributes: { 'sync.proxy': proxyBaseUrl } }, + async (span) => { + try { + const response = await mx.slidingSync( + { + lists: { + probe: { + ranges: [[0, 0]], + timeline_limit: 1, + required_state: [], + }, + }, + timeout: 0, + clientTimeout: probeTimeoutMs, }, - }, - timeout: 0, - clientTimeout: probeTimeoutMs, - }, - proxyBaseUrl - ); - - return typeof response.pos === 'string' && response.pos.length > 0; - } catch { - return false; - } + proxyBaseUrl + ); + + const supported = typeof response.pos === 'string' && response.pos.length > 0; + span.setAttribute('probe.supported', supported); + return supported; + } catch { + span.setAttribute('probe.supported', false); + return false; + } + } + ); } } diff --git a/src/index.css b/src/index.css index 453dc7f00..1c32c9170 100755 --- a/src/index.css +++ b/src/index.css @@ -187,6 +187,7 @@ pre { body.reduced-motion * { animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; } diff --git a/src/index.tsx b/src/index.tsx index 76ebaf91b..1cd63692c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,3 +1,4 @@ +import './instrument'; import { createRoot } from 'react-dom/client'; import { enableMapSet } from 'immer'; import '@fontsource-variable/nunito'; @@ -80,7 +81,7 @@ if ('serviceWorker' in navigator) { const activeId = getLocalStorageItem(ACTIVE_SESSION_KEY, undefined); const active = sessions.find((s) => s.userId === activeId) ?? sessions[0] ?? getFallbackSession(); - pushSessionToSW(active?.baseUrl, active?.accessToken); + pushSessionToSW(active?.baseUrl, active?.accessToken, active?.userId); }; navigator.serviceWorker @@ -139,6 +140,41 @@ const injectIOSMetaTags = () => { injectIOSMetaTags(); +// Handle chunk loading failures with automatic retry +const CHUNK_RETRY_KEY = 'cinny_chunk_retry_count'; +const MAX_CHUNK_RETRIES = 2; + +window.addEventListener('error', (event) => { + // Check if this is a chunk loading error + const isChunkLoadError = + event.message?.includes('dynamically imported module') || + event.message?.includes('Failed to fetch') || + event.error?.name === 'ChunkLoadError'; + + if (isChunkLoadError) { + const retryCount = parseInt(sessionStorage.getItem(CHUNK_RETRY_KEY) ?? '0', 10); + + if (retryCount < MAX_CHUNK_RETRIES) { + // Increment retry count and reload + sessionStorage.setItem(CHUNK_RETRY_KEY, String(retryCount + 1)); + log.warn(`Chunk load failed, reloading (attempt ${retryCount + 1}/${MAX_CHUNK_RETRIES})`); + window.location.reload(); + + // Prevent default error handling since we're reloading + event.preventDefault(); + } else { + // Max retries exceeded, clear counter and let error bubble up + sessionStorage.removeItem(CHUNK_RETRY_KEY); + log.error('Chunk load failed after max retries, showing error'); + } + } +}); + +// Clear chunk retry counter on successful page load +window.addEventListener('load', () => { + sessionStorage.removeItem(CHUNK_RETRY_KEY); +}); + const mountApp = () => { const rootContainer = document.getElementById('root'); diff --git a/src/instrument.ts b/src/instrument.ts new file mode 100644 index 000000000..3c6346b1d --- /dev/null +++ b/src/instrument.ts @@ -0,0 +1,309 @@ +/** + * Sentry instrumentation - MUST be imported first in the application lifecycle + * + * Configure via environment variables: + * - VITE_SENTRY_DSN: Your Sentry DSN (required to enable Sentry) + * - VITE_SENTRY_ENVIRONMENT: Environment name (defaults to MODE) + * - VITE_APP_VERSION: Release version for tracking + */ +import * as Sentry from '@sentry/react'; +import React from 'react'; +import { + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, +} from 'react-router-dom'; +import { scrubMatrixIds, scrubDataObject, scrubMatrixUrl } from './app/utils/sentryScrubbers'; + +const dsn = import.meta.env.VITE_SENTRY_DSN; +const environment = import.meta.env.VITE_SENTRY_ENVIRONMENT || import.meta.env.MODE; +const release = import.meta.env.VITE_APP_VERSION; + +// Per-session error event counter for rate limiting +let sessionErrorCount = 0; +const SESSION_ERROR_LIMIT = 50; + +// Default off: Sentry only runs when the user has opted in via the banner or Settings. +const sentryEnabled = localStorage.getItem('sable_sentry_enabled') === 'true'; +const replayEnabled = localStorage.getItem('sable_sentry_replay_enabled') === 'true'; + +// Only initialize if DSN is provided and user hasn't opted out +if (dsn && sentryEnabled) { + Sentry.init({ + dsn, + environment, + release, + + // Do not send PII (IP addresses, user identifiers) to protect privacy + sendDefaultPii: false, + + integrations: [ + // React Router v6 browser tracing integration + Sentry.reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + // Session replay with privacy settings (only if user opted in) + ...(replayEnabled + ? [ + Sentry.replayIntegration({ + maskAllText: true, // Mask all text for privacy + blockAllMedia: true, // Block images/video/audio for privacy + maskAllInputs: true, // Mask form inputs + }), + ] + : []), + // Capture console.error/warn as structured logs in the Sentry Logs product + Sentry.consoleLoggingIntegration({ levels: ['error', 'warn'] }), + // Browser profiling — captures JS call stacks during Sentry transactions + Sentry.browserProfilingIntegration(), + ], + + // Performance Monitoring - Tracing + // 100% in development and preview, lower in production for cost control + tracesSampleRate: environment === 'development' || environment === 'preview' ? 1.0 : 0.1, + + // Browser profiling — profiles every sampled session (requires Document-Policy: js-profiling response header) + profileSessionSampleRate: + environment === 'development' || environment === 'preview' ? 1.0 : 0.1, + + // Control which URLs get distributed tracing headers + tracePropagationTargets: [ + 'localhost', + /^https:\/\/[^/]*\.sable\.chat/, + // Add your Matrix homeserver domains here if needed + ], + + // Session Replay sampling + // Record 100% in development and preview for testing, 10% in production + // Always record 100% of sessions with errors + replaysSessionSampleRate: + environment === 'development' || environment === 'preview' ? 1.0 : 0.1, + replaysOnErrorSampleRate: 1.0, + + // Enable structured logging to Sentry + enableLogs: true, + + // Scrub sensitive data from structured logs before sending to Sentry + beforeSendLog(log) { + // Drop debug-level logs in production to reduce noise and quota usage + if (log.level === 'debug' && environment === 'production') return null; + // Redact Matrix IDs and tokens from the log message string + if (typeof log.message === 'string') { + // eslint-disable-next-line no-param-reassign + log.message = scrubMatrixIds(log.message); + } + // Redact Matrix IDs from any string-valued log attributes (e.g. roomId, userId) + // These are flattened from the structured data object and sent as searchable attributes. + if (log.attributes && typeof log.attributes === 'object') { + // eslint-disable-next-line no-param-reassign + log.attributes = scrubDataObject(log.attributes) as typeof log.attributes; + } + return log; + }, + + // Rate limiting: cap error events per page-load session to avoid quota exhaustion. + // Separate counters for errors and transactions so perf traces do not drain the error budget. + beforeSendTransaction(event) { + // Scrub Matrix identifiers from the transaction name (the matched route or page URL). + // React Router normally parameterises routes (e.g. /home/:roomIdOrAlias/) but falls + // back to the raw URL when matching fails, so we scrub defensively here. + if (event.transaction) { + // eslint-disable-next-line no-param-reassign + event.transaction = scrubMatrixUrl(event.transaction); + } + + // Scrub Matrix identifiers from HTTP span descriptions and data URLs. + // We scrub ALL string values in span.data rather than a single known key because + // Sentry / OTel HTTP instrumentation has used multiple attribute names across versions: + // http.url (OTel semconv < 1.23, Sentry classic) + // url.full (OTel semconv ≥ 1.23) + // http.target, server.address, url, etc. + // For each string value: apply URL scrubbing when the value starts with "http", + // then apply ID scrubbing to catch any remaining bare Matrix IDs. + if (event.spans) { + // eslint-disable-next-line no-param-reassign + event.spans = event.spans.map((span) => { + const newDesc = span.description ? scrubMatrixUrl(span.description) : span.description; + const spanData = span.data as Record | undefined; + const newData = spanData + ? Object.fromEntries( + Object.entries(spanData).map(([k, v]) => [ + k, + typeof v === 'string' + ? scrubMatrixIds(v.startsWith('http') ? scrubMatrixUrl(v) : v) + : v, + ]) + ) + : undefined; + + const descChanged = newDesc !== span.description; + const dataChanged = + newData !== undefined && JSON.stringify(newData) !== JSON.stringify(spanData); + + if (!descChanged && !dataChanged) return span; + return { + ...span, + ...(descChanged ? { description: newDesc } : {}), + ...(dataChanged ? { data: newData as typeof span.data } : {}), + }; + }); + } + return event; + }, + + // Sanitize sensitive data from all breadcrumb messages and HTTP data URLs before sending to Sentry + beforeBreadcrumb(breadcrumb) { + // Scrub Matrix paths from HTTP breadcrumb data.url (captures full request URLs) + const bData = breadcrumb.data as Record | undefined; + const rawUrl = typeof bData?.url === 'string' ? bData.url : undefined; + const scrubbedUrl = rawUrl ? scrubMatrixUrl(rawUrl) : undefined; + const urlChanged = scrubbedUrl !== undefined && scrubbedUrl !== rawUrl; + + // Scrub Matrix paths from navigation breadcrumb data.from / data.to (page URLs that + // may contain room IDs or user IDs as path segments in the app's client-side routes) + const rawFrom = typeof bData?.from === 'string' ? bData.from : undefined; + const rawTo = typeof bData?.to === 'string' ? bData.to : undefined; + const scrubbedFrom = rawFrom ? scrubMatrixUrl(rawFrom) : undefined; + const scrubbedTo = rawTo ? scrubMatrixUrl(rawTo) : undefined; + const fromChanged = scrubbedFrom !== undefined && scrubbedFrom !== rawFrom; + const toChanged = scrubbedTo !== undefined && scrubbedTo !== rawTo; + + // Scrub Matrix IDs from all remaining string values in the breadcrumb data object. + // debugLog passes structured data (e.g. { roomId, targetEventId }) that would otherwise + // bypass the URL-specific scrubbers above. + const scrubbedData = bData ? (scrubDataObject(bData) as Record) : undefined; + + // Scrub message text — token values and Matrix entity IDs + const message = breadcrumb.message ? scrubMatrixIds(breadcrumb.message) : breadcrumb.message; + const messageChanged = message !== breadcrumb.message; + + if (!messageChanged && !scrubbedData) return breadcrumb; + return { + ...breadcrumb, + ...(messageChanged ? { message } : {}), + ...(scrubbedData + ? { + data: { + ...scrubbedData, + ...(urlChanged ? { url: scrubbedUrl } : {}), + ...(fromChanged ? { from: scrubbedFrom } : {}), + ...(toChanged ? { to: scrubbedTo } : {}), + }, + } + : {}), + }; + }, + + beforeSend(event, hint) { + sessionErrorCount += 1; + if (sessionErrorCount > SESSION_ERROR_LIMIT) { + return null; // Drop event — session limit reached + } + + // Improve grouping for Matrix API errors. + // MatrixError objects carry an `errcode` (e.g. M_FORBIDDEN, M_NOT_FOUND) — use it to + // split errors into meaningful issue groups rather than merging them all by stack trace. + const originalException = hint?.originalException; + if ( + originalException !== null && + typeof originalException === 'object' && + 'errcode' in originalException && + typeof (originalException as Record).errcode === 'string' + ) { + const errcode = (originalException as Record).errcode as string; + // Preserve default grouping AND split by errcode + // eslint-disable-next-line no-param-reassign + event.fingerprint = ['{{ default }}', errcode]; + } + + // Scrub sensitive data from error messages and exception values using shared helpers + if (event.message) { + // eslint-disable-next-line no-param-reassign + event.message = scrubMatrixIds(event.message); + } + + // Scrub sensitive data from exception values + if (event.exception?.values) { + event.exception.values.forEach((exception) => { + if (exception.value) { + // eslint-disable-next-line no-param-reassign + exception.value = scrubMatrixUrl(scrubMatrixIds(exception.value)); + } + }); + } + + // Scrub contexts (e.g. debugLog context from captureMessage in debugLogger.ts, + // which can carry structured data fields like roomId, targetEventId, etc.) + if (event.contexts) { + // eslint-disable-next-line no-param-reassign + event.contexts = scrubDataObject(event.contexts) as typeof event.contexts; + } + + // Scrub request data + if (event.request?.url) { + // eslint-disable-next-line no-param-reassign + event.request.url = scrubMatrixUrl( + event.request.url.replace( + /(access_token|password|token)([=:]\s*)([^\s&]+)/gi, + '$1$2[REDACTED]' + ) + ); + } + + // Scrub the transaction name on error events (set when the error occurred during a + // page-load or navigation transaction — raw URL leaks here when route matching fails) + if (event.transaction) { + // eslint-disable-next-line no-param-reassign + event.transaction = scrubMatrixUrl(event.transaction); + } + + if (event.request?.headers) { + const headers = event.request.headers as Record; + if (headers.Authorization) { + headers.Authorization = '[REDACTED]'; + } + } + + return event; + }, + }); + + // Expose Sentry globally for debugging and console testing + // Set app-wide attributes on the global scope so they appear on all events and logs + Sentry.getGlobalScope().setAttributes({ + 'app.name': 'sable', + 'app.version': release ?? 'unknown', + }); + + // Tag all events with the PR number when running in a PR preview deployment + const prNumber = import.meta.env.VITE_SENTRY_PR; + if (prNumber) { + Sentry.getGlobalScope().setTag('pr', prNumber); + } + + // @ts-expect-error - Adding to window for debugging + window.Sentry = Sentry; + + // eslint-disable-next-line no-console + console.info( + `[Sentry] Initialized for ${environment} environment${replayEnabled ? ' with Session Replay' : ''}` + ); + // eslint-disable-next-line no-console + console.info(`[Sentry] DSN configured: ${dsn?.substring(0, 30)}...`); + // eslint-disable-next-line no-console + console.info(`[Sentry] Release: ${release || 'not set'}`); +} else if (!sentryEnabled) { + // eslint-disable-next-line no-console + console.info('[Sentry] Disabled by user preference'); +} else { + // eslint-disable-next-line no-console + console.info('[Sentry] Disabled - no DSN provided'); +} + +// Export Sentry for use in other parts of the application +export { Sentry }; diff --git a/src/sw-session.ts b/src/sw-session.ts index 4b2ec055d..e4d2672da 100644 --- a/src/sw-session.ts +++ b/src/sw-session.ts @@ -1,4 +1,4 @@ -export function pushSessionToSW(baseUrl?: string, accessToken?: string) { +export function pushSessionToSW(baseUrl?: string, accessToken?: string, userId?: string) { if (!('serviceWorker' in navigator)) return; if (!navigator.serviceWorker.controller) return; @@ -6,5 +6,6 @@ export function pushSessionToSW(baseUrl?: string, accessToken?: string) { type: 'setSession', accessToken, baseUrl, + userId, }); } diff --git a/src/sw.ts b/src/sw.ts index 9f3314889..064b293fc 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -24,6 +24,10 @@ const { handlePushNotificationPushData } = createPushNotifications(self, () => ( const SW_SETTINGS_CACHE = 'sable-sw-settings-v1'; const SW_SETTINGS_URL = '/sw-settings-meta'; +/** Cache key used to persist the active session so push-event fetches work after SW restart. */ +const SW_SESSION_CACHE = 'sable-sw-session-v1'; +const SW_SESSION_URL = '/sw-session-meta'; + async function persistSettings() { try { const cache = await self.caches.open(SW_SETTINGS_CACHE); @@ -62,9 +66,51 @@ async function loadPersistedSettings() { } } +async function persistSession(session: SessionInfo): Promise { + try { + const cache = await self.caches.open(SW_SESSION_CACHE); + await cache.put( + SW_SESSION_URL, + new Response(JSON.stringify(session), { headers: { 'Content-Type': 'application/json' } }) + ); + } catch { + // Ignore — caches may be unavailable in some environments. + } +} + +async function clearPersistedSession(): Promise { + try { + const cache = await self.caches.open(SW_SESSION_CACHE); + await cache.delete(SW_SESSION_URL); + } catch { + // Ignore. + } +} + +async function loadPersistedSession(): Promise { + try { + const cache = await self.caches.open(SW_SESSION_CACHE); + const response = await cache.match(SW_SESSION_URL); + if (!response) return undefined; + const s = await response.json(); + if (typeof s.accessToken === 'string' && typeof s.baseUrl === 'string') { + return { + accessToken: s.accessToken, + baseUrl: s.baseUrl, + userId: typeof s.userId === 'string' ? s.userId : undefined, + }; + } + return undefined; + } catch { + return undefined; + } +} + type SessionInfo = { accessToken: string; baseUrl: string; + /** Matrix user ID of the account, used to identify which account a push belongs to. */ + userId?: string; }; /** @@ -88,12 +134,22 @@ async function cleanupDeadClients() { }); } -function setSession(clientId: string, accessToken: unknown, baseUrl: unknown) { +function setSession(clientId: string, accessToken: unknown, baseUrl: unknown, userId?: unknown) { if (typeof accessToken === 'string' && typeof baseUrl === 'string') { - sessions.set(clientId, { accessToken, baseUrl }); + const info: SessionInfo = { + accessToken, + baseUrl, + userId: typeof userId === 'string' ? userId : undefined, + }; + sessions.set(clientId, info); + console.debug('[SW] setSession: stored', clientId, baseUrl); + // Persist so push-event fetches work after iOS restarts the SW. + persistSession(info).catch(() => undefined); } else { // Logout or invalid session sessions.delete(clientId); + console.debug('[SW] setSession: removed', clientId); + clearPersistedSession().catch(() => undefined); } const resolveSession = clientToResolve.get(clientId); @@ -124,17 +180,277 @@ async function requestSessionWithTimeout( timeoutMs = 3000 ): Promise { const client = await self.clients.get(clientId); - if (!client) return undefined; + if (!client) { + console.warn('[SW] requestSessionWithTimeout: client not found', clientId); + return undefined; + } const sessionPromise = requestSession(client); const timeout = new Promise((resolve) => { - setTimeout(() => resolve(undefined), timeoutMs); + setTimeout(() => { + console.warn('[SW] requestSessionWithTimeout: timed out after', timeoutMs, 'ms', clientId); + resolve(undefined); + }, timeoutMs); }); return Promise.race([sessionPromise, timeout]); } +// --------------------------------------------------------------------------- +// Encrypted push — decryption relay +// --------------------------------------------------------------------------- + +/** + * The shape returned by the client tab after decrypting an encrypted push event. + * Also used as a partial pushData object for handlePushNotificationPushData. + */ +type DecryptionResult = { + eventId: string; + success: boolean; + eventType?: string; + content?: unknown; + sender_display_name?: string; + room_name?: string; + /** document.visibilityState reported by the responding app tab. */ + visibilityState?: string; +}; + +/** Pending decryption requests keyed by event_id. */ +const decryptionPendingMap = new Map void>(); + +/** + * Fetch a single raw Matrix event from the homeserver. + * Returns undefined on error (e.g. network failure, auth error, redacted event). + */ +async function fetchRawEvent( + baseUrl: string, + accessToken: string, + roomId: string, + eventId: string +): Promise | undefined> { + try { + const url = `${baseUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/event/${encodeURIComponent(eventId)}`; + const res = await fetch(url, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!res.ok) { + console.warn('[SW fetchRawEvent] HTTP', res.status, 'for', eventId); + return undefined; + } + return (await res.json()) as Record; + } catch (err) { + console.warn('[SW fetchRawEvent] error', err); + return undefined; + } +} + +/** + * Fetch the m.room.name state event from the homeserver. + * Returns undefined when not set (DMs and many encrypted rooms have no explicit name). + */ +async function fetchRoomName( + baseUrl: string, + accessToken: string, + roomId: string +): Promise { + try { + const url = `${baseUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.name`; + const res = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` } }); + if (!res.ok) return undefined; + const data = (await res.json()) as Record; + const { name } = data; + return typeof name === 'string' && name.trim() ? name.trim() : undefined; + } catch { + return undefined; + } +} + +/** + * Fetch a room member's displayname from homeserver member state. + * Returns undefined if the member has no displayname or the request fails. + */ +async function fetchMemberDisplayName( + baseUrl: string, + accessToken: string, + roomId: string, + userId: string +): Promise { + try { + const url = `${baseUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.member/${encodeURIComponent(userId)}`; + const res = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` } }); + if (!res.ok) return undefined; + const data = (await res.json()) as Record; + const name = data.displayname; + return typeof name === 'string' && name.trim() ? name.trim() : undefined; + } catch { + return undefined; + } +} + +/** + * Return the first any-session we have stored (used for push fetches where we + * don't have a client ID, e.g. when the app is backgrounded but still loaded). + */ +function getAnyStoredSession(): SessionInfo | undefined { + return sessions.values().next().value; +} + +/** + * Extract the MXID localpart (@user:server → user) for fallback display names. + */ +function mxidLocalpart(userId: string): string { + return userId.match(/^@([^:]+):/)?.[1] ?? userId; +} + +/** + * Post a decryptPushEvent request to one of the open window clients and wait + * up to 5 s for the pushDecryptResult reply. + */ +async function requestDecryptionFromClient( + windowClients: readonly Client[], + rawEvent: Record +): Promise { + const eventId = rawEvent.event_id as string; + + // Chain clients sequentially using reduce to avoid await-in-loop and for-of. + return Array.from(windowClients).reduce( + async (prevPromise, client) => { + const prev = await prevPromise; + if (prev?.success) return prev; + + const promise = new Promise((resolve) => { + decryptionPendingMap.set(eventId, resolve); + }); + + const timeout = new Promise((resolve) => { + setTimeout(() => { + decryptionPendingMap.delete(eventId); + console.warn('[SW decryptRelay] timed out waiting for client', client.id); + resolve(undefined); + }, 5000); + }); + + try { + (client as WindowClient).postMessage({ type: 'decryptPushEvent', rawEvent }); + } catch (err) { + decryptionPendingMap.delete(eventId); + console.warn('[SW decryptRelay] postMessage error', err); + return undefined; + } + + return Promise.race([promise, timeout]); + }, + Promise.resolve(undefined) as Promise + ); +} + +/** + * Handle a minimal push payload (event_id_only format). + * Fetches the event from the homeserver and shows a notification. + * For encrypted events, attempts to relay decryption to an open app tab. + */ +async function handleMinimalPushPayload( + roomId: string, + eventId: string, + windowClients: readonly Client[] +): Promise { + // On iOS the SW is killed and restarted for every push, clearing the in-memory sessions + // Map. Fall back to the Cache Storage copy that was written when the user last opened + // the app (same pattern as settings persistence). + const session = getAnyStoredSession() ?? (await loadPersistedSession()); + + if (!session) { + // No session anywhere — app was never opened since install, or the user logged out. + // Show a minimal actionable notification so the user can tap through to the room. + console.debug('[SW push] minimal payload: no session, showing generic notification'); + await self.registration.showNotification('New Message', { + body: undefined, + icon: '/public/res/apple/apple-touch-icon-180x180.png', + badge: '/public/res/apple/apple-touch-icon-72x72.png', + tag: `room-${roomId}`, + renotify: true, + data: { room_id: roomId, event_id: eventId }, + } as NotificationOptions); + return; + } + + // Fetch the raw event and room name state in parallel — both need only roomId. + const [rawEvent, roomNameFromState] = await Promise.all([ + fetchRawEvent(session.baseUrl, session.accessToken, roomId, eventId), + fetchRoomName(session.baseUrl, session.accessToken, roomId), + ]); + + if (!rawEvent) { + await self.registration.showNotification('New Message', { + body: undefined, + icon: '/public/res/apple/apple-touch-icon-180x180.png', + badge: '/public/res/apple/apple-touch-icon-72x72.png', + tag: `room-${roomId}`, + renotify: true, + data: { room_id: roomId, event_id: eventId, user_id: session.userId }, + } as NotificationOptions); + return; + } + + const eventType = rawEvent.type as string | undefined; + const sender = rawEvent.sender as string | undefined; + // Fetch sender's display name from room member state; fall back to MXID localpart. + const senderDisplay = + (sender + ? await fetchMemberDisplayName(session.baseUrl, session.accessToken, roomId, sender) + : undefined) ?? (sender ? mxidLocalpart(sender) : 'Someone'); + // For DMs (no m.room.name state), use the sender's display name as the room name. + const resolvedRoomName = roomNameFromState ?? senderDisplay; + const baseData = { + room_id: roomId, + event_id: eventId, + user_id: session.userId, + }; + + if (eventType === 'm.room.encrypted') { + // Try to relay decryption to an open app tab. + const result = + windowClients.length > 0 + ? await requestDecryptionFromClient(windowClients, rawEvent) + : undefined; + + // If the relay responded and the app is currently visible, the in-app UI is already + // displaying the message — skip the OS notification entirely. + if (result?.visibilityState === 'visible') return; + + if (result?.success) { + // App was backgrounded but not frozen — decryption succeeded. + await handlePushNotificationPushData({ + ...baseData, + type: result.eventType, + content: result.content, + sender_display_name: result.sender_display_name ?? senderDisplay, + // Prefer relay's room name (has m.direct / computed SDK name); fall back to state fetch. + room_name: result.room_name || resolvedRoomName, + }); + } else { + // App is frozen or fully closed — show "Encrypted message" fallback. + await handlePushNotificationPushData({ + ...baseData, + type: 'm.room.encrypted', + content: {}, + sender_display_name: senderDisplay, + room_name: resolvedRoomName, + }); + } + } else { + // Unencrypted event — we have the plaintext, show it. + await handlePushNotificationPushData({ + ...baseData, + type: eventType, + content: rawEvent.content, + sender_display_name: senderDisplay, + room_name: resolvedRoomName, + }); + } +} + self.addEventListener('install', (event: ExtendableEvent) => { event.waitUntil(self.skipWaiting()); }); @@ -157,12 +473,23 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { const { data } = event; if (!data || typeof data !== 'object') return; - const { type, accessToken, baseUrl } = data as Record; + const { type, accessToken, baseUrl, userId } = data as Record; if (type === 'setSession') { - setSession(client.id, accessToken, baseUrl); + setSession(client.id, accessToken, baseUrl, userId); event.waitUntil(cleanupDeadClients()); } + if (type === 'pushDecryptResult') { + // Resolve a pending decryption request from handleMinimalPushPayload + const { eventId } = data as { eventId?: string }; + if (typeof eventId === 'string') { + const resolve = decryptionPendingMap.get(eventId); + if (resolve) { + decryptionPendingMap.delete(eventId); + resolve(data as DecryptionResult); + } + } + } if (type === 'setAppVisible') { if (typeof (data as { visible?: unknown }).visible === 'boolean') { appIsVisible = (data as { visible: boolean }).visible; @@ -269,24 +596,52 @@ self.addEventListener('fetch', (event: FetchEvent) => { return; } + // Since widgets like element call have their own client ids, + // we need this logic. We just go through the sessions list and get a session + // with the right base url. Media requests to a homeserver simply are fine with any account + // on the homeserver authenticating it, so this is fine. But it can be technically wrong. + // If you have two tabs for different users on the same homeserver, it might authenticate + // as the wrong one. + // Thus any logic in the future which cares about which user is authenticating the request + // might break this. Also, again, it is technically wrong. + const byBaseUrl = [...sessions.values()].find((s) => validMediaRequest(url, s.baseUrl)); + if (byBaseUrl) { + event.respondWith(fetch(url, { ...fetchConfig(byBaseUrl.accessToken), redirect })); + return; + } + event.respondWith( requestSessionWithTimeout(clientId).then((s) => { if (s && validMediaRequest(url, s.baseUrl)) { return fetch(url, { ...fetchConfig(s.accessToken), redirect }); } + console.warn( + '[SW fetch] No valid session for media request', + { url, clientId, hasSession: !!s }, + 'falling back to unauthenticated fetch' + ); return fetch(event.request); }) ); }); +// Detect a minimal (event_id_only) payload: has room_id + event_id but no +// event type field — meaning the homeserver stripped the event content. +function isMinimalPushPayload(data: unknown): data is { room_id: string; event_id: string } { + if (!data || typeof data !== 'object') return false; + const d = data as Record; + return typeof d.room_id === 'string' && typeof d.event_id === 'string' && !d.type; +} + const onPushNotification = async (event: PushEvent) => { if (!event?.data) return; // The SW may have been restarted by the OS (iOS is aggressive about this), // so in-memory settings would be at their defaults. Reload from cache and // match active clients in parallel — they are independent operations. - const [, clients] = await Promise.all([ + const [, , clients] = await Promise.all([ loadPersistedSettings(), + loadPersistedSession(), self.clients.matchAll({ type: 'window', includeUncontrolled: true }), ]); @@ -333,9 +688,21 @@ const onPushNotification = async (event: PushEvent) => { // Badging API absent (Firefox/Gecko) — continue to show the notification. } + // event_id_only format: fetch the event ourselves and (for E2EE rooms) try + // to relay decryption to an open app tab. + if (isMinimalPushPayload(pushData)) { + console.debug('[SW push] minimal payload detected — fetching event', pushData.event_id); + await handleMinimalPushPayload(pushData.room_id, pushData.event_id, clients); + return; + } + await handlePushNotificationPushData(pushData); }; +// --------------------------------------------------------------------------- +// Push handler +// --------------------------------------------------------------------------- + self.addEventListener('push', (event: PushEvent) => event.waitUntil(onPushNotification(event))); self.addEventListener('notificationclick', (event: NotificationEvent) => { diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 000000000..7b0828bfa --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/src/types/matrix-sdk.ts b/src/types/matrix-sdk.ts index 71621d885..06a47368b 100644 --- a/src/types/matrix-sdk.ts +++ b/src/types/matrix-sdk.ts @@ -51,3 +51,6 @@ export * from 'matrix-js-sdk/lib/common-crypto/CryptoBackend'; export * from 'matrix-js-sdk/lib/matrixrtc/CallMembership'; export * from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession'; + +export { ThreadEvent } from 'matrix-js-sdk/lib/models/thread'; +export type { Thread } from 'matrix-js-sdk/lib/models/thread'; diff --git a/tsconfig.json b/tsconfig.json index 839ef9d31..a18b41f91 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -32,5 +32,5 @@ "lib": ["ES2022", "DOM"] }, "exclude": ["node_modules", "dist"], - "include": ["src", "vite.config.ts"] + "include": ["src", "vite.config.ts", "vitest.config.ts"] } diff --git a/vite.config.ts b/vite.config.ts index 4f38d92ac..a66f458a8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -16,6 +16,7 @@ import fs from 'fs'; import path from 'path'; import { cloudflare } from '@cloudflare/vite-plugin'; import { createRequire } from 'module'; +import { sentryVitePlugin } from '@sentry/vite-plugin'; import buildConfig from './build.config'; const packageJson = JSON.parse( @@ -54,7 +55,7 @@ const isReleaseTag = (() => { if (envVal !== undefined && envVal !== '') return envVal === 'true'; try { const tag = execSync('git describe --exact-match --tags HEAD 2>/dev/null').toString().trim(); - return tag.startsWith('sable/v'); + return tag.startsWith('v'); } catch { return false; } @@ -63,7 +64,7 @@ const isReleaseTag = (() => { const copyFiles = { targets: [ { - src: 'node_modules/@element-hq/element-call-embedded/dist/*', + src: 'node_modules/@sableclient/sable-call-embedded/dist/*', dest: 'public/element-call', }, { @@ -189,6 +190,26 @@ export default defineConfig({ ], include: /\.(html|xml|css|json|js|mjs|svg|yaml|yml|toml|wasm|txt|map)$/, }), + // Sentry source map upload — only active when credentials are provided at build time + ...(process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && process.env.SENTRY_PROJECT + ? [ + sentryVitePlugin({ + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + authToken: process.env.SENTRY_AUTH_TOKEN, + sourcemaps: { + filesToDeleteAfterUpload: ['dist/**/*.map'], + }, + release: { + name: appVersion, + }, + // Annotate React components with data-sentry-* attributes at build + // time so Sentry can show component names in breadcrumbs, spans, + // and replay search instead of raw CSS selectors. + reactComponentAnnotation: { enabled: true }, + }), + ] + : []), ], optimizeDeps: { // Rebuild dep optimizer cache on each dev start to avoid stale API shapes. diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 000000000..fedea1151 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,51 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin'; +import path from 'path'; + +// Standalone Vitest config — intentionally excludes Cloudflare, PWA, compression, +// and other production-only Vite plugins that don't apply to unit tests. +export default defineConfig({ + plugins: [react(), vanillaExtractPlugin()], + resolve: { + alias: { + $hooks: path.resolve(__dirname, 'src/app/hooks'), + $plugins: path.resolve(__dirname, 'src/app/plugins'), + $components: path.resolve(__dirname, 'src/app/components'), + $features: path.resolve(__dirname, 'src/app/features'), + $state: path.resolve(__dirname, 'src/app/state'), + $styles: path.resolve(__dirname, 'src/app/styles'), + $utils: path.resolve(__dirname, 'src/app/utils'), + $pages: path.resolve(__dirname, 'src/app/pages'), + $types: path.resolve(__dirname, 'src/types'), + $public: path.resolve(__dirname, 'public'), + $client: path.resolve(__dirname, 'src/client'), + }, + }, + define: { + APP_VERSION: JSON.stringify('test'), + BUILD_HASH: JSON.stringify(''), + IS_RELEASE_TAG: JSON.stringify(false), + }, + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test/setup.ts'], + include: ['src/**/*.{test,spec}.{ts,tsx}'], + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'lcov'], + include: ['src/**/*.{ts,tsx}'], + exclude: [ + 'src/**/*.d.ts', + 'src/index.tsx', + 'src/sw.ts', + 'src/sw-session.ts', + 'src/instrument.ts', + 'src/test/**', + 'src/**/*.test.{ts,tsx}', + 'src/**/*.spec.{ts,tsx}', + ], + }, + }, +}); From 1ef961e08485bde0730fe185816156eb2213388b Mon Sep 17 00:00:00 2001 From: 7w1 Date: Tue, 17 Mar 2026 23:27:33 -0500 Subject: [PATCH 13/16] begin pulling apart the giant timeline file smh --- src/app/utils/timeline.ts | 146 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 src/app/utils/timeline.ts diff --git a/src/app/utils/timeline.ts b/src/app/utils/timeline.ts new file mode 100644 index 000000000..27a8bba9c --- /dev/null +++ b/src/app/utils/timeline.ts @@ -0,0 +1,146 @@ +import { Direction, EventTimeline, MatrixEvent, Room } from '$types/matrix-sdk'; +import { roomHaveNotification, roomHaveUnread, reactionOrEditEvent } from '$utils/room'; + +export const PAGINATION_LIMIT = 60; + +export const getLiveTimeline = (room: Room): EventTimeline => + room.getUnfilteredTimelineSet().getLiveTimeline(); + +export const getEventTimeline = (room: Room, eventId: string): EventTimeline | undefined => { + const timelineSet = room.getUnfilteredTimelineSet(); + return timelineSet.getTimelineForEvent(eventId) ?? undefined; +}; + +export const getFirstLinkedTimeline = ( + timeline: EventTimeline, + direction: Direction +): EventTimeline => { + const linkedTm = timeline.getNeighbouringTimeline(direction); + if (!linkedTm) return timeline; + return getFirstLinkedTimeline(linkedTm, direction); +}; + +export const getLinkedTimelines = (timeline: EventTimeline): EventTimeline[] => { + const firstTimeline = getFirstLinkedTimeline(timeline, Direction.Backward); + const timelines: EventTimeline[] = []; + + for ( + let nextTimeline: EventTimeline | null = firstTimeline; + nextTimeline; + nextTimeline = nextTimeline.getNeighbouringTimeline(Direction.Forward) + ) { + timelines.push(nextTimeline); + } + return timelines; +}; + +export const timelineToEventsCount = (t: EventTimeline) => { + if (!t) return 0; + const events = t.getEvents(); + return events ? events.length : 0; +}; + +export const getTimelinesEventsCount = (timelines: EventTimeline[]): number => { + const timelineEventCountReducer = (count: number, tm: EventTimeline) => + count + timelineToEventsCount(tm); + return (timelines || []) + .filter(Boolean) + .reduce((accumulator, element) => timelineEventCountReducer(accumulator, element), 0); +}; + +export const getTimelineAndBaseIndex = ( + timelines: EventTimeline[], + index: number +): [EventTimeline | undefined, number] => { + let uptoTimelineLen = 0; + const validTimelines = (timelines || []).filter(Boolean); + + const timeline = validTimelines.find((t) => { + const events = t.getEvents(); + if (!events) return false; + + uptoTimelineLen += events.length; + return index < uptoTimelineLen; + }); + + if (!timeline) return [undefined, 0]; + + const events = timeline.getEvents(); + const timelineLen = events ? events.length : 0; + + return [timeline, Math.max(0, uptoTimelineLen - timelineLen)]; +}; + +export const getTimelineRelativeIndex = (absoluteIndex: number, timelineBaseIndex: number) => + absoluteIndex - timelineBaseIndex; + +export const getTimelineEvent = ( + timeline: EventTimeline, + index: number +): MatrixEvent | undefined => { + if (!timeline) return undefined; + const events = timeline.getEvents(); + return events ? events[index] : undefined; +}; + +export const getEventIdAbsoluteIndex = ( + timelines: EventTimeline[], + eventTimeline: EventTimeline, + eventId: string +): number | undefined => { + const timelineIndex = timelines.indexOf(eventTimeline); + if (timelineIndex === -1) return undefined; + + const currentEvents = eventTimeline.getEvents(); + if (!currentEvents) return undefined; + + const eventIndex = currentEvents.findIndex((evt: MatrixEvent) => evt.getId() === eventId); + if (eventIndex === -1) return undefined; + + const baseIndex = timelines.slice(0, timelineIndex).reduce((accValue, timeline) => { + const evs = timeline.getEvents(); + return (evs ? evs.length : 0) + accValue; + }, 0); + + return baseIndex + eventIndex; +}; + +export const getInitialTimeline = (room: Room) => { + const linkedTimelines = getLinkedTimelines(getLiveTimeline(room)); + const evLength = getTimelinesEventsCount(linkedTimelines); + return { + linkedTimelines, + range: { + start: Math.max(evLength - PAGINATION_LIMIT, 0), + end: evLength, + }, + }; +}; + +export const getEmptyTimeline = () => ({ + range: { start: 0, end: 0 }, + linkedTimelines: [], +}); + +export const getRoomUnreadInfo = (room: Room, scrollTo = false) => { + if (!roomHaveNotification(room) && !roomHaveUnread(room.client, room)) return undefined; + + const readUptoEventId = room.getEventReadUpTo(room.client.getUserId() ?? ''); + if (!readUptoEventId) return undefined; + const evtTimeline = getEventTimeline(room, readUptoEventId); + const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward); + return { + readUptoEventId, + inLiveTimeline: latestTimeline === room.getLiveTimeline(), + scrollTo, + }; +}; + +export const getThreadReplyCount = (room: Room, mEventId: string): number => + room + .getUnfilteredTimelineSet() + .getLiveTimeline() + .getEvents() + .filter( + (ev) => ev.threadRootId === mEventId && ev.getId() !== mEventId && !reactionOrEditEvent(ev) + ).length; From 13bd62a8c8cde8273dea3df8e000e4e10582507d Mon Sep 17 00:00:00 2001 From: 7w1 Date: Wed, 18 Mar 2026 00:44:21 -0500 Subject: [PATCH 14/16] make better yes --- src/app/utils/timeline.ts | 66 +++++++++++++++++++++++++-------------- 1 file changed, 43 insertions(+), 23 deletions(-) diff --git a/src/app/utils/timeline.ts b/src/app/utils/timeline.ts index 27a8bba9c..fda048389 100644 --- a/src/app/utils/timeline.ts +++ b/src/app/utils/timeline.ts @@ -15,9 +15,11 @@ export const getFirstLinkedTimeline = ( timeline: EventTimeline, direction: Direction ): EventTimeline => { - const linkedTm = timeline.getNeighbouringTimeline(direction); - if (!linkedTm) return timeline; - return getFirstLinkedTimeline(linkedTm, direction); + let current = timeline; + while (current.getNeighbouringTimeline(direction)) { + current = current.getNeighbouringTimeline(direction)!; + } + return current; }; export const getLinkedTimelines = (timeline: EventTimeline): EventTimeline[] => { @@ -52,23 +54,28 @@ export const getTimelineAndBaseIndex = ( timelines: EventTimeline[], index: number ): [EventTimeline | undefined, number] => { - let uptoTimelineLen = 0; const validTimelines = (timelines || []).filter(Boolean); - const timeline = validTimelines.find((t) => { - const events = t.getEvents(); - if (!events) return false; + const result = validTimelines.reduce<{ + found?: EventTimeline; + baseIndex: number; + }>( + (acc, timeline) => { + if (acc.found) return acc; - uptoTimelineLen += events.length; - return index < uptoTimelineLen; - }); + const events = timeline.getEvents(); + const len = events ? events.length : 0; - if (!timeline) return [undefined, 0]; + if (index < acc.baseIndex + len) { + return { ...acc, found: timeline }; + } - const events = timeline.getEvents(); - const timelineLen = events ? events.length : 0; + return { ...acc, baseIndex: acc.baseIndex + len }; + }, + { baseIndex: 0 } + ); - return [timeline, Math.max(0, uptoTimelineLen - timelineLen)]; + return [result.found, result.found ? result.baseIndex : 0]; }; export const getTimelineRelativeIndex = (absoluteIndex: number, timelineBaseIndex: number) => @@ -127,8 +134,18 @@ export const getRoomUnreadInfo = (room: Room, scrollTo = false) => { const readUptoEventId = room.getEventReadUpTo(room.client.getUserId() ?? ''); if (!readUptoEventId) return undefined; + const evtTimeline = getEventTimeline(room, readUptoEventId); - const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward); + + if (!evtTimeline) { + return { + readUptoEventId, + inLiveTimeline: false, + scrollTo, + }; + } + + const latestTimeline = getFirstLinkedTimeline(evtTimeline, Direction.Forward); return { readUptoEventId, inLiveTimeline: latestTimeline === room.getLiveTimeline(), @@ -136,11 +153,14 @@ export const getRoomUnreadInfo = (room: Room, scrollTo = false) => { }; }; -export const getThreadReplyCount = (room: Room, mEventId: string): number => - room - .getUnfilteredTimelineSet() - .getLiveTimeline() - .getEvents() - .filter( - (ev) => ev.threadRootId === mEventId && ev.getId() !== mEventId && !reactionOrEditEvent(ev) - ).length; +export const getThreadReplyCount = (room: Room, mEventId: string): number => { + const thread = room.getThread(mEventId); + if (thread) return thread.length; + + const linkedTimelines = getLinkedTimelines(getLiveTimeline(room)); + const allEvents = linkedTimelines.flatMap((tl) => tl.getEvents()); + + return allEvents.filter( + (ev) => ev.threadRootId === mEventId && ev.getId() !== mEventId && !reactionOrEditEvent(ev) + ).length; +}; From b396ccec70752cca4cdc4720aa2fb6a7d759587c Mon Sep 17 00:00:00 2001 From: 7w1 Date: Wed, 18 Mar 2026 00:52:10 -0500 Subject: [PATCH 15/16] remove the comments >:D and merge the updated roomtimeline to have the new stuff and use the util file and yes --- src/app/features/room/RoomTimeline.tsx | 493 ++----------------------- src/app/utils/timeline.ts | 33 +- 2 files changed, 56 insertions(+), 470 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 72d469fe9..6b691fdbb 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -84,8 +84,6 @@ import { renderMatrixMention, } from '$plugins/react-custom-html-parser'; import { - roomHaveNotification, - roomHaveUnread, canEditEvent, decryptAllTimelineEvent, getEditedEvent, @@ -96,6 +94,22 @@ import { isMembershipChanged, reactionOrEditEvent, } from '$utils/room'; +import { + getLiveTimeline, + getEventTimeline, + getFirstLinkedTimeline, + getLinkedTimelines, + getTimelinesEventsCount, + getTimelineAndBaseIndex, + getTimelineRelativeIndex, + getTimelineEvent, + getEventIdAbsoluteIndex, + getInitialTimeline, + getEmptyTimeline, + getRoomUnreadInfo, + getThreadReplyCount, + PAGINATION_LIMIT, +} from '$utils/timeline'; import { useSetting } from '$state/hooks/settings'; import { MessageLayout, settingsAtom } from '$state/settings'; import { nicknamesAtom } from '$state/nicknames'; @@ -166,117 +180,6 @@ const TimelineDivider = as<'div', { variant?: ContainerColor | 'Inherit' }>( ) ); -export const getLiveTimeline = (room: Room): EventTimeline => - room.getUnfilteredTimelineSet().getLiveTimeline(); - -export const getEventTimeline = (room: Room, eventId: string): EventTimeline | undefined => { - const timelineSet = room.getUnfilteredTimelineSet(); - return timelineSet.getTimelineForEvent(eventId) ?? undefined; -}; - -export const getFirstLinkedTimeline = ( - timeline: EventTimeline, - direction: Direction -): EventTimeline => { - const linkedTm = timeline.getNeighbouringTimeline(direction); - if (!linkedTm) return timeline; - return getFirstLinkedTimeline(linkedTm, direction); -}; - -export const getLinkedTimelines = (timeline: EventTimeline): EventTimeline[] => { - const firstTimeline = getFirstLinkedTimeline(timeline, Direction.Backward); - const timelines: EventTimeline[] = []; - - for ( - let nextTimeline: EventTimeline | null = firstTimeline; - nextTimeline; - nextTimeline = nextTimeline.getNeighbouringTimeline(Direction.Forward) - ) { - timelines.push(nextTimeline); - } - return timelines; -}; - -export const timelineToEventsCount = (t: EventTimeline) => { - if (!t) return 0; - const events = t.getEvents(); - return events ? events.length : 0; -}; - -export const getTimelinesEventsCount = (timelines: EventTimeline[]): number => { - const timelineEventCountReducer = (count: number, tm: EventTimeline) => - count + timelineToEventsCount(tm); - return (timelines || []) - .filter(Boolean) - .reduce((accumulator, element) => timelineEventCountReducer(accumulator, element), 0); -}; - -export const getTimelineAndBaseIndex = ( - timelines: EventTimeline[], - index: number -): [EventTimeline | undefined, number] => { - let uptoTimelineLen = 0; - const validTimelines = (timelines || []).filter(Boolean); - - const timeline = validTimelines.find((t) => { - const events = t.getEvents(); - if (!events) return false; - - uptoTimelineLen += events.length; - return index < uptoTimelineLen; - }); - - if (!timeline) return [undefined, 0]; - - const events = timeline.getEvents(); - const timelineLen = events ? events.length : 0; - - return [timeline, Math.max(0, uptoTimelineLen - timelineLen)]; -}; - -export const getTimelineRelativeIndex = (absoluteIndex: number, timelineBaseIndex: number) => - absoluteIndex - timelineBaseIndex; - -export const getTimelineEvent = ( - timeline: EventTimeline, - index: number -): MatrixEvent | undefined => { - if (!timeline) return undefined; - const events = timeline.getEvents(); - return events ? events[index] : undefined; -}; - -export const getEventIdAbsoluteIndex = ( - timelines: EventTimeline[], - eventTimeline: EventTimeline, - eventId: string -): number | undefined => { - const timelineIndex = timelines.indexOf(eventTimeline); - if (timelineIndex === -1) return undefined; - - const currentEvents = eventTimeline.getEvents(); - if (!currentEvents) return undefined; - - const eventIndex = currentEvents.findIndex((evt: MatrixEvent) => evt.getId() === eventId); - if (eventIndex === -1) return undefined; - - const baseIndex = timelines.slice(0, timelineIndex).reduce((accValue, timeline) => { - const evs = timeline.getEvents(); - return (evs ? evs.length : 0) + accValue; - }, 0); - - return baseIndex + eventIndex; -}; - -type RoomTimelineProps = { - room: Room; - eventId?: string; - roomInputRef: RefObject; - editor: Editor; - onEditorReset?: () => void; -}; - -const PAGINATION_LIMIT = 60; const EVENT_TIMELINE_LOAD_TIMEOUT_MS = 12000; type PaginationStatus = 'idle' | 'loading' | 'error'; @@ -379,7 +282,7 @@ const useTimelinePagination = ( const topAddedTm = topTmIndex === -1 ? [] : newLTimelines.slice(0, topTmIndex); const topTmAddedEvt = - timelineToEventsCount(newLTimelines[topTmIndex]) - timelinesEventsCount[0]; + (newLTimelines[topTmIndex]?.getEvents()?.length ?? 0) - timelinesEventsCount[0]; const offsetRange = getTimelinesEventsCount(topAddedTm) + (backwards ? topTmAddedEvt : 0); setTimeline((currentTimeline) => ({ @@ -401,7 +304,7 @@ const useTimelinePagination = ( if (fetchingRef.current[directionKey]) return; const { linkedTimelines: lTimelines } = timelineRef.current; - const timelinesEventsCount = lTimelines.map(timelineToEventsCount); + const timelinesEventsCount = lTimelines.map((t) => t.getEvents()?.length ?? 0); const timelineToPaginate = backwards ? lTimelines[0] : lTimelines.at(-1); if (!timelineToPaginate) return; @@ -490,16 +393,10 @@ const useTimelinePagination = ( }; const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void) => { - // Stable ref so the effect dep array only contains `room`. The listener is - // registered once per room mount; onArrive can change freely without causing - // listener churn during rapid re-renders (e.g. sync error/retry cycles). const onArriveRef = useRef(onArrive); onArriveRef.current = onArrive; useEffect(() => { - // Capture the live timeline and registration time. Events appended to the - // live timeline AFTER this point can be genuinely new even when - // liveEvent=false (older sliding sync proxies that omit num_live). const liveTimeline = getLiveTimeline(room); const registeredAt = Date.now(); const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = ( @@ -510,11 +407,6 @@ const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void) data: IRoomTimelineData ) => { if (eventRoom?.roomId !== room.roomId) return; - // Standard sync: liveEvent=true for real-time events. - // Sliding sync fallback: liveEvent=false on buggy proxies. Treat events - // on the live timeline as new only when their server timestamp is within - // 60 s before registration — this filters out initial-sync backfill that - // happens to fire after mount while excluding genuine reconnect messages. const isLive = data.liveEvent || (!toStartOfTimeline && @@ -538,7 +430,7 @@ const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void) room.removeListener(RoomEvent.Timeline, handleTimelineEvent); room.removeListener(RoomEvent.Redaction, handleRedaction); }; - }, [room]); // stable: re-register only when room changes, not on callback identity changes + }, [room]); }; const useRelationUpdate = (room: Room, onRelation: () => void) => { @@ -553,9 +445,6 @@ const useRelationUpdate = (room: Room, onRelation: () => void) => { _removed: boolean, data: IRoomTimelineData ) => { - // Live Replace events are handled by useLiveEventArrive re-render. - // Non-live Replace events (bundled/historical edits from sliding sync) - // also need to trigger a re-render so makeReplaced state is reflected. if (eventRoom?.roomId !== room.roomId || data.liveEvent) return; if (mEvent.getRelation()?.rel_type === RelationType.Replace) { onRelationRef.current(); @@ -575,30 +464,10 @@ const useLiveTimelineRefresh = (room: Room, onRefresh: () => void) => { useEffect(() => { const handleTimelineRefresh: RoomEventHandlerMap[RoomEvent.TimelineRefresh] = (r: Room) => { if (r.roomId !== room.roomId) return; - // App-initiated full reinit (e.g. from refreshLiveTimeline()). Rare in normal usage. - debugLog.debug('timeline', 'TimelineRefresh: app-initiated live timeline reinit', { - roomId: room.roomId, - trigger: 'TimelineRefresh', - }); onRefreshRef.current(); }; - // The SDK fires RoomEvent.TimelineReset on the EventTimelineSet (not the Room) - // when a limited sync response replaces the live EventTimeline with a fresh one. - // This happens in classic /sync on limited=true (gap after idle/reconnect) AND in - // sliding sync when the proxy sends a limited room update. const handleTimelineReset: EventTimelineSetHandlerMap[RoomEvent.TimelineReset] = () => { - debugLog.info('timeline', 'TimelineReset: SDK-initiated (limited sync / sync gap)', { - roomId: room.roomId, - trigger: 'TimelineReset', - liveTimelineEvents: room.getUnfilteredTimelineSet().getLiveTimeline().getEvents().length, - }); Sentry.metrics.count('sable.timeline.limited_reset', 1); - Sentry.addBreadcrumb({ - category: 'timeline.sync', - message: 'TimelineReset: limited sync gap', - level: 'info', - data: { roomId: room.roomId }, - }); onRefreshRef.current(); }; const unfilteredTimelineSet = room.getUnfilteredTimelineSet(); @@ -612,14 +481,11 @@ const useLiveTimelineRefresh = (room: Room, onRefresh: () => void) => { }, [room]); }; -// Trigger re-render when thread reply counts change so the thread chip updates. const useThreadUpdate = (room: Room, onUpdate: () => void) => { const onUpdateRef = useRef(onUpdate); onUpdateRef.current = onUpdate; useEffect(() => { - // Stable wrapper: the same function identity is kept for the lifetime of - // the room so add/removeListener calls always match. const handler = () => onUpdateRef.current(); room.on(ThreadEvent.New, handler); room.on(ThreadEvent.Update, handler); @@ -632,18 +498,6 @@ const useThreadUpdate = (room: Room, onUpdate: () => void) => { }, [room]); }; -// Returns the number of replies in a thread, counting actual reply events -// (excluding the root event, reactions, and edits) from the live timeline. -// Always uses timeline-based counting for accuracy and live updates. -const getThreadReplyCount = (room: Room, mEventId: string): number => - room - .getUnfilteredTimelineSet() - .getLiveTimeline() - .getEvents() - .filter( - (ev) => ev.threadRootId === mEventId && ev.getId() !== mEventId && !reactionOrEditEvent(ev) - ).length; - type ThreadReplyChipProps = { room: Room; mEventId: string; @@ -749,35 +603,12 @@ function ThreadReplyChip({ ); } -const getInitialTimeline = (room: Room) => { - const linkedTimelines = getLinkedTimelines(getLiveTimeline(room)); - const evLength = getTimelinesEventsCount(linkedTimelines); - return { - linkedTimelines, - range: { - start: Math.max(evLength - PAGINATION_LIMIT, 0), - end: evLength, - }, - }; -}; - -const getEmptyTimeline = () => ({ - range: { start: 0, end: 0 }, - linkedTimelines: [], -}); - -const getRoomUnreadInfo = (room: Room, scrollTo = false) => { - if (!roomHaveNotification(room) && !roomHaveUnread(room.client, room)) return undefined; - - const readUptoEventId = room.getEventReadUpTo(room.client.getUserId() ?? ''); - if (!readUptoEventId) return undefined; - const evtTimeline = getEventTimeline(room, readUptoEventId); - const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward); - return { - readUptoEventId, - inLiveTimeline: latestTimeline === room.getLiveTimeline(), - scrollTo, - }; +type RoomTimelineProps = { + room: Room; + eventId?: string; + roomInputRef: RefObject; + editor: Editor; + onEditorReset?: () => void; }; export function RoomTimeline({ @@ -845,8 +676,6 @@ export function RoomTimeline({ const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents); const [unreadInfo, setUnreadInfo] = useState(() => getRoomUnreadInfo(room, true)); - // Stable ref so listeners that only need to *read* unreadInfo don't force - // effect re-registration (and listener churn) every time a new message arrives. const unreadInfoRef = useRef(unreadInfo); unreadInfoRef.current = unreadInfo; const readUptoEventIdRef = useRef(); @@ -857,16 +686,9 @@ export function RoomTimeline({ const hideReadsRef = useRef(hideReads); hideReadsRef.current = hideReads; - const unreadInfoRef = useRef(unreadInfo); - unreadInfoRef.current = unreadInfo; - const atBottomAnchorRef = useRef(null); - - // TODO: The return value of "useState" should be destructured and named symmetrically (typescript:S6754) const [atBottom, setAtBottomState] = useState(true); const atBottomRef = useRef(atBottom); - // Tracks when atBottom last changed so we can detect rapid true→false flips - // (characteristic of the IO false-positive on bulk event loads). const atBottomLastChangedRef = useRef(0); const setAtBottom = useCallback( (val: boolean) => { @@ -880,9 +702,6 @@ export function RoomTimeline({ level: 'info', data: { roomId: room.roomId, msSincePrevious }, }); - // Rapid flip: bottom→away within 200 ms is characteristic of the known - // IntersectionObserver false-positive triggered by bulk event loads causing - // a DOM layout shift (see memory: "RoomTimeline Stay at Bottom False-Positive"). if (!val && msSincePrevious < 200) { Sentry.captureMessage('Timeline: rapid atBottom flip (possible spurious scroll reset)', { level: 'warning', @@ -895,12 +714,7 @@ export function RoomTimeline({ [room.roomId] ); - // Set to true by the useLiveTimelineRefresh callback when the timeline is - // re-initialised (TimelineRefresh or TimelineReset). Allows the range self-heal - // effect below to run even when atBottom=false, so the virtual paginator window - // is restored to the live end without forcing a viewport scroll. const timelineJustResetRef = useRef(false); - const scrollRef = useRef(null); const scrollToBottomRef = useRef({ count: 0, @@ -960,12 +774,8 @@ export function RoomTimeline({ ); const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines); const liveTimelineLinked = timeline.linkedTimelines.at(-1) === getLiveTimeline(room); - - // Track previous eventsLength so we can calculate batch sizes. const prevEventsLengthRef = useRef(eventsLength); - // Breadcrumb every time the timeline gains events so we can correlate message - // batches (sliding sync chunks) with scroll state changes in Sentry Replay. useEffect(() => { const prev = prevEventsLengthRef.current; const delta = eventsLength - prev; @@ -974,7 +784,6 @@ export function RoomTimeline({ if (delta === 0) return; const isBatch = delta > 1; - // Classify by size: single new message vs small batch vs large catch-up load let batchSize: string; if (delta === 1) batchSize = 'single'; else if (delta <= 20) batchSize = 'small'; @@ -993,14 +802,10 @@ export function RoomTimeline({ liveTimelineLinked, rangeEnd: timeline.range.end, atBottom: atBottomRef.current, - // Gap between live end and visible window — non-zero while user is scrolled back rangeGap: eventsLength - timeline.range.end, }, }); - // A large batch (> 50) while liveTimelineLinked is the sliding-sync - // adaptive load pattern that can trigger the IO false-positive scroll reset. - // Capture a warning so it's searchable in Sentry even when no reset fires. if (delta > 50 && liveTimelineLinked) { Sentry.captureMessage('Timeline: large event batch from sliding sync', { level: 'warning', @@ -1008,11 +813,8 @@ export function RoomTimeline({ tags: { feature: 'timeline', batchSize }, }); } - // atBottomRef and timeline.range.end are intentionally read at effect time, not as deps - // eslint-disable-next-line react-hooks/exhaustive-deps }, [eventsLength, liveTimelineLinked]); - // Log timeline component mount/unmount useEffect(() => { const mode = eventId ? 'jump' : 'live'; Sentry.metrics.count('sable.timeline.open', 1, { attributes: { mode } }); @@ -1022,19 +824,8 @@ export function RoomTimeline({ attributes: { encrypted: String(room.hasEncryptionStateEvent()), mode }, }); } - debugLog.info('timeline', 'Timeline mounted', { - roomId: room.roomId, - eventId, - initialEventsCount: eventsLength, - liveTimelineLinked, - }); - return () => { - debugLog.info('timeline', 'Timeline unmounted', { roomId: room.roomId }); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [room.roomId, eventId]); // Only log on mount/unmount - intentionally capturing initial values + }, [room.roomId, eventId]); - // Log live timeline linking state changes useEffect(() => { debugLog.debug('timeline', 'Live timeline link state changed', { roomId: room.roomId, @@ -1042,6 +833,7 @@ export function RoomTimeline({ eventsLength, }); }, [liveTimelineLinked, room.roomId, eventsLength]); + const canPaginateBack = typeof timeline.linkedTimelines[0]?.getPaginationToken(Direction.Backward) === 'string'; const rangeAtStart = timeline.range.start === 0; @@ -1071,8 +863,6 @@ export function RoomTimeline({ return currentTimeline; } - // Log range changes with scroll state so we can correlate visible-content - // jumps with paginator window shifts. scrollRef is a stable ref — safe here. const scrollEl = scrollRef.current; const ds = newRange.start - currentTimeline.range.start; const de = newRange.end - currentTimeline.range.end; @@ -1085,19 +875,6 @@ export function RoomTimeline({ scrollHeight: scrollEl?.scrollHeight, clientHeight: scrollEl?.clientHeight, }); - Sentry.addBreadcrumb({ - category: 'ui.timeline', - message: 'Timeline window shifted', - level: 'debug', - data: { - prevStart: currentTimeline.range.start, - prevEnd: currentTimeline.range.end, - newStart: newRange.start, - newEnd: newRange.end, - deltaStart: ds, - deltaEnd: de, - }, - }); return { ...currentTimeline, range: newRange }; }); @@ -1156,23 +933,13 @@ export function RoomTimeline({ room, useCallback( (mEvt: MatrixEvent) => { - // Thread reply events are re-emitted from the Thread to the Room and - // must not increment the main timeline range or scroll it. - // useThreadUpdate handles the chip re-render for these events. if (mEvt.threadRootId !== undefined) return; - // if user is at bottom of timeline - // keep paginating timeline and conditionally mark as read - // otherwise we update timeline without paginating - // so timeline can be updated with evt like: edits, reactions etc if (atBottomRef.current && atLiveEndRef.current) { if ( document.hasFocus() && (!unreadInfoRef.current || mEvt.getSender() === mx.getUserId()) ) { - // Check if the document is in focus (user is actively viewing the app), - // and either there are no unread messages or the latest message is from the current user. - // If either condition is met, trigger the markAsRead function to send a read receipt. requestAnimationFrame(() => markAsRead(mx, mEvt.getRoomId()!, hideReadsRef.current)); } @@ -1180,8 +947,7 @@ export function RoomTimeline({ setUnreadInfo(getRoomUnreadInfo(room)); } - scrollToBottomRef.current.count += 1; // Use instant scroll when the current user sent the message - // to avoid Android WebView smooth-scroll not reaching bottom. + scrollToBottomRef.current.count += 1; scrollToBottomRef.current.smooth = mEvt.getSender() !== mx.getUserId(); setTimeline((ct) => ({ @@ -1229,12 +995,6 @@ export function RoomTimeline({ highlight = true, onScroll: ((scrolled: boolean) => void) | undefined = undefined ) => { - debugLog.info('timeline', 'Jumping to event', { - roomId: room.roomId, - eventId: evtId, - highlight, - }); - const evtTimeline = getEventTimeline(room, evtId); const absoluteIndex = evtTimeline && getEventIdAbsoluteIndex(timeline.linkedTimelines, evtTimeline, evtId); @@ -1253,16 +1013,7 @@ export function RoomTimeline({ scrollTo: !scrolled, highlight, }); - debugLog.debug('timeline', 'Event found in current timeline', { - roomId: room.roomId, - eventId: evtId, - index: absoluteIndex, - }); } else { - debugLog.debug('timeline', 'Event not in current timeline, loading timeline', { - roomId: room.roomId, - eventId: evtId, - }); loadEventTimeline(evtId); } }, @@ -1272,36 +1023,7 @@ export function RoomTimeline({ useLiveTimelineRefresh( room, useCallback(() => { - // Always reinitialize on TimelineRefresh/TimelineReset. With sliding sync, - // a limited response replaces the room's live EventTimeline with a brand-new - // object. At that moment liveTimelineLinked is stale-false (stored - // linkedTimelines reference the old detached chain), so any guard on that - // flag would skip reinit, causing back-pagination to no-op silently and the - // room to appear frozen. Unconditional reinit is correct: both events signal - // that stored range/indices against the old chain are invalid. - // - // Only force the viewport to the bottom if the user was already there. - // When the user has scrolled up to read history and a sync gap fires, we - // must still reinit (the old timeline is gone), but scrolling them back to - // the bottom is jarring. Instead we set timelineJustResetRef=true so the - // self-heal effect below can advance the range as events arrive on the fresh - // timeline, without atBottom=true being required. - // - // Also force atBottom=true and queue a scroll-to-bottom. The SDK fires - // TimelineRefresh before adding new events to the fresh live timeline, so - // getInitialTimeline captures range.end=0. Once events arrive the - // rangeAtEnd self-heal useEffect needs atBottom=true to run; the - // IntersectionObserver may have transiently fired isIntersecting=false - // during the render transition, leaving atBottom=false and causing the - // "Jump to Latest" button to stick permanently. Forcing atBottom here is - // correct: TimelineRefresh always reinits to the live end, so the user - // should be repositioned to the bottom regardless. Sentry.metrics.count('sable.timeline.reinit', 1); - - // When the user WAS at the bottom we still call setAtBottom(true) so a - // transient isIntersecting=false from the IntersectionObserver during the - // DOM transition cannot stick the "Jump to Latest" button on-screen. - debugLog.info('timeline', 'Live timeline refresh triggered', { roomId: room.roomId }); const wasAtBottom = atBottomRef.current; timelineJustResetRef.current = true; setTimeline(getInitialTimeline(room)); @@ -1313,8 +1035,6 @@ export function RoomTimeline({ }, [room, setAtBottom]) ); - // Re-render when non-live Replace relations arrive (bundled/historical edits - // from sliding sync that wouldn't otherwise trigger a timeline update). useRelationUpdate( room, useCallback(() => { @@ -1322,8 +1042,6 @@ export function RoomTimeline({ }, []) ); - // Re-render when thread reply counts change (new reply or thread update) so - // the thread chip on root messages reflects the correct count. useThreadUpdate( room, useCallback(() => { @@ -1331,44 +1049,12 @@ export function RoomTimeline({ }, []) ); - // When historical events load (e.g., from active subscription), stay at bottom - // by adjusting the range. The virtual paginator expects the range to match the - // position we want to display. Without this, loading more history makes it look - // like we've scrolled up because the range (0, 10) is now showing the old events - // instead of the latest ones. - // - // Also runs after a timeline reset (timelineJustResetRef=true) even when - // atBottom=false. After TimelineReset the SDK fires the event before populating - // the fresh timeline, so getInitialTimeline sees range.end=0. When events - // arrive eventsLength grows and we need to heal the range back to the live end - // regardless of the user's scroll position. useEffect(() => { const resetPending = timelineJustResetRef.current; if ((atBottom || resetPending) && liveTimelineLinked && eventsLength > timeline.range.end) { if (resetPending) timelineJustResetRef.current = false; - // More events exist than our current range shows. Adjust to the live end. - // - // IMPORTANT: also queue a scroll-to-bottom here. The scroll that was queued - // during TimelineReset / initial mount fires when range.end is still 0 - // (the SDK fires Reset *before* populating the fresh timeline), so the DOM - // has no items yet and the scroll is a no-op. This second increment fires - // after setTimeline renders the full range, guaranteeing we actually land - // at the bottom once the events are visible. - const rangeGap = eventsLength - timeline.range.end; scrollToBottomRef.current.count += 1; scrollToBottomRef.current.smooth = false; - Sentry.addBreadcrumb({ - category: 'ui.scroll', - message: 'Timeline: stay-at-bottom range expansion + scroll', - level: 'info', - data: { - eventsLength, - prevRangeEnd: timeline.range.end, - rangeGap, - wasReset: resetPending, - atBottom, - }, - }); setTimeline((ct) => ({ ...ct, range: { @@ -1379,8 +1065,6 @@ export function RoomTimeline({ } }, [atBottom, liveTimelineLinked, eventsLength, timeline.range.end]); - // Recover from transient empty timeline state when the live timeline - // already has events (can happen when opening by event id, then fallbacking). useEffect(() => { if (eventId) return; if (timeline.linkedTimelines.length > 0) return; @@ -1388,13 +1072,11 @@ export function RoomTimeline({ setTimeline(getInitialTimeline(room)); }, [eventId, room, timeline.linkedTimelines.length]); - // Stay at bottom when room editor resize useResizeObserver( useMemo(() => { let mounted = false; return (entries) => { if (!mounted) { - // skip initial mounting call mounted = true; return; } @@ -1433,15 +1115,11 @@ export function RoomTimeline({ if (!targetEntry) return; if (targetEntry.isIntersecting) { - // User has reached the bottom - debugLog.debug('timeline', 'Scrolled to bottom', { roomId: room.roomId }); setAtBottom(true); if (atLiveEndRef.current && document.hasFocus()) { tryAutoMarkAsRead(); } } else { - // User has intentionally scrolled up. - debugLog.debug('timeline', 'Scrolled away from bottom', { roomId: room.roomId }); setAtBottom(false); } }, @@ -1468,7 +1146,6 @@ export function RoomTimeline({ ) ); - // Handle up arrow edit useKeyDown( window, useCallback( @@ -1492,10 +1169,6 @@ export function RoomTimeline({ ) ); - // Keep a stable ref so timeline state updates (new messages arriving) don't - // cause handleOpenEvent to rebuild and re-trigger this effect, yanking the - // user back to the notification event on every incoming message. - // We only want to scroll once per unique eventId value. const handleOpenEventRef = useRef(handleOpenEvent); handleOpenEventRef.current = handleOpenEvent; @@ -1503,53 +1176,22 @@ export function RoomTimeline({ if (eventId) { handleOpenEventRef.current(eventId); } - }, [eventId]); // handleOpenEvent intentionally omitted — use ref above + }, [eventId]); - // Scroll to bottom on initial timeline load useLayoutEffect(() => { const scrollEl = scrollRef.current; if (scrollEl) { - const preScrollTop = scrollEl.scrollTop; - const preScrollHeight = scrollEl.scrollHeight; - const { clientHeight } = scrollEl; scrollToBottom(scrollEl); - // Log whether we were actually away from bottom at mount — useful for diagnosing - // rooms that open with the wrong scroll position. - const distanceFromBottom = preScrollHeight - preScrollTop - clientHeight; - debugLog.debug('timeline', 'Initial scroll to bottom (mount)', { - preScrollTop, - preScrollHeight, - clientHeight, - postScrollTop: scrollEl.scrollTop, - distanceFromBottom, - alreadyAtBottom: distanceFromBottom <= 2, - }); - if (distanceFromBottom > 0) { - Sentry.metrics.distribution('sable.timeline.initial_scroll_offset_px', distanceFromBottom); - } } }, []); - // Rescroll to bottom when images load at the start useEffect(() => { const scrollEl = scrollRef.current; const contentEl = scrollEl?.firstElementChild as HTMLElement; if (!scrollEl || !contentEl) return () => {}; const forceScroll = () => { - // if the user isn't scrolling jump down to latest content const wasAtBottom = atBottomRef.current; - const preScrollTop = scrollEl?.scrollTop ?? 0; - const preScrollHeight = scrollEl?.scrollHeight ?? 0; - // Log every resize so we can see when media loads move the timeline and whether - // we corrected it (atBottom=true) or left it (atBottom=false, user is scrolled up). - debugLog.debug('timeline', 'Content resized (image/media load)', { - atBottom: wasAtBottom, - preScrollTop, - preScrollHeight, - clientHeight: scrollEl?.clientHeight, - distanceFromBottom: preScrollHeight - preScrollTop - (scrollEl?.clientHeight ?? 0), - }); if (!wasAtBottom) return; scrollToBottom(scrollEl, 'instant'); }; @@ -1565,8 +1207,6 @@ export function RoomTimeline({ }; }, [room]); - // if live timeline is linked and unreadInfo change - // Scroll to last read message useLayoutEffect(() => { const { readUptoEventId, inLiveTimeline, scrollTo } = unreadInfo ?? {}; if (readUptoEventId && inLiveTimeline && scrollTo) { @@ -1584,7 +1224,6 @@ export function RoomTimeline({ } }, [room, unreadInfo, scrollToItem]); - // scroll to focused message useLayoutEffect(() => { if (focusItem?.scrollTo) { scrollToItem(focusItem.index, { @@ -1605,47 +1244,14 @@ export function RoomTimeline({ return () => clearTimeout(timeoutId); }, [alive, focusItem, scrollToItem]); - // scroll to bottom of timeline const scrollToBottomCount = scrollToBottomRef.current.count; useLayoutEffect(() => { if (scrollToBottomCount > 0) { const scrollEl = scrollRef.current; if (scrollEl) { const behavior = scrollToBottomRef.current.smooth && !reducedMotion ? 'smooth' : 'instant'; - const wasAtBottom = atBottomRef.current; - Sentry.addBreadcrumb({ - category: 'ui.scroll', - message: 'Timeline: scroll-to-bottom triggered', - level: 'info', - data: { roomId: room.roomId, behavior, wasAtBottom }, - }); - // A scroll-to-bottom while the user was NOT at the bottom and no timeline - // reset is expected is a sign of an unexpected scroll jump. - if (!wasAtBottom && !timelineJustResetRef.current) { - Sentry.captureMessage('Timeline: scroll-to-bottom fired while user was scrolled up', { - level: 'warning', - extra: { roomId: room.roomId, behavior }, - tags: { feature: 'timeline' }, - }); - } - // Use requestAnimationFrame to ensure the virtual paginator has finished - // updating the DOM before we scroll. This prevents scroll position from - // being stale when new messages arrive while at the bottom. requestAnimationFrame(() => { - const preScrollTop = scrollEl.scrollTop; - const { scrollHeight } = scrollEl; scrollToBottom(scrollEl, behavior); - debugLog.debug('timeline', 'scrollToBottom fired', { - behavior, - preScrollTop, - scrollHeight, - postScrollTop: scrollEl.scrollTop, - remainingOffset: scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight, - }); - // On Android WebView, layout may still settle after the initial scroll. - // Fire a second instant scroll after a short delay to guarantee we - // reach the true bottom (e.g. after images finish loading or the - // virtual keyboard shifts the viewport). if (behavior === 'instant') { setTimeout(() => { scrollToBottom(scrollEl, 'instant'); @@ -1656,14 +1262,12 @@ export function RoomTimeline({ } }, [scrollToBottomCount, reducedMotion, room.roomId]); - // Remove unreadInfo on mark as read useEffect(() => { if (!unread) { setUnreadInfo(undefined); } }, [unread]); - // scroll out of view msg editor in view. useEffect(() => { if (editId) { const editMsgElement = @@ -1715,7 +1319,6 @@ export function RoomTimeline({ if (!userId) return; const cachedData = globalProfiles[userId]; - const cleanExtended = cachedData?.extended ? { ...cachedData.extended } : undefined; if (cleanExtended) { @@ -1789,23 +1392,13 @@ export function RoomTimeline({ : replyEvt.getWireContent(); const senderId = replyEvt.getSender(); if (senderId) { - if (typeof body === 'string') { - setReplyDraft({ - userId: senderId, - eventId: replyId, - body, - formattedBody, - relation, - }); - } else { - setReplyDraft({ - userId: senderId, - eventId: replyId, - body: '', - formattedBody: '', - relation, - }); - } + setReplyDraft({ + userId: senderId, + eventId: replyId, + body: typeof body === 'string' ? body : '', + formattedBody: typeof formattedBody === 'string' ? formattedBody : '', + relation, + }); } }, [room, setReplyDraft, activeReplyId] @@ -1819,7 +1412,6 @@ export function RoomTimeline({ return; } if (startThread) { - // Create thread if it doesn't exist, then open the thread drawer const rootEvent = room.findEventById(replyId); if (rootEvent && !room.getThread(replyId)) { room.createThread(replyId, rootEvent, [], false); @@ -1834,8 +1426,6 @@ export function RoomTimeline({ const handleReactionToggle = useCallback( (targetEventId: string, key: string, shortcode?: string) => { - debugLog.info('ui', 'Reaction toggled', { roomId: room.roomId, targetEventId, key }); - Sentry.metrics.count('sable.message.reaction.toggle', 1); toggleReaction(mx, room, targetEventId, key, shortcode); }, [mx, room] @@ -1904,9 +1494,6 @@ export function RoomTimeline({ const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); const editedNewContent = editedEvent?.getContent()['m.new_content']; - // If makeReplaced was called with a stripped edit (no m.new_content), - // mEvent.getContent() returns {}. Fall back to getOriginalContent() so - // the message renders with its original content instead of breaking. const baseContent = mEvent.getContent(); const safeContent = Object.keys(baseContent).length > 0 ? baseContent : mEvent.getOriginalContent(); @@ -1916,7 +1503,6 @@ export function RoomTimeline({ const senderDisplayName = getMemberDisplayName(room, senderId, nicknames) ?? getMxIdLocalPart(senderId) ?? senderId; - // determine if message is forwarded by checking for the presence of the 'moe.sable.message.forward' key in the event content const forwardContent = safeContent['moe.sable.message.forward'] as | { original_timestamp?: unknown; @@ -2635,6 +2221,7 @@ export function RoomTimeline({ ); } ); + const processedEvents = useMemo(() => { const items = getItems(); let prevEvent: MatrixEvent | undefined; @@ -2654,7 +2241,6 @@ export function RoomTimeline({ if (!mEvent || !mEventId) return null; const eventSender = mEvent.getSender(); - if (eventSender && ignoredUsersSet.has(eventSender)) { return null; } @@ -2666,9 +2252,7 @@ export function RoomTimeline({ } if (!dayDivider) { dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false; - } // Thread REPLIES belong only in the thread timeline; filter them from the - // main room timeline. Keep thread ROOT events (threadRootId === their own - // event ID) so they remain visible with the ThreadReplyChip attached. + } if (mEvent.threadRootId !== undefined && mEvent.threadRootId !== mEventId) { return null; @@ -2710,7 +2294,6 @@ export function RoomTimeline({ }) .filter((e): e is NonNullable => e !== null); - // Reverse for column-reverse rendering return chronologicallyProcessed.reverse(); }, [timeline.linkedTimelines, getItems, ignoredUsersSet, showHiddenEvents, mx]); diff --git a/src/app/utils/timeline.ts b/src/app/utils/timeline.ts index fda048389..4ed92ab16 100644 --- a/src/app/utils/timeline.ts +++ b/src/app/utils/timeline.ts @@ -22,18 +22,18 @@ export const getFirstLinkedTimeline = ( return current; }; +const collectTimelines = ( + tl: EventTimeline | null, + dir: Direction, + acc: EventTimeline[] = [] +): EventTimeline[] => { + if (!tl) return acc; + return collectTimelines(tl.getNeighbouringTimeline(dir), dir, [...acc, tl]); +}; + export const getLinkedTimelines = (timeline: EventTimeline): EventTimeline[] => { const firstTimeline = getFirstLinkedTimeline(timeline, Direction.Backward); - const timelines: EventTimeline[] = []; - - for ( - let nextTimeline: EventTimeline | null = firstTimeline; - nextTimeline; - nextTimeline = nextTimeline.getNeighbouringTimeline(Direction.Forward) - ) { - timelines.push(nextTimeline); - } - return timelines; + return collectTimelines(firstTimeline, Direction.Forward); }; export const timelineToEventsCount = (t: EventTimeline) => { @@ -158,9 +158,12 @@ export const getThreadReplyCount = (room: Room, mEventId: string): number => { if (thread) return thread.length; const linkedTimelines = getLinkedTimelines(getLiveTimeline(room)); - const allEvents = linkedTimelines.flatMap((tl) => tl.getEvents()); - - return allEvents.filter( - (ev) => ev.threadRootId === mEventId && ev.getId() !== mEventId && !reactionOrEditEvent(ev) - ).length; + return linkedTimelines.reduce((acc, tl) => { + const threadEvents = tl + .getEvents() + .filter( + (ev) => ev.threadRootId === mEventId && ev.getId() !== mEventId && !reactionOrEditEvent(ev) + ); + return acc + threadEvents.length; + }, 0); }; From 21dc9c5b2f246622c27fc66decd3222396a809d0 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Wed, 18 Mar 2026 01:09:43 -0500 Subject: [PATCH 16/16] pull out scroll logic --- src/app/hooks/useScrollManager.ts | 79 +++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 src/app/hooks/useScrollManager.ts diff --git a/src/app/hooks/useScrollManager.ts b/src/app/hooks/useScrollManager.ts new file mode 100644 index 000000000..0cf0dcb5e --- /dev/null +++ b/src/app/hooks/useScrollManager.ts @@ -0,0 +1,79 @@ +import { useCallback, useRef, useState, useLayoutEffect } from 'react'; +import { scrollToBottom as domScrollToBottom, getScrollInfo } from '$utils/dom'; + +type ScrollBehavior = 'auto' | 'instant' | 'smooth'; + +export const useScrollManager = (scrollRef: React.RefObject) => { + const [isAtBottom, setIsAtBottom] = useState(true); + const isAtBottomRef = useRef(true); + const autoScrollRef = useRef(false); + + const checkAtBottom = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + + const { top, height, viewHeight } = getScrollInfo(el); + const distanceToBottom = height - top - viewHeight; + + const atBottom = distanceToBottom <= 5; + + if (atBottom !== isAtBottomRef.current) { + isAtBottomRef.current = atBottom; + setIsAtBottom(atBottom); + } + }, [scrollRef]); + + const scrollToBottom = useCallback( + (behavior: ScrollBehavior = 'instant') => { + const el = scrollRef.current; + if (el) { + autoScrollRef.current = true; + domScrollToBottom(el, behavior); + + if (behavior === 'instant') { + setTimeout(() => { + if (scrollRef.current) domScrollToBottom(scrollRef.current, 'instant'); + }, 50); + } + } + }, + [scrollRef] + ); + + const onScroll = useCallback(() => { + if (autoScrollRef.current) { + autoScrollRef.current = false; + return; + } + checkAtBottom(); + }, [checkAtBottom]); + + useLayoutEffect(() => { + const el = scrollRef.current; + + if (!el) { + return undefined; + } + + const resizeObserver = new ResizeObserver(() => { + if (isAtBottomRef.current) { + scrollToBottom('instant'); + } + }); + + if (el.firstElementChild) { + resizeObserver.observe(el.firstElementChild); + } + + return () => { + resizeObserver.disconnect(); + }; + }, [scrollRef, scrollToBottom]); + + return { + isAtBottom, + onScroll, + scrollToBottom, + checkAtBottom, + }; +};