flotilla/src/routes/spaces/[relay]/threads/[id]/+page.svelte

237 lines
7.7 KiB
Svelte

<script lang="ts">
import {onDestroy} from "svelte"
import {page} from "$app/state"
import {spec} from "@welshman/lib"
import type {TrustedEvent} 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 LoadingFallback from "@lib/components/LoadingFallback.svelte"
import Button from "@lib/components/Button.svelte"
import ThreadPost from "@app/components/ThreadPost.svelte"
import ThreadSummaryBar from "@app/components/ThreadSummaryBar.svelte"
import EventReply from "@app/components/EventReply.svelte"
import {events, network} from "@app/core"
import {makeFeedContext} from "@app/feeds"
import {getChecked} from "@app/notifications"
import {decodeRelay} from "@app/relays"
import {getPermalinkTarget, scrollToEvent} from "@app/routes"
import type {PageProps} from "./$types"
const REPLY_BATCH_SIZE = 20
const {params}: PageProps = $props()
const {relay, id} = params
const url = decodeRelay(relay)
const event = $events.one(id, [url]).$
// Rows register themselves with `related`, so the whole page's reactions load in one batch
const context = makeFeedContext({relays: [url]})
const filters = $derived($event ? getCommentFiltersForRoot([$event]) : [])
const replies = $derived($events.asc(filters).$)
const scrollToTop = () => element?.scrollTo({top: 0, behavior: "smooth"})
const scrollToBottom = () => element?.scrollTo({top: element.scrollHeight, behavior: "smooth"})
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 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),
)
const visibleReplies = $derived($replies.slice(Math.max(0, $replies.length - visibleCount)))
const hiddenCount = $derived($replies.length - visibleReplies.length)
// Reaching back by hand hands control back to the reader.
const showEarlier = () => {
const nextCount = visibleCount + REPLY_BATCH_SIZE
released = true
revealed = nextCount
}
const openReply = (post: TrustedEvent) => {
replyTo = post
showReply = true
}
const closeReply = () => {
showReply = false
replyTo = undefined
}
const openThreadReply = () => {
if ($event) {
openReply($event)
}
}
const clearReplyParent = () => {
if ($event) {
replyTo = $event
}
}
onDestroy(context.cleanup)
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(() => {
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)
}
})
// Replies arrive after the page and their images later, so the bottom moves as the thread settles.
$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()
}
})
$effect(() => {
if (filters.length > 0) {
const controller = new AbortController()
$network.request({relays: [url], filters, signal: controller.signal})
return () => controller.abort()
}
})
</script>
<div class="relative flex min-h-0 flex-1 flex-col">
<PageContent bind:element noPad class="flex flex-col">
{#if $event}
<div bind:this={panel} class="bg-surface border-y" style="border-color: var(--line)">
<div bind:this={root}>
<ThreadPost
{url}
{context}
event={$event}
threadPubkey={$event.pubkey}
replyCount={$replies.length}
onReply={openReply} />
</div>
{#if hiddenCount > 0}
<div class="flex justify-center py-4">
<Button class="button button-neutral button-sm" onclick={showEarlier}>
Show earlier replies ({hiddenCount})
</Button>
</div>
{/if}
{#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
{url}
{context}
event={reply}
threadPubkey={$event.pubkey}
onReply={openReply} />
{/each}
</div>
{#if showReply && replyTo && $event}
<EventReply
{url}
event={$event}
parent={replyTo.id === $event.id ? undefined : replyTo}
onClose={closeReply}
onClearParent={clearReplyParent}
onSubmit={closeReply} />
{:else}
<div class="flex justify-end p-4">
<Button class="button button-primary" onclick={openThreadReply}>
<Icon icon={Reply} />
Reply to thread
</Button>
</div>
{/if}
{:else}
<LoadingFallback loadingMessage="Loading thread..." failureMessage="Failed to load thread." />
{/if}
</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>