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:
parent
15fcaf1279
commit
4d7b052724
4 changed files with 140 additions and 10 deletions
|
|
@ -1553,6 +1553,30 @@ Acceptance:
|
||||||
own room list carries it.
|
own room list carries it.
|
||||||
- Reading the other space takes the dot down.
|
- 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
|
## Out of scope
|
||||||
|
|
||||||
Features the e2e suite cannot exercise, and what stops it.
|
Features the e2e suite cannot exercise, and what stops it.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import {neventEncode, npubEncode} from "nostr-tools/nip19"
|
import {neventEncode, npubEncode} from "nostr-tools/nip19"
|
||||||
import {HOUR, MINUTE} from "@welshman/lib"
|
import {HOUR, MINUTE} from "@welshman/lib"
|
||||||
import {displayRelayUrl} from "@welshman/util"
|
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 type {Locator, Page} from "@playwright/test"
|
||||||
import {expect, roomPath, spacePath, test, users} from "../harness"
|
import {expect, roomPath, spacePath, test, users} from "../harness"
|
||||||
import type {SeededSpace, TestUser} 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(roomLink(bob, "Random"))).toBeVisible()
|
||||||
await expect(unreadDot(spacesButton)).toHaveCount(0)
|
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)
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -28,14 +28,19 @@
|
||||||
const title = tagValue(tagSpec("title"), event.tags)
|
const title = tagValue(tagSpec("title"), event.tags)
|
||||||
const path = makeThreadPath(url, event.id)
|
const path = makeThreadPath(url, event.id)
|
||||||
const onClick = () => goto(path)
|
const onClick = () => goto(path)
|
||||||
// notification={$notifications.has(path)}
|
|
||||||
</script>
|
</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}
|
{#if mobile}
|
||||||
<Link
|
<Link
|
||||||
href={path}
|
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">
|
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">
|
<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">
|
<span class="flex min-w-0 items-center gap-1.5">
|
||||||
<ProfileCircle pubkey={event.pubkey} {url} size={4} />
|
<ProfileCircle pubkey={event.pubkey} {url} size={4} />
|
||||||
|
|
@ -52,10 +57,7 @@
|
||||||
onclick={onClick}
|
onclick={onClick}
|
||||||
class="hover:bg-surface-less cursor-pointer border-b border-solid border-line text-sm transition-colors">
|
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">
|
<td class="max-w-0 truncate px-4 py-3 align-top">
|
||||||
{#if $notifications.has(path)}
|
{@render unread()}{title || "Untitled thread"}
|
||||||
<div class="h-2 w-2 rounded-full bg-primary inline-block mr-1" transition:fade></div>
|
|
||||||
{/if}
|
|
||||||
{title || "Untitled thread"}
|
|
||||||
</td>
|
</td>
|
||||||
<td class="w-32 px-4 py-3 align-middle">
|
<td class="w-32 px-4 py-3 align-middle">
|
||||||
<div class="flex min-w-0 items-center gap-2">
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ const getPaths = (path: string) =>
|
||||||
.map((_, i, segments) => segments.slice(0, i + 1).join("/"))
|
.map((_, i, segments) => segments.slice(0, i + 1).join("/"))
|
||||||
.slice(1)
|
.slice(1)
|
||||||
|
|
||||||
const getCheckedPaths = (pathname: string, relay?: string) => {
|
const getExitPaths = (pathname: string, relay?: string) => {
|
||||||
const paths = getPaths(pathname)
|
const paths = getPaths(pathname)
|
||||||
|
|
||||||
if (relay) {
|
if (relay) {
|
||||||
|
|
@ -88,7 +88,7 @@ export const syncChecked = () => {
|
||||||
return $checked
|
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
|
// Set checked when we visit a given page - but delay it a tad
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -104,7 +104,7 @@ export const syncChecked = () => {
|
||||||
})
|
})
|
||||||
}, 300)
|
}, 300)
|
||||||
|
|
||||||
prev = paths
|
prev = getExitPaths($page.url.pathname, $page.params.relay)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue