Show which threads are unread instead of clearing them on arrival (#417)

Co-authored-by: Coracle-Bot <npub1klq6260@nostr.local>
This commit is contained in:
Coracle-Bot 2026-09-03 18:50:02 +00:00 committed by hodlbod
parent 15fcaf1279
commit 4d7b052724
4 changed files with 140 additions and 10 deletions

View file

@ -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.

View file

@ -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)
})

View file

@ -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)}
</script>
{#snippet unread()}
{#if $notifications.has(path)}
<div class="mr-1 inline-block h-2 w-2 rounded-full bg-primary" transition:fade></div>
{/if}
{/snippet}
{#if mobile}
<Link
href={path}
class="cv hover:bg-surface-less flex w-full flex-col gap-2 border-b border-solid border-line px-4 py-3 text-left text-sm transition-colors">
<p class="truncate font-medium">{title || "Untitled thread"}</p>
<p class="truncate font-medium">{@render unread()}{title || "Untitled thread"}</p>
<div class="text-content-muted flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span class="flex min-w-0 items-center gap-1.5">
<ProfileCircle pubkey={event.pubkey} {url} size={4} />
@ -52,10 +57,7 @@
onclick={onClick}
class="hover:bg-surface-less cursor-pointer border-b border-solid border-line text-sm transition-colors">
<td class="max-w-0 truncate px-4 py-3 align-top">
{#if $notifications.has(path)}
<div class="h-2 w-2 rounded-full bg-primary inline-block mr-1" transition:fade></div>
{/if}
{title || "Untitled thread"}
{@render unread()}{title || "Untitled thread"}
</td>
<td class="w-32 px-4 py-3 align-middle">
<div class="flex min-w-0 items-center gap-2">

View file

@ -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)
})
}