diff --git a/src/app/core/requests.ts b/src/app/core/requests.ts index b695cc3f..5fe491bd 100644 --- a/src/app/core/requests.ts +++ b/src/app/core/requests.ts @@ -28,7 +28,7 @@ import { import type {TrustedEvent, Filter, List} from "@welshman/util" import {load, request, mergeRepositoryUpdates} from "@welshman/net" import type {RepositoryUpdate} from "@welshman/net" -import {repository, loadRelay, tracker} from "@welshman/app" +import {pubkey, repository, loadRelay, tracker} from "@welshman/app" import {createScroller} from "@lib/html" import {daysBetween} from "@lib/util" import {getEventsForUrl} from "@app/core/state" @@ -41,6 +41,7 @@ export const makeFeed = ({ element, onBackwardExhausted, onForwardExhausted, + allowOptimisticSelfEvents = false, at = now(), }: { url: string @@ -48,6 +49,7 @@ export const makeFeed = ({ element: HTMLElement onBackwardExhausted?: () => void onForwardExhausted?: () => void + allowOptimisticSelfEvents?: boolean at?: number }) => { const controller = new AbortController() @@ -113,7 +115,15 @@ export const makeFeed = ({ } const matching = added.filter( - event => matchFilters(filters, event) && tracker.getRelays(event.id).has(url), + event => + matchFilters(filters, event) && + (tracker.getRelays(event.id).has(url) || + // In Safari, relay confirmation can lag behind local repository updates. + // Only enable this for chat-like feeds that explicitly opt in. + (allowOptimisticSelfEvents && + event.pubkey === pubkey.get() && + tracker.getRelays(event.id).size === 0 && + event.created_at >= now() - 60)), ) if (matching.length > 0) { diff --git a/src/routes/spaces/[relay]/[h]/+page.svelte b/src/routes/spaces/[relay]/[h]/+page.svelte index 91a2c007..7e793d26 100644 --- a/src/routes/spaces/[relay]/[h]/+page.svelte +++ b/src/routes/spaces/[relay]/[h]/+page.svelte @@ -15,7 +15,7 @@ import InfoCircle from "@assets/icons/info-circle.svg?dataurl" import Login2 from "@assets/icons/login-3.svg?dataurl" import cx from "classnames" - import {slide, fade, fly} from "@lib/transition" + import {fade, fly} from "@lib/transition" import Button from "@lib/components/Button.svelte" import Divider from "@lib/components/Divider.svelte" import Icon from "@lib/components/Icon.svelte" @@ -246,6 +246,7 @@ if (!isProgrammaticScroll) { userHasScrolled = true isUserScrolling = true + wasAtBottom = !element || Math.abs(element.scrollTop) < 100 clearIsUserScrolling() manageScrollPosition() } @@ -281,6 +282,7 @@ let events: Readable = $state(readable([])) let compose: RoomCompose | undefined = $state() let eventToEdit: TrustedEvent | undefined = $state() + let wasAtBottom = true const clearIsUserScrolling = debounce(150, () => { isUserScrolling = false @@ -359,8 +361,20 @@ }) $effect(() => { - if (elements.length > 0 && !isUserScrolling) { - requestAnimationFrame(manageScrollPosition) + if (elements.length > 0) { + requestAnimationFrame(() => { + // Safari does not implement CSS scroll anchoring for flex-col-reverse. + // When a new message is inserted, Safari shifts scrollTop upward to preserve + // visual position rather than keeping it pinned at 0 (visual bottom). + // Snap back to 0 whenever the user was at the bottom before the update. + if (element && isNaN(at) && wasAtBottom) { + isProgrammaticScroll = true + element.scrollTop = 0 + } + if (!isUserScrolling) { + manageScrollPosition() + } + }) } }) @@ -371,6 +385,7 @@ url, at: at || now(), element: element!, + allowOptimisticSelfEvents: true, filters: [{kinds: [MESSAGE, ROOM_ADD_MEMBER], "#h": [h]}], onBackwardExhausted: () => { loadingBackward = false @@ -404,7 +419,7 @@ onMount(() => { start() - return cleanup + return () => cleanup?.() }) @@ -496,7 +511,7 @@ {#if event.kind === ROOM_ADD_MEMBER} {:else} -
+
= $state(readable([])) let compose: RoomCompose | undefined = $state() let eventToEdit: TrustedEvent | undefined = $state() + let wasAtBottom = true const clearIsUserScrolling = debounce(150, () => { isUserScrolling = false @@ -252,8 +254,20 @@ }) $effect(() => { - if (elements.length > 0 && !isUserScrolling) { - requestAnimationFrame(manageScrollPosition) + if (elements.length > 0) { + requestAnimationFrame(() => { + // Safari does not implement CSS scroll anchoring for flex-col-reverse. + // When a new message is inserted, Safari shifts scrollTop upward to preserve + // visual position rather than keeping it pinned at 0 (visual bottom). + // Snap back to 0 whenever the user was at the bottom before the update. + if (element && isNaN(at) && wasAtBottom) { + isProgrammaticScroll = true + element.scrollTop = 0 + } + if (!isUserScrolling) { + manageScrollPosition() + } + }) } }) @@ -264,6 +278,7 @@ url, at: at || now(), element: element!, + allowOptimisticSelfEvents: true, filters: [{kinds: [MESSAGE, RELAY_ADD_MEMBER]}], onBackwardExhausted: () => { loadingBackward = false @@ -297,7 +312,7 @@ onMount(() => { start() - return cleanup + return () => cleanup?.() })