Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d5c54166a4 | |||
| f438018400 | |||
| 89003f71a4 | |||
| b364c53c61 | |||
| 9c5456b492 | |||
| b057795735 | |||
| c81a036c4e | |||
| bdc017e609 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chat-app/desktop",
|
||||
"version": "0.21.8",
|
||||
"version": "0.21.11",
|
||||
"private": true,
|
||||
"description": "Electron desktop client (Windows / macOS / Linux)",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Reusable avatar that prefers an uploaded image and falls back to a coloured
|
||||
// letter circle. Use this everywhere the app needs to render a profile.
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useCachedAvatarUrl } from '../lib/avatarCache';
|
||||
|
||||
interface Props {
|
||||
@@ -27,7 +29,13 @@ export function Avatar({
|
||||
loading = 'lazy',
|
||||
}: Props) {
|
||||
const effectiveUrl = useCachedAvatarUrl(url);
|
||||
if (effectiveUrl) {
|
||||
// If the image URL is non-empty but unreachable (e.g. the storage object is
|
||||
// missing / 404s), the bare <img> would render broken with no fallback.
|
||||
// Track a load error and degrade to the letter circle instead. Reset on URL
|
||||
// change so a fresh, valid avatar is retried.
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => setFailed(false), [effectiveUrl]);
|
||||
if (effectiveUrl && !failed) {
|
||||
return (
|
||||
<img
|
||||
src={effectiveUrl}
|
||||
@@ -35,6 +43,7 @@ export function Avatar({
|
||||
className={'shrink-0 rounded-full object-cover ' + className}
|
||||
draggable={false}
|
||||
loading={loading}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,12 +7,10 @@ import {
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type MutableRefObject,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from 'react';
|
||||
|
||||
import { isNearBottom, isNearTop } from '../lib/scrollController';
|
||||
import { isNearBottom, isNearTop, nextStickIntent } from '../lib/scrollController';
|
||||
import type { VirtuosoRow } from '../pages/ConversationPage';
|
||||
|
||||
export interface MessageListHandle {
|
||||
@@ -26,8 +24,8 @@ export interface MessageListProps {
|
||||
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 behind a spinner until true, so the
|
||||
* post-paint height cascade (reactions/divider) is never visible. */
|
||||
/** 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;
|
||||
@@ -53,12 +51,30 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
||||
) {
|
||||
const scrollElRef = useRef<HTMLDivElement>(null);
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const atBottomRef = useRef(true);
|
||||
|
||||
// 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,
|
||||
@@ -77,71 +93,132 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
||||
const pinToBottom = useCallback(() => {
|
||||
const el = scrollElRef.current;
|
||||
if (!el) return;
|
||||
const before = el.scrollTop;
|
||||
programmaticRef.current = performance.now();
|
||||
el.scrollTop = el.scrollHeight;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[scroll] pin sh=${el.scrollHeight} ch=${el.clientHeight} top:${Math.round(before)}->${Math.round(el.scrollTop)}`,
|
||||
);
|
||||
lastScrollTopRef.current = el.scrollTop;
|
||||
}, []);
|
||||
|
||||
// Deferred reveal: when ready, position at the anchor (before paint), let one
|
||||
// measure cycle settle (just-rendered rows get their real heights), re-pin,
|
||||
// then reveal — so what appears is already final.
|
||||
//
|
||||
// For the bottom case we drive scrollTop = scrollHeight DIRECTLY rather than
|
||||
// virtualizer.scrollToIndex: scrollToIndex depends on the virtualizer's own
|
||||
// layout effect having run first (effect ordering is not guaranteed) and on its
|
||||
// size estimates — when it lost that race the list stayed pinned at the TOP.
|
||||
// Driving the DOM scrollTop is order-independent and always lands at the true
|
||||
// bottom; the stick-to-bottom effect re-pins as the heights settle.
|
||||
// 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;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[scroll] reveal anchor=${initialAnchor.type} rows=${rows.length} total=${Math.round(virtualizer.getTotalSize())} sh=${el.scrollHeight} ch=${el.clientHeight}`,
|
||||
const rowIdx = Math.max(
|
||||
0,
|
||||
Math.min(initialAnchor.type === 'row' ? initialAnchor.index : 0, rows.length - 1),
|
||||
);
|
||||
if (initialAnchor.type === 'bottom') {
|
||||
stickRef.current = true;
|
||||
pinToBottom();
|
||||
atBottomRef.current = true;
|
||||
} else {
|
||||
virtualizer.scrollToIndex(Math.max(0, Math.min(initialAnchor.index, rows.length - 1)), {
|
||||
align: 'start',
|
||||
});
|
||||
atBottomRef.current = false;
|
||||
stickRef.current = false;
|
||||
programmaticRef.current = performance.now();
|
||||
virtualizer.scrollToIndex(rowIdx, { align: 'start' });
|
||||
}
|
||||
onAtBottomChange?.(atBottomRef.current);
|
||||
requestAnimationFrame(() => {
|
||||
if (atBottomRef.current) {
|
||||
pinToBottom();
|
||||
} else if (initialAnchor.type === 'row') {
|
||||
// Re-apply after the virtualizer's own layout effect has run + measured,
|
||||
// so the saved scrolled-up row lands accurately (same ordering caveat as
|
||||
// the bottom case, handled here by deferring a frame).
|
||||
virtualizer.scrollToIndex(Math.max(0, Math.min(initialAnchor.index, rows.length - 1)), {
|
||||
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;
|
||||
}
|
||||
requestAnimationFrame(() => setRevealed(true));
|
||||
});
|
||||
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]);
|
||||
|
||||
// Stick-to-bottom: whenever content grows (new row OR a measured row got
|
||||
// taller) and we were at the bottom, re-pin to the true bottom. Runs while
|
||||
// hidden too, so the list stays pinned through the initial measure settle.
|
||||
useLayoutEffect(() => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[scroll] stick? atBottom=${atBottomRef.current} total=${Math.round(virtualizer.getTotalSize())} revealed=${revealed}`,
|
||||
);
|
||||
if (!atBottomRef.current) return;
|
||||
pinToBottom();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rows.length, virtualizer.getTotalSize()]);
|
||||
|
||||
// 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.
|
||||
@@ -152,6 +229,7 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
||||
const delta = el.scrollHeight - prevScrollHeightRef.current;
|
||||
if (delta > 0 && el.scrollTop < atBottomThreshold * 4) {
|
||||
el.scrollTop += delta;
|
||||
lastScrollTopRef.current = el.scrollTop;
|
||||
}
|
||||
}
|
||||
prevFirstKeyRef.current = firstKey;
|
||||
@@ -159,50 +237,69 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
||||
// 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 atBottom = isNearBottom(m, atBottomThreshold);
|
||||
if (atBottom !== atBottomRef.current) {
|
||||
atBottomRef.current = atBottom;
|
||||
onAtBottomChange?.(atBottom);
|
||||
}
|
||||
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, onAtBottomChange, onReachTop, onTopRowChange, readMetrics, virtualizer]);
|
||||
}, [atBottomThreshold, onReachTop, onTopRowChange, readMetrics, reportAtBottom, virtualizer]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
scrollToBottom: () => {
|
||||
atBottomRef.current = true;
|
||||
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],
|
||||
[virtualizer, rows.length, reportAtBottom, pinToBottom],
|
||||
);
|
||||
|
||||
const items = virtualizer.getVirtualItems();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollDebugOverlay
|
||||
scrollElRef={scrollElRef}
|
||||
getTotal={() => virtualizer.getTotalSize()}
|
||||
atBottomRef={atBottomRef}
|
||||
revealed={revealed}
|
||||
ready={ready}
|
||||
/>
|
||||
<div
|
||||
ref={scrollElRef}
|
||||
onScroll={handleScroll}
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
style={{ opacity: revealed ? 1 : 0, position: 'relative' }}
|
||||
>
|
||||
<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
|
||||
@@ -221,60 +318,8 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* 12px bottom breathing space (matches the old Virtuoso Footer). */}
|
||||
<div style={{ height: 12 }} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
interface ScrollDebugOverlayProps {
|
||||
scrollElRef: RefObject<HTMLDivElement>;
|
||||
getTotal: () => number;
|
||||
atBottomRef: MutableRefObject<boolean>;
|
||||
revealed: boolean;
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
// TEMP on-screen debug readout — remove after the chat-switch scroll bug is fixed.
|
||||
function ScrollDebugOverlay({
|
||||
scrollElRef,
|
||||
getTotal,
|
||||
atBottomRef,
|
||||
revealed,
|
||||
ready,
|
||||
}: ScrollDebugOverlayProps) {
|
||||
const [, force] = useState(0);
|
||||
useEffect(() => {
|
||||
let raf = 0;
|
||||
const loop = () => {
|
||||
force((n) => (n + 1) % 1_000_000);
|
||||
raf = requestAnimationFrame(loop);
|
||||
};
|
||||
raf = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, []);
|
||||
const el = scrollElRef.current;
|
||||
const sh = el?.scrollHeight ?? 0;
|
||||
const ch = el?.clientHeight ?? 0;
|
||||
const st = el ? Math.round(el.scrollTop) : 0;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 8,
|
||||
right: 8,
|
||||
zIndex: 99999,
|
||||
background: 'rgba(0,0,0,0.82)',
|
||||
color: '#19ff8a',
|
||||
font: '11px/1.4 monospace',
|
||||
padding: '6px 9px',
|
||||
borderRadius: 5,
|
||||
pointerEvents: 'none',
|
||||
whiteSpace: 'pre',
|
||||
}}
|
||||
>
|
||||
{`scrollHeight=${sh} clientHeight=${ch}\nscrollTop=${st}\ntotalSize=${Math.round(getTotal())}\nSCROLLABLE=${sh > ch + 4 ? 'YES' : 'NO'}\natBottom=${atBottomRef.current} revealed=${revealed} ready=${ready}`}
|
||||
{/* 12px bottom breathing space (matches the old Virtuoso Footer). */}
|
||||
<div style={{ height: 12 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isNearBottom, isNearTop, resolveInitialAnchor } from './scrollController';
|
||||
import { isNearBottom, isNearTop, nextStickIntent, resolveInitialAnchor } from './scrollController';
|
||||
|
||||
const m = (scrollTop: number, scrollHeight: number, clientHeight: number) => ({
|
||||
scrollTop,
|
||||
@@ -55,3 +55,19 @@ describe('resolveInitialAnchor', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,3 +41,22 @@ export function resolveInitialAnchor(saved: SavedPosition | null, rowCount: numb
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
createWatchTogetherPayload,
|
||||
createGamePayload,
|
||||
} from '../lib/conversationFeatures';
|
||||
import { resolveInitialAnchor } from '../lib/scrollController';
|
||||
const WhiteboardModal = lazy(() =>
|
||||
import('../components/WhiteboardModal').then((m) => ({ default: m.WhiteboardModal })),
|
||||
);
|
||||
@@ -143,21 +144,18 @@ export function ConversationPage() {
|
||||
byMessage: reactionsByMessage,
|
||||
toggle: toggleReaction,
|
||||
voteExclusive: votePoll,
|
||||
ready: reactionsReady,
|
||||
} = 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);
|
||||
// Reveal gate for MessageList: as soon as messages exist (cache hit = first
|
||||
// render, so no spinner and no wait), let the list reveal. We deliberately do
|
||||
// NOT gate on reactions readiness: on a cache-hit chat switch the messages are
|
||||
// already present, and gating on the async reactions fetch held the list at
|
||||
// opacity:0 for up to 300ms and then "popped" it in — that was the residual
|
||||
// chat-switch flicker. Reaction chips stream in a beat later; because the list
|
||||
// is pinned to the bottom, their height growth re-pins with no visible jump.
|
||||
// MessageList still defers its own reveal a few frames until the row-height
|
||||
// measurement settles, so the list still appears already at the final bottom.
|
||||
const listReady = !loading && messages.length > 0;
|
||||
|
||||
const myId = session?.user.id;
|
||||
|
||||
@@ -485,11 +483,14 @@ export function ConversationPage() {
|
||||
// previous visit to this chat AND the user wasn't sticking to the
|
||||
// bottom, restore the saved row index (clamped to the current row
|
||||
// count in case the cache was trimmed).
|
||||
// Reuses the unit-tested resolveInitialAnchor so the "where do I open" rule
|
||||
// lives in one tested place. MessageList only reads this at reveal time (when
|
||||
// rows are loaded), so depending on the row count clamps a stale saved index
|
||||
// correctly without freezing a mount-time count of 0.
|
||||
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' };
|
||||
}, []);
|
||||
const anchor = resolveInitialAnchor(savedPositionRef.current, virtuosoRows.length);
|
||||
return anchor.align === 'end' ? { type: 'bottom' } : { type: 'row', index: anchor.index };
|
||||
}, [virtuosoRows.length]);
|
||||
|
||||
const jumpToMessage = useCallback(
|
||||
(targetId: string) => {
|
||||
@@ -712,15 +713,22 @@ export function ConversationPage() {
|
||||
const handleRangeChanged = useCallback(
|
||||
(range: { startIndex: number; endIndex: number }) => {
|
||||
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);
|
||||
scrollPositions.set(id, {
|
||||
topmostIndex: range.startIndex,
|
||||
stickToBottom: prev?.stickToBottom ?? true,
|
||||
topmostIndex: prev?.topmostIndex ?? range.startIndex,
|
||||
stickToBottom: true,
|
||||
});
|
||||
} else {
|
||||
scrollPositions.set(id, { topmostIndex: range.startIndex, stickToBottom: false });
|
||||
}
|
||||
},
|
||||
[id],
|
||||
[id, stickToBottom],
|
||||
);
|
||||
|
||||
const jumpToBottom = useCallback(() => {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
# Incident fix (2026-06-02): after the netralax.cloud -> netralax.de move, every
|
||||
# storage object GET returned HTTP 500 with:
|
||||
# { "code": "ENODATA", "errno": 61, "message": "The extended attribute does not exist." }
|
||||
#
|
||||
# Root cause: the OBJECT BYTES were copied to the new server, but Supabase
|
||||
# Storage (supabase/storage-api:v1.48.26, file backend) keeps each object's
|
||||
# response metadata in Linux extended attributes (xattrs) on the version file:
|
||||
# user.supabase.content-type
|
||||
# user.supabase.cache-control
|
||||
# user.supabase.etag
|
||||
# The migration copy did not preserve xattrs, so storage's getObject() throws
|
||||
# ENODATA when it reads them. The OLD server is gone, so we cannot re-copy —
|
||||
# but every value we need is still in the database column storage.objects.metadata
|
||||
# (mimetype / cacheControl / eTag). This script reconstructs the missing xattrs
|
||||
# from that column. It is idempotent and only ADDS metadata xattrs; it never
|
||||
# touches object bytes.
|
||||
#
|
||||
# RUN ON THE NEW SERVER (needs /opt/supabase, docker, and root for setxattr):
|
||||
# sudo python3 04-restore-storage-xattrs.py # all buckets
|
||||
# sudo python3 04-restore-storage-xattrs.py --dry-run # show, change nothing
|
||||
# sudo python3 04-restore-storage-xattrs.py --bucket profile-avatars
|
||||
# After it finishes, no storage restart is needed (xattrs are read per request).
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
SUPABASE_DIR = "/opt/supabase"
|
||||
# Single-tenant self-hosted layout: <volume>/stub/stub/<bucket>/<name>/<version-file>
|
||||
STORAGE_ROOT = os.path.join(SUPABASE_DIR, "volumes/storage/stub/stub")
|
||||
|
||||
# DB metadata field -> (xattr name, default when the field is absent)
|
||||
XATTRS = [
|
||||
("mimetype", "user.supabase.content-type", "application/octet-stream"),
|
||||
("cacheControl", "user.supabase.cache-control", "no-cache"),
|
||||
("eTag", "user.supabase.etag", None), # None default => skip if missing
|
||||
]
|
||||
|
||||
|
||||
def fetch_objects():
|
||||
"""Return [(bucket_id, name, metadata_dict), ...] from storage.objects."""
|
||||
# Tab-separate so object names containing '|' can't break parsing.
|
||||
query = (
|
||||
"select bucket_id||chr(9)||name||chr(9)||coalesce(metadata::text,'{}') "
|
||||
"from storage.objects"
|
||||
)
|
||||
raw = subprocess.check_output(
|
||||
[
|
||||
"docker", "compose", "exec", "-T", "db",
|
||||
"psql", "-U", "postgres", "-d", "postgres", "-tAc", query,
|
||||
],
|
||||
cwd=SUPABASE_DIR,
|
||||
).decode()
|
||||
rows = []
|
||||
for line in raw.splitlines():
|
||||
line = line.rstrip("\r")
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split("\t", 2)
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
bucket, name, meta = parts
|
||||
try:
|
||||
md = json.loads(meta) if meta else {}
|
||||
except json.JSONDecodeError:
|
||||
md = {}
|
||||
rows.append((bucket, name, md))
|
||||
return rows
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--bucket", help="only this bucket (e.g. profile-avatars)")
|
||||
ap.add_argument("--dry-run", action="store_true", help="print, change nothing")
|
||||
args = ap.parse_args()
|
||||
|
||||
objects = fetch_objects()
|
||||
fixed = missing_dir = missing_file = 0
|
||||
|
||||
for bucket, name, md in objects:
|
||||
if args.bucket and bucket != args.bucket:
|
||||
continue
|
||||
objdir = os.path.join(STORAGE_ROOT, bucket, name)
|
||||
if not os.path.isdir(objdir):
|
||||
print("NO_DIR ", bucket, name)
|
||||
missing_dir += 1
|
||||
continue
|
||||
version_files = [
|
||||
os.path.join(objdir, f)
|
||||
for f in os.listdir(objdir)
|
||||
if os.path.isfile(os.path.join(objdir, f))
|
||||
]
|
||||
if not version_files:
|
||||
print("NO_FILE ", bucket, name)
|
||||
missing_file += 1
|
||||
continue
|
||||
for path in version_files:
|
||||
for field, xattr, default in XATTRS:
|
||||
value = md.get(field, default)
|
||||
if value is None:
|
||||
continue
|
||||
if args.dry_run:
|
||||
print(f" would set {xattr}={value!r} on {path}")
|
||||
else:
|
||||
os.setxattr(path, xattr, str(value).encode())
|
||||
fixed += 1
|
||||
print("OK ", bucket, name)
|
||||
|
||||
print(
|
||||
f"\n{'DRY-RUN: would fix' if args.dry_run else 'fixed'} {fixed} file(s); "
|
||||
f"{missing_dir} missing dir(s), {missing_file} empty object dir(s)."
|
||||
)
|
||||
if missing_dir or missing_file:
|
||||
print(
|
||||
"NOTE: objects with a missing dir/file have lost their bytes and "
|
||||
"cannot be recovered from xattrs — those are genuinely gone."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if os.geteuid() != 0 and "--dry-run" not in sys.argv:
|
||||
print("Re-run with sudo (setxattr needs root).", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
main()
|
||||
Reference in New Issue
Block a user