diff --git a/e2e/USER_STORIES.md b/e2e/USER_STORIES.md index 2eb0b9ae..9d37834e 100644 --- a/e2e/USER_STORIES.md +++ b/e2e/USER_STORIES.md @@ -1553,6 +1553,30 @@ Acceptance: own room list carries it. - Reading the other space takes the dot down. +### US-112 — See which threads are unread + +As bob, I want the Threads dot to lead me to the thread that raised it, so that +the indicator is something I can act on rather than dismiss. + +Acceptance: + +- A thread alice posted raises an unread dot on the Threads nav item; one bob + posted himself does not. +- Opening the list keeps the dot on alice's row, so he can tell which thread is + new, and bob's row still carries none. +- Leaving the list marks its threads read, and neither the row nor the nav item + shows a dot afterwards. + +### US-113 — See which threads are unread on a phone + +As bob on a phone, I want the same dot on the thread that raised it, so that the +Threads indicator is as actionable on a phone as it is on a desktop. + +Acceptance: + +- The thread list below the md breakpoint is a list of links rather than a + table, and alice's thread carries a dot there; bob's own does not. + ## 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 237191fd..09e518c6 100644 --- a/e2e/specs/notifications.spec.ts +++ b/e2e/specs/notifications.spec.ts @@ -1,7 +1,7 @@ import {neventEncode, npubEncode} from "nostr-tools/nip19" import {HOUR, MINUTE} from "@welshman/lib" import {displayRelayUrl} from "@welshman/util" -import {MessagingRelayList, RelayList} from "@welshman/domain" +import {MessagingRelayList, RelayList, Thread} from "@welshman/domain" import type {Locator, Page} from "@playwright/test" import {expect, roomPath, spacePath, test, users} from "../harness" import type {SeededSpace, TestUser} from "../harness" @@ -475,3 +475,107 @@ test("US-110 see another space's unread activity from a phone", async ({seed, as await expect(unreadDot(roomLink(bob, "Random"))).toBeVisible() await expect(unreadDot(spacesButton)).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. +const contentNavItem = (page: Page, name: string) => + page.locator(".secondary-nav").getByRole("link", {name}) + +const seedThread = (space: SeededSpace, user: TestUser, title: string, createdAt: number) => + space.event( + user, + () => + space + .kind(Thread) + .writer() + .setRoom(space.url, "general") + .setTitle(title) + .setContent("worth talking about") + .renderTemplate(), + createdAt, + ) + +test("US-112 see which threads are unread", 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") + + // A thread bob wrote raises no indicator of its own, so it is the control for a row with no dot + seedThread(space, user.bob, "where is the sextant", at(3, HOUR)) + seedThread(space, user.alice, "the server is on fire", at(2, HOUR)) + }) + + const space = scenario.space("space") + const bob = await as(users.bob, roomPath(space.url, "general")) + + const threadsNav = contentNavItem(bob, "Threads") + + await expect(threadsNav).toBeVisible() + await expect(unreadDot(threadsNav)).toBeVisible() + + await threadsNav.click() + + const hers = bob.getByRole("row").filter({hasText: "the server is on fire"}) + const his = bob.getByRole("row").filter({hasText: "where is the sextant"}) + + await expect(his).toBeVisible() + + // syncChecked marks the landed-on page read 300ms later and latestActivityByPath is throttled to + // a second, so a dot the list is about to clear stays up well past the click. Nothing on screen + // reports the tick — the nav dot goes down on the route change either way — so wait it out. + await bob.waitForTimeout(1500) + + await expect(unreadDot(hers)).toBeVisible() + await expect(unreadDot(his)).toHaveCount(0) + + await hers.click() + + await expect(bob.locator('[data-component="PageBar"]')).toContainText("the server is on fire") + + // Leaving the list is what marks its threads read, so the dot is gone on the way back + await bob.goBack() + + await expect(hers).toBeVisible() + await expect(unreadDot(hers)).toHaveCount(0) + + await roomLink(bob, "General").click() + + await expect(threadsNav).toBeVisible() + await expect(unreadDot(threadsNav)).toHaveCount(0) +}) + +test("US-113 see which threads are unread on a phone", 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") + + seedThread(space, user.bob, "where is the sextant", at(3, HOUR)) + seedThread(space, user.alice, "the server is on fire", at(2, HOUR)) + }) + + const space = scenario.space("space") + + // ThreadBoard swaps its table for a list of links below tailwind's md breakpoint, and the two + // branches render the thread separately, so a dot on one says nothing about the other. + const bob = await as(users.bob, `${spacePath(space.url)}/threads`, { + context: {viewport: {width: 390, height: 844}, hasTouch: true}, + }) + + const hers = bob.getByRole("link").filter({hasText: "the server is on fire"}) + const his = bob.getByRole("link").filter({hasText: "where is the sextant"}) + + await expect(his).toBeVisible() + + // Same tick and throttle as US-112: a dot read before both have run is one the list may still be + // about to clear. + await bob.waitForTimeout(1500) + + await expect(unreadDot(hers)).toBeVisible() + await expect(unreadDot(his)).toHaveCount(0) +}) diff --git a/src/app/components/ThreadBoardItem.svelte b/src/app/components/ThreadBoardItem.svelte index dfe6f255..fd30a9f9 100644 --- a/src/app/components/ThreadBoardItem.svelte +++ b/src/app/components/ThreadBoardItem.svelte @@ -28,14 +28,19 @@ const title = tagValue(tagSpec("title"), event.tags) const path = makeThreadPath(url, event.id) const onClick = () => goto(path) - // notification={$notifications.has(path)} +{#snippet unread()} + {#if $notifications.has(path)} +
+ {/if} +{/snippet} + {#if mobile} -

{title || "Untitled thread"}

+

{@render unread()}{title || "Untitled thread"}

@@ -52,10 +57,7 @@ onclick={onClick} class="hover:bg-surface-less cursor-pointer border-b border-solid border-line text-sm transition-colors"> - {#if $notifications.has(path)} -
- {/if} - {title || "Untitled thread"} + {@render unread()}{title || "Untitled thread"}
diff --git a/src/app/notifications.ts b/src/app/notifications.ts index 31ef9971..9c3e03a0 100644 --- a/src/app/notifications.ts +++ b/src/app/notifications.ts @@ -61,7 +61,7 @@ const getPaths = (path: string) => .map((_, i, segments) => segments.slice(0, i + 1).join("/")) .slice(1) -const getCheckedPaths = (pathname: string, relay?: string) => { +const getExitPaths = (pathname: string, relay?: string) => { const paths = getPaths(pathname) if (relay) { @@ -88,7 +88,7 @@ export const syncChecked = () => { return $checked }) - const paths = getCheckedPaths($page.url.pathname, $page.params.relay) + const paths = getPaths($page.url.pathname) // Set checked when we visit a given page - but delay it a tad setTimeout(() => { @@ -104,7 +104,7 @@ export const syncChecked = () => { }) }, 300) - prev = paths + prev = getExitPaths($page.url.pathname, $page.params.relay) }) }