chore: redesign threads as a linear phpBB-style forum view (#300)

Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
This commit is contained in:
2026-06-17 16:08:58 +00:00
committed by hodlbod
parent b86632e86e
commit deb2b31466
9 changed files with 438 additions and 64 deletions
+16 -12
View File
@@ -3,10 +3,9 @@
import {readable} from "svelte/store"
import type {Readable} from "svelte/store"
import {page} from "$app/stores"
import {sortBy, partition, spec, max, pushToMapKey} from "@welshman/lib"
import {sortBy, partition, spec, max, pushToMapKey, groupBy} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {THREAD, getTagValue} from "@welshman/util"
import {fly} from "@lib/transition"
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
import Add from "@assets/icons/add.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
@@ -14,9 +13,10 @@
import PageContent from "@lib/components/PageContent.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import ThreadItem from "@app/components/ThreadItem.svelte"
import ThreadBoard from "@app/components/ThreadBoard.svelte"
import ThreadCreate from "@app/components/ThreadCreate.svelte"
import {decodeRelay} from "@app/relays"
import {displayRoom} from "@app/groups"
import {makeCommentFilter} from "@app/content"
import {makeFeed} from "@app/feeds"
import {pushModal} from "@app/modal"
@@ -29,9 +29,9 @@
const createThread = () => pushModal(ThreadCreate, {url})
const items = $derived.by(() => {
const threadFeed = $derived.by(() => {
const scores = new Map<string, number[]>()
const [goals, comments] = partition(spec({kind: THREAD}), $events)
const [threads, comments] = partition(spec({kind: THREAD}), $events)
for (const comment of comments) {
const id = getTagValue("E", comment.tags)
@@ -41,7 +41,13 @@
}
}
return sortBy(e => -max([...(scores.get(e.id) || []), e.created_at]), goals)
const items = sortBy(e => -max([...(scores.get(e.id) || []), e.created_at]), threads)
const byRoom = groupBy(e => getTagValue("h", e.tags) || "", items)
const roomName = (h: string) => (h ? displayRoom(url, h) : "general").toLowerCase()
const boards = sortBy(([h]) => roomName(h), Array.from(byRoom.entries()))
return {items, boards}
})
onMount(() => {
@@ -77,17 +83,15 @@
{/snippet}
</SpaceBar>
<PageContent bind:element class="flex flex-col gap-2 p-2">
{#each items as event (event.id)}
<div in:fly>
<ThreadItem {url} event={$state.snapshot(event)} />
</div>
<PageContent bind:element class="flex flex-col gap-4 p-2">
{#each threadFeed.boards as [h, threads] (h || "general")}
<ThreadBoard {url} {h} {threads} />
{/each}
<p class="flex h-10 items-center justify-center py-20">
<Spinner {loading}>
{#if loading}
Looking for threads...
{:else if items.length === 0}
{:else if threadFeed.items.length === 0}
No threads found.
{:else}
That's all!
@@ -1,26 +1,31 @@
<script lang="ts">
import {onMount} from "svelte"
import * as nip19 from "nostr-tools/nip19"
import {page} from "$app/stores"
import {goto} from "$app/navigation"
import {sleep} from "@welshman/lib"
import type {MakeNonOptional} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {COMMENT, getTagValue} from "@welshman/util"
import {repository} from "@welshman/app"
import {request} from "@welshman/net"
import {deriveEventsById, deriveEventsAsc} from "@welshman/store"
import SortVertical from "@assets/icons/sort-vertical.svg?dataurl"
import Reply from "@assets/icons/reply-2.svg?dataurl"
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 NoteContent from "@app/components/NoteContent.svelte"
import NoteCard from "@app/components/NoteCard.svelte"
import ThreadActions from "@app/components/ThreadActions.svelte"
import CommentActions from "@app/components/CommentActions.svelte"
import ThreadPost from "@app/components/ThreadPost.svelte"
import ThreadPagination from "@app/components/ThreadPagination.svelte"
import EventReply from "@app/components/EventReply.svelte"
import RoomName from "@app/components/RoomName.svelte"
import {deriveEvent} from "@app/repository"
import {decodeRelay} from "@app/relays"
import {makeSpacePath, scrollToEvent} from "@app/routes"
const POSTS_PER_PAGE = 20
const {relay, id} = $page.params as MakeNonOptional<typeof $page.params>
const url = decodeRelay(relay)
@@ -30,20 +35,106 @@
const back = () => history.back()
const openReply = () => {
const posts = $derived.by(() => {
if (!$event) return []
return [$event, ...$replies]
})
const replyCount = $derived(Math.max(0, posts.length - 1))
const h = $derived(getTagValue("h", $event?.tags || []))
const pageCount = $derived(Math.max(1, Math.ceil(posts.length / POSTS_PER_PAGE)))
const currentPage = $derived.by(() => {
const raw = parseInt($page.url.searchParams.get("page") || "1")
if (Number.isNaN(raw) || raw < 1) return 1
if (raw > pageCount) return pageCount
return raw
})
const pagePosts = $derived(
posts.slice((currentPage - 1) * POSTS_PER_PAGE, currentPage * POSTS_PER_PAGE),
)
const setPage = (nextPage: number) => {
const params = new URLSearchParams($page.url.searchParams)
if (nextPage <= 1) {
params.delete("page")
} else {
params.set("page", String(nextPage))
}
const search = params.toString()
goto(`${$page.url.pathname}${search ? `?${search}` : ""}`, {
keepFocus: true,
noScroll: true,
})
}
const openReply = (post: TrustedEvent) => {
replyTo = post
showReply = true
}
const closeReply = () => {
showReply = false
replyTo = undefined
}
const expand = () => {
showAll = true
const openThreadReply = () => {
if ($event) {
openReply($event)
}
}
const clearReplyParent = () => {
if ($event) {
replyTo = $event
}
}
let showAll = $state(false)
let showReply = $state(false)
let replyTo: TrustedEvent | undefined = $state()
let hashHandled = $state(false)
$effect(() => {
if (hashHandled || posts.length === 0) return
const hash = window.location.hash.replace(/^#/, "")
if (!hash.startsWith("nevent1")) return
let eventId: string
try {
const decoded = nip19.decode(hash)
if (decoded.type !== "nevent") return
eventId = decoded.data.id
} catch {
return
}
const index = posts.findIndex(post => post.id === eventId)
if (index < 0) return
hashHandled = true
const targetPage = Math.ceil((index + 1) / POSTS_PER_PAGE)
if (targetPage !== currentPage) {
setPage(targetPage)
}
setTimeout(() => scrollToEvent(posts[index]!.id), 100)
})
onMount(() => {
const controller = new AbortController()
@@ -56,43 +147,44 @@
})
</script>
<SpaceBar {back}>
<SpaceBar {back} class="!h-auto min-h-20 py-3">
{#snippet title()}
<h1 class="text-xl">{getTagValue("title", $event?.tags || []) || ""}</h1>
<div class="flex min-w-0 flex-col gap-0.5">
<h1 class="ellipsize text-base leading-none font-bold sm:text-xl">
{getTagValue("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 class="flex flex-col gap-2 p-2">
<PageContent class="flex flex-col">
{#if $event}
<div class="flex flex-col gap-3">
<NoteCard event={$event} {url} class="card2 bg-alt z-feature w-full">
<div class="col-3 ml-12">
<NoteContent showEntire event={$event} {url} />
<ThreadActions showRoom event={$event} {url} />
</div>
</NoteCard>
{#if !showAll && $replies.length > 4}
<div class="flex justify-center">
<Button class="btn btn-link" onclick={expand}>
<Icon icon={SortVertical} />
Show all {$replies.length} replies
</Button>
</div>
{/if}
{#each $replies.slice(0, showAll ? undefined : 4) as reply (reply.id)}
<NoteCard event={reply} {url} class="card2 bg-alt z-feature w-full">
<div class="col-3 ml-12">
<NoteContent showEntire event={reply} {url} />
<CommentActions segment="threads" event={reply} {url} />
</div>
</NoteCard>
<div class="border-y border-base-content/15 bg-base-100">
{#each pagePosts as post (post.id)}
<ThreadPost {url} event={post} threadPubkey={$event.pubkey} onReply={openReply} />
{/each}
</div>
{#if showReply}
<EventReply {url} event={$event} onClose={closeReply} onSubmit={closeReply} />
{#if pageCount > 1}
<ThreadPagination page={currentPage} {pageCount} onPage={setPage} />
{/if}
{#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">
<Button class="btn btn-primary" onclick={openReply}>
<div class="flex justify-end p-4">
<Button class="btn btn-primary" onclick={openThreadReply}>
<Icon icon={Reply} />
Reply to thread
</Button>