diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index d7b5e774e..bdecdd45d 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -153,7 +153,7 @@ Sources: [follow import](app/Actions/CreateUserGitHubFollows.php), [suggestions] Logs are the app's timelines. **Following** contains your commits and those of developers you follow, newest first; it requires sign-in. **Latest** contains the newest public commits across the app. **Trending** favors recent public commits that attract engagement from distinct other developers across the thread, with replies and quotes weighing more than stars and diminishing returns from additional engagement. Pastéis don't affect Trending. On the web, Trending reshuffles each time you open it: higher-scoring commits are more likely to appear first, but the order varies between visits and the same draw is used while you scroll. The signed API does not redraw on each visit. -Logs list top-level commits, including quotes, rather than every reply, 20 at a time as you scroll, and show your own pending commits to you. Following and Latest check for new commits every 30 seconds and mark where you left off. Following and Latest load new commits automatically. At the top, they animate into view without a notification. If you have scrolled down, your reading position stays still and a **New commits** control appears only after new commits are loaded above you. It previews up to three distinct authors from the new commits waiting above you, with a +N circle for additional authors. Each check removes deleted or no-longer-visible arrivals from the announcement and loaded feed; the control disappears if none remain. Select it to scroll to the new commits; it disappears when you reach them. On touch screens, swipe sideways to move between logs. An empty Following log suggests three recent commits from people to follow. Muted and blocked people are filtered for signed-in viewers. The last log you opened is remembered. +Logs list top-level commits, including quotes, rather than every reply, 20 at a time as you scroll, and show your own pending commits to you. Following and Latest check for new commits every 30 seconds and mark where you left off. Following and Latest load new commits automatically. New arrivals stay above the content you are reading, even when you start at the top or their images and link previews finish loading later. A **New commits** control appears after they load above you and stays until you return to the top or see the newest arrival below the sticky tabs. Switching away from the tab does not mark arrivals as seen. It previews up to three distinct authors from the new commits waiting above you, with a +N circle for additional authors. Each check removes deleted or no-longer-visible arrivals from the announcement and loaded feed; the control disappears if none remain. Select it to scroll to the new commits; it disappears when you reach them. On touch screens, swipe sideways to move between logs. An empty Following log suggests three recent commits from people to follow. Muted and blocked people are filtered for signed-in viewers. The last log you opened is remembered. Browser Back and Forward restore expanded replies and loaded thread continuations. Offscreen commits are measured before scroll restoration so returning to a log or discussion keeps the content in place. diff --git a/resources/js/pages/log/show.tsx b/resources/js/pages/log/show.tsx index fed5ade38..aab7c12d4 100644 --- a/resources/js/pages/log/show.tsx +++ b/resources/js/pages/log/show.tsx @@ -64,11 +64,30 @@ export default function Show({ const tabsRef = useRef(null); const [tabsHeight, setTabsHeight] = useState(0); const feedRef = useRef(null); - const followAnchor = useRef<{ row: HTMLElement; top: number } | null>(null); + const followAnchor = useRef<{ + row: HTMLElement; + top: number; + scrollY: number; + } | null>(null); + const returningToTop = useRef(false); + const nativeAnchoring = useRef(null); const [composer, setComposer] = useState(null); useLaneSwipe(feedRef, log); useComposerVisibility(composer, tabsHeight); + useLayoutEffect(() => { + followAnchor.current = null; + returningToTop.current = false; + + return () => { + if (nativeAnchoring.current !== null) { + document.documentElement.style.overflowAnchor = + nativeAnchoring.current; + nativeAnchoring.current = null; + } + }; + }, [log]); + useEffect(() => { const tabs = tabsRef.current; @@ -86,21 +105,118 @@ export default function Show({ return () => observer.disconnect(); }, []); - const preserveReadingPosition = useCallback((ids: Set) => { - const headerBottom = - tabsRef.current?.getBoundingClientRect().bottom ?? 0; - const rows = - feedRef.current?.querySelectorAll('[data-commit]') ?? - []; - const row = Array.from(rows).find( - (row) => - ids.has(row.dataset.commit ?? '') && - row.getBoundingClientRect().bottom > headerBottom, - ); - followAnchor.current = row - ? { row, top: row.getBoundingClientRect().top } - : null; - }, []); + const preserveReadingPosition = useCallback( + (ids: Set) => { + if (returningToTop.current) return; + const headerBottom = + tabsRef.current?.getBoundingClientRect().bottom ?? 0; + const focused = document.activeElement?.closest( + 'input, textarea, [contenteditable="true"]', + ); + const rows = + feedRef.current?.querySelectorAll( + '[data-commit]', + ) ?? []; + const row = + focused && + composer?.contains(focused) && + focused.getBoundingClientRect().bottom > headerBottom && + focused.getBoundingClientRect().top < window.innerHeight + ? focused + : Array.from(rows).find( + (row) => + ids.has(row.dataset.commit ?? '') && + row.getBoundingClientRect().bottom > headerBottom, + ); + followAnchor.current = row + ? { + row, + top: row.getBoundingClientRect().top, + scrollY: window.scrollY, + } + : null; + if (row && nativeAnchoring.current === null) { + // Take ownership before insertion so the browser cannot also compensate for it. + const style = document.documentElement.style; + nativeAnchoring.current = style.overflowAnchor; + style.overflowAnchor = 'none'; + } + }, + [composer], + ); + + const restoreReadingPosition = useCallback(() => { + if (returningToTop.current) return; + const anchor = followAnchor.current; + if (!anchor?.row.isConnected) return; + + const top = anchor.row.getBoundingClientRect().top; + const scrollDelta = window.scrollY - anchor.scrollY; + // Layout compensation must retain viewport movement, even when both change in one frame. + const targetTop = anchor.top - scrollDelta; + if (top !== targetTop) window.scrollBy(0, top - targetTop); + anchor.top = targetTop; + anchor.scrollY = window.scrollY; + + if (scrollDelta !== 0) { + preserveReadingPosition( + new Set(commits.data.map((commit) => commit.id)), + ); + } + }, [commits, preserveReadingPosition]); + + useLayoutEffect(() => { + const feed = feedRef.current; + if (!feed || isRanked) return; + + const observer = new ResizeObserver(restoreReadingPosition); + observer.observe(feed); + const mutations = new MutationObserver(restoreReadingPosition); + mutations.observe(feed, { + childList: true, + subtree: true, + characterData: true, + attributes: true, + attributeFilter: ['style'], + }); + const updateFocus = () => { + if (followAnchor.current) + preserveReadingPosition( + new Set(commits.data.map((commit) => commit.id)), + ); + }; + let scrollTimer: ReturnType | undefined; + const updateScroll = () => { + if (!returningToTop.current) { + restoreReadingPosition(); + return; + } + + // Smooth scrolling owns the viewport until it finishes or the reader interrupts it. + clearTimeout(scrollTimer); + scrollTimer = setTimeout(() => { + returningToTop.current = false; + preserveReadingPosition( + new Set(commits.data.map((commit) => commit.id)), + ); + }, 150); + }; + window.addEventListener('focusin', updateFocus); + window.addEventListener('focusout', updateFocus); + window.addEventListener('scroll', updateScroll, { + passive: true, + }); + if (returningToTop.current) updateScroll(); + + return () => { + observer.disconnect(); + mutations.disconnect(); + clearTimeout(scrollTimer); + window.removeEventListener('focusin', updateFocus); + window.removeEventListener('focusout', updateFocus); + window.removeEventListener('scroll', updateScroll); + }; + }, [commits, isRanked, preserveReadingPosition, restoreReadingPosition]); useEffect( () => @@ -114,7 +230,6 @@ export default function Show({ } const incoming = page.props.commits as CursorPaginated; - followAnchor.current = null; if (incoming === commits) { return; @@ -128,11 +243,6 @@ export default function Show({ .map((commit) => commit.id) .filter((id) => !known.has(id)), ]); - - // Someone reading further down keeps their place, while someone at the top watches the commits arrive. - if (window.scrollY <= 0) { - return; - } } preserveReadingPosition( @@ -142,17 +252,7 @@ export default function Show({ [log, commits, preserveReadingPosition], ); - useLayoutEffect(() => { - const anchor = followAnchor.current; - followAnchor.current = null; - - if (anchor?.row.isConnected) { - window.scrollBy( - 0, - anchor.row.getBoundingClientRect().top - anchor.top, - ); - } - }, [commits]); + useLayoutEffect(restoreReadingPosition, [restoreReadingPosition]); useLayoutEffect(() => { const ids = freshIds.current; @@ -165,36 +265,9 @@ export default function Show({ feedRef.current?.querySelectorAll('[data-commit]') ?? [], ).filter((row) => ids.has(row.dataset.commit ?? '')); - const reduced = window.matchMedia( - '(prefers-reduced-motion: reduce)', - ).matches; - - rows.filter((row) => row.dataset.fresh === undefined).forEach( - (row, index) => { - row.dataset.fresh = ''; - - if (!reduced && window.scrollY <= 0) { - row.animate( - [ - { - opacity: 0, - transform: 'translateY(-12px)', - }, - { - opacity: 1, - transform: 'none', - }, - ], - { - duration: 520, - delay: index * 140, - easing: 'cubic-bezier(0.22, 1, 0.36, 1)', - fill: 'backwards', - }, - ); - } - }, - ); + rows.filter((row) => row.dataset.fresh === undefined).forEach((row) => { + row.dataset.fresh = ''; + }); // They stay lit until they are on screen, then while nobody is touching the page, and fade soon after the pointer // starts moving. Commits that arrived while the reader was further down wait for them to scroll back up. @@ -280,7 +353,12 @@ export default function Show({ !known.has(commit.id) && commit.user_id !== user?.id, ); - if (window.scrollY <= 0 || arrivals.length === 0) return; + if ( + arrivals.length === 0 || + (document.visibilityState === 'visible' && + window.scrollY <= 0) + ) + return; setNotice((current) => ({ commits: [ @@ -388,15 +466,29 @@ export default function Show({ const observer = new IntersectionObserver( (entries) => { - if (entries.some((entry) => entry.isIntersecting)) + if ( + document.visibilityState === 'visible' && + entries.some((entry) => entry.intersectionRatio >= 0.6) + ) dismissNotice(); }, - { threshold: 0.6 }, + { + rootMargin: `-${tabsHeight}px 0px 0px 0px`, + threshold: 0.6, + }, ); - observer.observe(row); + const update = () => { + observer.disconnect(); + if (document.visibilityState === 'visible') observer.observe(row); + }; + update(); + document.addEventListener('visibilitychange', update); - return () => observer.disconnect(); - }, [noticeNewestId, commits, dismissNotice]); + return () => { + observer.disconnect(); + document.removeEventListener('visibilitychange', update); + }; + }, [noticeNewestId, commits, tabsHeight, dismissNotice]); useEffect( () => () => { @@ -407,11 +499,16 @@ export default function Show({ useEffect(() => { const update = () => { - if (window.scrollY <= 0) dismissNotice(); + if (document.visibilityState === 'visible' && window.scrollY <= 0) + dismissNotice(); }; window.addEventListener('scroll', update, { passive: true }); + document.addEventListener('visibilitychange', update); - return () => window.removeEventListener('scroll', update); + return () => { + window.removeEventListener('scroll', update); + document.removeEventListener('visibilitychange', update); + }; }, [dismissNotice]); const authors = [ @@ -467,6 +564,8 @@ export default function Show({