Show a timestamp on every note card and widen the home network feed to all content kinds

This commit is contained in:
Coracle-Bot 2026-09-09 15:43:05 +00:00 committed by hodlbod
parent b79025f827
commit 3e265a8a39
3 changed files with 41 additions and 19 deletions

View file

@ -419,6 +419,7 @@ 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 quiet = "the ferry is running on time"
const reply = "they were reprinted last week" const reply = "they were reprinted last week"
const topic = "Dredging the channel"
await seed(({relay, user}) => { await seed(({relay, user}) => {
const space = relay("space") const space = relay("space")
@ -445,6 +446,16 @@ test("US-117 read the network feed on home", async ({seed, as}) => {
space.event(user.bob, () => space.event(user.bob, () =>
space.kind(Note).writer().setParent(posted.event).setContent(reply).renderTemplate(), space.kind(Note).writer().setParent(posted.event).setContent(reply).renderTemplate(),
) )
space.event(user.bob, () =>
space
.kind(Thread)
.writer()
.setRoom(space.url, "general")
.setTitle(topic)
.setContent("The barges keep grounding.")
.renderTemplate(),
)
}) })
const page = await as(users.alice, "/home") const page = await as(users.alice, "/home")
@ -460,6 +471,16 @@ test("US-117 read the network feed on home", async ({seed, as}) => {
// The count is there whether or not anybody replied, so every note reads the same. // The count is there whether or not anybody replied, so every note reads the same.
await expect(page.getByText(quiet)).toBeVisible() await expect(page.getByText(quiet)).toBeVisible()
await expect(page.getByRole("button", {name: "0 replies"})).toBeVisible() await expect(page.getByRole("button", {name: "0 replies"})).toBeVisible()
// The feed carries every kind of post a follow writes, not only their notes.
await expect(page.getByText(topic)).toBeVisible()
// Every card says when it was posted.
const network = page
.locator("section")
.filter({has: page.getByRole("heading", {name: "Network"})})
await expect(network.getByRole("button", {name: /\d+\/\d+\/\d+/}).first()).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

@ -2,9 +2,9 @@
import {onDestroy} from "svelte" import {onDestroy} from "svelte"
import {writable} from "svelte/store" import {writable} from "svelte/store"
import type {Writable} from "svelte/store" import type {Writable} from "svelte/store"
import {sortBy} from "@welshman/lib" import {sortBy, uniqBy} from "@welshman/lib"
import type {Maybe} from "@welshman/lib" import type {Maybe} from "@welshman/lib"
import {NOTE, outbox} from "@welshman/util" import {NOTE, getIdOrAddress, outbox} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {getReplyTags} from "@welshman/domain" import {getReplyTags} from "@welshman/domain"
import Planet from "@assets/icons/planet.svg?dataurl" import Planet from "@assets/icons/planet.svg?dataurl"
@ -13,6 +13,7 @@
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import HomeSection from "@app/components/HomeSection.svelte" import HomeSection from "@app/components/HomeSection.svelte"
import HomeNetworkItem from "@app/components/HomeNetworkItem.svelte" import HomeNetworkItem from "@app/components/HomeNetworkItem.svelte"
import {CONTENT_KINDS} from "@app/content"
import {followLists, relayLists, router, user} from "@app/core" import {followLists, relayLists, router, user} from "@app/core"
import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds" import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds"
@ -36,23 +37,27 @@
let started = false let started = false
let stop: Maybe<() => void> let stop: Maybe<() => void>
// Replies are left out - without their parent they read as half a conversation. // Kind 1 replies are left out - without their parent they read as half a conversation. Every
const notes = $derived( // other kind here is a root, and an edited one is shown once, at its latest version.
sortBy( const isRoot = (event: TrustedEvent) =>
e => -e.created_at, event.kind !== NOTE || getReplyTags(event.tags).replies.length === 0
$events.filter(e => getReplyTags(e.tags).replies.length === 0),
const items = $derived(
uniqBy(
getIdOrAddress,
sortBy(e => -e.created_at, $events.filter(isRoot)),
), ),
) )
const loading = $derived(isFeedLoading($older)) const loading = $derived(isFeedLoading($older))
const exhausted = $derived($older?.status === "exhausted") const exhausted = $derived($older?.status === "exhausted")
const isEmpty = $derived(follows?.length === 0 || (exhausted && notes.length === 0)) const isEmpty = $derived(follows?.length === 0 || (exhausted && items.length === 0))
const start = async (pubkeys: string[]) => { const start = async (pubkeys: string[]) => {
const scenario = await $router.resolve(pubkeys.map(pubkey => outbox(pubkey))) const scenario = await $router.resolve(pubkeys.map(pubkey => outbox(pubkey)))
const feed = makeFeed({ const feed = makeFeed({
relays: scenario.limit(RELAY_LIMIT).getUrls(), relays: scenario.limit(RELAY_LIMIT).getUrls(),
filters: [{kinds: [NOTE], authors: pubkeys}], filters: [{kinds: [NOTE, ...CONTENT_KINDS], authors: pubkeys}],
onEvent: context.add, onEvent: context.add,
}) })
@ -85,16 +90,16 @@
<div class="flex flex-col items-center gap-3 pb-4 text-center"> <div class="flex flex-col items-center gap-3 pb-4 text-center">
<p class="font-medium">Follow a few people to fill this out</p> <p class="font-medium">Follow a few people to fill this out</p>
<p class="max-w-md text-sm opacity-75"> <p class="max-w-md text-sm opacity-75">
Notes from the people you follow collect here. Spaces are a good place to find some. Posts from the people you follow collect here. Spaces are a good place to find some.
</p> </p>
<Link href="/spaces" class="button button-neutral button-sm">Browse spaces</Link> <Link href="/spaces" class="button button-neutral button-sm">Browse spaces</Link>
</div> </div>
{:else if notes.length === 0} {:else if items.length === 0}
<div class="flex justify-center pb-4"> <div class="flex justify-center pb-4">
<Spinner loading>Looking for notes from people you follow…</Spinner> <Spinner loading>Looking for posts from people you follow…</Spinner>
</div> </div>
{:else} {:else}
<Masonry items={notes} getKey={event => event.id} columnWidth={80} maxColumns={2} gap={3}> <Masonry {items} getKey={event => event.id} columnWidth={80} maxColumns={2} gap={3}>
{#snippet child(event)} {#snippet child(event)}
<HomeNetworkItem {event} {context} /> <HomeNetworkItem {event} {context} />
{/snippet} {/snippet}

View file

@ -42,11 +42,7 @@
<Button class="link ml-8" onclick={ignoreMute}>Show anyway</Button> <Button class="link ml-8" onclick={ignoreMute}>Show anyway</Button>
</div> </div>
{:else} {:else}
<!-- The size container is the header, not the card: container-type contains a box's inline <div class="flex items-start justify-between gap-2">
size, so a card that is its own container reports no width to an ancestor sized by its
contents and collapses a quote inside a chat bubble. The header fills the card either way,
so the query still measures the card's width. -->
<div class="@container flex items-start justify-between gap-2">
{#if !hideProfile} {#if !hideProfile}
{#if minimal} {#if minimal}
@<ProfileName pubkey={event.pubkey} {url} /> @<ProfileName pubkey={event.pubkey} {url} />
@ -55,7 +51,7 @@
{/if} {/if}
{/if} {/if}
<Button <Button
class={cx("text-sm opacity-75 @max-sm:hidden", {"text-xs": minimal})} class={cx("whitespace-nowrap text-sm opacity-75", {"text-xs": minimal})}
onclick={goToNote}> onclick={goToNote}>
{formatTimestamp(event.created_at)} {formatTimestamp(event.created_at)}
</Button> </Button>