Show nav notification badges for activity under the current section (#404)
Co-authored-by: Coracle-Bot <npub1klq6260@nostr.local>
This commit is contained in:
parent
c02072863a
commit
4578644924
4 changed files with 78 additions and 3 deletions
|
|
@ -1538,6 +1538,21 @@ Acceptance:
|
||||||
- An unresolvable nostr link redirects to the app's home rather than showing a
|
- An unresolvable nostr link redirects to the app's home rather than showing a
|
||||||
broken page.
|
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
|
## Out of scope
|
||||||
|
|
||||||
Features the e2e suite cannot exercise, and what stops it.
|
Features the e2e suite cannot exercise, and what stops it.
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,11 @@ const settingRow = (page: Page, label: string) =>
|
||||||
const spaceNavItem = (page: Page, name: string) =>
|
const spaceNavItem = (page: Page, name: string) =>
|
||||||
page.locator(`.primary-nav [data-tip^="${name}"]`)
|
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.
|
// The space menu's header, the one button in the secondary nav carrying the relay's address.
|
||||||
const spaceMenu = (page: Page, url: string) =>
|
const spaceMenu = (page: Page, url: string) =>
|
||||||
page.locator(".secondary-nav").getByRole("button", {name: pattern(displayRelayUrl(url))})
|
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$/)
|
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)
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
import {userSpaceUrls} from "@app/rooms"
|
import {userSpaceUrls} from "@app/rooms"
|
||||||
import {PLATFORM_RELAYS, PLATFORM_LOGO} from "@app/env"
|
import {PLATFORM_RELAYS, PLATFORM_LOGO} from "@app/env"
|
||||||
import {notifications} from "@app/notifications"
|
import {notifications} from "@app/notifications"
|
||||||
|
import {makeSpacePath} from "@app/routes"
|
||||||
|
|
||||||
let windowHeight = $state(0)
|
let windowHeight = $state(0)
|
||||||
|
|
||||||
|
|
@ -17,7 +18,9 @@
|
||||||
const navPadding = 8 * itemHeight
|
const navPadding = 8 * itemHeight
|
||||||
const itemLimit = $derived(Math.max(0, (windowHeight - navPadding) / itemHeight))
|
const itemLimit = $derived(Math.max(0, (windowHeight - navPadding) / itemHeight))
|
||||||
const [primarySpaceUrls, secondarySpaceUrls] = $derived(splitAt(itemLimit, $userSpaceUrls))
|
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
|
// Tippy mounts its content component once, so pass a stable reactive object it can read from
|
||||||
const overflowProps = $state({urls: [] as string[]})
|
const overflowProps = $state({urls: [] as string[]})
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
{#if onclick}
|
{#if onclick}
|
||||||
<Button {onclick} data-tip={title} class={className}>
|
<Button {onclick} data-tip={title} class={className}>
|
||||||
{@render children?.()}
|
{@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 class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary text-primary-content">
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -35,7 +35,7 @@
|
||||||
{:else}
|
{:else}
|
||||||
<Link {href} data-tip={title} class={className}>
|
<Link {href} data-tip={title} class={className}>
|
||||||
{@render children?.()}
|
{@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 class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary text-primary-content">
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue