Fix scrolling to targeted message

This commit is contained in:
Jon Staab 2026-08-31 08:47:55 -07:00
parent f5bd6e3852
commit fe3afa248d
4 changed files with 129 additions and 39 deletions

View file

@ -236,6 +236,66 @@
const getElementKey = (element: {id: string}) => element.id
// The list renders from the newest message outward, so a row deep in history isn't on the page
// until it's asked for — and asking renders it on the next flush, which the lookup has to wait
// for. Messages carry the id as a data attribute; the new-messages divider as its element id.
// Where a row sits inside the scroll container, in screen terms rather than scroll terms, so
// the reversed layout doesn't come into it
const topOf = (target: HTMLElement) =>
target.getBoundingClientRect().top - element!.getBoundingClientRect().top
// Messages loading in below the pinned row are inserted at the scroll origin, which pushes
// everything else away from it. The browser has no reason to compensate for that, so put the
// row back where it was. scrollBy is in visual terms, so this reads the same way either way up.
const keepPinned = () => {
const target = pinned && element?.querySelector(`[data-event="${pinned.id}"]`)
if (target instanceof HTMLElement && pinned) {
const drift = topOf(target) - pinned.top
if (Math.abs(drift) >= 1) {
isProgrammaticScroll = true
element!.scrollBy({top: drift})
}
}
}
// Any scroll event at all fires while content loads, so taking over has to be a real gesture
const release = () => {
released = true
pinned = undefined
}
const scrollToRow = (
id: string,
{
behavior = "auto",
highlight = false,
pin = false,
}: {behavior?: ScrollBehavior; highlight?: boolean; pin?: boolean} = {},
) => {
virtualList?.reveal(id)
requestAnimationFrame(() => {
const target = element?.querySelector(`[data-event="${id}"]`) ?? document.getElementById(id)
if (target instanceof HTMLElement) {
isProgrammaticScroll = true
target.scrollIntoView({behavior, block: "center"})
if (highlight) {
target.classList.add("highlight-target")
}
if (pin) {
pinned = {id, top: topOf(target)}
}
}
jumpSettled = true
})
}
const manageScrollPosition = () => {
// Only treat an `at` jump as "scrolled up" when it targets an event below the
// newest one; jumping to the most recent message already lands us at the bottom.
@ -259,27 +319,20 @@
}
}
if (!userHasScrolled && !isNaN(at)) {
if (!released && !pinned && !isNaN(at)) {
const targetEvent = $events.find(event => event.created_at >= at)
if (targetEvent) {
// The list renders from the newest message outward, so a jump target deep in history
// may not be on the page yet
virtualList?.reveal(targetEvent.id)
const target = element?.querySelector(`[data-event="${targetEvent.id}"]`)
if (target instanceof HTMLElement) {
isProgrammaticScroll = true
target.scrollIntoView({block: "center"})
}
scrollToRow(targetEvent.id, {highlight: true, pin: true})
} else {
// Nothing to jump to yet, so don't hold the room back waiting for it
jumpSettled = true
}
}
}
const onScroll = () => {
if (!isProgrammaticScroll) {
userHasScrolled = true
isUserScrolling = true
clearIsUserScrolling()
manageScrollPosition()
@ -288,16 +341,7 @@
isProgrammaticScroll = false
}
const scrollToNewMessages = () => {
virtualList?.reveal("new-messages")
// Revealing it renders it on the next flush, so the scroll has to wait for that
requestAnimationFrame(() =>
document
.getElementById("new-messages")
?.scrollIntoView({behavior: "smooth", block: "center"}),
)
}
const scrollToNewMessages = () => scrollToRow("new-messages", {behavior: "smooth"})
const scrollToBottom = () => {
if (!isNaN(at)) {
@ -321,7 +365,9 @@
let joining = $state(false)
let leaving = $state(false)
let userHasScrolled = $state(false)
let jumpSettled = $state(false)
let released = false
let pinned: Maybe<{id: string; top: number}>
let isProgrammaticScroll = $state(false)
let isUserScrolling = $state(false)
let virtualList: Maybe<VirtualListController> = $state()
@ -341,6 +387,10 @@
let compose: RoomCompose | undefined = $state()
let eventToEdit: TrustedEvent | undefined = $state()
// A link into history renders the newest messages first and only then scrolls, so the room is
// held back for that frame rather than showing the wrong end of the conversation and jumping.
const awaitingJump = $derived(!isNaN(at) && !jumpSettled)
// There is always more history until the feed says otherwise, so this stays up rather than
// blinking between spans while it walks a quiet room.
const loadingBackward = $derived($older?.status !== "exhausted")
@ -451,6 +501,33 @@
}
})
// Content can arrive mid-scroll too, so this runs whether or not the reader is moving
$effect(() => {
if (elements.length > 0) {
const frame = requestAnimationFrame(keepPinned)
return () => cancelAnimationFrame(frame)
}
})
// Bound here rather than in the markup: these watch for the reader taking over, they don't make
// the transcript a control, and declaring them as handlers would claim it is one.
$effect(() => {
if (element) {
const target = element
for (const type of ["wheel", "touchmove", "keydown"]) {
target.addEventListener(type, release, {passive: true})
}
return () => {
for (const type of ["wheel", "touchmove", "keydown"]) {
target.removeEventListener(type, release)
}
}
}
})
const start = () => {
cleanup?.()
@ -557,7 +634,12 @@
{/if}
<div class="relative flex min-h-0 flex-1 flex-col">
<div bind:this={element} onscroll={onScroll} class="room__content scroll-container">
<div
bind:this={element}
onscroll={onScroll}
class={cx("room__content scroll-container transition-opacity", {
"opacity-0": awaitingJump,
})}>
{#if $room?.meta?.isPrivate() && $membershipStatus !== MembershipStatus.Granted}
<div class="py-20">
<div class="card flex flex-col gap-8 m-auto max-w-md items-center text-center">

View file

@ -220,22 +220,14 @@ export const makeEventPermalink = (event: TrustedEvent, url?: string) => {
}
export const scrollToEvent = (id: string) => {
const element = document.querySelector(`[data-event="${id}"]`) as any
const element = document.querySelector(`[data-event="${id}"]`)
if (element) {
if (element instanceof HTMLElement) {
element.scrollIntoView({behavior: "smooth", block: "center"})
element.style = "filter: brightness(1.5); transition-property: all; transition-duration: 400ms;"
setTimeout(() => {
element.style = "transition-property: all; transition-duration: 300ms;"
}, 800)
setTimeout(() => {
element.style = ""
}, 800 + 400)
element.classList.add("highlight-target")
}
return Boolean(element)
return element instanceof HTMLElement
}
export const goToEvent = (event: TrustedEvent, options: Record<string, any> = {}) => {

View file

@ -36,9 +36,6 @@
// guessed height. Everything on screen is real, so the scrollbar is honest and nothing the
// reader is looking at can shift under them. Guessing at the height of rows that have never
// been mounted is what makes a virtualised list lurch, and a list that only grows never has to.
//
// The cost is that a long scrollback ends up holding every row it passed, which is what the
// list did all the time before. What this buys is the opening render.
let edgeKey: Maybe<string> = $state()
// Held by key rather than index: messages arriving at the origin shift every index along, and

View file

@ -101,6 +101,25 @@
@utility cv {
content-visibility: auto;
}
/* Marks the event a link pointed at, so the reader can pick it out of the conversation once the
jump lands. Brightness rather than a background so it works over any row's own surface. */
@utility highlight-target {
animation: highlight-target 1.6s ease-out;
}
@keyframes highlight-target {
0%,
50% {
filter: brightness(1.5);
}
100% {
filter: brightness(1);
}
}
@media (prefers-reduced-motion: reduce) {
.highlight-target {
animation: none;
}
}
@utility pt-sai {
padding-top: var(--sait);
}