Drop thread pagination for one continuous list of replies under the root post
This commit is contained in:
parent
a444a8e352
commit
6be382a6d1
4 changed files with 109 additions and 200 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}) => {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
||||||
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)
|
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} />
|
</div>
|
||||||
|
{#if hiddenCount > 0}
|
||||||
|
<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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{#if pageCount > 1}
|
|
||||||
<ThreadPagination page={currentPage} {pageCount} onPage={goToPage} />
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if showReply && replyTo && $event}
|
{#if showReply && replyTo && $event}
|
||||||
<EventReply
|
<EventReply
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue