Scroll to unread/bottom of thread detail
This commit is contained in:
parent
e42d6fa707
commit
f36b748a70
4 changed files with 167 additions and 74 deletions
|
|
@ -105,10 +105,6 @@
|
||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
setTimeout(() => {
|
|
||||||
spacer.scrollIntoView({block: "end", behavior: "smooth"})
|
|
||||||
})
|
|
||||||
|
|
||||||
const observer = new ResizeObserver(() => {
|
const observer = new ResizeObserver(() => {
|
||||||
spacer!.style.minHeight = `${form!.offsetHeight + 60}px`
|
spacer!.style.minHeight = `${form!.offsetHeight + 60}px`
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
import Badge from "@lib/components/Badge.svelte"
|
import Badge from "@lib/components/Badge.svelte"
|
||||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||||
import ProfileDetail from "@app/components/ProfileDetail.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 CommentActions from "@app/components/CommentActions.svelte"
|
||||||
import type {FeedContext} from "@app/feeds"
|
import type {FeedContext} from "@app/feeds"
|
||||||
import ThreadActions from "@app/components/ThreadActions.svelte"
|
import ThreadActions from "@app/components/ThreadActions.svelte"
|
||||||
|
|
@ -78,7 +78,7 @@
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-3 py-4 @lg:px-4">
|
<div class="px-3 py-4 @lg:px-4">
|
||||||
<Content showEntire {event} {url} />
|
<NoteContent showEntire {event} {url} />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="bg-surface flex shrink-0 flex-col gap-2 border-t px-3 py-3 @lg:flex-row @lg:items-center @lg:justify-between @lg:px-4"
|
class="bg-surface flex shrink-0 flex-col gap-2 border-t px-3 py-3 @lg:flex-row @lg:items-center @lg:justify-between @lg:px-4"
|
||||||
|
|
|
||||||
31
src/app/components/ThreadSummaryBar.svelte
Normal file
31
src/app/components/ThreadSummaryBar.svelte
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<script lang="ts">
|
||||||
|
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)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onclick={onClick}
|
||||||
|
aria-label="Back to the top of the thread"
|
||||||
|
class="bg-surface flex w-full items-center gap-3 border-b px-4 py-2 shadow-md"
|
||||||
|
style="border-color: var(--line)">
|
||||||
|
<ProfileCircle pubkey={event.pubkey} {url} size={8} class="shrink-0" />
|
||||||
|
<div class="flex min-w-0 flex-col">
|
||||||
|
<span class="truncate text-sm font-bold">{title || "Untitled thread"}</span>
|
||||||
|
<div class="min-w-0 text-xs opacity-75">
|
||||||
|
<ContentMinimal {event} {url} singleLine />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
|
@ -4,22 +4,23 @@
|
||||||
import {sleep, spec} from "@welshman/lib"
|
import {sleep, spec} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {deriveEventsAsc} from "@welshman/store"
|
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 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 Icon from "@lib/components/Icon.svelte"
|
||||||
import PageContent from "@lib/components/PageContent.svelte"
|
import PageContent from "@lib/components/PageContent.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import Button from "@lib/components/Button.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 ThreadPost from "@app/components/ThreadPost.svelte"
|
||||||
|
import ThreadSummaryBar from "@app/components/ThreadSummaryBar.svelte"
|
||||||
import EventReply from "@app/components/EventReply.svelte"
|
import EventReply from "@app/components/EventReply.svelte"
|
||||||
import RoomName from "@app/components/RoomName.svelte"
|
|
||||||
import {deriveEvent, deriveEventsById} from "@app/repository"
|
import {deriveEvent, deriveEventsById} from "@app/repository"
|
||||||
import {network} from "@app/core"
|
import {network} from "@app/core"
|
||||||
import {makeFeedContext} from "@app/feeds"
|
import {makeFeedContext} from "@app/feeds"
|
||||||
|
import {getChecked} from "@app/notifications"
|
||||||
import {decodeRelay} from "@app/relays"
|
import {decodeRelay} from "@app/relays"
|
||||||
import {getPermalinkTarget, makeSpacePath, scrollToEvent} from "@app/routes"
|
import {getPermalinkTarget, scrollToEvent} from "@app/routes"
|
||||||
import type {PageProps} from "./$types"
|
import type {PageProps} from "./$types"
|
||||||
|
|
||||||
const REPLY_BATCH_SIZE = 20
|
const REPLY_BATCH_SIZE = 20
|
||||||
|
|
@ -34,22 +35,29 @@
|
||||||
const filters = $derived($event ? getCommentFiltersForRoot([$event]) : [])
|
const filters = $derived($event ? getCommentFiltersForRoot([$event]) : [])
|
||||||
const replies = $derived(deriveEventsAsc(deriveEventsById(filters)))
|
const replies = $derived(deriveEventsAsc(deriveEventsById(filters)))
|
||||||
|
|
||||||
const back = () => history.back()
|
const scrollToTop = () => element?.scrollTo({top: 0, behavior: "smooth"})
|
||||||
|
|
||||||
const replyCount = $derived($replies.length)
|
const scrollToBottom = () => element?.scrollTo({top: element.scrollHeight, behavior: "smooth"})
|
||||||
const h = $derived(tagValue(tagSpec("h"), $event?.tags || []))
|
|
||||||
|
|
||||||
// A permalink's target. Replies stream in newest first, so the post it names is usually here
|
const permalink = getPermalinkTarget(page.url)
|
||||||
// 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.
|
// Visiting the thread clears its badge 300ms later, so the cutoff is read on the way in.
|
||||||
let target: string | undefined = $state(getPermalinkTarget(page.url))
|
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)
|
let revealed = $state(REPLY_BATCH_SIZE)
|
||||||
|
|
||||||
const targetIndex = $derived(target ? $replies.findIndex(spec({id: target})) : -1)
|
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
|
// A thread reads from its newest end, and a target reaches back as far as it has to on its own.
|
||||||
// permalink reaches back as far as it has to on its own.
|
|
||||||
const visibleCount = $derived(
|
const visibleCount = $derived(
|
||||||
targetIndex < 0 ? revealed : Math.max(revealed, $replies.length - targetIndex),
|
targetIndex < 0 ? revealed : Math.max(revealed, $replies.length - targetIndex),
|
||||||
)
|
)
|
||||||
|
|
@ -62,7 +70,7 @@
|
||||||
const showEarlier = () => {
|
const showEarlier = () => {
|
||||||
const nextCount = visibleCount + REPLY_BATCH_SIZE
|
const nextCount = visibleCount + REPLY_BATCH_SIZE
|
||||||
|
|
||||||
target = undefined
|
released = true
|
||||||
revealed = nextCount
|
revealed = nextCount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,10 +100,56 @@
|
||||||
|
|
||||||
let showReply = $state(false)
|
let showReply = $state(false)
|
||||||
let replyTo: TrustedEvent | undefined = $state()
|
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(() => {
|
$effect(() => {
|
||||||
if (target && (target === $event?.id || targetIndex >= 0)) {
|
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,27 +164,17 @@
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<SpaceBar {back} class="!h-auto min-h-20 py-3">
|
<div class="relative flex min-h-0 flex-1 flex-col">
|
||||||
{#snippet title()}
|
<PageContent bind:element noPad class="flex flex-col">
|
||||||
<div class="flex min-w-0 flex-col gap-0.5">
|
|
||||||
<h1 class="truncate min-w-0 font-bold sm:text-xl">
|
|
||||||
{tagValue(tagSpec("title"), $event?.tags || []) || ""}
|
|
||||||
</h1>
|
|
||||||
<p class="text-xs opacity-75">
|
|
||||||
{replyCount}
|
|
||||||
{replyCount === 1 ? "reply" : "replies"}
|
|
||||||
{#if h}
|
|
||||||
· <Link href={makeSpacePath(url, h)} class="link">#<RoomName {url} {h} /></Link>
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
</SpaceBar>
|
|
||||||
|
|
||||||
<PageContent noPad class="flex flex-col">
|
|
||||||
{#if $event}
|
{#if $event}
|
||||||
<div class="bg-surface border-y" style="border-color: var(--line)">
|
<div bind:this={panel} class="bg-surface border-y" style="border-color: var(--line)">
|
||||||
<ThreadPost {url} {context} event={$event} threadPubkey={$event.pubkey} onReply={openReply} />
|
<div bind:this={root}>
|
||||||
|
<ThreadPost
|
||||||
|
{url}
|
||||||
|
{context}
|
||||||
|
event={$event}
|
||||||
|
threadPubkey={$event.pubkey}
|
||||||
|
onReply={openReply} />
|
||||||
</div>
|
</div>
|
||||||
{#if hiddenCount > 0}
|
{#if hiddenCount > 0}
|
||||||
<div class="flex justify-center py-4">
|
<div class="flex justify-center py-4">
|
||||||
|
|
@ -139,9 +183,16 @@
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if visibleReplies.length > 0}
|
|
||||||
<div class="bg-surface border-y" style="border-color: var(--line)">
|
|
||||||
{#each visibleReplies as reply (reply.id)}
|
{#each visibleReplies as reply (reply.id)}
|
||||||
|
{#if reply.id === oldestUnread?.id}
|
||||||
|
<div bind:this={unreadDivider} class="flex items-center gap-2 px-4 py-2 text-xs">
|
||||||
|
<div class="h-px grow bg-primary"></div>
|
||||||
|
<p class="rounded-full bg-primary px-2 py-1" style="color: var(--primary-content)">
|
||||||
|
New replies
|
||||||
|
</p>
|
||||||
|
<div class="h-px grow bg-primary"></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<ThreadPost
|
<ThreadPost
|
||||||
{url}
|
{url}
|
||||||
{context}
|
{context}
|
||||||
|
|
@ -150,7 +201,6 @@
|
||||||
onReply={openReply} />
|
onReply={openReply} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
{#if showReply && replyTo && $event}
|
{#if showReply && replyTo && $event}
|
||||||
<EventReply
|
<EventReply
|
||||||
{url}
|
{url}
|
||||||
|
|
@ -176,4 +226,20 @@
|
||||||
{/await}
|
{/await}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</PageContent>
|
</PageContent>
|
||||||
|
{#if $event && scrolledPastRoot}
|
||||||
|
<div transition:fly={{y: -20, duration: 200}} class="absolute inset-x-0 top-0 z-nav">
|
||||||
|
<ThreadSummaryBar {url} event={$event} onClick={scrollToTop} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if scrolledUp && !showReply}
|
||||||
|
<div transition:fade class="absolute right-4 bottom-20 z-nav mb-sai md:bottom-4 md:mb-0">
|
||||||
|
<Button
|
||||||
|
aria-label="Scroll to newest"
|
||||||
|
class="button button-neutral button-circle shadow-xl"
|
||||||
|
onclick={scrollToBottom}>
|
||||||
|
<Icon icon={AltArrowDown} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue