diff --git a/src/app/components/RoomChat.svelte b/src/app/components/RoomChat.svelte index 391dd964..4d493909 100644 --- a/src/app/components/RoomChat.svelte +++ b/src/app/components/RoomChat.svelte @@ -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 = $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}
-
+
{#if $room?.meta?.isPrivate() && $membershipStatus !== MembershipStatus.Granted}
diff --git a/src/app/routes.ts b/src/app/routes.ts index 3805a410..984a833f 100644 --- a/src/app/routes.ts +++ b/src/app/routes.ts @@ -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 = {}) => { diff --git a/src/lib/components/VirtualList.svelte b/src/lib/components/VirtualList.svelte index beaf62d9..c75fb14a 100644 --- a/src/lib/components/VirtualList.svelte +++ b/src/lib/components/VirtualList.svelte @@ -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 = $state() // Held by key rather than index: messages arriving at the origin shift every index along, and diff --git a/src/lib/components/base.css b/src/lib/components/base.css index b03dc209..d036cafc 100644 --- a/src/lib/components/base.css +++ b/src/lib/components/base.css @@ -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); }