Show nav notification badges for activity under the current section (#404)

Co-authored-by: Coracle-Bot <npub1klq6260@nostr.local>
This commit is contained in:
Coracle-Bot 2026-09-02 22:43:18 +00:00 committed by hodlbod
parent c02072863a
commit 4578644924
4 changed files with 78 additions and 3 deletions

View file

@ -1538,6 +1538,21 @@ Acceptance:
- An unresolvable nostr link redirects to the app's home rather than showing a
broken page.
### US-110 — See another space's unread activity from a phone
As bob on a phone, I want the bottom bar's Spaces button to tell me another
space wants attention, so that I don't have to leave the room I'm reading to
find out.
Acceptance:
- While bob has a room open, a message in a space he isn't in raises an unread
dot on the Spaces button, matching the dot that space's row carries in
`/spaces`.
- A message in another room of the space he's already in does not; that space's
own room list carries it.
- Reading the other space takes the dot down.
## Out of scope
Features the e2e suite cannot exercise, and what stops it.

View file

@ -41,6 +41,11 @@ const settingRow = (page: Page, label: string) =>
const spaceNavItem = (page: Page, name: string) =>
page.locator(`.primary-nav [data-tip^="${name}"]`)
// The desktop rail and the phone's bottom bar each carry a link to the space list, and neither one
// has an accessible name — both icons are masked svgs. Below tailwind's md breakpoint the rail is
// display:none, so on a phone the visible one is the bar's.
const spacesNavItem = (page: Page) => page.locator('a[href="/spaces"]:visible')
// The space menu's header, the one button in the secondary nav carrying the relay's address.
const spaceMenu = (page: Page, url: string) =>
page.locator(".secondary-nav").getByRole("button", {name: pattern(displayRelayUrl(url))})
@ -418,3 +423,55 @@ test("US-107 open a nostr link", async ({seed, as}) => {
await expect(page).toHaveURL(/\/home$/)
})
test("US-110 see another space's unread activity from a phone", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
const other = relay("other")
space.room("general", {name: "General"})
space.room("random", {name: "Random"})
other.room("general", {name: "General"})
space.join(user.alice, "general", "random")
space.join(user.bob, "general", "random")
other.join(user.alice, "general")
other.join(user.bob, "general")
})
const space = scenario.space("space")
const other = scenario.space("other")
// A phone has no room list on screen while a room is open, so the bottom bar is the only place
// activity elsewhere can surface
const bob = await as(users.bob, roomPath(space.url, "general"), {
context: {viewport: {width: 390, height: 844}, hasTouch: true},
})
const spacesButton = spacesNavItem(bob)
await expect(spacesButton).toBeVisible()
await expect(unreadDot(spacesButton)).toHaveCount(0)
const inOther = await as(users.alice, roomPath(other.url, "general"))
await send(inOther, "the server is on fire")
await expect(unreadDot(spacesButton)).toBeVisible()
// Meanwhile the space bob is sitting in gets a message too, in a room he isn't reading
const inSpace = await as(users.alice, roomPath(space.url, "random"))
await send(inSpace, "anyone seen the sextant?")
await bob.goto(roomPath(other.url, "general"))
await expect(message(bob, "the server is on fire")).toBeVisible()
// Reading the other space empties the bar, even though bob's own space still has an unread room
// — that one is the space's business, and its own indicators carry it
await bob.goto(spacePath(space.url))
await expect(unreadDot(roomLink(bob, "Random"))).toBeVisible()
await expect(unreadDot(spacesButton)).toHaveCount(0)
})

View file

@ -10,6 +10,7 @@
import {userSpaceUrls} from "@app/rooms"
import {PLATFORM_RELAYS, PLATFORM_LOGO} from "@app/env"
import {notifications} from "@app/notifications"
import {makeSpacePath} from "@app/routes"
let windowHeight = $state(0)
@ -17,7 +18,9 @@
const navPadding = 8 * itemHeight
const itemLimit = $derived(Math.max(0, (windowHeight - navPadding) / itemHeight))
const [primarySpaceUrls, secondarySpaceUrls] = $derived(splitAt(itemLimit, $userSpaceUrls))
const otherSpaceNotifications = $derived(secondarySpaceUrls.some(p => $notifications.has(p)))
const otherSpaceNotifications = $derived(
secondarySpaceUrls.some(url => $notifications.has(makeSpacePath(url))),
)
// Tippy mounts its content component once, so pass a stable reactive object it can read from
const overflowProps = $state({urls: [] as string[]})

View file

@ -27,7 +27,7 @@
{#if onclick}
<Button {onclick} data-tip={title} class={className}>
{@render children?.()}
{#if !active && notification}
{#if notification}
<div class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary text-primary-content">
</div>
{/if}
@ -35,7 +35,7 @@
{:else}
<Link {href} data-tip={title} class={className}>
{@render children?.()}
{#if !active && notification}
{#if notification}
<div class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary text-primary-content">
</div>
{/if}