diff --git a/src/app/components/sidebar/SidebarUnreadBadge.tsx b/src/app/components/sidebar/SidebarUnreadBadge.tsx index 42476cc6b4..b3a18f7078 100644 --- a/src/app/components/sidebar/SidebarUnreadBadge.tsx +++ b/src/app/components/sidebar/SidebarUnreadBadge.tsx @@ -8,6 +8,7 @@ type SidebarUnreadBadgeProps = { highlight?: boolean; count: number; dm?: boolean; + estimated?: boolean; mode?: UnreadBadgeMode; }; @@ -15,6 +16,7 @@ export function SidebarUnreadBadge({ highlight, count, dm, + estimated, mode, }: Readonly) { const [showUnreadCounts] = useSetting(settingsAtom, 'showUnreadCounts'); @@ -26,6 +28,7 @@ export function SidebarUnreadBadge({ highlight, count, dm, + estimated, showUnreadCounts, badgeCountDMsOnly, showPingCounts, diff --git a/src/app/components/unread-badge/UnreadBadge.test.tsx b/src/app/components/unread-badge/UnreadBadge.test.tsx index 8c42164393..eb09dd2c7c 100644 --- a/src/app/components/unread-badge/UnreadBadge.test.tsx +++ b/src/app/components/unread-badge/UnreadBadge.test.tsx @@ -36,6 +36,18 @@ describe('resolveUnreadBadgeMode', () => { ).toBe('count'); }); + it('returns dot for an estimated count even when unread counts are enabled', () => { + expect( + resolveUnreadBadgeMode({ + count: 1, + estimated: true, + showUnreadCounts: true, + badgeCountDMsOnly: false, + showPingCounts: false, + }) + ).toBe('dot'); + }); + it('returns dot for a room unread when unread counts are disabled', () => { expect( resolveUnreadBadgeMode({ diff --git a/src/app/components/unread-badge/UnreadBadge.tsx b/src/app/components/unread-badge/UnreadBadge.tsx index 6f86f87e7b..5b708c173b 100644 --- a/src/app/components/unread-badge/UnreadBadge.tsx +++ b/src/app/components/unread-badge/UnreadBadge.tsx @@ -8,6 +8,7 @@ type UnreadBadgeProps = { count: number; /** Whether this badge belongs to a DM room. Used with the badgeCountDMsOnly setting. */ dm?: boolean; + estimated?: boolean; mode?: UnreadBadgeMode; }; @@ -28,18 +29,21 @@ export type UnreadBadgeMode = 'dot' | 'count'; * @param options.showUnreadCounts Whether regular room unread badges should show counts. * @param options.badgeCountDMsOnly Whether direct message unread badges should show counts. * @param options.showPingCounts Whether highlight badges should show counts. + * @param options.estimated Whether the count is a placeholder awaiting backfill. * @returns `'count'` when the current badge context is allowed to show a number, otherwise `'dot'`. */ export function resolveUnreadBadgeMode({ highlight, count, dm, + estimated, showUnreadCounts, badgeCountDMsOnly, showPingCounts, }: ResolveUnreadBadgeModeOptions): UnreadBadgeMode { const showNumber = count > 0 && + !estimated && ((dm && badgeCountDMsOnly) || (!dm && showUnreadCounts) || (highlight && showPingCounts)); return showNumber ? 'count' : 'dot'; @@ -68,7 +72,7 @@ export function UnreadBadgeCenter({ children }: { children: ReactNode }) { ); } -export function UnreadBadge({ highlight, count, dm, mode }: UnreadBadgeProps) { +export function UnreadBadge({ highlight, count, dm, estimated, mode }: UnreadBadgeProps) { const [showUnreadCounts] = useSetting(settingsAtom, 'showUnreadCounts'); const [badgeCountDMsOnly] = useSetting(settingsAtom, 'badgeCountDMsOnly'); const [showPingCounts] = useSetting(settingsAtom, 'showPingCounts'); @@ -79,6 +83,7 @@ export function UnreadBadge({ highlight, count, dm, mode }: UnreadBadgeProps) { highlight, count, dm, + estimated, showUnreadCounts, badgeCountDMsOnly, showPingCounts, diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 8c6d0f447b..d8f5c9577e 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -562,6 +562,7 @@ export function RoomNavItem({ 0} count={unread.highlight > 0 ? unread.highlight : unread.total} + estimated={unread.estimated} /> )} @@ -601,6 +602,7 @@ export function RoomNavItem({ highlight={!!unread && unread.highlight > 0} count={unreadCount} dm={direct} + estimated={unread?.estimated} /> )} diff --git a/src/app/features/room/RoomTimeline.test.tsx b/src/app/features/room/RoomTimeline.test.tsx index 79f55f4aca..e3daf042e5 100644 --- a/src/app/features/room/RoomTimeline.test.tsx +++ b/src/app/features/room/RoomTimeline.test.tsx @@ -1117,6 +1117,34 @@ describe('unread read marker (normal sync)', () => { await act(() => new Promise((resolve) => requestAnimationFrame(resolve))); expect(markAsReadMock).not.toHaveBeenCalled(); }); + + it('does not mark the room read before the initial scroll settles', async () => { + getRoomUnreadInfoMock.mockReturnValue(undefined); + windowFocused.current = true; + + renderTimeline(); + await act(() => new Promise((resolve) => requestAnimationFrame(resolve))); + + expect(markAsReadMock).not.toHaveBeenCalled(); + }); + + it('resolves the read marker when the boundary loads after mount', async () => { + getRoomUnreadInfoMock.mockReturnValue(undefined); + const { rerender } = renderTimeline(); + await settleInitialScroll(); + + expect(processedTimelineOptions.current?.readUptoEventId).toBeUndefined(); + + getRoomUnreadInfoMock.mockReturnValue({ + readUptoEventId: '$read:example.org', + inLiveTimeline: true, + scrollTo: false, + }); + timelineSync.eventsLength = 2; + rerender(); + + expect(processedTimelineOptions.current?.readUptoEventId).toBe('$read:example.org'); + }); }); describe('unread read marker (sliding sync)', () => { diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 498fe6ad92..9632017a93 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -424,6 +424,7 @@ export function RoomTimeline({ const readUptoEventIdRef = useRef(undefined); if (unreadInfo) readUptoEventIdRef.current = unreadInfo.readUptoEventId; + const unreadResolvedRef = useRef(unreadInfo !== undefined); const hideReadsRef = useRef(hideReads); hideReadsRef.current = hideReads; @@ -895,6 +896,15 @@ export function RoomTimeline({ setAtBottom(true); }, [eventId, focusLiveTimeline, setAtBottom]); + useEffect(() => { + if (unreadResolvedRef.current) return; + const resolved = getRoomUnreadInfo(room, !isReady); + if (!resolved) return; + unreadResolvedRef.current = true; + readUptoEventIdRef.current = resolved.readUptoEventId; + setUnreadInfo(resolved); + }, [room, isReady, timelineSync.eventsLength]); + useEffect(() => { if (eventId) return; if (isReady) return; @@ -1048,6 +1058,7 @@ export function RoomTimeline({ const tryAutoMarkAsRead = useCallback(() => { if (isInactivePanel) return; // Don't clear unread while room is behind the list + if (!isReady) return; if (!atBottomRef.current) return; if (!readUptoEventIdRef.current) { requestAnimationFrame(() => markAsRead(mx, room.roomId, hideReads)); @@ -1058,7 +1069,7 @@ export function RoomTimeline({ if (latestTimeline === room.getLiveTimeline()) { requestAnimationFrame(() => markAsRead(mx, room.roomId, hideReads)); } - }, [mx, room, hideReads, isInactivePanel]); + }, [mx, room, hideReads, isInactivePanel, isReady]); useDocumentFocusChange( useCallback( diff --git a/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx b/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx index 9b2a4c873f..6d63995750 100644 --- a/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx +++ b/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx @@ -1180,78 +1180,85 @@ export function ThemeCatalogSettings({ mode, onBrowseOpenChange }: ThemeCatalogS be missing or not paired as `*.preview.sable.css`. ) : ( - - {localPreviewsQuery.data.map((row) => { - const slug = row.basename.replace(/[^a-zA-Z0-9_-]/g, '-') || 'theme'; - const kindLabel = row.kind === 'dark' ? 'Dark' : 'Light'; - const line1 = `${kindLabel} · ${row.contrast} contrast`; - const line2 = `${row.author ? `by ${row.author}` : ''}${ - row.tags.length > 0 - ? `${row.author ? ' · ' : ''}${row.tags.join(', ')}` - : '' - }`.trim(); - const subtitle = ( - <> - {line1} - {line2 ? ( - <> -
- {line2} - - ) : null} - - ); - return ( - + {localPreviewsQuery.data.map((row) => { + const slug = row.basename.replace(/[^a-zA-Z0-9_-]/g, '-') || 'theme'; + const kindLabel = row.kind === 'dark' ? 'Dark' : 'Light'; + const line1 = `${kindLabel} · ${row.contrast} contrast`; + const line2 = `${row.author ? `by ${row.author}` : ''}${ + row.tags.length > 0 + ? `${row.author ? ' · ' : ''}${row.tags.join(', ')}` + : '' + }`.trim(); + const subtitle = ( + <> + {line1} + {line2 ? ( + <> +
+ {line2} + + ) : null} + + ); + return ( + removeFavorite(row.fullUrl)} - onExport={() => downloadThemeFile(row)} - systemTheme={systemTheme} - onApplyLight={ - systemTheme ? () => applyFavoriteToLight(row) : undefined - } - onApplyDark={systemTheme ? () => applyFavoriteToDark(row) : undefined} - onApplyManual={ - !systemTheme ? () => applyFavoriteToManual(row) : undefined - } - isAppliedLight={lightRemoteFullUrl === row.fullUrl} - isAppliedDark={darkRemoteFullUrl === row.fullUrl} - isAppliedManual={manualRemoteFullUrl === row.fullUrl} - /> - ); - })} -
+ ) + } + isFavorited + onToggleFavorite={() => removeFavorite(row.fullUrl)} + onExport={() => downloadThemeFile(row)} + systemTheme={systemTheme} + onApplyLight={ + systemTheme ? () => applyFavoriteToLight(row) : undefined + } + onApplyDark={ + systemTheme ? () => applyFavoriteToDark(row) : undefined + } + onApplyManual={ + !systemTheme ? () => applyFavoriteToManual(row) : undefined + } + isAppliedLight={lightRemoteFullUrl === row.fullUrl} + isAppliedDark={darkRemoteFullUrl === row.fullUrl} + isAppliedManual={manualRemoteFullUrl === row.fullUrl} + /> + ); + })} + + )} )} @@ -1273,77 +1280,81 @@ export function ThemeCatalogSettings({ mode, onBrowseOpenChange }: ThemeCatalogS )} {localTweaksQuery.isSuccess && tweakFavorites.length > 0 && ( - - {localTweaksQuery.data.length > 0 && unresolvedLegacyTweakCount > 0 && ( - - {unresolvedLegacyTweakCount} saved local tweak - {unresolvedLegacyTweakCount === 1 ? ' is' : 's are'} waiting to migrate. - - )} - {localTweaksQuery.data.length === 0 ? ( - - {unresolvedLegacyTweakCount === tweakFavorites.length - ? 'Some saved local tweaks are waiting to migrate. Open Sable on a device that still has them to finish syncing.' - : 'Could not load tweak CSS. Check the URL or your connection.'} - - ) : ( - localTweaksQuery.data.map((row) => { - const isOn = enabledTweakFullUrls.includes(row.fullUrl); - const descParts = [ - row.description, - row.author ? `by ${row.author}` : '', - row.tags.length > 0 ? row.tags.join(', ') : '', - ].filter(Boolean); - const desc = - descParts.join(' · ') || - 'Applies on top of your current theme after it loads.'; - return ( - removeTweakFavorite(row.fullUrl)} - onExport={() => downloadTweakFile(row)} - cssText={row.fullCssText} - sourceLabel={themeSourceLabel({ - importedLocal: row.importedLocal, - official: + + {localTweaksQuery.data.length > 0 && unresolvedLegacyTweakCount > 0 && ( + + {unresolvedLegacyTweakCount} saved local tweak + {unresolvedLegacyTweakCount === 1 ? ' is' : 's are'} waiting to migrate. + + )} + {localTweaksQuery.data.length === 0 ? ( + + {unresolvedLegacyTweakCount === tweakFavorites.length + ? 'Some saved local tweaks are waiting to migrate. Open Sable on a device that still has them to finish syncing.' + : 'Could not load tweak CSS. Check the URL or your connection.'} + + ) : ( + localTweaksQuery.data.map((row) => { + const isOn = enabledTweakFullUrls.includes(row.fullUrl); + const descParts = [ + row.description, + row.author ? `by ${row.author}` : '', + row.tags.length > 0 ? row.tags.join(', ') : '', + ].filter(Boolean); + const desc = + descParts.join(' · ') || + 'Applies on top of your current theme after it loads.'; + return ( + - setTweakApplied(row.fullUrl, v, { - displayName: row.displayName, - basename: row.basename, - }) - } - /> - ); - }) - )} - + ) + } + isFavorited + onToggleFavorite={() => removeTweakFavorite(row.fullUrl)} + onExport={() => downloadTweakFile(row)} + cssText={row.fullCssText} + sourceLabel={themeSourceLabel({ + importedLocal: row.importedLocal, + official: + !row.importedLocal && + !isThirdPartyThemeUrl( + row.fullUrl, + clientConfig.themeCatalogApprovedHostPrefixes + ), + url: row.fullUrl, + })} + isOn={isOn} + onSetApplied={(v) => + setTweakApplied(row.fullUrl, v, { + displayName: row.displayName, + basename: row.basename, + }) + } + /> + ); + }) + )} + + )} )} diff --git a/src/app/state/room/roomToUnread.ts b/src/app/state/room/roomToUnread.ts index d532837b1d..870bb09f5e 100644 --- a/src/app/state/room/roomToUnread.ts +++ b/src/app/state/room/roomToUnread.ts @@ -46,6 +46,7 @@ const unreadInfoToUnread = (unreadInfo: UnreadInfo): Unread => ({ highlight: unreadInfo.highlight, total: unreadInfo.total, from: null, + estimated: unreadInfo.estimated, }); const putUnreadInfo = ( diff --git a/src/app/utils/timeline.ts b/src/app/utils/timeline.ts index 1c4b6df154..6756b10bea 100644 --- a/src/app/utils/timeline.ts +++ b/src/app/utils/timeline.ts @@ -1,6 +1,6 @@ import type { EventTimeline, MatrixEvent, Room } from '$types/matrix-sdk'; import { Direction } from '$types/matrix-sdk'; -import { roomHaveNotification, roomHaveUnread } from '$utils/room/unread'; +import { getFullyReadEventId, roomHaveNotification, roomHaveUnread } from '$utils/room/unread'; export const PAGINATION_LIMIT = 60; @@ -100,9 +100,8 @@ export const getEmptyTimeline = () => ({ }); export const getRoomUnreadInfo = (room: Room, scrollTo = false) => { - if (!roomHaveNotification(room) && !roomHaveUnread(room.client, room)) return undefined; - - const readUptoEventId = room.getEventReadUpTo(room.client.getUserId() ?? ''); + const readUptoEventId = + room.getEventReadUpTo(room.client.getUserId() ?? '') ?? getFullyReadEventId(room); if (!readUptoEventId) return undefined; const evtTimeline = getEventTimeline(room, readUptoEventId); @@ -116,9 +115,14 @@ export const getRoomUnreadInfo = (room: Room, scrollTo = false) => { } const latestTimeline = getFirstLinkedTimeline(evtTimeline, Direction.Forward); + const inLiveTimeline = latestTimeline === room.getLiveTimeline(); + if (inLiveTimeline && !roomHaveNotification(room) && !roomHaveUnread(room.client, room)) { + return undefined; + } + return { readUptoEventId, - inLiveTimeline: latestTimeline === room.getLiveTimeline(), + inLiveTimeline, scrollTo, }; }; diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts index 15a596bfe4..f74936957b 100644 --- a/src/types/matrix/room.ts +++ b/src/types/matrix/room.ts @@ -60,6 +60,7 @@ export type Unread = { total: number; highlight: number; from: Set | null; + estimated?: boolean; }; export type RoomToUnread = Map; export type UnreadInfo = {