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()}
-