Show a reply count on home network notes and stop virtualizing the feed

This commit is contained in:
Coracle-Bot 2026-09-09 15:09:45 +00:00 committed by hodlbod
parent 9027634567
commit b79025f827
5 changed files with 58 additions and 47 deletions

View file

@ -1571,6 +1571,9 @@ Acceptance:
- The Network section lists notes from her follows, resolved through the relays - The Network section lists notes from her follows, resolved through the relays
those people publish to. those people publish to.
- It is a list of notes: a reply is counted on the note it answers rather than
drawn underneath it, and never appears as an item of its own.
- Every note carries its reply count, including the ones with no replies.
- Scrolling to the end of the feed loads more rather than asking her to. - Scrolling to the end of the feed loads more rather than asking her to.
### US-106 — Share text into the app ### US-106 — Share text into the app

View file

@ -417,6 +417,8 @@ test("US-116 read the home dashboard", async ({seed, as}) => {
test("US-117 read the network feed on home", async ({seed, as}) => { test("US-117 read the network feed on home", async ({seed, as}) => {
const note = "the tide charts are wrong again" const note = "the tide charts are wrong again"
const quiet = "the ferry is running on time"
const reply = "they were reprinted last week"
await seed(({relay, user}) => { await seed(({relay, user}) => {
const space = relay("space") const space = relay("space")
@ -433,13 +435,31 @@ test("US-117 read the network feed on home", async ({seed, as}) => {
space.event(user.alice, () => space.event(user.alice, () =>
space.kind(FollowList).writer().follow(user.bob.pubkey).renderTemplate(), space.kind(FollowList).writer().follow(user.bob.pubkey).renderTemplate(),
) )
space.event(user.bob, () => space.kind(Note).writer().setContent(note).renderTemplate())
const posted = space.event(user.bob, () =>
space.kind(Note).writer().setContent(note).renderTemplate(),
)
space.event(user.bob, () => space.kind(Note).writer().setContent(quiet).renderTemplate())
space.event(user.bob, () =>
space.kind(Note).writer().setParent(posted.event).setContent(reply).renderTemplate(),
)
}) })
const page = await as(users.alice, "/home") const page = await as(users.alice, "/home")
await expect(page.getByRole("heading", {name: "Network"})).toBeVisible() await expect(page.getByRole("heading", {name: "Network"})).toBeVisible()
await expect(page.getByText(note)).toBeVisible() await expect(page.getByText(note)).toBeVisible()
// The feed is notes only: a reply is counted on the note it answers rather than drawn
// underneath it, and it never gets a card of its own.
await expect(page.getByRole("button", {name: "1 reply", exact: true})).toBeVisible()
await expect(page.getByText(reply)).toHaveCount(0)
// The count is there whether or not anybody replied, so every note reads the same.
await expect(page.getByText(quiet)).toBeVisible()
await expect(page.getByRole("button", {name: "0 replies"})).toBeVisible()
}) })
test("US-106 share text into the app", async ({seed, as}) => { test("US-106 share text into the app", async ({seed, as}) => {

View file

@ -24,10 +24,9 @@
setReplyTo?: (comment?: TrustedEvent) => void setReplyTo?: (comment?: TrustedEvent) => void
url?: string url?: string
context: FeedContext context: FeedContext
maxDepth?: number
} }
const {node, root, replyTo, setReplyTo, url, context, maxDepth = Infinity}: Props = $props() const {node, root, replyTo, setReplyTo, url, context}: Props = $props()
const composing = $derived(replyTo?.id === node.comment.id) const composing = $derived(replyTo?.id === node.comment.id)
@ -94,21 +93,14 @@
onSubmit={clearReplyTo} /> onSubmit={clearReplyTo} />
</div> </div>
{/if} {/if}
{#if node.children.length > 0 && maxDepth > 1} {#if node.children.length > 0}
<!-- The thread line runs under the avatar's center and indents replies to line up with <!-- The thread line runs under the avatar's center and indents replies to line up with
this comment's text column; it brightens while the subtree is hovered. --> this comment's text column; it brightens while the subtree is hovered. -->
<div <div
data-component="CommentReplies" data-component="CommentReplies"
class="border-line-less hover:border-line ml-4 flex flex-col border-l pl-7 transition-colors"> class="border-line-less hover:border-line ml-4 flex flex-col border-l pl-7 transition-colors">
{#each node.children as child (child.comment.id)} {#each node.children as child (child.comment.id)}
<CommentTree <CommentTree node={child} {root} {replyTo} {setReplyTo} {url} {context} />
node={child}
{root}
{replyTo}
{setReplyTo}
{url}
{context}
maxDepth={maxDepth - 1} />
{/each} {/each}
</div> </div>
{/if} {/if}

View file

@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import {sortBy} from "@welshman/lib"
import {COMMENT, NOTE} from "@welshman/util" import {COMMENT, NOTE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import Cv from "@lib/components/Cv.svelte" import Reply from "@assets/icons/reply-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import NoteItem from "@app/components/NoteItem.svelte" import NoteItem from "@app/components/NoteItem.svelte"
import CommentTree from "@app/components/CommentTree.svelte"
import type {FeedContext} from "@app/feeds" import type {FeedContext} from "@app/feeds"
import {buildCommentTree} from "@app/social" import {goToEvent} from "@app/routes"
type Props = { type Props = {
event: TrustedEvent event: TrustedEvent
@ -17,26 +17,18 @@
const related = context.related(event) const related = context.related(event)
// buildCommentTree adopts a comment whose parent never loaded, and reads oldest first so // Kind 1 notes are replied to with notes as well as with NIP-22 comments.
// that it adopts a parent before its own children. const replyCount = $derived($related.filter(e => e.kind === COMMENT || e.kind === NOTE).length)
const nodes = $derived(
buildCommentTree( const goToReplies = () => goToEvent(event)
event,
sortBy(
e => e.created_at,
$related.filter(e => e.kind === COMMENT || e.kind === NOTE),
),
),
)
</script> </script>
<Cv class="card card-interactive flex flex-col gap-3"> <div class="card card-interactive flex flex-col gap-3">
<NoteItem {event} {context} class="" /> <NoteItem {event} {context} card={false}>
{#if nodes.length > 0} <Button class="button button-neutral button-xs rounded-full" onclick={goToReplies}>
<div class="border-line-less flex flex-col border-t"> <Icon icon={Reply} size={4} />
{#each nodes as node (node.comment.id)} {replyCount}
<CommentTree {node} root={event} {context} maxDepth={2} /> {replyCount === 1 ? "reply" : "replies"}
{/each} </Button>
</div> </NoteItem>
{/if} </div>
</Cv>

View file

@ -19,16 +19,10 @@
children?: Snippet children?: Snippet
context: FeedContext context: FeedContext
url?: string url?: string
class?: string card?: boolean
} }
const { const {url, event, children, context, card = true}: Props = $props()
url,
event,
children,
context,
class: className = "card card-interactive",
}: Props = $props()
const getRelays = () => (url ? [url] : $router.resolver.relays([seen(event)])) const getRelays = () => (url ? [url] : $router.resolver.relays([seen(event)]))
@ -41,7 +35,7 @@
const onEmoji = (emoji: NativeEmoji) => createReaction({content: emoji.unicode, tags: []}) const onEmoji = (emoji: NativeEmoji) => createReaction({content: emoji.unicode, tags: []})
</script> </script>
<Cv tag={NoteCard} {event} {url} class={className}> {#snippet body()}
<NoteContent {event} expandMode="inline" /> <NoteContent {event} expandMode="inline" />
<div class="flex w-full justify-between gap-2"> <div class="flex w-full justify-between gap-2">
<ReactionSummary <ReactionSummary
@ -60,4 +54,14 @@
</ReactionSummary> </ReactionSummary>
{@render children?.()} {@render children?.()}
</div> </div>
</Cv> {/snippet}
{#if card}
<Cv tag={NoteCard} {event} {url} class="card card-interactive">
{@render body()}
</Cv>
{:else}
<NoteCard {event} {url}>
{@render body()}
</NoteCard>
{/if}