diff --git a/src/app/components/EventReply.svelte b/src/app/components/EventReply.svelte index 7d5a8599..64d30d3c 100644 --- a/src/app/components/EventReply.svelte +++ b/src/app/components/EventReply.svelte @@ -105,10 +105,6 @@ }) onMount(() => { - setTimeout(() => { - spacer.scrollIntoView({block: "end", behavior: "smooth"}) - }) - const observer = new ResizeObserver(() => { spacer!.style.minHeight = `${form!.offsetHeight + 60}px` }) diff --git a/src/app/components/ThreadPost.svelte b/src/app/components/ThreadPost.svelte index 3af18672..cd5ff950 100644 --- a/src/app/components/ThreadPost.svelte +++ b/src/app/components/ThreadPost.svelte @@ -10,7 +10,7 @@ import Badge from "@lib/components/Badge.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte" import ProfileDetail from "@app/components/ProfileDetail.svelte" - import Content from "@app/components/Content.svelte" + import NoteContent from "@app/components/NoteContent.svelte" import CommentActions from "@app/components/CommentActions.svelte" import type {FeedContext} from "@app/feeds" import ThreadActions from "@app/components/ThreadActions.svelte" @@ -78,7 +78,7 @@
- +
+ import type {TrustedEvent} from "@welshman/util" + import {tagSpec, tagValue} from "@welshman/util" + import Button from "@lib/components/Button.svelte" + import ProfileCircle from "@app/components/ProfileCircle.svelte" + import ContentMinimal from "@app/components/ContentMinimal.svelte" + + type Props = { + url: string + event: TrustedEvent + onClick: () => void + } + + const {url, event, onClick}: Props = $props() + + const title = tagValue(tagSpec("title"), event.tags) + + + diff --git a/src/routes/spaces/[relay]/threads/[id]/+page.svelte b/src/routes/spaces/[relay]/threads/[id]/+page.svelte index 0fcec8be..03993cb7 100644 --- a/src/routes/spaces/[relay]/threads/[id]/+page.svelte +++ b/src/routes/spaces/[relay]/threads/[id]/+page.svelte @@ -4,22 +4,23 @@ import {sleep, spec} from "@welshman/lib" import type {TrustedEvent} from "@welshman/util" import {deriveEventsAsc} from "@welshman/store" - import {getCommentFiltersForRoot, tagValue, tagSpec} from "@welshman/util" + import {getCommentFiltersForRoot} from "@welshman/util" import Reply from "@assets/icons/reply-2.svg?dataurl" + import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl" + import {fade, fly} from "@lib/transition" import Icon from "@lib/components/Icon.svelte" import PageContent from "@lib/components/PageContent.svelte" import Spinner from "@lib/components/Spinner.svelte" import Button from "@lib/components/Button.svelte" - import Link from "@lib/components/Link.svelte" - import SpaceBar from "@app/components/SpaceBar.svelte" import ThreadPost from "@app/components/ThreadPost.svelte" + import ThreadSummaryBar from "@app/components/ThreadSummaryBar.svelte" import EventReply from "@app/components/EventReply.svelte" - import RoomName from "@app/components/RoomName.svelte" import {deriveEvent, deriveEventsById} from "@app/repository" import {network} from "@app/core" import {makeFeedContext} from "@app/feeds" + import {getChecked} from "@app/notifications" import {decodeRelay} from "@app/relays" - import {getPermalinkTarget, makeSpacePath, scrollToEvent} from "@app/routes" + import {getPermalinkTarget, scrollToEvent} from "@app/routes" import type {PageProps} from "./$types" const REPLY_BATCH_SIZE = 20 @@ -34,22 +35,29 @@ const filters = $derived($event ? getCommentFiltersForRoot([$event]) : []) const replies = $derived(deriveEventsAsc(deriveEventsById(filters))) - const back = () => history.back() + const scrollToTop = () => element?.scrollTo({top: 0, behavior: "smooth"}) - const replyCount = $derived($replies.length) - const h = $derived(tagValue(tagSpec("h"), $event?.tags || [])) + const scrollToBottom = () => element?.scrollTo({top: element.scrollHeight, behavior: "smooth"}) - // A permalink's target. Replies stream in newest first, so the post it names is usually here - // long before the ones above it — its position is only right once the thread has finished - // arriving, so this is kept and re-read rather than acted on the first time it shows up. - let target: string | undefined = $state(getPermalinkTarget(page.url)) + const permalink = getPermalinkTarget(page.url) + + // Visiting the thread clears its badge 300ms later, so the cutoff is read on the way in. + const unreadAfter = getChecked(page.url.pathname) + + const oldestUnread = $derived( + unreadAfter ? $replies.find(reply => reply.created_at > unreadAfter) : undefined, + ) + + let released = $state(false) + + // Replies stream in newest first, so a target's position is only right once they've all arrived. + const target = $derived(released ? undefined : (permalink ?? oldestUnread?.id)) let revealed = $state(REPLY_BATCH_SIZE) const targetIndex = $derived(target ? $replies.findIndex(spec({id: target})) : -1) - // A thread reads from its newest end, and the reader pulls earlier replies in from there. A - // permalink reaches back as far as it has to on its own. + // A thread reads from its newest end, and a target reaches back as far as it has to on its own. const visibleCount = $derived( targetIndex < 0 ? revealed : Math.max(revealed, $replies.length - targetIndex), ) @@ -62,7 +70,7 @@ const showEarlier = () => { const nextCount = visibleCount + REPLY_BATCH_SIZE - target = undefined + released = true revealed = nextCount } @@ -92,10 +100,56 @@ let showReply = $state(false) let replyTo: TrustedEvent | undefined = $state() + let element: Element | undefined = $state() + let panel: HTMLElement | undefined = $state() + let root: HTMLElement | undefined = $state() + let unreadDivider: HTMLElement | undefined = $state() + let scrolledPastRoot = $state(false) + let scrolledUp = $state(false) $effect(() => { if (target && (target === $event?.id || targetIndex >= 0)) { - setTimeout(() => scrollToEvent(target!), 100) + setTimeout(() => { + if (permalink) { + scrollToEvent(permalink) + } else { + unreadDivider?.scrollIntoView({behavior: "smooth", block: "center"}) + } + }, 100) + } + }) + + $effect(() => { + const container = element + const rootPost = root + + if (container && rootPost) { + const onScroll = () => { + scrolledPastRoot = container.scrollTop > rootPost.offsetHeight + scrolledUp = container.scrollHeight - container.scrollTop - container.clientHeight > 500 + } + + container.addEventListener("scroll", onScroll, {passive: true}) + onScroll() + + return () => container.removeEventListener("scroll", onScroll) + } + }) + + // A reader who's caught up lands at the bottom. Replies arrive after the page does and the + // images in them later still, so the bottom moves until the thread has finished settling. + $effect(() => { + const container = element + const content = panel + + if (container && content && unreadAfter && !target && !released) { + const observer = new ResizeObserver(() => { + container.scrollTo({top: container.scrollHeight, behavior: "smooth"}) + }) + + observer.observe(content) + + return () => observer.disconnect() } }) @@ -110,38 +164,35 @@ }) - - {#snippet title()} -
-

- {tagValue(tagSpec("title"), $event?.tags || []) || ""} -

-

- {replyCount} - {replyCount === 1 ? "reply" : "replies"} - {#if h} - · # +

+ + {#if $event} +
+
+ +
+ {#if hiddenCount > 0} +
+ +
{/if} -

-
- {/snippet} - - - - {#if $event} -
- -
- {#if hiddenCount > 0} -
- -
- {/if} - {#if visibleReplies.length > 0} -
{#each visibleReplies as reply (reply.id)} + {#if reply.id === oldestUnread?.id} +
+
+

+ New replies +

+
+
+ {/if} {/each}
- {/if} - {#if showReply && replyTo && $event} - + {#if showReply && replyTo && $event} + + {:else} +
+ +
+ {/if} {:else} -
- +
+ {#await sleep(5000)} + Loading thread... + {:then} +

Failed to load thread.

+ {/await}
{/if} - {:else} -
- {#await sleep(5000)} - Loading thread... - {:then} -

Failed to load thread.

- {/await} + + {#if $event && scrolledPastRoot} +
+
{/if} - + {#if scrolledUp && !showReply} +
+ +
+ {/if} +