From 45786449241af42fa7c05e44b8bdeb41dc0b26e2 Mon Sep 17 00:00:00 2001 From: Coracle-Bot Date: Wed, 2 Sep 2026 22:43:18 +0000 Subject: [PATCH] Show nav notification badges for activity under the current section (#404) Co-authored-by: Coracle-Bot --- e2e/USER_STORIES.md | 15 ++++++ e2e/specs/notifications.spec.ts | 57 ++++++++++++++++++++++ src/app/components/PrimaryNavSpaces.svelte | 5 +- src/lib/components/PrimaryNavItem.svelte | 4 +- 4 files changed, 78 insertions(+), 3 deletions(-) diff --git a/e2e/USER_STORIES.md b/e2e/USER_STORIES.md index e18c7b26..2eb0b9ae 100644 --- a/e2e/USER_STORIES.md +++ b/e2e/USER_STORIES.md @@ -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. diff --git a/e2e/specs/notifications.spec.ts b/e2e/specs/notifications.spec.ts index 015048b8..237191fd 100644 --- a/e2e/specs/notifications.spec.ts +++ b/e2e/specs/notifications.spec.ts @@ -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) +}) diff --git a/src/app/components/PrimaryNavSpaces.svelte b/src/app/components/PrimaryNavSpaces.svelte index 89ac0961..63eb0838 100644 --- a/src/app/components/PrimaryNavSpaces.svelte +++ b/src/app/components/PrimaryNavSpaces.svelte @@ -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[]}) diff --git a/src/lib/components/PrimaryNavItem.svelte b/src/lib/components/PrimaryNavItem.svelte index 30e4aaee..921150ff 100644 --- a/src/lib/components/PrimaryNavItem.svelte +++ b/src/lib/components/PrimaryNavItem.svelte @@ -27,7 +27,7 @@ {#if onclick}