chore: redesign threads as a linear phpBB-style forum view #300
@@ -29,20 +29,22 @@
|
||||
<Button onclick={back} class="place-self-start pr-3 md:hidden">
|
||||
<Icon icon={ArrowLeft} size={7} />
|
||||
</Button>
|
||||
<div class="ellipsize whitespace-nowrap flex grow items-center justify-between gap-4">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex gap-2 items-center">
|
||||
<RelayIcon {url} size={5} class="rounded-full md:hidden" />
|
||||
<div class="hidden md:contents">
|
||||
<div class="flex grow items-center justify-between gap-4">
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<RelayIcon {url} size={5} class="shrink-0 rounded-full md:hidden" />
|
||||
<div class="hidden shrink-0 md:flex md:items-center">
|
||||
{@render leading?.()}
|
||||
</div>
|
||||
{@render title?.()}
|
||||
</div>
|
||||
<div class="text-xs text-primary md:hidden">
|
||||
{displayRelayUrl(url)}
|
||||
<div class="flex min-w-0 flex-col">
|
||||
{@render title?.()}
|
||||
<div class="text-xs text-primary md:hidden">
|
||||
{displayRelayUrl(url)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-start">
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
{@render action?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import RoomName from "@app/components/RoomName.svelte"
|
||||
import ThreadBoardItem from "@app/components/ThreadBoardItem.svelte"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
h: string
|
||||
threads: TrustedEvent[]
|
||||
}
|
||||
|
||||
const {url, h, threads}: Props = $props()
|
||||
</script>
|
||||
|
||||
<section class="overflow-hidden rounded-box border border-base-content/15 bg-base-100 shadow-sm">
|
||||
<header
|
||||
class="flex items-center justify-between gap-2 border-b border-base-content/15 bg-base-200/70 px-4 py-2.5">
|
||||
<h2 class="text-sm font-bold sm:text-base">
|
||||
{#if h}
|
||||
|
|
||||
#<RoomName {url} {h} />
|
||||
{:else}
|
||||
General
|
||||
{/if}
|
||||
</h2>
|
||||
<span class="text-xs opacity-60">
|
||||
{threads.length}
|
||||
{threads.length === 1 ? "topic" : "topics"}
|
||||
</span>
|
||||
</header>
|
||||
<div
|
||||
class="hidden border-b border-base-content/10 bg-base-200/40 px-4 py-2 text-xs font-bold uppercase tracking-wide opacity-60 sm:grid sm:grid-cols-[1fr_8rem_5rem_8rem] sm:gap-x-4">
|
||||
<span>Topic</span>
|
||||
<span>Author</span>
|
||||
<span class="text-center">Replies</span>
|
||||
<span class="text-right">Last post</span>
|
||||
</div>
|
||||
{#each threads as event (event.id)}
|
||||
<ThreadBoardItem {url} {event} />
|
||||
{/each}
|
||||
</section>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import {formatTimestamp, max} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {COMMENT, getTagValue} from "@welshman/util"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
import ProfileName from "@app/components/ProfileName.svelte"
|
||||
import {deriveEventsForUrl} from "@app/repository"
|
||||
import {makeThreadPath} from "@app/routes"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
event: TrustedEvent
|
||||
}
|
||||
|
||||
const {url, event}: Props = $props()
|
||||
|
||||
const filters = [{kinds: [COMMENT], "#E": [event.id]}]
|
||||
const replies = deriveEventsForUrl(url, filters)
|
||||
const replyCount = $derived($replies.length)
|
||||
const lastActive = $derived(max([...$replies, event].map(e => e.created_at)))
|
||||
const title = getTagValue("title", event.tags)
|
||||
</script>
|
||||
|
||||
<Link
|
||||
href={makeThreadPath(url, event.id)}
|
||||
class="grid grid-cols-[1fr_auto] gap-x-3 gap-y-1 border-b border-base-content/10 px-3 py-3 transition-colors hover:bg-base-200/50 sm:grid-cols-[1fr_8rem_5rem_8rem] sm:items-center sm:gap-x-4 sm:px-4">
|
||||
<div class="col-span-2 min-w-0 sm:col-span-1">
|
||||
<p class="ellipsize text-sm font-bold sm:text-base">{title || "Untitled thread"}</p>
|
||||
<p class="ellipsize mt-0.5 text-xs opacity-60 sm:hidden">
|
||||
by <ProfileName pubkey={event.pubkey} {url} />
|
||||
</p>
|
||||
</div>
|
||||
<div class="hidden items-center gap-2 sm:flex">
|
||||
<ProfileCircle pubkey={event.pubkey} {url} size={6} />
|
||||
<span class="ellipsize text-sm">
|
||||
<ProfileName pubkey={event.pubkey} {url} />
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-right text-xs opacity-75 sm:text-center sm:text-sm">
|
||||
<span class="opacity-60 sm:hidden">Replies · </span>
|
||||
{replyCount}
|
||||
</p>
|
||||
<p class="text-right text-xs opacity-75 sm:text-sm">
|
||||
<span class="opacity-60 sm:hidden">Last · </span>
|
||||
{formatTimestamp(lastActive)}
|
||||
</p>
|
||||
</Link>
|
||||
@@ -0,0 +1,66 @@
|
||||
<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="flex flex-col items-center gap-3 border-t border-base-content/10 py-4">
|
||||
<p class="text-sm opacity-75">Page {page} of {pageCount}</p>
|
||||
<div class="join">
|
||||
<Button class="btn join-item btn-sm" disabled={page <= 1} onclick={goFirst}>
|
||||
<Icon icon={DoubleAltArrowLeft} size={4} />
|
||||
</Button>
|
||||
<Button class="btn join-item btn-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="btn join-item btn-sm btn-disabled" disabled>…</Button>
|
||||
{/if}
|
||||
<Button
|
||||
class={cx("btn join-item btn-sm", page === p && "btn-primary")}
|
||||
onclick={() => goToPage(p)}>
|
||||
{p}
|
||||
</Button>
|
||||
{/each}
|
||||
<Button class="btn join-item btn-sm" disabled={page >= pageCount} onclick={goNext}>
|
||||
<Icon icon={AltArrowRight} size={4} />
|
||||
</Button>
|
||||
<Button class="btn join-item btn-sm" disabled={page >= pageCount} onclick={goLast}>
|
||||
<Icon icon={DoubleAltArrowRight} size={4} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import {formatTimestamp} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {COMMENT} from "@welshman/util"
|
||||
import {deriveHandleForPubkey, deriveProfileDisplay, displayHandle} from "@welshman/app"
|
||||
import Reply from "@assets/icons/reply-2.svg?dataurl"
|
||||
import SquareArrowRightUp from "@assets/icons/square-arrow-right-up.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||
import NoteContent from "@app/components/NoteContent.svelte"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
import CommentActions from "@app/components/CommentActions.svelte"
|
||||
import ThreadActions from "@app/components/ThreadActions.svelte"
|
||||
import {PLATFORM_URL} from "@app/env"
|
||||
import {makeSpacePath} from "@app/routes"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {clip} from "@app/toast"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
threadId: string
|
||||
event: TrustedEvent
|
||||
number: number
|
||||
threadPubkey: string
|
||||
onReply: (event: TrustedEvent) => void
|
||||
}
|
||||
|
||||
const {url, threadId, event, number, threadPubkey, onReply}: Props = $props()
|
||||
|
||||
|
hodlbod
commented
url should never be undefined right? url should never be undefined right?
|
||||
const profileDisplay = deriveProfileDisplay(event.pubkey, [url])
|
||||
const handle = deriveHandleForPubkey(event.pubkey)
|
||||
const isOp = event.pubkey === threadPubkey
|
||||
const isComment = event.kind === COMMENT
|
||||
|
||||
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey, url})
|
||||
|
||||
const copyPermalink = () => {
|
||||
const path = makeSpacePath(url, "threads", threadId)
|
||||
const link = `${PLATFORM_URL}${path}#post-${number}`
|
||||
|
hodlbod
commented
origin=localhost on android, use PLATFORM_URL origin=localhost on android, use PLATFORM_URL
|
||||
|
||||
clip(link)
|
||||
}
|
||||
|
||||
const reply = () => onReply(event)
|
||||
</script>
|
||||
|
||||
<article
|
||||
id="post-{number}"
|
||||
data-event={event.id}
|
||||
class="border-b border-base-content/15 bg-base-100">
|
||||
<div class="flex flex-col md:flex-row">
|
||||
<aside
|
||||
class="flex shrink-0 flex-row items-center gap-3 border-b border-base-content/10 bg-base-200/50 p-3 md:w-40 md:flex-col md:items-center md:border-b-0 md:border-r md:p-4 md:text-center">
|
||||
<Button onclick={openProfile}>
|
||||
<ProfileCircle pubkey={event.pubkey} {url} size={10} class="md:size-14" />
|
||||
</Button>
|
||||
<div class="flex min-w-0 flex-col gap-1 md:items-center">
|
||||
<Button onclick={openProfile} class="text-bold ellipsize text-sm">
|
||||
{$profileDisplay}
|
||||
</Button>
|
||||
{#if $handle}
|
||||
<span class="ellipsize text-xs opacity-75">{displayHandle($handle)}</span>
|
||||
{/if}
|
||||
{#if isOp}
|
||||
<span class="badge badge-primary badge-sm">OP</span>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
<div class="flex min-w-0 grow flex-col">
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-2 border-b border-base-content/10 bg-base-200/40 px-3 py-2 text-xs sm:px-4 sm:text-sm">
|
||||
<span class="opacity-75">{formatTimestamp(event.created_at)}</span>
|
||||
<Button class="btn btn-ghost btn-xs h-auto min-h-0 gap-1 px-1 py-0" onclick={copyPermalink}>
|
||||
Post #{number}
|
||||
<Icon icon={SquareArrowRightUp} size={3} />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="px-3 py-4 sm:px-4">
|
||||
{#if isComment}
|
||||
<Content showEntire {event} {url} />
|
||||
{:else}
|
||||
<NoteContent showEntire {event} {url} />
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="flex shrink-0 flex-col gap-2 border-t border-base-content/10 bg-base-200/20 px-3 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-4">
|
||||
<Button class="btn btn-neutral btn-xs w-fit gap-1" onclick={reply}>
|
||||
<Icon icon={Reply} size={4} />
|
||||
Reply
|
||||
</Button>
|
||||
{#if isComment}
|
||||
<CommentActions segment="threads" {event} {url} />
|
||||
{:else}
|
||||
<ThreadActions {event} {url} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -6,7 +6,6 @@
|
||||
import {sortBy, partition, spec, max, pushToMapKey} 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 {roomsByUrl} 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,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
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 = new Map<string, TrustedEvent[]>()
|
||||
|
||||
for (const event of items) {
|
||||
const h = getTagValue("h", event.tags) || ""
|
||||
const roomThreads = byRoom.get(h) || []
|
||||
|
||||
roomThreads.push(event)
|
||||
byRoom.set(h, roomThreads)
|
||||
}
|
||||
|
||||
const roomOrder = new Map(($roomsByUrl.get(url) || []).map((room, index) => [room.h, index]))
|
||||
|
||||
const boards = sortBy(
|
||||
([h]) => roomOrder.get(h) ?? Number.MAX_SAFE_INTEGER,
|
||||
sortBy(
|
||||
([h]) => (h ? 0 : 1),
|
||||
sortBy(([h]) => h, Array.from(byRoom.entries())),
|
||||
),
|
||||
)
|
||||
|
||||
return {items, boards}
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
@@ -77,17 +99,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,30 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
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} from "@app/routes"
|
||||
|
||||
const POSTS_PER_PAGE = 20
|
||||
|
||||
const {relay, id} = $page.params as MakeNonOptional<typeof $page.params>
|
||||
const url = decodeRelay(relay)
|
||||
@@ -30,69 +34,136 @@
|
||||
|
||||
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}` : ""}`, {
|
||||
replaceState: true,
|
||||
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 scrollToPost = (hash: string) => {
|
||||
const element = document.getElementById(hash)
|
||||
|
||||
if (element) {
|
||||
element.scrollIntoView({behavior: "smooth", block: "start"})
|
||||
}
|
||||
}
|
||||
|
||||
let showAll = $state(false)
|
||||
let showReply = $state(false)
|
||||
let replyTo: TrustedEvent | undefined = $state()
|
||||
|
||||
onMount(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
request({relays: [url], filters, signal: controller.signal})
|
||||
|
||||
const hash = window.location.hash.replace(/^#/, "")
|
||||
|
||||
if (hash.startsWith("post-")) {
|
||||
const postNumber = parseInt(hash.replace("post-", ""))
|
||||
|
||||
if (!Number.isNaN(postNumber) && postNumber > 0) {
|
||||
setPage(Math.ceil(postNumber / POSTS_PER_PAGE))
|
||||
setTimeout(() => scrollToPost(hash), 100)
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
controller.abort()
|
||||
}
|
||||
})
|
||||
</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-tight 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, i (post.id)}
|
||||
{@const number = (currentPage - 1) * POSTS_PER_PAGE + i + 1}
|
||||
<ThreadPost
|
||||
{url}
|
||||
threadId={id}
|
||||
event={post}
|
||||
{number}
|
||||
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}
|
||||
<EventReply {url} event={replyTo} onClose={closeReply} 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>
|
||||
|
||||
Reference in New Issue
Block a user
Instead of threading replyCounts and lastActive through, create a new ThreadBoardItem which calculates them locally. You can derive stuff from the repository using one of the
deriveEventsutilities with anEtag. This shouldn't cause performance problems compared to iterating over all comments.