Drop thread pagination for one continuous list of replies under the root post

This commit is contained in:
Coracle-Bot 2026-09-10 03:37:37 +00:00
parent a444a8e352
commit 6be382a6d1
4 changed files with 109 additions and 200 deletions

View file

@ -711,21 +711,23 @@ Acceptance:
before submitting. before submitting.
- A reply to a thread in a room is tagged into that room, so the relay handles - A reply to a thread in a room is tagged into that room, so the relay handles
it as part of the group. it as part of the group.
- The thread's opening post carries an "OP" badge on every page. - The opening post stays above the replies, and the thread's author carries an
"OP" badge wherever their posts turn up.
### US-044 — Navigate a long thread ### US-044 — Navigate a long thread
As bob, I want a long thread paginated and its posts individually linkable, so As bob, I want a long thread to open on its newest replies with the earlier ones
that I can move around it and point people at one message. within reach, and its posts individually linkable, so that I can catch up on it
and point people at one message.
Acceptance: Acceptance:
- A thread with more than 20 posts shows pagination controls, and the page - A thread with more than 20 replies opens on its newest 20, under the opening
number, next/prev, and first/last controls each move to the matching slice post, with a "Show earlier replies" control for the rest.
with the "Page X of Y" indicator updating. - That control reveals the next 20 without leaving the page.
- "Permalink" on a post copies a link to that post. - "Permalink" on a post copies a link to that post.
- Opening that link as carol loads the thread, navigates to the page holding - Opening that link as carol loads the thread, reveals the post it names however
that post, and scrolls it into view. far back it is, and scrolls it into view.
### US-045 — Turn a chat message into a thread ### US-045 — Turn a chat message into a thread

View file

