Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c5456b492 | |||
| b057795735 | |||
| c81a036c4e | |||
| bdc017e609 | |||
| 27160145f9 | |||
| 8ea2cb48e9 | |||
| c3ef995404 | |||
| b4ed3aced0 | |||
| 30d00194be | |||
| 31b394a6e0 | |||
| 271d6fff5c | |||
| 8b8d71bc4d | |||
| 40f36cb182 | |||
| 2372731504 | |||
| 43a99a8d6d | |||
| f73abbd860 | |||
| e822f6f58f | |||
| 255dbdc712 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@chat-app/desktop",
|
"name": "@chat-app/desktop",
|
||||||
"version": "0.21.5",
|
"version": "0.21.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Electron desktop client (Windows / macOS / Linux)",
|
"description": "Electron desktop client (Windows / macOS / Linux)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"@livekit/components-react": "^2.9.0",
|
"@livekit/components-react": "^2.9.0",
|
||||||
"@livekit/track-processors": "^0.7.2",
|
"@livekit/track-processors": "^0.7.2",
|
||||||
"@supabase/supabase-js": "^2.46.0",
|
"@supabase/supabase-js": "^2.46.0",
|
||||||
|
"@tanstack/react-virtual": "^3.10.0",
|
||||||
"better-sqlite3": "^11.3.0",
|
"better-sqlite3": "^11.3.0",
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
"electron-updater": "^6.3.0",
|
"electron-updater": "^6.3.0",
|
||||||
@@ -35,7 +36,6 @@
|
|||||||
"react-easy-crop": "^5.5.7",
|
"react-easy-crop": "^5.5.7",
|
||||||
"react-i18next": "^15.1.1",
|
"react-i18next": "^15.1.1",
|
||||||
"react-router-dom": "^6.28.0",
|
"react-router-dom": "^6.28.0",
|
||||||
"react-virtuoso": "^4.18.7",
|
|
||||||
"zustand": "^5.0.1"
|
"zustand": "^5.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import { isNearBottom, isNearTop, nextStickIntent } from '../lib/scrollController';
|
||||||
|
import type { VirtuosoRow } from '../pages/ConversationPage';
|
||||||
|
|
||||||
|
export interface MessageListHandle {
|
||||||
|
scrollToBottom(behavior?: ScrollBehavior): void;
|
||||||
|
scrollToRow(index: number, align?: 'center' | 'end', behavior?: ScrollBehavior): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageListProps {
|
||||||
|
rows: VirtuosoRow[];
|
||||||
|
renderRow: (index: number, row: VirtuosoRow) => ReactNode;
|
||||||
|
computeKey: (row: VirtuosoRow) => string;
|
||||||
|
/** Initial scroll target for a freshly-mounted list. */
|
||||||
|
initialAnchor: { type: 'bottom' } | { type: 'row'; index: number };
|
||||||
|
/** Reveal gate — the list stays hidden until reactions/heights are loaded, so
|
||||||
|
* the post-paint height cascade is never visible. */
|
||||||
|
ready: boolean;
|
||||||
|
estimateRowHeight?: number;
|
||||||
|
atBottomThreshold?: number;
|
||||||
|
onReachTop?: () => void;
|
||||||
|
onAtBottomChange?: (atBottom: boolean) => void;
|
||||||
|
onTopRowChange?: (topIndex: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MessageList = forwardRef<MessageListHandle, MessageListProps>(function MessageList(
|
||||||
|
{
|
||||||
|
rows,
|
||||||
|
renderRow,
|
||||||
|
computeKey,
|
||||||
|
initialAnchor,
|
||||||
|
ready,
|
||||||
|
estimateRowHeight = 64,
|
||||||
|
atBottomThreshold = 64,
|
||||||
|
onReachTop,
|
||||||
|
onAtBottomChange,
|
||||||
|
onTopRowChange,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const scrollElRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [revealed, setRevealed] = useState(false);
|
||||||
|
|
||||||
|
// THE single source of truth: should the view stay pinned to the bottom?
|
||||||
|
// Only a genuine user up-input (wheel / key / touch / scrollbar drag) turns
|
||||||
|
// this OFF; only reaching the bottom turns it ON. A measurement reflow must
|
||||||
|
// never flip it — that was the root cause of the chat-switch bug.
|
||||||
|
const stickRef = useRef(true);
|
||||||
|
// Debounce for onAtBottomChange — fire the parent only on a real transition.
|
||||||
|
const lastReportedAtBottomRef = useRef<boolean | null>(null);
|
||||||
|
// Guard: scrolls WE cause (pin / measure re-pin / scrollToIndex) fire onScroll
|
||||||
|
// a tick later. Within this window we don't treat a scrollTop decrease as the
|
||||||
|
// user dragging up.
|
||||||
|
const programmaticRef = useRef(0);
|
||||||
|
// Previous scrollTop, to detect a genuine scrollbar/keyboard up-drag.
|
||||||
|
const lastScrollTopRef = useRef(0);
|
||||||
|
// Load-older preservation: remember the first row key + scrollHeight so a
|
||||||
|
// prepend can be detected and the viewport restored.
|
||||||
|
const prevFirstKeyRef = useRef<string | null>(null);
|
||||||
|
const prevScrollHeightRef = useRef(0);
|
||||||
|
|
||||||
|
// Latest onAtBottomChange, read through a ref so the input-listener effect
|
||||||
|
// can stay mounted once (deps []) without capturing a stale callback.
|
||||||
|
const onAtBottomChangeRef = useRef(onAtBottomChange);
|
||||||
|
onAtBottomChangeRef.current = onAtBottomChange;
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: rows.length,
|
||||||
|
getScrollElement: () => scrollElRef.current,
|
||||||
|
estimateSize: () => estimateRowHeight,
|
||||||
|
overscan: 8,
|
||||||
|
getItemKey: (index) => computeKey(rows[index]!),
|
||||||
|
});
|
||||||
|
|
||||||
|
const readMetrics = useCallback(() => {
|
||||||
|
const el = scrollElRef.current;
|
||||||
|
return el
|
||||||
|
? { scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight }
|
||||||
|
: { scrollTop: 0, scrollHeight: 0, clientHeight: 0 };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pinToBottom = useCallback(() => {
|
||||||
|
const el = scrollElRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
programmaticRef.current = performance.now();
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
lastScrollTopRef.current = el.scrollTop;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Report at-bottom to the parent only on a true transition, always driven by
|
||||||
|
// the INTENT (stickRef) — never the raw position. This is what kills the
|
||||||
|
// feedback loop: a transient "not at bottom" mid-reflow is never persisted.
|
||||||
|
const reportAtBottom = useCallback((atBottom: boolean) => {
|
||||||
|
if (lastReportedAtBottomRef.current === atBottom) return;
|
||||||
|
lastReportedAtBottomRef.current = atBottom;
|
||||||
|
onAtBottomChangeRef.current?.(atBottom);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// A genuine user up-input: drop the stick intent immediately.
|
||||||
|
const markUserMovedUp = useCallback(() => {
|
||||||
|
if (!stickRef.current) return;
|
||||||
|
stickRef.current = false;
|
||||||
|
reportAtBottom(false);
|
||||||
|
}, [reportAtBottom]);
|
||||||
|
|
||||||
|
// Re-pin to the true bottom whenever the content (or viewport) resizes while
|
||||||
|
// sticking. ResizeObserver fires after layout / before paint, so as rows
|
||||||
|
// measure and the list grows the bottom stays pinned with no stale frame.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollElRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
const e = scrollElRef.current;
|
||||||
|
if (stickRef.current && e) {
|
||||||
|
programmaticRef.current = performance.now();
|
||||||
|
e.scrollTop = e.scrollHeight;
|
||||||
|
lastScrollTopRef.current = e.scrollTop;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ro.observe(el);
|
||||||
|
const inner = el.firstElementChild;
|
||||||
|
if (inner) ro.observe(inner);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Genuine-user-intent listeners. These are the ONLY way (besides reaching the
|
||||||
|
// bottom) the stick intent turns off, so a reflow can never unstick the list.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollElRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
if (e.deltaY < 0) markUserMovedUp();
|
||||||
|
};
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'PageUp' || e.key === 'Home' || e.key === 'ArrowUp') markUserMovedUp();
|
||||||
|
};
|
||||||
|
let touchStartY = 0;
|
||||||
|
const onTouchStart = (e: TouchEvent) => {
|
||||||
|
touchStartY = e.touches[0]?.clientY ?? 0;
|
||||||
|
};
|
||||||
|
const onTouchMove = (e: TouchEvent) => {
|
||||||
|
const y = e.touches[0]?.clientY ?? 0;
|
||||||
|
// Finger dragged DOWN (content scrolls up toward older messages). Guard on
|
||||||
|
// scrollTop>0 so an overscroll bounce at the bottom doesn't unstick.
|
||||||
|
if (y - touchStartY > 8 && (scrollElRef.current?.scrollTop ?? 0) > 0) markUserMovedUp();
|
||||||
|
};
|
||||||
|
el.addEventListener('wheel', onWheel, { passive: true });
|
||||||
|
el.addEventListener('keydown', onKeyDown);
|
||||||
|
el.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||||
|
el.addEventListener('touchmove', onTouchMove, { passive: true });
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('wheel', onWheel);
|
||||||
|
el.removeEventListener('keydown', onKeyDown);
|
||||||
|
el.removeEventListener('touchstart', onTouchStart);
|
||||||
|
el.removeEventListener('touchmove', onTouchMove);
|
||||||
|
};
|
||||||
|
}, [markUserMovedUp]);
|
||||||
|
|
||||||
|
// Deferred reveal: when ready, pin to the anchor and keep pinning each frame
|
||||||
|
// until the list height has SETTLED over two consecutive frames, THEN reveal —
|
||||||
|
// so what appears is already at its final position with no top-then-jump.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!ready || revealed || rows.length === 0) return;
|
||||||
|
const el = scrollElRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const rowIdx = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(initialAnchor.type === 'row' ? initialAnchor.index : 0, rows.length - 1),
|
||||||
|
);
|
||||||
|
if (initialAnchor.type === 'bottom') {
|
||||||
|
stickRef.current = true;
|
||||||
|
pinToBottom();
|
||||||
|
} else {
|
||||||
|
stickRef.current = false;
|
||||||
|
programmaticRef.current = performance.now();
|
||||||
|
virtualizer.scrollToIndex(rowIdx, { align: 'start' });
|
||||||
|
}
|
||||||
|
reportAtBottom(stickRef.current);
|
||||||
|
|
||||||
|
let prevSH = -1;
|
||||||
|
let stableFrames = 0;
|
||||||
|
const settle = (attempts: number): void => {
|
||||||
|
const e = scrollElRef.current;
|
||||||
|
if (!e) {
|
||||||
|
setRevealed(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
programmaticRef.current = performance.now();
|
||||||
|
if (stickRef.current) {
|
||||||
|
e.scrollTop = e.scrollHeight;
|
||||||
|
lastScrollTopRef.current = e.scrollTop;
|
||||||
|
} else {
|
||||||
|
virtualizer.scrollToIndex(rowIdx, { align: 'start' });
|
||||||
|
}
|
||||||
|
const sh = e.scrollHeight;
|
||||||
|
// Require TWO consecutive stable-height frames: a single stable frame can
|
||||||
|
// land mid-cascade (between reactions and the unread divider measuring)
|
||||||
|
// and reveal a not-yet-final layout that then jumps.
|
||||||
|
stableFrames = sh === prevSH ? stableFrames + 1 : 0;
|
||||||
|
prevSH = sh;
|
||||||
|
if (stableFrames >= 2 || attempts <= 0) {
|
||||||
|
setRevealed(true);
|
||||||
|
} else {
|
||||||
|
requestAnimationFrame(() => settle(attempts - 1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
requestAnimationFrame(() => settle(12));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [ready, rows.length]);
|
||||||
|
|
||||||
|
// Load-older preservation: if rows were prepended (first key changed and the
|
||||||
|
// user is near the top), restore scrollTop by the height delta so the viewport
|
||||||
|
// stays put instead of jumping.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const firstKey = rows.length > 0 ? computeKey(rows[0]!) : null;
|
||||||
|
const el = scrollElRef.current;
|
||||||
|
if (el && revealed && prevFirstKeyRef.current && firstKey !== prevFirstKeyRef.current) {
|
||||||
|
const delta = el.scrollHeight - prevScrollHeightRef.current;
|
||||||
|
if (delta > 0 && el.scrollTop < atBottomThreshold * 4) {
|
||||||
|
el.scrollTop += delta;
|
||||||
|
lastScrollTopRef.current = el.scrollTop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevFirstKeyRef.current = firstKey;
|
||||||
|
prevScrollHeightRef.current = el?.scrollHeight ?? 0;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
// Re-pin on any rows change while sticking. Covers the two-phase data swap
|
||||||
|
// (the cached array is replaced by the freshly-decrypted one ~100ms after
|
||||||
|
// reveal) which the ResizeObserver can miss when the new content happens to
|
||||||
|
// measure to the same height.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (revealed && stickRef.current) pinToBottom();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
const m = readMetrics();
|
||||||
|
const programmatic = performance.now() - programmaticRef.current < 120;
|
||||||
|
const nearBottom = isNearBottom(m, atBottomThreshold);
|
||||||
|
// A scrollbar drag or keyboard scroll surfaces here as a scrollTop decrease.
|
||||||
|
// Suppress it inside the programmatic window so our own re-pin / settle is
|
||||||
|
// never mistaken for the user moving up. 2px deadzone absorbs sub-pixel jitter.
|
||||||
|
const userMovedUp = !programmatic && m.scrollTop < lastScrollTopRef.current - 2;
|
||||||
|
lastScrollTopRef.current = m.scrollTop;
|
||||||
|
|
||||||
|
stickRef.current = nextStickIntent(stickRef.current, { nearBottom, userMovedUp });
|
||||||
|
reportAtBottom(stickRef.current);
|
||||||
|
|
||||||
|
if (isNearTop(m, atBottomThreshold * 4)) onReachTop?.();
|
||||||
|
const first = virtualizer.getVirtualItems()[0];
|
||||||
|
if (first) onTopRowChange?.(first.index);
|
||||||
|
}, [atBottomThreshold, onReachTop, onTopRowChange, readMetrics, reportAtBottom, virtualizer]);
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
scrollToBottom: () => {
|
||||||
|
stickRef.current = true;
|
||||||
|
reportAtBottom(true);
|
||||||
|
pinToBottom();
|
||||||
|
},
|
||||||
|
scrollToRow: (index, align = 'center') => {
|
||||||
|
// The user is jumping to a specific row — drop the stick intent first so
|
||||||
|
// the ResizeObserver doesn't immediately drag the target back to the bottom.
|
||||||
|
stickRef.current = false;
|
||||||
|
reportAtBottom(false);
|
||||||
|
programmaticRef.current = performance.now();
|
||||||
|
virtualizer.scrollToIndex(index, { align });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[virtualizer, rows.length, reportAtBottom, pinToBottom],
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = virtualizer.getVirtualItems();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={scrollElRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
tabIndex={0}
|
||||||
|
className="min-h-0 flex-1 overflow-y-auto"
|
||||||
|
style={{
|
||||||
|
opacity: revealed ? 1 : 0,
|
||||||
|
position: 'relative',
|
||||||
|
overflowAnchor: 'none',
|
||||||
|
outline: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}>
|
||||||
|
{items.map((vi) => (
|
||||||
|
<div
|
||||||
|
key={vi.key}
|
||||||
|
data-index={vi.index}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
transform: `translateY(${vi.start}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderRow(vi.index, rows[vi.index]!)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* 12px bottom breathing space (matches the old Virtuoso Footer). */}
|
||||||
|
<div style={{ height: 12 }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { isNearBottom, isNearTop, nextStickIntent, resolveInitialAnchor } from './scrollController';
|
||||||
|
|
||||||
|
const m = (scrollTop: number, scrollHeight: number, clientHeight: number) => ({
|
||||||
|
scrollTop,
|
||||||
|
scrollHeight,
|
||||||
|
clientHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isNearBottom', () => {
|
||||||
|
it('true exactly at the bottom', () => {
|
||||||
|
expect(isNearBottom(m(900, 1000, 100), 64)).toBe(true);
|
||||||
|
});
|
||||||
|
it('true within threshold', () => {
|
||||||
|
expect(isNearBottom(m(860, 1000, 100), 64)).toBe(true);
|
||||||
|
});
|
||||||
|
it('false beyond threshold', () => {
|
||||||
|
expect(isNearBottom(m(800, 1000, 100), 64)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isNearTop', () => {
|
||||||
|
it('true at top', () => {
|
||||||
|
expect(isNearTop(m(0, 1000, 100), 64)).toBe(true);
|
||||||
|
});
|
||||||
|
it('false past threshold', () => {
|
||||||
|
expect(isNearTop(m(200, 1000, 100), 64)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveInitialAnchor', () => {
|
||||||
|
it('anchors to last row at end by default (no saved position)', () => {
|
||||||
|
expect(resolveInitialAnchor(null, 50)).toEqual({ index: 49, align: 'end' });
|
||||||
|
});
|
||||||
|
it('anchors to bottom when saved position stuck to bottom', () => {
|
||||||
|
expect(resolveInitialAnchor({ topmostIndex: 10, stickToBottom: true }, 50)).toEqual({
|
||||||
|
index: 49,
|
||||||
|
align: 'end',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('restores the saved row at the top when scrolled up', () => {
|
||||||
|
expect(resolveInitialAnchor({ topmostIndex: 12, stickToBottom: false }, 50)).toEqual({
|
||||||
|
index: 12,
|
||||||
|
align: 'start',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('clamps a stale saved index to the current row count', () => {
|
||||||
|
expect(resolveInitialAnchor({ topmostIndex: 999, stickToBottom: false }, 50)).toEqual({
|
||||||
|
index: 49,
|
||||||
|
align: 'start',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('handles an empty list', () => {
|
||||||
|
expect(resolveInitialAnchor(null, 0)).toEqual({ index: 0, align: 'end' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('nextStickIntent', () => {
|
||||||
|
it('turns ON when the bottom is reached', () => {
|
||||||
|
expect(nextStickIntent(false, { nearBottom: true, userMovedUp: false })).toBe(true);
|
||||||
|
});
|
||||||
|
it('turns OFF when the user genuinely moves up', () => {
|
||||||
|
expect(nextStickIntent(true, { nearBottom: false, userMovedUp: true })).toBe(false);
|
||||||
|
});
|
||||||
|
it('keeps the previous intent on a neutral scroll (measurement reflow)', () => {
|
||||||
|
expect(nextStickIntent(true, { nearBottom: false, userMovedUp: false })).toBe(true);
|
||||||
|
expect(nextStickIntent(false, { nearBottom: false, userMovedUp: false })).toBe(false);
|
||||||
|
});
|
||||||
|
it('reaching the bottom wins over a simultaneous move-up signal', () => {
|
||||||
|
expect(nextStickIntent(false, { nearBottom: true, userMovedUp: true })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// Pure, DOM-free scroll-decision logic for MessageList. Unit-tested so the
|
||||||
|
// tricky math is verified without a browser (jsdom has no layout).
|
||||||
|
|
||||||
|
export interface ScrollMetrics {
|
||||||
|
scrollTop: number;
|
||||||
|
scrollHeight: number;
|
||||||
|
clientHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Distance from the bottom edge is within `threshold` px. */
|
||||||
|
export function isNearBottom(m: ScrollMetrics, threshold: number): boolean {
|
||||||
|
return m.scrollHeight - (m.scrollTop + m.clientHeight) <= threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scroll offset is within `threshold` px of the top. */
|
||||||
|
export function isNearTop(m: ScrollMetrics, threshold: number): boolean {
|
||||||
|
return m.scrollTop <= threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SavedPosition {
|
||||||
|
topmostIndex: number;
|
||||||
|
stickToBottom: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Anchor {
|
||||||
|
index: number;
|
||||||
|
align: 'start' | 'end';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a freshly-opened chat should start.
|
||||||
|
* - default / "left at bottom" → last row, aligned to the viewport bottom.
|
||||||
|
* - "left scrolled up" → the saved top-most row, aligned to the viewport top
|
||||||
|
* (clamped in case the cached row count shrank).
|
||||||
|
*/
|
||||||
|
export function resolveInitialAnchor(saved: SavedPosition | null, rowCount: number): Anchor {
|
||||||
|
if (rowCount <= 0) return { index: 0, align: 'end' };
|
||||||
|
if (saved && !saved.stickToBottom) {
|
||||||
|
const index = Math.max(0, Math.min(saved.topmostIndex, rowCount - 1));
|
||||||
|
return { index, align: 'start' };
|
||||||
|
}
|
||||||
|
return { index: rowCount - 1, align: 'end' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The next stick-to-bottom intent given the current intent and the latest
|
||||||
|
* scroll signal. Intent only flips on a *definitive* signal:
|
||||||
|
* - reaching the bottom turns it ON,
|
||||||
|
* - a genuine user move-up turns it OFF.
|
||||||
|
* A neutral scroll — e.g. a measurement reflow that grows the content while
|
||||||
|
* rows settle — leaves the intent unchanged. This is the core fix for the
|
||||||
|
* chat-switch bug: a reflow must never be mistaken for the user scrolling up
|
||||||
|
* and so must never silently unstick the list.
|
||||||
|
*/
|
||||||
|
export function nextStickIntent(
|
||||||
|
prev: boolean,
|
||||||
|
signal: { nearBottom: boolean; userMovedUp: boolean },
|
||||||
|
): boolean {
|
||||||
|
if (signal.nearBottom) return true;
|
||||||
|
if (signal.userMovedUp) return false;
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
@@ -19,6 +19,10 @@ export interface UseMessageReactionsResult {
|
|||||||
byMessage: Map<string, AggregatedReaction[]>;
|
byMessage: Map<string, AggregatedReaction[]>;
|
||||||
toggle: (messageId: string, emoji: string) => Promise<void>;
|
toggle: (messageId: string, emoji: string) => Promise<void>;
|
||||||
voteExclusive: (messageId: string, emoji: string, exclusiveEmojis: string[]) => Promise<void>;
|
voteExclusive: (messageId: string, emoji: string, exclusiveEmojis: string[]) => Promise<void>;
|
||||||
|
// True once the reactions for the current message-id set have been fetched
|
||||||
|
// (or there are no messages). Drives MessageList's deferred reveal so the
|
||||||
|
// chat opens already showing reaction chips — no post-paint height jump.
|
||||||
|
ready: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batch-fetches reactions for the given message ids + subscribes to the
|
// Batch-fetches reactions for the given message ids + subscribes to the
|
||||||
@@ -29,10 +33,12 @@ export function useMessageReactions(
|
|||||||
): UseMessageReactionsResult {
|
): UseMessageReactionsResult {
|
||||||
const idsKey = useMemo(() => messageIds.join(','), [messageIds]);
|
const idsKey = useMemo(() => messageIds.join(','), [messageIds]);
|
||||||
const [rows, setRows] = useState<MessageReaction[]>([]);
|
const [rows, setRows] = useState<MessageReaction[]>([]);
|
||||||
|
const [readyKey, setReadyKey] = useState<string | null>(null);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
if (messageIds.length === 0) {
|
if (messageIds.length === 0) {
|
||||||
setRows([]);
|
setRows([]);
|
||||||
|
setReadyKey(idsKey);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -40,6 +46,8 @@ export function useMessageReactions(
|
|||||||
setRows(data);
|
setRows(data);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('listReactionsForMessages failed', err);
|
console.error('listReactionsForMessages failed', err);
|
||||||
|
} finally {
|
||||||
|
setReadyKey(idsKey);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [idsKey]);
|
}, [idsKey]);
|
||||||
@@ -129,5 +137,7 @@ export function useMessageReactions(
|
|||||||
[byMessage, myId, refresh],
|
[byMessage, myId, refresh],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { byMessage, toggle, voteExclusive };
|
const ready = readyKey === idsKey;
|
||||||
|
|
||||||
|
return { byMessage, toggle, voteExclusive, ready };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { extractErrorCode } from '@chat-app/shared/i18n';
|
|||||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { Virtuoso, type VirtuosoHandle, type IndexLocationWithAlign } from 'react-virtuoso';
|
import { MessageList, type MessageListHandle } from '../components/MessageList';
|
||||||
|
|
||||||
import { ComposerActionsMenu } from '../components/ComposerActionsMenu';
|
import { ComposerActionsMenu } from '../components/ComposerActionsMenu';
|
||||||
import { ConversationHeader } from '../components/ConversationHeader';
|
import { ConversationHeader } from '../components/ConversationHeader';
|
||||||
@@ -54,6 +54,7 @@ import {
|
|||||||
createWatchTogetherPayload,
|
createWatchTogetherPayload,
|
||||||
createGamePayload,
|
createGamePayload,
|
||||||
} from '../lib/conversationFeatures';
|
} from '../lib/conversationFeatures';
|
||||||
|
import { resolveInitialAnchor } from '../lib/scrollController';
|
||||||
const WhiteboardModal = lazy(() =>
|
const WhiteboardModal = lazy(() =>
|
||||||
import('../components/WhiteboardModal').then((m) => ({ default: m.WhiteboardModal })),
|
import('../components/WhiteboardModal').then((m) => ({ default: m.WhiteboardModal })),
|
||||||
);
|
);
|
||||||
@@ -84,7 +85,7 @@ import { clearDraft, getDraftSync, setDraft } from '../lib/composerDraftStore';
|
|||||||
// pending bubbles and the "load older" tile inside the same Virtuoso
|
// pending bubbles and the "load older" tile inside the same Virtuoso
|
||||||
// instance means scroll-to-bottom / followOutput stay coherent across both
|
// instance means scroll-to-bottom / followOutput stay coherent across both
|
||||||
// (we don't need a sibling scroll container for pending items).
|
// (we don't need a sibling scroll container for pending items).
|
||||||
type VirtuosoRow =
|
export type VirtuosoRow =
|
||||||
| { kind: 'loader'; key: string }
|
| { kind: 'loader'; key: string }
|
||||||
| { kind: 'message'; key: string; message: DecryptedMessage; idx: number }
|
| { kind: 'message'; key: string; message: DecryptedMessage; idx: number }
|
||||||
| { kind: 'pending'; key: string; item: OutboxItem };
|
| { kind: 'pending'; key: string; item: OutboxItem };
|
||||||
@@ -143,8 +144,22 @@ export function ConversationPage() {
|
|||||||
byMessage: reactionsByMessage,
|
byMessage: reactionsByMessage,
|
||||||
toggle: toggleReaction,
|
toggle: toggleReaction,
|
||||||
voteExclusive: votePoll,
|
voteExclusive: votePoll,
|
||||||
|
ready: reactionsReady,
|
||||||
} = useMessageReactions(messageIds, session?.user.id);
|
} = useMessageReactions(messageIds, session?.user.id);
|
||||||
|
|
||||||
|
// Deferred-reveal gate for MessageList: keep the list hidden until messages
|
||||||
|
// AND their reactions (the main post-paint height changer) are loaded, so the
|
||||||
|
// chat opens already-stable instead of flickering through the load cascade.
|
||||||
|
// A 300 ms max-timeout ensures a slow/empty reactions fetch never hangs it.
|
||||||
|
const [revealTimedOut, setRevealTimedOut] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
setRevealTimedOut(false);
|
||||||
|
if (!id || loading || messages.length === 0) return;
|
||||||
|
const tmo = window.setTimeout(() => setRevealTimedOut(true), 300);
|
||||||
|
return () => window.clearTimeout(tmo);
|
||||||
|
}, [id, loading, messages.length]);
|
||||||
|
const listReady = !loading && messages.length > 0 && (reactionsReady || revealTimedOut);
|
||||||
|
|
||||||
const myId = session?.user.id;
|
const myId = session?.user.id;
|
||||||
|
|
||||||
const ownMessageIds = useMemo(
|
const ownMessageIds = useMemo(
|
||||||
@@ -303,7 +318,7 @@ export function ConversationPage() {
|
|||||||
},
|
},
|
||||||
[send],
|
[send],
|
||||||
);
|
);
|
||||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
const listRef = useRef<MessageListHandle>(null);
|
||||||
const topmostIndexRef = useRef<number>(0);
|
const topmostIndexRef = useRef<number>(0);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const composerRef = useRef<HTMLTextAreaElement>(null);
|
const composerRef = useRef<HTMLTextAreaElement>(null);
|
||||||
@@ -471,30 +486,14 @@ export function ConversationPage() {
|
|||||||
// previous visit to this chat AND the user wasn't sticking to the
|
// previous visit to this chat AND the user wasn't sticking to the
|
||||||
// bottom, restore the saved row index (clamped to the current row
|
// bottom, restore the saved row index (clamped to the current row
|
||||||
// count in case the cache was trimmed).
|
// count in case the cache was trimmed).
|
||||||
const initialTopMostIndex = useMemo<number | IndexLocationWithAlign>(() => {
|
// Reuses the unit-tested resolveInitialAnchor so the "where do I open" rule
|
||||||
const saved = savedPositionRef.current;
|
// lives in one tested place. MessageList only reads this at reveal time (when
|
||||||
if (saved && !saved.stickToBottom) {
|
// rows are loaded), so depending on the row count clamps a stale saved index
|
||||||
// Restore the row the user was reading, pinned to the TOP of the
|
// correctly without freezing a mount-time count of 0.
|
||||||
// viewport — that's the anchor the index was captured at
|
const initialAnchor = useMemo<{ type: 'bottom' } | { type: 'row'; index: number }>(() => {
|
||||||
// (handleRangeChanged stores range.startIndex).
|
const anchor = resolveInitialAnchor(savedPositionRef.current, virtuosoRows.length);
|
||||||
const idx = Math.max(0, Math.min(saved.topmostIndex, virtuosoRows.length - 1));
|
return anchor.align === 'end' ? { type: 'bottom' } : { type: 'row', index: anchor.index };
|
||||||
return { index: idx, align: 'start' };
|
}, [virtuosoRows.length]);
|
||||||
}
|
|
||||||
// Bottom case (the common one): anchor the LAST row to the END (bottom)
|
|
||||||
// edge of the viewport. This is the fix for the "jumps once on chat
|
|
||||||
// switch" bug: a plain numeric index aligns the row to the TOP, so
|
|
||||||
// react-virtuoso paints with estimated row heights, then measures the
|
|
||||||
// real (taller) heights of the dynamic bubbles (avatars, attachments,
|
|
||||||
// multi-line text, reactions) and corrects scrollTop — a visible jump on
|
|
||||||
// every mount. `align: 'end'` pins the bottom edge instead, so the
|
|
||||||
// post-measurement height growth happens above the fold and the viewport
|
|
||||||
// stays put. This is react-virtuoso's canonical "start at the bottom" form.
|
|
||||||
return { index: 'LAST', align: 'end' };
|
|
||||||
// virtuosoRows.length flipping 0 -> >0 is the intentional trigger so a
|
|
||||||
// freshly-loaded chat anchors on first paint; we deliberately don't
|
|
||||||
// re-derive on every row append — Virtuoso owns scroll position after.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [virtuosoRows.length > 0]);
|
|
||||||
|
|
||||||
const jumpToMessage = useCallback(
|
const jumpToMessage = useCallback(
|
||||||
(targetId: string) => {
|
(targetId: string) => {
|
||||||
@@ -521,11 +520,7 @@ export function ConversationPage() {
|
|||||||
// resolve the target row. Without this, the scroll either no-ops or
|
// resolve the target row. Without this, the scroll either no-ops or
|
||||||
// lands on a stale row.
|
// lands on a stale row.
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
virtuosoRef.current?.scrollToIndex({
|
listRef.current?.scrollToRow(rowIndex, 'center', 'smooth');
|
||||||
index: rowIndex,
|
|
||||||
align: 'center',
|
|
||||||
behavior: 'smooth',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
setHighlightedId(targetId);
|
setHighlightedId(targetId);
|
||||||
window.setTimeout(
|
window.setTimeout(
|
||||||
@@ -721,23 +716,26 @@ export function ConversationPage() {
|
|||||||
const handleRangeChanged = useCallback(
|
const handleRangeChanged = useCallback(
|
||||||
(range: { startIndex: number; endIndex: number }) => {
|
(range: { startIndex: number; endIndex: number }) => {
|
||||||
topmostIndexRef.current = range.startIndex;
|
topmostIndexRef.current = range.startIndex;
|
||||||
if (id) {
|
if (!id) return;
|
||||||
|
if (stickToBottom) {
|
||||||
|
// Pinned to the bottom: the topmost-visible row drifts as the
|
||||||
|
// virtualizer mounts/unmounts rows. Persisting it would later reopen the
|
||||||
|
// chat at that arbitrary row (the second writer behind the scrollTop=0
|
||||||
|
// bug). Keep the last real reading position and only refresh the flag.
|
||||||
const prev = scrollPositions.get(id);
|
const prev = scrollPositions.get(id);
|
||||||
scrollPositions.set(id, {
|
scrollPositions.set(id, {
|
||||||
topmostIndex: range.startIndex,
|
topmostIndex: prev?.topmostIndex ?? range.startIndex,
|
||||||
stickToBottom: prev?.stickToBottom ?? true,
|
stickToBottom: true,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
scrollPositions.set(id, { topmostIndex: range.startIndex, stickToBottom: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[id],
|
[id, stickToBottom],
|
||||||
);
|
);
|
||||||
|
|
||||||
const jumpToBottom = useCallback(() => {
|
const jumpToBottom = useCallback(() => {
|
||||||
virtuosoRef.current?.scrollToIndex({
|
listRef.current?.scrollToBottom('smooth');
|
||||||
index: 'LAST',
|
|
||||||
align: 'end',
|
|
||||||
behavior: 'smooth',
|
|
||||||
});
|
|
||||||
setStickToBottom(true);
|
setStickToBottom(true);
|
||||||
setNewMessagesWhileAway(0);
|
setNewMessagesWhileAway(0);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -757,11 +755,7 @@ export function ConversationPage() {
|
|||||||
const lastPendingCountRef = useRef(pending.length);
|
const lastPendingCountRef = useRef(pending.length);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pending.length > lastPendingCountRef.current) {
|
if (pending.length > lastPendingCountRef.current) {
|
||||||
virtuosoRef.current?.scrollToIndex({
|
listRef.current?.scrollToBottom('auto');
|
||||||
index: 'LAST',
|
|
||||||
align: 'end',
|
|
||||||
behavior: 'auto',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
lastPendingCountRef.current = pending.length;
|
lastPendingCountRef.current = pending.length;
|
||||||
}, [pending.length]);
|
}, [pending.length]);
|
||||||
@@ -776,11 +770,7 @@ export function ConversationPage() {
|
|||||||
// targets the correct bottom edge.
|
// targets the correct bottom edge.
|
||||||
const snapToBottom = useCallback(() => {
|
const snapToBottom = useCallback(() => {
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
virtuosoRef.current?.scrollToIndex({
|
listRef.current?.scrollToBottom('auto');
|
||||||
index: 'LAST',
|
|
||||||
align: 'end',
|
|
||||||
behavior: 'auto',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -1065,49 +1055,24 @@ export function ConversationPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Virtuoso
|
<MessageList
|
||||||
ref={virtuosoRef}
|
ref={listRef}
|
||||||
className="flex-1"
|
rows={virtuosoRows}
|
||||||
style={{ height: '100%' }}
|
// Deferred reveal: the list stays hidden until messages + reactions
|
||||||
data={virtuosoRows}
|
// + the unread divider are loaded, then anchors and reveals — so the
|
||||||
computeItemKey={(_idx, row) => row.key}
|
// post-paint height cascade is never visible (no chat-switch flicker).
|
||||||
// Initial position: either restored from per-conv memory, or
|
ready={listReady}
|
||||||
// pinned to the bottom for fresh entry. Virtuoso applies this
|
computeKey={(row) => row.key}
|
||||||
// synchronously before its first paint so the user doesn't see
|
initialAnchor={initialAnchor}
|
||||||
// a "loaded at top, then jumped" flicker (matches the layout-
|
// 250px at-bottom tolerance — a tall appended row (image, voice note,
|
||||||
// effect behavior we used in the non-virtualized version).
|
// grouped attachments) shouldn't push the user out of the at-bottom zone.
|
||||||
initialTopMostItemIndex={initialTopMostIndex}
|
|
||||||
// followOutput auto-scrolls only when the user was already at
|
|
||||||
// the bottom; returning `false` from the callback when they're
|
|
||||||
// scrolled up preserves their reading position when realtime
|
|
||||||
// messages arrive (critical UX: do NOT jerk the user).
|
|
||||||
//
|
|
||||||
// We deliberately use 'auto' (instant) rather than 'smooth':
|
|
||||||
// with a smooth scroll animation, atBottomStateChange fires
|
|
||||||
// `false` mid-animation (scrollTop is briefly above the new
|
|
||||||
// bottom) and then `true` after settle — that flips
|
|
||||||
// stickToBottom twice, flashing the "Zum neuesten" pill and
|
|
||||||
// re-rendering the whole list. Instant scroll has zero
|
|
||||||
// mid-animation state so the cascade never happens.
|
|
||||||
followOutput={(isAtBottom) => (isAtBottom ? 'auto' : false)}
|
|
||||||
atBottomStateChange={handleAtBottomStateChange}
|
|
||||||
// 250 px tolerance — large enough that appending a tall row
|
|
||||||
// (image, voice note, grouped attachments) doesn't push the
|
|
||||||
// user out of the at-bottom zone. The previous 80 px flipped
|
|
||||||
// stickToBottom on nearly every typical message arrival.
|
|
||||||
atBottomThreshold={250}
|
atBottomThreshold={250}
|
||||||
rangeChanged={handleRangeChanged}
|
onReachTop={handleStartReached}
|
||||||
startReached={handleStartReached}
|
onAtBottomChange={handleAtBottomStateChange}
|
||||||
// Render rows just outside the viewport so fast scrolling
|
onTopRowChange={(topIndex) =>
|
||||||
// doesn't briefly flash empty space.
|
handleRangeChanged({ startIndex: topIndex, endIndex: topIndex })
|
||||||
increaseViewportBy={400}
|
}
|
||||||
// Visual breathing space below the last message so a bubble
|
renderRow={(_index, row) => {
|
||||||
// bottom doesn't sit flush against the composer top — matches
|
|
||||||
// Discord's chat-pane bottom padding.
|
|
||||||
components={{
|
|
||||||
Footer: () => <div style={{ height: '12px' }} />,
|
|
||||||
}}
|
|
||||||
itemContent={(_index, row) => {
|
|
||||||
if (row.kind === 'loader') {
|
if (row.kind === 'loader') {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-2 text-xs text-fg-muted">
|
<div className="flex items-center justify-center py-2 text-xs text-fg-muted">
|
||||||
|
|||||||
@@ -0,0 +1,673 @@
|
|||||||
|
# Message-List / Scroll Rewrite — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Replace the `react-virtuoso` message list with a TanStack-Virtual list that opens/switches chats flicker-free (Discord-like), preserving every existing behavior.
|
||||||
|
|
||||||
|
**Architecture:** Pure scroll-decision logic (`scrollController.ts`, unit-tested) + an isolated virtualization component (`MessageList.tsx`, TanStack Virtual, deferred reveal) + `ConversationPage` wiring. The flicker is killed by keeping the list hidden until messages+reactions+divider are stable, then anchoring before paint.
|
||||||
|
|
||||||
|
**Tech Stack:** React 18, TypeScript, `@tanstack/react-virtual` (new), vitest, electron-vite.
|
||||||
|
|
||||||
|
Spec: `docs/superpowers/specs/2026-06-02-message-list-scroll-rewrite-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- Create: `apps/desktop/src/lib/scrollController.ts` — pure scroll math (no DOM/React).
|
||||||
|
- Create: `apps/desktop/src/lib/scrollController.test.ts` — vitest unit tests.
|
||||||
|
- Create: `apps/desktop/src/components/MessageList.tsx` — TanStack virtual list + reveal/stick/load-older. Exports `MessageList`, `MessageListHandle`, `VirtuosoRow` is imported from ConversationPage's shared type (moved in Task 5).
|
||||||
|
- Modify: `apps/desktop/src/lib/useMessageReactions.ts` — add `ready` flag for the reveal gate.
|
||||||
|
- Modify: `apps/desktop/src/pages/ConversationPage.tsx` — export the row type, swap `<Virtuoso>` for `<MessageList>`, drive the handle, pass `ready`.
|
||||||
|
- Modify: `apps/desktop/package.json` — add `@tanstack/react-virtual`; remove `react-virtuoso` (Task 8).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Add the TanStack Virtual dependency
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/desktop/package.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Install**
|
||||||
|
|
||||||
|
Run (from repo root `chat-app/`):
|
||||||
|
```bash
|
||||||
|
pnpm --filter @chat-app/desktop add @tanstack/react-virtual@^3.10.0
|
||||||
|
```
|
||||||
|
Expected: adds `@tanstack/react-virtual` to `apps/desktop/package.json` dependencies; lockfile updated.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify it resolves**
|
||||||
|
|
||||||
|
Run: `pnpm --filter @chat-app/desktop exec node -e "require.resolve('@tanstack/react-virtual'); console.log('ok')"`
|
||||||
|
Expected: `ok`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/desktop/package.json pnpm-lock.yaml
|
||||||
|
git commit -m "build(desktop): add @tanstack/react-virtual"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Pure scroll-decision logic (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `apps/desktop/src/lib/scrollController.ts`
|
||||||
|
- Test: `apps/desktop/src/lib/scrollController.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// apps/desktop/src/lib/scrollController.test.ts
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { isNearBottom, isNearTop, resolveInitialAnchor } from './scrollController';
|
||||||
|
|
||||||
|
const m = (scrollTop: number, scrollHeight: number, clientHeight: number) => ({
|
||||||
|
scrollTop,
|
||||||
|
scrollHeight,
|
||||||
|
clientHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isNearBottom', () => {
|
||||||
|
it('true exactly at the bottom', () => {
|
||||||
|
expect(isNearBottom(m(900, 1000, 100), 64)).toBe(true);
|
||||||
|
});
|
||||||
|
it('true within threshold', () => {
|
||||||
|
expect(isNearBottom(m(860, 1000, 100), 64)).toBe(true);
|
||||||
|
});
|
||||||
|
it('false beyond threshold', () => {
|
||||||
|
expect(isNearBottom(m(800, 1000, 100), 64)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isNearTop', () => {
|
||||||
|
it('true at top', () => {
|
||||||
|
expect(isNearTop(m(0, 1000, 100), 64)).toBe(true);
|
||||||
|
});
|
||||||
|
it('false past threshold', () => {
|
||||||
|
expect(isNearTop(m(200, 1000, 100), 64)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveInitialAnchor', () => {
|
||||||
|
it('anchors to last row at end by default (no saved position)', () => {
|
||||||
|
expect(resolveInitialAnchor(null, 50)).toEqual({ index: 49, align: 'end' });
|
||||||
|
});
|
||||||
|
it('anchors to bottom when saved position stuck to bottom', () => {
|
||||||
|
expect(resolveInitialAnchor({ topmostIndex: 10, stickToBottom: true }, 50)).toEqual({
|
||||||
|
index: 49,
|
||||||
|
align: 'end',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('restores the saved row at the top when scrolled up', () => {
|
||||||
|
expect(resolveInitialAnchor({ topmostIndex: 12, stickToBottom: false }, 50)).toEqual({
|
||||||
|
index: 12,
|
||||||
|
align: 'start',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('clamps a stale saved index to the current row count', () => {
|
||||||
|
expect(resolveInitialAnchor({ topmostIndex: 999, stickToBottom: false }, 50)).toEqual({
|
||||||
|
index: 49,
|
||||||
|
align: 'start',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('handles an empty list', () => {
|
||||||
|
expect(resolveInitialAnchor(null, 0)).toEqual({ index: 0, align: 'end' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run, verify FAIL**
|
||||||
|
|
||||||
|
Run: `pnpm --filter @chat-app/desktop exec vitest run src/lib/scrollController.test.ts`
|
||||||
|
Expected: FAIL — "Failed to resolve import './scrollController'".
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// apps/desktop/src/lib/scrollController.ts
|
||||||
|
// Pure, DOM-free scroll-decision logic for MessageList. Unit-tested so the
|
||||||
|
// tricky math is verified without a browser (jsdom has no layout).
|
||||||
|
|
||||||
|
export interface ScrollMetrics {
|
||||||
|
scrollTop: number;
|
||||||
|
scrollHeight: number;
|
||||||
|
clientHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Distance from the bottom edge is within `threshold` px. */
|
||||||
|
export function isNearBottom(m: ScrollMetrics, threshold: number): boolean {
|
||||||
|
return m.scrollHeight - (m.scrollTop + m.clientHeight) <= threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scroll offset is within `threshold` px of the top. */
|
||||||
|
export function isNearTop(m: ScrollMetrics, threshold: number): boolean {
|
||||||
|
return m.scrollTop <= threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SavedPosition {
|
||||||
|
topmostIndex: number;
|
||||||
|
stickToBottom: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Anchor {
|
||||||
|
index: number;
|
||||||
|
align: 'start' | 'end';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a freshly-opened chat should start.
|
||||||
|
* - default / "left at bottom" → last row, aligned to the viewport bottom.
|
||||||
|
* - "left scrolled up" → the saved top-most row, aligned to the viewport top
|
||||||
|
* (clamped in case the cached row count shrank).
|
||||||
|
*/
|
||||||
|
export function resolveInitialAnchor(saved: SavedPosition | null, rowCount: number): Anchor {
|
||||||
|
if (rowCount <= 0) return { index: 0, align: 'end' };
|
||||||
|
if (saved && !saved.stickToBottom) {
|
||||||
|
const index = Math.max(0, Math.min(saved.topmostIndex, rowCount - 1));
|
||||||
|
return { index, align: 'start' };
|
||||||
|
}
|
||||||
|
return { index: rowCount - 1, align: 'end' };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run, verify PASS**
|
||||||
|
|
||||||
|
Run: `pnpm --filter @chat-app/desktop exec vitest run src/lib/scrollController.test.ts`
|
||||||
|
Expected: PASS (11 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/desktop/src/lib/scrollController.ts apps/desktop/src/lib/scrollController.test.ts
|
||||||
|
git commit -m "feat(desktop): pure scroll-decision logic for new message list"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Reveal-gate flag on `useMessageReactions`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/desktop/src/lib/useMessageReactions.ts`
|
||||||
|
|
||||||
|
Reactions are the main post-paint height changer. The list reveal waits on their first
|
||||||
|
fetch, so add a `ready` flag that is true once reactions for the current message-id set
|
||||||
|
have been fetched (or there are no messages).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `ready` to the result type + state**
|
||||||
|
|
||||||
|
In `UseMessageReactionsResult` add:
|
||||||
|
```ts
|
||||||
|
ready: boolean;
|
||||||
|
```
|
||||||
|
After `const [rows, setRows] = useState<MessageReaction[]>([]);` add:
|
||||||
|
```ts
|
||||||
|
const [readyKey, setReadyKey] = useState<string | null>(null);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Set the key after each fetch**
|
||||||
|
|
||||||
|
Replace the `refresh` callback body so both branches stamp `readyKey`:
|
||||||
|
```ts
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
if (messageIds.length === 0) {
|
||||||
|
setRows([]);
|
||||||
|
setReadyKey(idsKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await listReactionsForMessages(supabase, messageIds);
|
||||||
|
setRows(data);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('listReactionsForMessages failed', err);
|
||||||
|
} finally {
|
||||||
|
setReadyKey(idsKey);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [idsKey]);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Derive + return `ready`**
|
||||||
|
|
||||||
|
Before the `return`:
|
||||||
|
```ts
|
||||||
|
const ready = readyKey === idsKey;
|
||||||
|
```
|
||||||
|
And add `ready` to the returned object:
|
||||||
|
```ts
|
||||||
|
return { byMessage, toggle, voteExclusive, ready };
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Typecheck**
|
||||||
|
|
||||||
|
Run: `pnpm --filter @chat-app/desktop typecheck`
|
||||||
|
Expected: PASS (no output).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/desktop/src/lib/useMessageReactions.ts
|
||||||
|
git commit -m "feat(desktop): expose reactions reveal-gate flag (ready)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Export the shared row type from ConversationPage
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/desktop/src/pages/ConversationPage.tsx`
|
||||||
|
|
||||||
|
`MessageList` needs the row union. Export it from ConversationPage (smallest change;
|
||||||
|
the type already lives there).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Export the type**
|
||||||
|
|
||||||
|
Change the `type VirtuosoRow = …` declaration (near the top of the file) to:
|
||||||
|
```ts
|
||||||
|
export type VirtuosoRow =
|
||||||
|
| { kind: 'loader'; key: string }
|
||||||
|
| { kind: 'message'; key: string; message: DecryptedMessage; idx: number }
|
||||||
|
| { kind: 'pending'; key: string; item: OutboxItem };
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Typecheck**
|
||||||
|
|
||||||
|
Run: `pnpm --filter @chat-app/desktop typecheck`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/desktop/src/pages/ConversationPage.tsx
|
||||||
|
git commit -m "refactor(desktop): export VirtuosoRow type for MessageList"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: The `MessageList` component
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `apps/desktop/src/components/MessageList.tsx`
|
||||||
|
|
||||||
|
This is the integration unit. It is verified by typecheck here and **visually in dev**
|
||||||
|
in Task 7 (jsdom can't layout-test it). The TanStack specifics (scrollToIndex timing,
|
||||||
|
prepend offset) are the parts to refine during dev iteration.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Implement**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// apps/desktop/src/components/MessageList.tsx
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useCallback as _unused, // placeholder removed below
|
||||||
|
} from 'react';
|
||||||
|
```
|
||||||
|
> NOTE for the implementer: write the file with the imports below (the line above is
|
||||||
|
> illustrative only — do not keep it). Full file:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useImperativeHandle,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import { isNearBottom, isNearTop, resolveInitialAnchor, type Anchor } from '../lib/scrollController';
|
||||||
|
import type { VirtuosoRow } from '../pages/ConversationPage';
|
||||||
|
|
||||||
|
export interface MessageListHandle {
|
||||||
|
scrollToBottom(behavior?: ScrollBehavior): void;
|
||||||
|
scrollToRow(index: number, align?: 'center' | 'end', behavior?: ScrollBehavior): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageListProps {
|
||||||
|
rows: VirtuosoRow[];
|
||||||
|
renderRow: (index: number, row: VirtuosoRow) => ReactNode;
|
||||||
|
computeKey: (row: VirtuosoRow) => string;
|
||||||
|
initialAnchor: { type: 'bottom' } | { type: 'row'; index: number };
|
||||||
|
/** Reveal gate — list stays hidden behind a spinner until true (no flicker). */
|
||||||
|
ready: boolean;
|
||||||
|
estimateRowHeight?: number;
|
||||||
|
atBottomThreshold?: number;
|
||||||
|
onReachTop?: () => void;
|
||||||
|
onAtBottomChange?: (atBottom: boolean) => void;
|
||||||
|
onTopRowChange?: (topIndex: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MessageList = forwardRef<MessageListHandle, MessageListProps>(function MessageList(
|
||||||
|
{
|
||||||
|
rows,
|
||||||
|
renderRow,
|
||||||
|
computeKey,
|
||||||
|
initialAnchor,
|
||||||
|
ready,
|
||||||
|
estimateRowHeight = 64,
|
||||||
|
atBottomThreshold = 64,
|
||||||
|
onReachTop,
|
||||||
|
onAtBottomChange,
|
||||||
|
onTopRowChange,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const scrollElRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [revealed, setRevealed] = useState(false);
|
||||||
|
const atBottomRef = useRef(true);
|
||||||
|
// Load-older preservation: remember scrollHeight + first key across renders.
|
||||||
|
const prevFirstKeyRef = useRef<string | null>(null);
|
||||||
|
const prevScrollHeightRef = useRef(0);
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: rows.length,
|
||||||
|
getScrollElement: () => scrollElRef.current,
|
||||||
|
estimateSize: () => estimateRowHeight,
|
||||||
|
overscan: 8,
|
||||||
|
getItemKey: (index) => computeKey(rows[index]!),
|
||||||
|
});
|
||||||
|
|
||||||
|
const metrics = () => {
|
||||||
|
const el = scrollElRef.current;
|
||||||
|
return el
|
||||||
|
? { scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight }
|
||||||
|
: { scrollTop: 0, scrollHeight: 0, clientHeight: 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyAnchor = useCallback(
|
||||||
|
(anchor: Anchor) => {
|
||||||
|
virtualizer.scrollToIndex(anchor.index, { align: anchor.align });
|
||||||
|
// Re-apply on the next frame: dynamic measurement settles after the first
|
||||||
|
// paint, so a single scrollToIndex can land a few px off. The list is still
|
||||||
|
// hidden here, so this correction is never visible.
|
||||||
|
requestAnimationFrame(() => virtualizer.scrollToIndex(anchor.index, { align: anchor.align }));
|
||||||
|
},
|
||||||
|
[virtualizer],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Deferred reveal: when ready, anchor (before paint) then reveal.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!ready || revealed || rows.length === 0) return;
|
||||||
|
const anchor: Anchor =
|
||||||
|
initialAnchor.type === 'bottom'
|
||||||
|
? { index: rows.length - 1, align: 'end' }
|
||||||
|
: { index: Math.max(0, Math.min(initialAnchor.index, rows.length - 1)), align: 'start' };
|
||||||
|
applyAnchor(anchor);
|
||||||
|
atBottomRef.current = initialAnchor.type === 'bottom';
|
||||||
|
onAtBottomChange?.(atBottomRef.current);
|
||||||
|
requestAnimationFrame(() => setRevealed(true));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [ready, rows.length]);
|
||||||
|
|
||||||
|
// Stick-to-bottom: when content grows and we were at the bottom, re-pin.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!revealed) return;
|
||||||
|
if (atBottomRef.current) {
|
||||||
|
virtualizer.scrollToIndex(rows.length - 1, { align: 'end' });
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [rows.length, virtualizer.getTotalSize()]);
|
||||||
|
|
||||||
|
// Load-older preservation: if rows were prepended (first key changed and count
|
||||||
|
// grew), restore scrollTop by the height delta so the viewport stays put.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const firstKey = rows.length > 0 ? computeKey(rows[0]!) : null;
|
||||||
|
const el = scrollElRef.current;
|
||||||
|
if (el && revealed && prevFirstKeyRef.current && firstKey !== prevFirstKeyRef.current) {
|
||||||
|
const delta = el.scrollHeight - prevScrollHeightRef.current;
|
||||||
|
if (delta > 0 && el.scrollTop < atBottomThreshold) {
|
||||||
|
el.scrollTop += delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevFirstKeyRef.current = firstKey;
|
||||||
|
prevScrollHeightRef.current = el?.scrollHeight ?? 0;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
const m = metrics();
|
||||||
|
const atBottom = isNearBottom(m, atBottomThreshold);
|
||||||
|
if (atBottom !== atBottomRef.current) {
|
||||||
|
atBottomRef.current = atBottom;
|
||||||
|
onAtBottomChange?.(atBottom);
|
||||||
|
}
|
||||||
|
if (isNearTop(m, atBottomThreshold * 4)) onReachTop?.();
|
||||||
|
const first = virtualizer.getVirtualItems()[0];
|
||||||
|
if (first) onTopRowChange?.(first.index);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [atBottomThreshold, onAtBottomChange, onReachTop, onTopRowChange, virtualizer]);
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
scrollToBottom: () => {
|
||||||
|
atBottomRef.current = true;
|
||||||
|
virtualizer.scrollToIndex(rows.length - 1, { align: 'end' });
|
||||||
|
},
|
||||||
|
scrollToRow: (index, align = 'center') => {
|
||||||
|
virtualizer.scrollToIndex(index, { align });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[virtualizer, rows.length],
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = virtualizer.getVirtualItems();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={scrollElRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
className="h-full overflow-y-auto"
|
||||||
|
style={{ opacity: revealed ? 1 : 0, position: 'relative' }}
|
||||||
|
>
|
||||||
|
<div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}>
|
||||||
|
{items.map((vi) => (
|
||||||
|
<div
|
||||||
|
key={vi.key}
|
||||||
|
data-index={vi.index}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }}
|
||||||
|
>
|
||||||
|
{renderRow(vi.index, rows[vi.index]!)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* 12px bottom breathing space (matches the old Footer). */}
|
||||||
|
<div style={{ height: 12 }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
> Implementer note: delete the illustrative first `import` snippet; keep only the full
|
||||||
|
> file. The `requestAnimationFrame` timing in `applyAnchor`/reveal is the most likely
|
||||||
|
> spot to refine during dev (Task 7).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Typecheck**
|
||||||
|
|
||||||
|
Run: `pnpm --filter @chat-app/desktop typecheck`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/desktop/src/components/MessageList.tsx
|
||||||
|
git commit -m "feat(desktop): TanStack-Virtual MessageList with deferred reveal"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Wire `MessageList` into `ConversationPage`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/desktop/src/pages/ConversationPage.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Imports + reveal gate**
|
||||||
|
|
||||||
|
Replace the `react-virtuoso` import with:
|
||||||
|
```ts
|
||||||
|
import { MessageList, type MessageListHandle } from '../components/MessageList';
|
||||||
|
```
|
||||||
|
Capture the reactions `ready` flag — change the `useMessageReactions` destructure to also pull `ready`:
|
||||||
|
```ts
|
||||||
|
const {
|
||||||
|
byMessage: reactionsByMessage,
|
||||||
|
toggle: toggleReaction,
|
||||||
|
voteExclusive: votePoll,
|
||||||
|
ready: reactionsReady,
|
||||||
|
} = useMessageReactions(messageIds, session?.user.id);
|
||||||
|
```
|
||||||
|
Add a reveal gate with a 300ms max-timeout fallback (so empty/slow reactions never hang):
|
||||||
|
```ts
|
||||||
|
const [revealTimedOut, setRevealTimedOut] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id || loading || messages.length === 0) return;
|
||||||
|
const t = window.setTimeout(() => setRevealTimedOut(true), 300);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [id, loading, messages.length]);
|
||||||
|
const listReady = !loading && messages.length > 0 && (reactionsReady || revealTimedOut);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace the `virtuosoRef` type + handle**
|
||||||
|
|
||||||
|
Change:
|
||||||
|
```ts
|
||||||
|
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```ts
|
||||||
|
const listRef = useRef<MessageListHandle>(null);
|
||||||
|
```
|
||||||
|
Replace every `virtuosoRef.current?.scrollToIndex({ index: 'LAST', align: 'end', behavior })`
|
||||||
|
call (in `jumpToBottom`, the pending-snap effect, `snapToBottom`) with:
|
||||||
|
```ts
|
||||||
|
listRef.current?.scrollToBottom('auto');
|
||||||
|
```
|
||||||
|
Replace the `jumpToMessage` scroll (`virtuosoRef.current?.scrollToIndex({ index: rowIndex, align: 'center', behavior: 'smooth' })`) with:
|
||||||
|
```ts
|
||||||
|
listRef.current?.scrollToRow(rowIndex, 'center', 'smooth');
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Compute `initialAnchor`**
|
||||||
|
|
||||||
|
Replace the `initialTopMostIndex` `useMemo` (the `IndexLocationWithAlign` one from the
|
||||||
|
earlier hotfix) with:
|
||||||
|
```ts
|
||||||
|
const initialAnchor = useMemo<{ type: 'bottom' } | { type: 'row'; index: number }>(() => {
|
||||||
|
const saved = savedPositionRef.current;
|
||||||
|
if (saved && !saved.stickToBottom) return { type: 'row', index: saved.topmostIndex };
|
||||||
|
return { type: 'bottom' };
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
Remove the now-unused `IndexLocationWithAlign` import.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Swap the JSX**
|
||||||
|
|
||||||
|
Replace the entire `<Virtuoso … />` element with:
|
||||||
|
```tsx
|
||||||
|
<MessageList
|
||||||
|
ref={listRef}
|
||||||
|
rows={virtuosoRows}
|
||||||
|
ready={listReady}
|
||||||
|
computeKey={(row) => row.key}
|
||||||
|
initialAnchor={initialAnchor}
|
||||||
|
atBottomThreshold={250}
|
||||||
|
onReachTop={handleStartReached}
|
||||||
|
onAtBottomChange={handleAtBottomStateChange}
|
||||||
|
onTopRowChange={(topIndex) => handleRangeChanged({ startIndex: topIndex, endIndex: topIndex })}
|
||||||
|
renderRow={(_index, row) => {
|
||||||
|
// ...exact same body the old `itemContent` had (loader / pending /
|
||||||
|
// message branches) — move it verbatim from the deleted <Virtuoso>.
|
||||||
|
return renderConversationRow(row);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
Move the old `itemContent` body into a local `renderConversationRow(row)` helper (or inline it) so the message/loader/pending branches are unchanged. `handleRangeChanged` already accepts `{ startIndex, endIndex }`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Typecheck + unit tests**
|
||||||
|
|
||||||
|
Run: `pnpm --filter @chat-app/desktop typecheck && pnpm --filter @chat-app/desktop test`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/desktop/src/pages/ConversationPage.tsx
|
||||||
|
git commit -m "feat(desktop): use MessageList in ConversationPage (replace react-virtuoso)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Dev verification (with the user) — iterate until smooth
|
||||||
|
|
||||||
|
**Files:** none (runtime verification)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run the dev build**
|
||||||
|
|
||||||
|
User runs (in `chat-app/`): `pnpm desktop:dev`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify behaviors live**
|
||||||
|
|
||||||
|
Switch between several chats repeatedly and confirm, using the `SCROLL_DEBUG` console
|
||||||
|
output where helpful:
|
||||||
|
- No jump and no multi-flicker on chat switch (opens cleanly at the bottom / saved row).
|
||||||
|
- New message while at bottom auto-scrolls; while scrolled up shows the pill.
|
||||||
|
- Unread divider present without a later shift.
|
||||||
|
- Scroll to top loads older without the viewport jumping.
|
||||||
|
- Jump-to-message (reply tap / pinned / search) scrolls to the target.
|
||||||
|
- Sent/pending message snaps to bottom.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Refine**
|
||||||
|
|
||||||
|
If any behavior is off, adjust `MessageList.tsx` (most likely the `applyAnchor`/reveal
|
||||||
|
`requestAnimationFrame` timing or the stick-to-bottom effect) and re-verify. Commit each
|
||||||
|
refinement:
|
||||||
|
```bash
|
||||||
|
git commit -am "fix(desktop): refine MessageList <specific behavior>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Cleanup + release 0.21.6
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/desktop/src/pages/ConversationPage.tsx` (remove instrumentation)
|
||||||
|
- Modify: `apps/desktop/package.json` (remove `react-virtuoso`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remove the `SCROLL_DEBUG` instrumentation**
|
||||||
|
|
||||||
|
Delete the `SCROLL_DEBUG`/`dbgNow`/`dbgLog` block, the render-logger `useEffect`, and the
|
||||||
|
`dbgLog(...)` calls inside `handleRangeChanged` / `handleAtBottomStateChange`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Remove the old dependency**
|
||||||
|
|
||||||
|
Run: `pnpm --filter @chat-app/desktop remove react-virtuoso`
|
||||||
|
Then confirm no references remain:
|
||||||
|
Run: `grep -rn "react-virtuoso\|Virtuoso\b" apps/desktop/src || echo "clean"`
|
||||||
|
Expected: `clean`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Typecheck + tests + commit**
|
||||||
|
|
||||||
|
Run: `pnpm --filter @chat-app/desktop typecheck && pnpm --filter @chat-app/desktop test`
|
||||||
|
Expected: PASS.
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "chore(desktop): drop react-virtuoso + scroll debug instrumentation"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Release**
|
||||||
|
|
||||||
|
Run (from `chat-app/`, tree clean): `node scripts/release.mjs 0.21.6 "- Nachrichtenliste komplett überarbeitet: Chat-Wechsel öffnet jetzt ruckel- und flackerfrei direkt unten\n- Älteren Verlauf laden springt nicht mehr"`
|
||||||
|
Then verify `latest.yml` shows 0.21.6 on `update.netralax.de` **and** `update.netralax.cloud`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
- **Spec coverage:** deferred reveal (Tasks 3,5,6) ✓; TanStack virtualization (Tasks 1,5) ✓; isolation into MessageList + scrollController (Tasks 2,5) ✓; stick-to-bottom (Task 5) ✓; load-older preservation (Task 5) ✓; preserved behaviors incl. jump-to-message/pill/divider/pending (Task 6) ✓; pure-logic unit tests (Task 2) ✓; dev verification (Task 7) ✓; cleanup + release (Task 8) ✓.
|
||||||
|
- **Placeholders:** the only prose-only steps are the deliberately runtime Task 7 (no code possible) and the "move itemContent verbatim" in Task 6 Step 4 (the body is large and unchanged — copying it verbatim, not rewriting). The illustrative throwaway import in Task 5 Step 1 is explicitly flagged for deletion.
|
||||||
|
- **Type consistency:** `MessageListHandle.scrollToBottom/scrollToRow`, `VirtuosoRow`, `Anchor`, `ScrollMetrics`, `resolveInitialAnchor` signatures are consistent across Tasks 2/5/6. `ready` flag added in Task 3 is consumed in Task 6.
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# Message-List / Scroll Rewrite — Design
|
||||||
|
|
||||||
|
Date: 2026-06-02
|
||||||
|
Status: Approved (brainstorming) — pending spec review → implementation plan
|
||||||
|
Scope: Desktop app only (`apps/desktop`). Mobile is out of scope.
|
||||||
|
|
||||||
|
## 1. Problem & Root Cause
|
||||||
|
|
||||||
|
Switching conversations causes a visible jump and then a multi-flicker. Root cause
|
||||||
|
(established via systematic debugging, not guessing):
|
||||||
|
|
||||||
|
- The message list is **virtualized** (`react-virtuoso`). Virtualization paints rows
|
||||||
|
with *estimated* heights, then measures real heights and corrects `scrollTop`.
|
||||||
|
- On chat open, several async sources change **row heights after the first paint**:
|
||||||
|
message **reactions** (`useMessageReactions`), the **unread divider**
|
||||||
|
(`firstUnreadId`), delivery/read **receipts**, and the **two-phase message load**
|
||||||
|
(in-memory cache render → server `refresh()` replaces the array).
|
||||||
|
- Each post-paint height change makes the virtualizer re-measure and re-anchor →
|
||||||
|
the viewport visibly moves several times = "flickert paar mal".
|
||||||
|
|
||||||
|
A first targeted fix (`initialTopMostItemIndex: { index: 'LAST', align: 'end' }`)
|
||||||
|
addressed only the *initial* anchor, not the post-paint cascade — so the flicker
|
||||||
|
remained/worsened. Conclusion: re-architect the scroll system.
|
||||||
|
|
||||||
|
## 2. Goals / Success Criteria
|
||||||
|
|
||||||
|
1. Opening or switching a chat lands cleanly at the bottom (or the saved scrolled-up
|
||||||
|
row) with **no visible jump or flicker**.
|
||||||
|
2. Discord-like live behavior: auto-scroll on new message when at bottom; "X new
|
||||||
|
messages" pill when scrolled up; unread divider; load-older without the viewport
|
||||||
|
jumping; jump-to-message / search / pin scroll.
|
||||||
|
3. Scales to **large conversations** (thousands of messages, deep back-scroll) —
|
||||||
|
virtualization stays.
|
||||||
|
4. No regression of the existing features that live in `ConversationPage`.
|
||||||
|
|
||||||
|
## 3. Decision
|
||||||
|
|
||||||
|
Build on **`@tanstack/react-virtual`** (MIT, free) as the virtualization primitive,
|
||||||
|
and kill the flicker at its root with a **deferred-reveal** strategy: never show the
|
||||||
|
list while its row heights are still settling.
|
||||||
|
|
||||||
|
Rejected alternatives: keeping `react-virtuoso` (we are fighting it); the commercial
|
||||||
|
`@virtuoso.dev/message-list` (license cost); dropping virtualization entirely
|
||||||
|
(large chats would render thousands of DOM nodes).
|
||||||
|
|
||||||
|
## 4. Architecture (isolation)
|
||||||
|
|
||||||
|
The scroll/virtualization logic moves out of the ~2000-line `ConversationPage` into
|
||||||
|
two focused, independently-testable units:
|
||||||
|
|
||||||
|
- **`apps/desktop/src/components/MessageList.tsx`** — owns the scroll container,
|
||||||
|
the TanStack virtualizer, dynamic measurement, deferred reveal, stick-to-bottom,
|
||||||
|
and load-older position preservation. Receives rows + a render function; emits
|
||||||
|
scroll events + exposes an imperative handle. Knows nothing about messages,
|
||||||
|
reactions, drafts, calls, etc.
|
||||||
|
- **`apps/desktop/src/lib/scrollController.ts`** — the **pure**, DOM-free decision
|
||||||
|
logic (anchor computation, "should auto-scroll to bottom?", load-older index/offset
|
||||||
|
math, at-bottom threshold). Unit-tested with vitest.
|
||||||
|
- **`ConversationPage`** keeps all feature state and rendering; it builds the same
|
||||||
|
`VirtuosoRow[]` discriminated union (`loader | message | pending`), passes them +
|
||||||
|
the existing per-row render (`itemContent`) into `<MessageList>`, and drives the
|
||||||
|
imperative handle for jump-to-message/search.
|
||||||
|
|
||||||
|
Boundary contract: *in* = rows + renderRow; *out* = scroll events + an imperative
|
||||||
|
handle. The internals of `MessageList` can change without touching `ConversationPage`.
|
||||||
|
|
||||||
|
## 5. No-Flicker Core
|
||||||
|
|
||||||
|
### 5.1 Deferred reveal
|
||||||
|
`MessageList` is always mounted (so TanStack can measure the initial window), but
|
||||||
|
rendered **visually hidden** (`opacity: 0`, pointer-events none) behind a spinner
|
||||||
|
until `ready` is true. When `ready` flips true, in a `useLayoutEffect` (before the
|
||||||
|
browser paints) it scrolls to `initialAnchor` (bottom, or the saved row), then
|
||||||
|
reveals (`opacity: 1`) and removes the spinner. The user sees: brief spinner →
|
||||||
|
final, correctly-anchored list. The height-changing cascade happens **while hidden**.
|
||||||
|
|
||||||
|
`ready` (owned by `ConversationPage`, passed in) is defined as:
|
||||||
|
- messages loaded (`!loading && rows.length > 0`), **AND**
|
||||||
|
- the initial **reactions** fetch for the current message-id set has completed
|
||||||
|
(requires adding a `ready`/`loaded` flag to `useMessageReactions`), **AND**
|
||||||
|
- a hard **max-timeout of ~300 ms** fallback so a slow/empty reactions fetch never
|
||||||
|
hangs the reveal.
|
||||||
|
|
||||||
|
The unread divider is computed synchronously in an effect right after messages load,
|
||||||
|
i.e. before reactions resolve — so it is present before reveal. Delivery/read receipts
|
||||||
|
render as inline ticks (no meaningful height change) and are intentionally **not**
|
||||||
|
gated.
|
||||||
|
|
||||||
|
### 5.2 Stick-to-bottom
|
||||||
|
A `ResizeObserver` on the inner content element: while the user is at the bottom
|
||||||
|
(within `atBottomThreshold`, default 64px), any content-size growth re-pins the view
|
||||||
|
to the bottom in a layout effect (before paint) — so a live incoming message/reaction
|
||||||
|
never leaves the newest message half-scrolled.
|
||||||
|
|
||||||
|
### 5.3 Dynamic measurement
|
||||||
|
TanStack `measureElement` (ResizeObserver per rendered row) handles variable bubble
|
||||||
|
heights. Only the virtual window (visible + overscan ~8 rows) is rendered/measured.
|
||||||
|
|
||||||
|
### 5.4 Load-older without jump
|
||||||
|
On `onReachTop`, `ConversationPage` grows `displayCount` (prepending older rows).
|
||||||
|
Because prepending shifts indices, `MessageList` preserves position: capture
|
||||||
|
`scrollHeight` before the row growth, then after re-render set
|
||||||
|
`scrollTop += (newScrollHeight − oldScrollHeight)` in a layout effect keyed on
|
||||||
|
"rows grew at the top". Items keep stable keys via `computeKey` (message id). This
|
||||||
|
also fixes the second audit gap (older-load jump).
|
||||||
|
|
||||||
|
## 6. Preserved Behaviors
|
||||||
|
|
||||||
|
| Behavior | New mechanism |
|
||||||
|
|---|---|
|
||||||
|
| Open → bottom / saved row | `initialAnchor` applied in `useLayoutEffect` before reveal |
|
||||||
|
| New message while at bottom → follow | stick-to-bottom controller |
|
||||||
|
| New message while scrolled up → pill | `onAtBottomChange` drives the existing pill |
|
||||||
|
| Unread divider | computed before reveal → no later height change |
|
||||||
|
| Load older (scroll to top) | `onReachTop` + scrollHeight-delta preservation |
|
||||||
|
| Jump-to-message / search / pin | imperative `scrollToRow(index, align)` |
|
||||||
|
| Pending (outbox) bubbles | stay as a row kind in the same list |
|
||||||
|
| Per-conversation scroll memory | unchanged module-scoped `scrollPositions` map, fed by `onAtBottomChange` + `onTopRowChange` |
|
||||||
|
|
||||||
|
## 7. Interface
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface MessageListHandle {
|
||||||
|
scrollToBottom(behavior?: 'auto' | 'smooth'): void;
|
||||||
|
scrollToRow(index: number, align?: 'center' | 'end', behavior?: 'auto' | 'smooth'): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageListProps {
|
||||||
|
rows: VirtuosoRow[]; // loader | message | pending
|
||||||
|
renderRow: (index: number, row: VirtuosoRow) => React.ReactNode;
|
||||||
|
computeKey: (row: VirtuosoRow) => string; // message id / pending id / '__loader__'
|
||||||
|
initialAnchor: { type: 'bottom' } | { type: 'row'; index: number };
|
||||||
|
ready: boolean; // reveal gate (§5.1)
|
||||||
|
estimateRowHeight?: number; // default ~64
|
||||||
|
atBottomThreshold?: number; // default 64px
|
||||||
|
onReachTop(): void; // load older
|
||||||
|
onAtBottomChange(atBottom: boolean): void;
|
||||||
|
onTopRowChange(topIndex: number): void; // scroll memory
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Error Handling / Edge Cases
|
||||||
|
|
||||||
|
- Empty conversation: `rows.length === 0` → `MessageList` renders nothing; `ready`
|
||||||
|
short-circuits to the existing empty state in `ConversationPage`.
|
||||||
|
- Single / very short conversation (content < viewport): bottom-anchor is a no-op;
|
||||||
|
reveal immediately.
|
||||||
|
- Rapid chat switching: each switch remounts `ConversationPage` (per-id key) → a
|
||||||
|
fresh `MessageList` instance with fresh measurements (no stale sizes carried over).
|
||||||
|
- Very large `displayCount` after deep back-scroll: only the virtual window renders;
|
||||||
|
memory bounded by overscan.
|
||||||
|
- Reactions fetch error/empty: max-timeout reveals the list anyway.
|
||||||
|
|
||||||
|
## 9. Testing
|
||||||
|
|
||||||
|
- **Unit (vitest):** `scrollController.ts` pure functions — anchor resolution,
|
||||||
|
should-auto-scroll decision, load-older offset math, at-bottom threshold.
|
||||||
|
- **Manual (dev):** iterate in `pnpm desktop:dev` with the temporary `SCROLL_DEBUG`
|
||||||
|
logging until chat-switch is flicker-free and all §6 behaviors verified live.
|
||||||
|
- No automated DOM/layout test (jsdom has no layout); the running app is the test.
|
||||||
|
|
||||||
|
## 10. Rollout
|
||||||
|
|
||||||
|
1. Add `@tanstack/react-virtual`.
|
||||||
|
2. Build `scrollController.ts` (+ tests) and `MessageList.tsx`.
|
||||||
|
3. Swap the `<Virtuoso>` block in `ConversationPage` for `<MessageList>`; add the
|
||||||
|
`ready` flag to `useMessageReactions`.
|
||||||
|
4. Verify in dev with the user (flicker-free + all behaviors).
|
||||||
|
5. Remove the `SCROLL_DEBUG` instrumentation and the `react-virtuoso` dependency.
|
||||||
|
6. Release **0.21.6** to `update.netralax.de` (served on `.de` + `.cloud`).
|
||||||
|
|
||||||
|
## 11. Out of Scope
|
||||||
|
|
||||||
|
Composer, header, dialogs, calls, search UI, message rendering (`MessageBubble`),
|
||||||
|
encryption/data layer, mobile app. Reaction/receipt *data* loading is touched only to
|
||||||
|
add the `ready` flag for the reveal gate.
|
||||||
|
|
||||||
|
## 12. Open Risks
|
||||||
|
|
||||||
|
- TanStack prepend position-preservation needs careful layout-effect timing; mitigated
|
||||||
|
by dev iteration before release.
|
||||||
|
- The `ready` reveal adds a brief (≤300 ms) spinner on chat open even for cached
|
||||||
|
chats; acceptable trade-off vs flicker. A future in-memory reaction cache could make
|
||||||
|
revisits instant (not in this scope).
|
||||||
Generated
+20
-14
@@ -65,6 +65,9 @@ importers:
|
|||||||
'@supabase/supabase-js':
|
'@supabase/supabase-js':
|
||||||
specifier: ^2.46.0
|
specifier: ^2.46.0
|
||||||
version: 2.103.3
|
version: 2.103.3
|
||||||
|
'@tanstack/react-virtual':
|
||||||
|
specifier: ^3.10.0
|
||||||
|
version: 3.14.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
better-sqlite3:
|
better-sqlite3:
|
||||||
specifier: ^11.3.0
|
specifier: ^11.3.0
|
||||||
version: 11.10.0
|
version: 11.10.0
|
||||||
@@ -98,9 +101,6 @@ importers:
|
|||||||
react-router-dom:
|
react-router-dom:
|
||||||
specifier: ^6.28.0
|
specifier: ^6.28.0
|
||||||
version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
react-virtuoso:
|
|
||||||
specifier: ^4.18.7
|
|
||||||
version: 4.18.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
|
||||||
zustand:
|
zustand:
|
||||||
specifier: ^5.0.1
|
specifier: ^5.0.1
|
||||||
version: 5.0.12(@types/react@18.3.28)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1))
|
version: 5.0.12(@types/react@18.3.28)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1))
|
||||||
@@ -1915,6 +1915,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==}
|
resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
'@tanstack/react-virtual@3.14.2':
|
||||||
|
resolution: {integrity: sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
|
'@tanstack/virtual-core@3.17.0':
|
||||||
|
resolution: {integrity: sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==}
|
||||||
|
|
||||||
'@testing-library/react-native@12.9.0':
|
'@testing-library/react-native@12.9.0':
|
||||||
resolution: {integrity: sha512-wIn/lB1FjV2N4Q7i9PWVRck3Ehwq5pkhAef5X5/bmQ78J/NoOsGbVY2/DG5Y9Lxw+RfE+GvSEh/fe5Tz6sKSvw==}
|
resolution: {integrity: sha512-wIn/lB1FjV2N4Q7i9PWVRck3Ehwq5pkhAef5X5/bmQ78J/NoOsGbVY2/DG5Y9Lxw+RfE+GvSEh/fe5Tz6sKSvw==}
|
||||||
deprecated: React Native Testing Library v12 is no longer maintained. Please upgrade to v13 or v14.
|
deprecated: React Native Testing Library v12 is no longer maintained. Please upgrade to v13 or v14.
|
||||||
@@ -5479,12 +5488,6 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18.3.1
|
react: ^18.3.1
|
||||||
|
|
||||||
react-virtuoso@4.18.7:
|
|
||||||
resolution: {integrity: sha512-xNF5zDGEEIMB7cKwcen/pLig0YDf6OnfFrVgKFa7sHPf9fRem0CaLshyObbBcP88jzn0enavL39EgplgdyT21g==}
|
|
||||||
peerDependencies:
|
|
||||||
react: '>=16 || >=17 || >= 18 || >= 19'
|
|
||||||
react-dom: '>=16 || >=17 || >= 18 || >=19'
|
|
||||||
|
|
||||||
react@18.3.1:
|
react@18.3.1:
|
||||||
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -8760,6 +8763,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
defer-to-connect: 2.0.1
|
defer-to-connect: 2.0.1
|
||||||
|
|
||||||
|
'@tanstack/react-virtual@3.14.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/virtual-core': 3.17.0
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
|
||||||
|
'@tanstack/virtual-core@3.17.0': {}
|
||||||
|
|
||||||
'@testing-library/react-native@12.9.0(react-native@0.76.9(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(encoding@0.1.13)(react@18.3.1))(react-test-renderer@18.3.1(react@18.3.1))(react@18.3.1)':
|
'@testing-library/react-native@12.9.0(react-native@0.76.9(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(encoding@0.1.13)(react@18.3.1))(react-test-renderer@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
jest-matcher-utils: 29.7.0
|
jest-matcher-utils: 29.7.0
|
||||||
@@ -12972,11 +12983,6 @@ snapshots:
|
|||||||
react-shallow-renderer: 16.15.0(react@18.3.1)
|
react-shallow-renderer: 16.15.0(react@18.3.1)
|
||||||
scheduler: 0.23.2
|
scheduler: 0.23.2
|
||||||
|
|
||||||
react-virtuoso@4.18.7(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)
|
|
||||||
|
|
||||||
react@18.3.1:
|
react@18.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
|
|||||||
Reference in New Issue
Block a user