Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-timeline-late-growth-scroll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

# Keep following the timeline when a message grows after it renders
5 changes: 5 additions & 0 deletions .changeset/fix-timeline-runaway-pagination.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

# Stop the timeline loading a room's whole history on open
2 changes: 2 additions & 0 deletions src/app/components/app-shell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { isTauri } from '@tauri-apps/api/core';
import { type as osType } from '@tauri-apps/plugin-os';

import { TauriFrontendReady } from '$components/tauri/TauriFrontendReady';
import { TauriWindowFocus } from '$components/tauri/TauriWindowFocus';
import { DesktopTitleBar } from '$components/tauri/DesktopTitleBar';
import { MacTitleBar } from '$components/tauri/MacTitleBar';
import { DesktopUpdater } from '$pages/client/DesktopUpdater';
Expand Down Expand Up @@ -80,6 +81,7 @@ function AppShellFrame({ children, portalContainer, onPortalContainerChange }: A
return (
<>
<TauriFrontendReady />
<TauriWindowFocus />
<div
style={{
display: 'flex',
Expand Down
114 changes: 114 additions & 0 deletions src/app/components/tauri/TauriWindowFocus.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { render, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TauriWindowFocus } from './TauriWindowFocus';
import { isWindowFocused, setNativeWindowFocused, subscribeWindowFocus } from '$utils/dom';

const { mockIsTauri, mockOsType, mockIsFocused, mockOnFocusChanged, mockGetCurrentWindow } =
vi.hoisted(() => ({
mockIsTauri: vi.fn<() => boolean>(),
mockOsType: vi.fn<() => string>(),
mockIsFocused: vi.fn<() => Promise<boolean>>(),
mockOnFocusChanged: vi.fn<(cb: (e: { payload: boolean }) => void) => Promise<() => void>>(),
mockGetCurrentWindow: vi.fn<() => unknown>(),
}));

vi.mock('@tauri-apps/api/core', () => ({ isTauri: mockIsTauri }));
vi.mock('@tauri-apps/plugin-os', () => ({ type: mockOsType }));
vi.mock('@tauri-apps/api/window', () => ({ getCurrentWindow: mockGetCurrentWindow }));
vi.mock('$utils/debug', () => ({
createLogger: () => ({
log: vi.fn<(...args: unknown[]) => void>(),
warn: vi.fn<(...args: unknown[]) => void>(),
}),
}));

describe('TauriWindowFocus', () => {
beforeEach(() => {
mockIsTauri.mockReturnValue(true);
mockOsType.mockReturnValue('linux');
mockIsFocused.mockResolvedValue(true);
mockOnFocusChanged.mockResolvedValue(() => {});
mockGetCurrentWindow.mockReturnValue({
isFocused: mockIsFocused,
onFocusChanged: mockOnFocusChanged,
});
});

afterEach(() => {
setNativeWindowFocused(undefined);
vi.clearAllMocks();
});

it('seeds focus from the OS on desktop', async () => {
mockIsFocused.mockResolvedValue(false);
render(<TauriWindowFocus />);
await waitFor(() => expect(isWindowFocused()).toBe(false));
});

it('tracks later focus changes and notifies subscribers', async () => {
render(<TauriWindowFocus />);
await waitFor(() => expect(mockOnFocusChanged).toHaveBeenCalled());

const seen: boolean[] = [];
const unsubscribe = subscribeWindowFocus((focused) => seen.push(focused));

const emit = mockOnFocusChanged.mock.calls[0]![0];
emit({ payload: false });
expect(isWindowFocused()).toBe(false);
expect(seen).toEqual([false]);

emit({ payload: true });
expect(isWindowFocused()).toBe(true);
expect(seen).toEqual([false, true]);

unsubscribe();
});

it('does not subscribe on mobile, leaving the DOM path authoritative', async () => {
mockOsType.mockReturnValue('android');
render(<TauriWindowFocus />);
await waitFor(() => expect(mockOnFocusChanged).not.toHaveBeenCalled());
});

it('does not subscribe outside Tauri', async () => {
mockIsTauri.mockReturnValue(false);
render(<TauriWindowFocus />);
await waitFor(() => expect(mockGetCurrentWindow).not.toHaveBeenCalled());
});

it('ignores a stale initial read that resolves after a focus change', async () => {
let resolveInitial: ((focused: boolean) => void) | undefined;
mockIsFocused.mockReturnValue(
new Promise<boolean>((resolve) => {
resolveInitial = resolve;
})
);

render(<TauriWindowFocus />);
await waitFor(() => expect(mockOnFocusChanged).toHaveBeenCalled());

mockOnFocusChanged.mock.calls[0]![0]({ payload: false });
expect(isWindowFocused()).toBe(false);

resolveInitial?.(true);
await Promise.resolve();
expect(isWindowFocused()).toBe(false);
});

it('releases the override on unmount so the DOM value applies again', async () => {
mockIsFocused.mockResolvedValue(false);
const { unmount } = render(<TauriWindowFocus />);
await waitFor(() => expect(isWindowFocused()).toBe(false));
unmount();
await waitFor(() => expect(isWindowFocused()).toBe(document.hasFocus()));
});

it('stops listening on unmount', async () => {
const stop = vi.fn<() => void>();
mockOnFocusChanged.mockResolvedValue(stop);
const { unmount } = render(<TauriWindowFocus />);
await waitFor(() => expect(mockOnFocusChanged).toHaveBeenCalled());
unmount();
await waitFor(() => expect(stop).toHaveBeenCalled());
});
});
50 changes: 50 additions & 0 deletions src/app/components/tauri/TauriWindowFocus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { useEffect } from 'react';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { isTauri } from '@tauri-apps/api/core';
import { type as osType } from '@tauri-apps/plugin-os';
import { setNativeWindowFocused } from '$utils/dom';
import { createLogger } from '$utils/debug';

const log = createLogger('TauriWindowFocus');

// Mobile has no window focus to report; the DOM path stays authoritative there.
const DESKTOP = new Set(['windows', 'linux', 'macos']);

export function TauriWindowFocus() {
useEffect(() => {
if (!isTauri() || !DESKTOP.has(osType())) return undefined;

let unlisten: (() => void) | undefined;
let cancelled = false;
// isFocused() is an IPC round-trip and can resolve after a newer focus change.
let sawEvent = false;

const appWindow = getCurrentWindow();

appWindow
.isFocused()
.then((focused) => {
if (!cancelled && !sawEvent) setNativeWindowFocused(focused);
})
.catch((error: unknown) => log.warn('Failed to read initial window focus:', error));

appWindow
.onFocusChanged(({ payload }) => {
sawEvent = true;
setNativeWindowFocused(payload);
})
.then((stop) => {
if (cancelled) stop();
else unlisten = stop;
})
.catch((error: unknown) => log.warn('Failed to subscribe to window focus:', error));

return () => {
cancelled = true;
unlisten?.();
setNativeWindowFocused(undefined);
};
}, []);

return null;
}
90 changes: 58 additions & 32 deletions src/app/features/room/RoomTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
import type { Editor } from 'slate';
import { useAtomValue, useSetAtom, useStore } from 'jotai';
import type { Room, MatrixEvent, EventTimelineSet } from '$types/matrix-sdk';
import { Direction, EventType } from '$types/matrix-sdk';
import { Direction, EventTimeline, EventType } from '$types/matrix-sdk';
import classNames from 'classnames';
import type { VListHandle } from 'virtua';
import { VList } from 'virtua';
Expand All @@ -27,6 +27,7 @@ import { useMessageEdit } from '$hooks/useMessageEdit';
import { useDocumentFocusChange } from '$hooks/useDocumentFocusChange';
import { useIsInactivePanel } from '$hooks/useRoom';
import { markAsRead } from '$utils/notifications';
import { isWindowFocused } from '$utils/dom';
import { today, yesterday, timeDayMonthYear } from '$utils/time';
import {
unwrapRelationJumpTarget,
Expand Down Expand Up @@ -71,6 +72,8 @@ import { useTimelineRendererContext } from '$hooks/timeline/useTimelineRendererC
import { TimelineScrollingProvider, useScrollActivity } from '$hooks/useTimelineScrollActivity';
import * as css from './RoomTimeline.css';

const MAX_VIEWPORT_FILL_PAGINATIONS = 5;

const TimelineFloat = as<'div', css.TimelineFloatVariants>(
({ position, className, ...props }, ref) => (
<Box
Expand Down Expand Up @@ -396,6 +399,18 @@ export function RoomTimeline({
atBottomRef.current = val;
}, []);

// Resizes move the bottom without emitting a scroll event.
const syncAtBottom = useCallback(
(offset?: number) => {
const v = vListRef.current;
if (!v) return;
const scrollTop = offset ?? v.scrollOffset;
const isNowAtBottom = v.scrollSize - scrollTop - v.viewportSize < 100;
if (isNowAtBottom !== atBottomRef.current) setAtBottom(isNowAtBottom);
},
[setAtBottom]
);

const [shift, setShift] = useState(false);
const [topSpacerHeight, setTopSpacerHeight] = useState(0);

Expand Down Expand Up @@ -546,6 +561,7 @@ export function RoomTimeline({

const canPaginateBackRef = useRef(timelineSync.canPaginateBack);
canPaginateBackRef.current = timelineSync.canPaginateBack;
const viewportFillCountRef = useRef(0);

const liveTimelineLinkedRef = useRef(timelineSync.liveTimelineLinked);
liveTimelineLinkedRef.current = timelineSync.liveTimelineLinked;
Expand Down Expand Up @@ -738,18 +754,36 @@ export function RoomTimeline({
const atBottom = atBottomRef.current;
const shrank = newHeight < prev;

if (shrank && atBottom) {
const lastIndex = processedEventsRef.current.length - 1;
if (lastIndex >= 0) {
vListRef.current?.scrollToIndex(lastIndex, { align: 'end' });
}
}
prevViewportHeightRef.current = newHeight;

const lastIndex = processedEventsRef.current.length - 1;
if (shrank && atBottom && lastIndex >= 0) {
// Geometry is still pre-scroll here; the repin's own scroll event resyncs.
vListRef.current?.scrollToIndex(lastIndex, { align: 'end' });
return;
}
syncAtBottom();
});

observer.observe(el);
return () => observer.disconnect();
}, []);
}, [syncAtBottom]);

// Decrypting rows and late-loading images grow without changing eventsLength,
// so useTimelineSync's auto-scroll never re-fires for them.
const lastScrollSizeRef = useRef(0);
useLayoutEffect(() => {
const v = vListRef.current;
if (!v) return;

const grew = v.scrollSize > lastScrollSizeRef.current;
lastScrollSizeRef.current = v.scrollSize;

if (!grew || !atBottomRef.current || !liveTimelineLinkedRef.current) return;

const lastIndex = processedEventsRef.current.length - 1;
if (lastIndex >= 0) v.scrollToIndex(lastIndex, { align: 'end' });
});

const actions = useTimelineActions({
room,
Expand Down Expand Up @@ -870,8 +904,7 @@ export function RoomTimeline({
);

useEffect(() => {
if (atBottomState && document.hasFocus() && timelineSync.liveTimelineLinked)
tryAutoMarkAsRead();
if (atBottomState && isWindowFocused() && timelineSync.liveTimelineLinked) tryAutoMarkAsRead();
}, [
atBottomState,
timelineSync.liveTimelineLinked,
Expand All @@ -886,10 +919,7 @@ export function RoomTimeline({
if (!v) return;

const distanceFromBottom = v.scrollSize - offset - v.viewportSize;
const isNowAtBottom = distanceFromBottom < 100;
if (isNowAtBottom !== atBottomRef.current) {
setAtBottom(isNowAtBottom);
}
syncAtBottom(offset);

if (offset < 500 && canPaginateBackRef.current && backwardStatusRef.current === 'idle') {
void timelineSyncRef.current.handleTimelinePagination(true);
Expand All @@ -902,7 +932,7 @@ export function RoomTimeline({
void timelineSyncRef.current.handleTimelinePagination(false);
}
},
[notifyScroll, setAtBottom]
[notifyScroll, syncAtBottom]
);

const showLoadingPlaceholders =
Expand Down Expand Up @@ -965,7 +995,10 @@ export function RoomTimeline({
timelineSync.backwardStatus === 'loading' && timelineSync.eventsLength > 0;
const showFrontPaginationSpinner =
timelineSync.forwardStatus === 'loading' && timelineSync.eventsLength > 0;
const hasPowerLevelState = !!room.currentState.getStateEvents(EventType.RoomPowerLevels, '');
const hasPowerLevelState = !!room
.getLiveTimeline()
?.getState(EventTimeline.FORWARDS)
?.getStateEvents(EventType.RoomPowerLevels, '');
const hideTimelineForRoomState = roomSyncLoading && hideMemberInReadOnly && !hasPowerLevelState;
const timelineBottomFloatLift =
!atBottomState && isReady ? { bottom: `calc(${config.space.S400} + ${toRem(52)})` } : undefined;
Expand Down Expand Up @@ -1030,24 +1063,18 @@ export function RoomTimeline({
}, [onEditLastMessageRef, mx, actions]);

useEffect(() => {
const v = vListRef.current;
if (!v) return;
if (
canPaginateBackRef.current &&
backwardStatusRef.current === 'idle' &&
v.scrollSize <= v.viewportSize
) {
void timelineSyncRef.current.handleTimelinePagination(true);
}
}, [timelineSync.eventsLength, timelineSync.backwardStatus]);
viewportFillCountRef.current = 0;
lastScrollSizeRef.current = 0;
}, [room.roomId]);

// Re-enters on every length change, so an unfillable viewport pages to the start of the
// room. Scrolling up is handled by handleVListScroll.
useEffect(() => {
if (!canPaginateBackRef.current) return () => {};

let rafId: number;
let attempts = 0;
const MAX_ATTEMPTS = 20;
const processedLengthAtEffectStart = processedEvents.length;

const check = () => {
const v = vListRef.current;
Expand All @@ -1062,18 +1089,17 @@ export function RoomTimeline({
if (!canPaginateBackRef.current) return;
if (backwardStatusRef.current !== 'idle') return;

const atTop = v.scrollOffset < 500;
const noVisibleGrowth = processedEvents.length === processedLengthAtEffectStart;
const hasRealScrollRoom = v.scrollSize > v.viewportSize + 300;
if (viewportFillCountRef.current >= MAX_VIEWPORT_FILL_PAGINATIONS) return;

if (!hasRealScrollRoom || (atTop && noVisibleGrowth)) {
if (v.scrollSize <= v.viewportSize + 300) {
viewportFillCountRef.current += 1;
void timelineSyncRef.current.handleTimelinePagination(true);
}
};

rafId = requestAnimationFrame(check);
return () => cancelAnimationFrame(rafId);
}, [timelineSync.eventsLength, timelineSync.backwardStatus, processedEvents.length]);
}, [room.roomId, timelineSync.eventsLength, timelineSync.backwardStatus]);

return (
<Box grow="Yes" style={{ position: 'relative' }}>
Expand Down
Loading
Loading