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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
241 changes: 170 additions & 71 deletions resources/js/pages/log/show.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,30 @@ export default function Show({
const tabsRef = useRef<HTMLDivElement>(null);
const [tabsHeight, setTabsHeight] = useState(0);
const feedRef = useRef<HTMLDivElement>(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<string | null>(null);
const [composer, setComposer] = useState<HTMLDivElement | null>(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;

Expand All @@ -86,21 +105,118 @@ export default function Show({
return () => observer.disconnect();
}, []);

const preserveReadingPosition = useCallback((ids: Set<string>) => {
const headerBottom =
tabsRef.current?.getBoundingClientRect().bottom ?? 0;
const rows =
feedRef.current?.querySelectorAll<HTMLElement>('[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<string>) => {
if (returningToTop.current) return;
const headerBottom =
tabsRef.current?.getBoundingClientRect().bottom ?? 0;
const focused = document.activeElement?.closest<HTMLElement>(
'input, textarea, [contenteditable="true"]',
);
const rows =
feedRef.current?.querySelectorAll<HTMLElement>(
'[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<typeof setTimeout> | 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(
() =>
Expand All @@ -114,7 +230,6 @@ export default function Show({
}

const incoming = page.props.commits as CursorPaginated<Commit>;
followAnchor.current = null;

if (incoming === commits) {
return;
Expand All @@ -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(
Expand All @@ -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;
Expand All @@ -165,36 +265,9 @@ export default function Show({
feedRef.current?.querySelectorAll<HTMLElement>('[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.
Expand Down Expand 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: [
Expand Down Expand Up @@ -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(
() => () => {
Expand All @@ -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 = [
Expand Down Expand Up @@ -467,6 +564,8 @@ export default function Show({
<button
type="button"
onClick={() => {
returningToTop.current = true;
followAnchor.current = null;
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
aria-label={announcementLabel}
Expand Down
Loading
Loading