@ -718,8 +718,8 @@ test("US-043 reply to a thread and to a specific post", async ({seed, as}) => {
at(3, HOUR), at(3, HOUR),
) )
// Twenty replies plus the opening post is one post past the first page, and the last of them // Twenty replies is exactly the window a thread opens with, and the last of them is alice's,
// is alice's, so her OP badge has to survive the page change. // so her OP badge has to survive bob's own reply pushing the oldest one out of it.
for (let i = 1; i <= 20; i++) { for (let i = 1; i <= 20; i++) {
space.event( space.event(
i === 20 ? user.alice : i % 2 === 0 ? user.carol : user.bob, i === 20 ? user.alice : i % 2 === 0 ? user.carol : user.bob,
@ -762,6 +762,7 @@ test("US-043 reply to a thread and to a specific post", async ({seed, as}) => {
await threadReply.getByRole("button", {name: "Post Reply"}).click() await threadReply.getByRole("button", {name: "Post Reply"}).click()
await expect(bob.getByText("21 replies")).toBeVisible() await expect(bob.getByText("21 replies")).toBeVisible()
await expect(bob.getByText("Twice a year here.")).toBeVisible()
// The same for a thread reply, which goes out through a different composer. // The same for a thread reply, which goes out through a different composer.
await expect await expect
@ -770,16 +771,28 @@ test("US-043 reply to a thread and to a specific post", async ({seed, as}) => {
) )
.toEqual(["lounge"]) .toEqual(["lounge"])
// The thread's author is marked OP wherever their posts turn up, second page included. // His is the twenty first reply, so the oldest one drops out of the window, and the opening
await bob.getByRole("button", {name: "2", exact: true}).click() // post stays above whatever the window holds.
await expect(bob.getByText("Page 2 of 2")).toBeVisible() const showEarlier = bob.getByRole("button", {name: "Show earlier replies"})
await expect(showEarlier).toBeVisible()
await expect(bob.getByText("Reply 01", {exact: true})).toHaveCount(0)
await expect(openingPost).toBeVisible()
// The thread's author is marked OP wherever their posts turn up.
const alicesLastPost = bob.locator("article").filter({hasText: "Reply 20"}) const alicesLastPost = bob.locator("article").filter({hasText: "Reply 20"})
await expect(alicesLastPost.getByText("OP", {exact: true})).toBeVisible() await expect(alicesLastPost.getByText("OP", {exact: true})).toBeVisible()
await expect(bob.getByText("Twice a year here.")).toBeVisible()
await showEarlier.click()
await expect(bob.getByText("Reply 01", {exact: true})).toBeVisible()
await expect(showEarlier).toHaveCount(0)
const carol = await as(users.carol, threadPath) const carol = await as(users.carol, threadPath)
await carol.getByRole("button", {name: "Show earlier replies"}).click()
const bobsFirstPost = carol.locator("article").filter({hasText: "Reply 01"}) const bobsFirstPost = carol.locator("article").filter({hasText: "Reply 01"})
await expect(bobsFirstPost).toBeVisible() await expect(bobsFirstPost).toBeVisible()
@ -798,23 +811,13 @@ test("US-043 reply to a thread and to a specific post", async ({seed, as}) => {
await noteEditor(postReply).pressSequentially("Answering the thread instead.") await noteEditor(postReply).pressSequentially("Answering the thread instead.")
await postReply.getByRole("button", {name: "Post Reply"}).click() await postReply.getByRole("button", {name: "Post Reply"}).click()
// Twenty one replies before hers is already a page and a bit, so her post is appended to the // Her post lands at the end of the one list, so there is nothing to click to reach it.
// last one rather than to the page she wrote it from. The last control in the join is "go last",
// which is the same button whatever the page count turns out to be.
await carol
.getByText(/^Page \d+ of \d+$/)
.locator("xpath=..")
.locator(".join")
.getByRole("button")
.last()
.click()
await expect(carol.getByText("Answering the thread instead.")).toBeVisible() await expect(carol.getByText("Answering the thread instead.")).toBeVisible()
}) })
test("US-044 navigate a long thread", async ({seed, as}) => { test("US-044 navigate a long thread", async ({seed, as}) => {
let thread!: Seeded let thread!: Seeded
let lastPost!: Seeded let firstReply!: Seeded
const scenario = await seed(({relay, user, at}) => { const scenario = await seed(({relay, user, at}) => {
const space = relay("space") const space = relay("space")
@ -840,7 +843,7 @@ test("US-044 navigate a long thread", async ({seed, as}) => {
at(4, HOUR), at(4, HOUR),
) )
// Forty one replies plus the opening post is three pages of twenty. // Forty one replies is two reveals past the twenty a thread opens with.
for (let i = 1; i <= 41; i++) { for (let i = 1; i <= 41; i++) {
const reply = space.event( const reply = space.event(
i % 2 === 0 ? user.alice : user.bob, i % 2 === 0 ? user.alice : user.bob,
@ -855,8 +858,8 @@ test("US-044 navigate a long thread", async ({seed, as}) => {
at(4, HOUR) + i * 60, at(4, HOUR) + i * 60,
) )
if (i === 41) { if (i === 1) {
lastPost = reply firstReply = reply
} }
} }
@ -869,55 +872,48 @@ test("US-044 navigate a long thread", async ({seed, as}) => {
context: {permissions: ["clipboard-read", "clipboard-write"]}, context: {permissions: ["clipboard-read", "clipboard-write"]},
}) })
const indicator = bob.getByText(/^Page \d+ of \d+$/) const showEarlier = bob.getByRole("button", {name: "Show earlier replies"})
await expect(indicator).toHaveText("Page 1 of 3") // Every assertion below is about which of the replies are in the window, so wait until they
await expect(bob.getByText("Reply 19", {exact: true})).toBeVisible() // have all arrived.
await expect(bob.getByText("41 replies")).toBeVisible()
// ThreadPagination is first, prev, a button per page, next, last, and only the page numbers // The thread opens on its newest twenty replies, with the opening post above them.
// carry an accessible name — the rest are icons — so with three pages the ends are 0, 1, 5, 6. await expect(bob.locator(`[data-event="${thread.id}"]`)).toBeVisible()
const controls = indicator.locator("xpath=..").locator(".join").getByRole("button") await expect(bob.getByText("Reply 41", {exact: true})).toBeVisible()
const goFirst = controls.nth(0) await expect(bob.getByText("Reply 22", {exact: true})).toBeVisible()
const goPrev = controls.nth(1) await expect(bob.getByText("Reply 21", {exact: true})).toHaveCount(0)
const goNext = controls.nth(5)
const goLast = controls.nth(6)
await goNext.click() // Each reveal reaches twenty further back without leaving the page.
await expect(indicator).toHaveText("Page 2 of 3") await showEarlier.click()
await expect(bob.getByText("Reply 20", {exact: true})).toBeVisible()
await goLast.click() await expect(bob.getByText("Reply 02", {exact: true})).toBeVisible()
await expect(indicator).toHaveText("Page 3 of 3") await expect(bob.getByText("Reply 01", {exact: true})).toHaveCount(0)
await expect(bob.getByText("Reply 41", {exact: true})).toBeVisible() await expect(bob.getByText("Reply 41", {exact: true})).toBeVisible()
await goPrev.click() await showEarlier.click()
await expect(indicator).toHaveText("Page 2 of 3")
await expect(bob.getByText("Reply 20", {exact: true})).toBeVisible()
await goFirst.click() await expect(bob.getByText("Reply 01", {exact: true})).toBeVisible()
await expect(indicator).toHaveText("Page 1 of 3") await expect(showEarlier).toHaveCount(0)
await expect(bob.getByText("Reply 19", {exact: true})).toBeVisible()
await bob.getByRole("button", {name: "3", exact: true}).click() const oldestPost = bob.locator(`[data-event="${firstReply.id}"]`)
await expect(indicator).toHaveText("Page 3 of 3")
const finalPost = bob.locator(`[data-event="${lastPost.id}"]`) await oldestPost.getByRole("button", {name: "Permalink"}).click()
await finalPost.getByRole("button", {name: "Permalink"}).click()
await expect(bob.getByRole("alert")).toContainText("Copied to clipboard!") await expect(bob.getByRole("alert")).toContainText("Copied to clipboard!")
const permalink = await bob.evaluate(() => navigator.clipboard.readText()) const permalink = await bob.evaluate(() => navigator.clipboard.readText())
const {pathname, hash} = new URL(permalink) const {pathname, hash} = new URL(permalink)
expect(pathname).toBe(threadPath) expect(pathname).toBe(threadPath)
expect(hash).toBe(`#${nip19.neventEncode({id: lastPost.id, relays: [url]})}`) expect(hash).toBe(`#${nip19.neventEncode({id: firstReply.id, relays: [url]})}`)
// A permalink reaches back as far as it has to on its own, so carol never sees the control.
const carol = await as(users.carol, pathname + hash) const carol = await as(users.carol, pathname + hash)
const target = carol.locator(`[data-event="${lastPost.id}"]`) const target = carol.locator(`[data-event="${firstReply.id}"]`)
await expect(carol.getByText(/^Page \d+ of \d+$/)).toHaveText("Page 3 of 3")
await expect(target).toBeVisible() await expect(target).toBeVisible()
await expect(target).toBeInViewport() await expect(target).toBeInViewport()
await expect(carol.getByRole("button", {name: "Show earlier replies"})).toHaveCount(0)
}) })
test("US-045 turn a chat message into a thread", async ({seed, as}) => { test("US-045 turn a chat message into a thread", async ({seed, as}) => {

View file

@ -1,67 +0,0 @@
<script lang="ts">
import cx from "classnames"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import DoubleAltArrowLeft from "@assets/icons/double-alt-arrow-left.svg?dataurl"
import DoubleAltArrowRight from "@assets/icons/double-alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
type Props = {
page: number
pageCount: number
onPage: (page: number) => void
}
const {page, pageCount, onPage}: Props = $props()
const goFirst = () => onPage(1)
const goPrev = () => onPage(page - 1)
const goNext = () => onPage(page + 1)
const goLast = () => onPage(pageCount)
const goToPage = (target: number) => onPage(target)
const pages = $derived.by(() => {
if (pageCount <= 7) {
return Array.from({length: pageCount}, (_, i) => i + 1)
}
const result = new Set<number>([1, pageCount, page])
if (page > 2) result.add(page - 1)
if (page < pageCount - 1) result.add(page + 1)
if (page > 3) result.add(page - 2)
if (page < pageCount - 2) result.add(page + 2)
return Array.from(result).sort((a, b) => a - b)
})
</script>
<div class="divider"></div>
<div class="flex flex-col items-center gap-3 py-4">
<p class="text-sm opacity-75">Page {page} of {pageCount}</p>
<div class="join">
<Button class="button-sm" disabled={page <= 1} onclick={goFirst}>
<Icon icon={DoubleAltArrowLeft} size={4} />
</Button>
<Button class="button-sm" disabled={page <= 1} onclick={goPrev}>
<Icon icon={AltArrowLeft} size={4} />
</Button>
{#each pages as p, i (p)}
{#if i > 0 && p - pages[i - 1] > 1}
<Button class="button-sm" disabled>…</Button>
{/if}
<Button
class={cx(`button button-${page === p ? "primary" : "neutral"} button-sm`)}
onclick={() => goToPage(p)}>
{p}
</Button>
{/each}
<Button class="button-sm" disabled={page >= pageCount} onclick={goNext}>
<Icon icon={AltArrowRight} size={4} />
</Button>
<Button class="button-sm" disabled={page >= pageCount} onclick={goLast}>
<Icon icon={DoubleAltArrowRight} size={4} />
</Button>
</div>
</div>

View file

@ -2,7 +2,6 @@
import {onDestroy} from "svelte" import {onDestroy} from "svelte"
import * as nip19 from "nostr-tools/nip19" import * as nip19 from "nostr-tools/nip19"
import {page} from "$app/stores" import {page} from "$app/stores"
import {goto} from "$app/navigation"
import {call, sleep, spec, tryCatch} from "@welshman/lib" import {call, sleep, spec, tryCatch} from "@welshman/lib"
import type {MakeNonOptional} from "@welshman/lib" import type {MakeNonOptional} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
@ -16,7 +15,6 @@
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte" import SpaceBar from "@app/components/SpaceBar.svelte"
import ThreadPost from "@app/components/ThreadPost.svelte" import ThreadPost from "@app/components/ThreadPost.svelte"
import ThreadPagination from "@app/components/ThreadPagination.svelte"
import EventReply from "@app/components/EventReply.svelte" import EventReply from "@app/components/EventReply.svelte"
import RoomName from "@app/components/RoomName.svelte" import RoomName from "@app/components/RoomName.svelte"
import {deriveEvent, deriveEventsById} from "@app/repository" import {deriveEvent, deriveEventsById} from "@app/repository"
@ -25,7 +23,7 @@
import {decodeRelay} from "@app/relays" import {decodeRelay} from "@app/relays"
import {makeSpacePath, scrollToEvent} from "@app/routes" import {makeSpacePath, scrollToEvent} from "@app/routes"
const POSTS_PER_PAGE = 20 const REPLY_BATCH_SIZE = 20
const {relay, id} = $page.params as MakeNonOptional<typeof $page.params> const {relay, id} = $page.params as MakeNonOptional<typeof $page.params>
const url = decodeRelay(relay) const url = decodeRelay(relay)
@ -37,45 +35,46 @@
const back = () => history.back() const back = () => history.back()
const posts = $derived.by(() => { const replyCount = $derived($replies.length)
if (!$event) return []
return [$event, ...$replies]
})
const replyCount = $derived(Math.max(0, posts.length - 1))
const h = $derived(tagValue(tagSpec("h"), $event?.tags || [])) const h = $derived(tagValue(tagSpec("h"), $event?.tags || []))
const pageCount = $derived(Math.max(1, Math.ceil(posts.length / POSTS_PER_PAGE))) // 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(
call(() => {
const hash = window.location.hash.replace(/^#/, "")
const currentPage = $derived.by(() => { if (hash.startsWith("nevent1")) {
const raw = parseInt($page.url.searchParams.get("page") || "1") const decoded = tryCatch(() => nip19.decode(hash))
if (Number.isNaN(raw) || raw < 1) return 1 if (decoded?.type === "nevent") {
if (raw > pageCount) return pageCount return decoded.data.id
}
return raw }
}) }),
const pagePosts = $derived(
posts.slice((currentPage - 1) * POSTS_PER_PAGE, currentPage * POSTS_PER_PAGE),
) )
const setPage = (nextPage: number) => { let revealed = $state(REPLY_BATCH_SIZE)
const params = new URLSearchParams($page.url.searchParams)
if (nextPage <= 1) { const targetIndex = $derived(target ? $replies.findIndex(spec({id: target})) : -1)
params.delete("page")
} else {
params.set("page", String(nextPage))
}
const search = params.toString() // 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.
const visibleCount = $derived(
targetIndex < 0 ? revealed : Math.max(revealed, $replies.length - targetIndex),
)
goto(`${$page.url.pathname}${search ? `?${search}` : ""}`, { const visibleReplies = $derived($replies.slice(Math.max(0, $replies.length - visibleCount)))
keepFocus: true,
noScroll: true, 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
target = undefined
revealed = nextCount
} }
const openReply = (post: TrustedEvent) => { const openReply = (post: TrustedEvent) => {
@ -105,45 +104,10 @@
let showReply = $state(false) let showReply = $state(false)
let replyTo: TrustedEvent | undefined = $state() let replyTo: TrustedEvent | undefined = $state()
// A permalink's target, read once because setPage drops the hash. Replies stream in newest
// first, so the post a permalink names is usually here long before the ones above it — its
// index, and the page it lands on, are only right once the thread has finished arriving. So
// this is kept and re-evaluated rather than acted on the first time the post shows up.
let target: string | undefined = $state(
call(() => {
const hash = window.location.hash.replace(/^#/, "")
if (hash.startsWith("nevent1")) {
const decoded = tryCatch(() => nip19.decode(hash))
if (decoded?.type === "nevent") {
return decoded.data.id
}
}
}),
)
// Paginating by hand hands control back to the reader.
const goToPage = (nextPage: number) => {
target = undefined
setPage(nextPage)
}
$effect(() => { $effect(() => {
if (!target) return if (target && (target === $event?.id || targetIndex >= 0)) {
setTimeout(() => scrollToEvent(target!), 100)
const index = posts.findIndex(spec({id: target}))
if (index < 0) return
const targetPage = Math.ceil((index + 1) / POSTS_PER_PAGE)
if (targetPage !== currentPage) {
setPage(targetPage)
} }
setTimeout(() => scrollToEvent(target!), 100)
}) })
$effect(() => { $effect(() => {
@ -177,12 +141,26 @@
<PageContent noPad class="flex flex-col"> <PageContent noPad class="flex flex-col">
{#if $event} {#if $event}
<div class="bg-surface border-y" style="border-color: var(--line)"> <div class="bg-surface border-y" style="border-color: var(--line)">
{#each pagePosts as post (post.id)} <ThreadPost {url} {context} event={$event} threadPubkey={$event.pubkey} onReply={openReply} />
<ThreadPost {url} {context} event={post} threadPubkey={$event.pubkey} onReply={openReply} />
{/each}
</div> </div>
{#if pageCount > 1} {#if hiddenCount > 0}
<ThreadPagination page={currentPage} {pageCount} onPage={goToPage} /> <div class="flex justify-center py-4">
<Button class="button button-neutral button-sm" onclick={showEarlier}>
Show earlier replies
</Button>
</div>
{/if}
{#if visibleReplies.length > 0}
<div class="bg-surface border-y" style="border-color: var(--line)">
{#each visibleReplies as reply (reply.id)}
<ThreadPost
{url}
{context}
event={reply}
threadPubkey={$event.pubkey}
onReply={openReply} />
{/each}
</div>
{/if} {/if}
{#if showReply && replyTo && $event} {#if showReply && replyTo && $event}
<EventReply <EventReply