Offer a space section when the relay holds one or something under it is unread

This commit is contained in:
Coracle-Bot 2026-09-15 15:33:01 +00:00
parent 241d2b3737
commit 1881a3710f
4 changed files with 152 additions and 14 deletions

View file

@ -1690,6 +1690,28 @@ Acceptance:
- Opening the list keeps the dot on the corner of alice's card, and bob's own
card carries none.
### US-123 — Find a section whose newest item is old
As bob, I want a space to offer every kind of content it holds, so that a quiet
section is something I can reach rather than something I have to guess at.
Acceptance:
- A poll older than the window the space sync asks for still puts the Polls nav
item on the menu, and opening it lists the poll.
### US-124 — Reach a badge raised by content the space doesn't have
As bob, I want a badge on a space to lead me to whatever raised it, so that an
indicator I can see is one I can clear.
Acceptance:
- A comment on a poll the space doesn't hold raises a dot on the space, and the
Polls nav item carries the same dot once the space is open.
- Opening the list says there are no polls, and afterwards the space stops
offering the section.
## Out of scope
Features the e2e suite cannot exercise, and what stops it.

View file

@ -1,8 +1,8 @@
import {neventEncode, npubEncode} from "nostr-tools/nip19"
import {HOUR, MINUTE} from "@welshman/lib"
import {displayRelayUrl} from "@welshman/util"
import {DAY, HOUR, MINUTE} from "@welshman/lib"
import {POLL, displayRelayUrl} from "@welshman/util"
import {RelayMessageType} from "@welshman/net"
import {Classified, FollowList, Note, Thread} from "@welshman/domain"
import {Classified, Comment, FollowList, Note, Poll, Thread} from "@welshman/domain"
import type {Locator, Page} from "@playwright/test"
import {
composer,
@ -679,8 +679,8 @@ test("US-110 see another space's unread activity from a phone", async ({seed, as
await expect(unreadDot(menuButton)).toHaveCount(0)
})
// SpaceMenuNavItems hides a content type until the space has an event of that kind, so this link
// appearing at all is what says the seeded threads loaded.
// SpaceMenuNavItems offers a content type once the space has an event of that kind or something
// under it is unread, so this link appearing at all is what says the seeded content loaded.
const contentNavItem = (page: Page, name: string) =>
page.locator(".secondary-nav").getByRole("link", {name})
@ -836,6 +836,103 @@ test("US-114 see which listings are unread", async ({seed, as}) => {
await expect(unreadDot(his)).toHaveCount(0)
})
const seedPoll = (space: SeededSpace, user: TestUser, title: string, createdAt: number) =>
space.event(
user,
() =>
space
.kind(Poll)
.writer()
.setTitle(title)
.setPollType("singlechoice")
.addOption("Yes", "poll-yes")
.addOption("No", "poll-no")
.setUrls([space.url])
.renderTemplate(),
createdAt,
)
test("US-123 find a section whose newest item is older than the sync window", async ({
seed,
as,
}) => {
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
// Older than the month the space sync asks for, so the nav can only know about it by asking
// the relay what the space holds rather than by reading what has loaded.
seedPoll(space, user.alice, "which sextant should we buy", at(60, DAY))
})
const space = scenario.space("space")
const bob = await as(users.bob, roomPath(space.url, "general"))
const pollsNav = contentNavItem(bob, "Polls")
await expect(pollsNav).toBeVisible()
await pollsNav.click()
await expect(bob.getByText("which sextant should we buy")).toBeVisible()
})
test("US-124 reach a badge raised by content the space doesn't have", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
const other = relay("other")
space.room("general", {name: "General"})
other.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
other.join(user.bob, "general")
// A comment on a poll nothing holds — the relay kept the comment and dropped its subject.
// It counts toward the space either way, so the section it belongs to has to be reachable.
space.event(
user.alice,
() =>
space
.kind(Comment)
.writer()
.setRoot(POLL, "f".repeat(64), users.alice.pubkey)
.setContent("the second option, surely")
.renderTemplate(),
at(2, HOUR),
)
})
const space = scenario.space("space")
const other = scenario.space("other")
// A space hides its own dot while you are in it, so the badge is only readable from elsewhere
const bob = await as(users.bob, roomPath(other.url, "general"))
const spaceButton = spaceNavItem(bob, space.name)
await expect(unreadDot(spaceButton)).toBeVisible()
await spaceButton.click()
const pollsNav = contentNavItem(bob, "Polls")
await expect(pollsNav).toBeVisible()
await expect(unreadDot(pollsNav)).toBeVisible()
await pollsNav.click()
await expect(bob.getByText("No polls found.")).toBeVisible()
// Reading it is the end of it: with nothing unread and no poll to list, the space stops
// offering the section at all
await roomLink(bob, "General").click()
await expect(pollsNav).toHaveCount(0)
})
test("US-120 read what a notification says", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")

View file

@ -1,4 +1,5 @@
<script lang="ts">
import {page} from "$app/stores"
import {getJson, setJson} from "@welshman/lib"
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, PINBOARD, POLL, LONG_FORM} from "@welshman/util"
import {deriveDeduplicatedByValue} from "@welshman/store"
@ -22,7 +23,7 @@
import {deriveSpaceSupportedMethods} from "@app/management"
import {deriveEventsForUrl} from "@app/repository"
import {makeSpacePath} from "@app/routes"
import {notifications} from "@app/notifications"
import {allNotifications, notifications} from "@app/notifications"
import {pushModal} from "@app/modal"
type Props = {
@ -39,6 +40,7 @@
const articlesPath = makeSpacePath(url, "articles")
const calendarPath = makeSpacePath(url, "calendar")
const pollsPath = makeSpacePath(url, "polls")
const libraryPath = makeSpacePath(url, "library")
// Content events aren't retained across page loads, so seed with the kinds seen last time
// to keep the nav from re-populating as they load in the background.
@ -50,9 +52,17 @@
$events => new Set([...cachedKinds, ...$events.map(e => e.kind)]),
)
// A section is also offered while something under it is unread, or while we're in it. A comment
// can name content this space doesn't have, and it counts toward the space either way — hiding
// the section it belongs to is what leaves a space lit up with nothing to read.
const showSection = (kind: number, path: string) =>
$spaceKinds.has(kind) || $allNotifications.has(path) || $page.url.pathname.startsWith(path)
const hasNip29 = $derived($relay?.hasNip(29) ?? false)
const supportedMethods = deriveSpaceSupportedMethods(url)
const showLibrary = $derived($spaceKinds.has(PINBOARD) || $supportedMethods.includes("signevent"))
const showLibrary = $derived(
showSection(PINBOARD, libraryPath) || $supportedMethods.includes("signevent"),
)
const openSearch = () => pushModal(SpaceSearch, {url})
@ -71,36 +81,36 @@
<Icon icon={UsersGroup} /> Directory
</SecondaryNavItem>
{#if showLibrary}
<SecondaryNavItem href={makeSpacePath(url, "library")}>
<SecondaryNavItem href={libraryPath} notification={$notifications.has(libraryPath)}>
<Icon icon={GalleryWide} /> Library
</SecondaryNavItem>
{/if}
{#if ENABLE_ZAPS && $spaceKinds.has(ZAP_GOAL)}
{#if ENABLE_ZAPS && showSection(ZAP_GOAL, goalsPath)}
<SecondaryNavItem href={goalsPath} notification={$notifications.has(goalsPath)}>
<Icon icon={StarFallMinimalistic} /> Goals
</SecondaryNavItem>
{/if}
{#if $spaceKinds.has(THREAD)}
{#if showSection(THREAD, threadsPath)}
<SecondaryNavItem href={threadsPath} notification={$notifications.has(threadsPath)}>
<Icon icon={NotesMinimalistic} /> Threads
</SecondaryNavItem>
{/if}
{#if $spaceKinds.has(LONG_FORM)}
{#if showSection(LONG_FORM, articlesPath)}
<SecondaryNavItem href={articlesPath} notification={$notifications.has(articlesPath)}>
<Icon icon={DocumentText} /> Articles
</SecondaryNavItem>
{/if}
{#if $spaceKinds.has(CLASSIFIED)}
{#if showSection(CLASSIFIED, classifiedsPath)}
<SecondaryNavItem href={classifiedsPath} notification={$notifications.has(classifiedsPath)}>
<Icon icon={CaseMinimalistic} /> Classifieds
</SecondaryNavItem>
{/if}
{#if $spaceKinds.has(EVENT_TIME)}
{#if showSection(EVENT_TIME, calendarPath)}
<SecondaryNavItem href={calendarPath} notification={$notifications.has(calendarPath)}>
<Icon icon={CalendarMinimalistic} /> Calendar
</SecondaryNavItem>
{/if}
{#if $spaceKinds.has(POLL)}
{#if showSection(POLL, pollsPath)}
<SecondaryNavItem href={pollsPath} notification={$notifications.has(pollsPath)}>
<Icon icon={Revote} /> Polls
</SecondaryNavItem>

View file

@ -290,6 +290,15 @@ const syncSpace = (url: string) => {
],
})
// Which sections a space offers is a question about its whole history rather than about the
// recent window above — a space whose newest poll is a year old still has polls. One event
// per kind answers it.
network.get().load({
relays: [url],
signal: controller.signal,
filters: CONTENT_KINDS.map(kind => ({kinds: [kind], limit: 1})),
})
return () => controller.abort()
}