Offer a space section when the relay holds one or something under it is unread
This commit is contained in:
parent
241d2b3737
commit
1881a3710f
4 changed files with 152 additions and 14 deletions
|
|
@ -1690,6 +1690,28 @@ Acceptance:
|
||||||
- Opening the list keeps the dot on the corner of alice's card, and bob's own
|
- Opening the list keeps the dot on the corner of alice's card, and bob's own
|
||||||
card carries none.
|
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
|
## Out of scope
|
||||||
|
|
||||||
Features the e2e suite cannot exercise, and what stops it.
|
Features the e2e suite cannot exercise, and what stops it.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import {neventEncode, npubEncode} from "nostr-tools/nip19"
|
import {neventEncode, npubEncode} from "nostr-tools/nip19"
|
||||||
import {HOUR, MINUTE} from "@welshman/lib"
|
import {DAY, HOUR, MINUTE} from "@welshman/lib"
|
||||||
import {displayRelayUrl} from "@welshman/util"
|
import {POLL, displayRelayUrl} from "@welshman/util"
|
||||||
import {RelayMessageType} from "@welshman/net"
|
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 type {Locator, Page} from "@playwright/test"
|
||||||
import {
|
import {
|
||||||
composer,
|
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)
|
await expect(unreadDot(menuButton)).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
// SpaceMenuNavItems hides a content type until the space has an event of that kind, so this link
|
// SpaceMenuNavItems offers a content type once the space has an event of that kind or something
|
||||||
// appearing at all is what says the seeded threads loaded.
|
// under it is unread, so this link appearing at all is what says the seeded content loaded.
|
||||||
const contentNavItem = (page: Page, name: string) =>
|
const contentNavItem = (page: Page, name: string) =>
|
||||||
page.locator(".secondary-nav").getByRole("link", {name})
|
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)
|
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}) => {
|
test("US-120 read what a notification says", async ({seed, as}) => {
|
||||||
const scenario = await seed(({relay, user, at}) => {
|
const scenario = await seed(({relay, user, at}) => {
|
||||||
const space = relay("space")
|
const space = relay("space")
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import {page} from "$app/stores"
|
||||||
import {getJson, setJson} from "@welshman/lib"
|
import {getJson, setJson} from "@welshman/lib"
|
||||||
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, PINBOARD, POLL, LONG_FORM} from "@welshman/util"
|
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, PINBOARD, POLL, LONG_FORM} from "@welshman/util"
|
||||||
import {deriveDeduplicatedByValue} from "@welshman/store"
|
import {deriveDeduplicatedByValue} from "@welshman/store"
|
||||||
|
|
@ -22,7 +23,7 @@
|
||||||
import {deriveSpaceSupportedMethods} from "@app/management"
|
import {deriveSpaceSupportedMethods} from "@app/management"
|
||||||
import {deriveEventsForUrl} from "@app/repository"
|
import {deriveEventsForUrl} from "@app/repository"
|
||||||
import {makeSpacePath} from "@app/routes"
|
import {makeSpacePath} from "@app/routes"
|
||||||
import {notifications} from "@app/notifications"
|
import {allNotifications, notifications} from "@app/notifications"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -39,6 +40,7 @@
|
||||||
const articlesPath = makeSpacePath(url, "articles")
|
const articlesPath = makeSpacePath(url, "articles")
|
||||||
const calendarPath = makeSpacePath(url, "calendar")
|
const calendarPath = makeSpacePath(url, "calendar")
|
||||||
const pollsPath = makeSpacePath(url, "polls")
|
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
|
// 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.
|
// 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)]),
|
$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 hasNip29 = $derived($relay?.hasNip(29) ?? false)
|
||||||
const supportedMethods = deriveSpaceSupportedMethods(url)
|
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})
|
const openSearch = () => pushModal(SpaceSearch, {url})
|
||||||
|
|
||||||
|
|
@ -71,36 +81,36 @@
|
||||||
<Icon icon={UsersGroup} /> Directory
|
<Icon icon={UsersGroup} /> Directory
|
||||||
</SecondaryNavItem>
|
</SecondaryNavItem>
|
||||||
{#if showLibrary}
|
{#if showLibrary}
|
||||||
<SecondaryNavItem href={makeSpacePath(url, "library")}>
|
<SecondaryNavItem href={libraryPath} notification={$notifications.has(libraryPath)}>
|
||||||
<Icon icon={GalleryWide} /> Library
|
<Icon icon={GalleryWide} /> Library
|
||||||
</SecondaryNavItem>
|
</SecondaryNavItem>
|
||||||
{/if}
|
{/if}
|
||||||
{#if ENABLE_ZAPS && $spaceKinds.has(ZAP_GOAL)}
|
{#if ENABLE_ZAPS && showSection(ZAP_GOAL, goalsPath)}
|
||||||
<SecondaryNavItem href={goalsPath} notification={$notifications.has(goalsPath)}>
|
<SecondaryNavItem href={goalsPath} notification={$notifications.has(goalsPath)}>
|
||||||
<Icon icon={StarFallMinimalistic} /> Goals
|
<Icon icon={StarFallMinimalistic} /> Goals
|
||||||
</SecondaryNavItem>
|
</SecondaryNavItem>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $spaceKinds.has(THREAD)}
|
{#if showSection(THREAD, threadsPath)}
|
||||||
<SecondaryNavItem href={threadsPath} notification={$notifications.has(threadsPath)}>
|
<SecondaryNavItem href={threadsPath} notification={$notifications.has(threadsPath)}>
|
||||||
<Icon icon={NotesMinimalistic} /> Threads
|
<Icon icon={NotesMinimalistic} /> Threads
|
||||||
</SecondaryNavItem>
|
</SecondaryNavItem>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $spaceKinds.has(LONG_FORM)}
|
{#if showSection(LONG_FORM, articlesPath)}
|
||||||
<SecondaryNavItem href={articlesPath} notification={$notifications.has(articlesPath)}>
|
<SecondaryNavItem href={articlesPath} notification={$notifications.has(articlesPath)}>
|
||||||
<Icon icon={DocumentText} /> Articles
|
<Icon icon={DocumentText} /> Articles
|
||||||
</SecondaryNavItem>
|
</SecondaryNavItem>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $spaceKinds.has(CLASSIFIED)}
|
{#if showSection(CLASSIFIED, classifiedsPath)}
|
||||||
<SecondaryNavItem href={classifiedsPath} notification={$notifications.has(classifiedsPath)}>
|
<SecondaryNavItem href={classifiedsPath} notification={$notifications.has(classifiedsPath)}>
|
||||||
<Icon icon={CaseMinimalistic} /> Classifieds
|
<Icon icon={CaseMinimalistic} /> Classifieds
|
||||||
</SecondaryNavItem>
|
</SecondaryNavItem>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $spaceKinds.has(EVENT_TIME)}
|
{#if showSection(EVENT_TIME, calendarPath)}
|
||||||
<SecondaryNavItem href={calendarPath} notification={$notifications.has(calendarPath)}>
|
<SecondaryNavItem href={calendarPath} notification={$notifications.has(calendarPath)}>
|
||||||
<Icon icon={CalendarMinimalistic} /> Calendar
|
<Icon icon={CalendarMinimalistic} /> Calendar
|
||||||
</SecondaryNavItem>
|
</SecondaryNavItem>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $spaceKinds.has(POLL)}
|
{#if showSection(POLL, pollsPath)}
|
||||||
<SecondaryNavItem href={pollsPath} notification={$notifications.has(pollsPath)}>
|
<SecondaryNavItem href={pollsPath} notification={$notifications.has(pollsPath)}>
|
||||||
<Icon icon={Revote} /> Polls
|
<Icon icon={Revote} /> Polls
|
||||||
</SecondaryNavItem>
|
</SecondaryNavItem>
|
||||||
|
|
|
||||||
|
|
@ -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()
|
return () => controller.abort()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue