From 3b8c217a2597bd1ad3ff5c12fadbb40d81e5b937 Mon Sep 17 00:00:00 2001 From: Coracle-Bot Date: Tue, 29 Sep 2026 12:52:30 +0000 Subject: [PATCH] Release the permalink pin when the reader jumps to the unread divider --- e2e/USER_STORIES.md | 10 ++++++ e2e/harness/app/boot.ts | 19 +++++++++-- e2e/harness/app/session.ts | 12 +++++++ e2e/harness/index.ts | 3 ++ e2e/specs/rooms.spec.ts | 42 +++++++++++++++++++++++- src/app/components/RoomTranscript.svelte | 6 +++- 6 files changed, 88 insertions(+), 4 deletions(-) diff --git a/e2e/USER_STORIES.md b/e2e/USER_STORIES.md index b9898011..e712a943 100644 --- a/e2e/USER_STORIES.md +++ b/e2e/USER_STORIES.md @@ -442,6 +442,16 @@ Acceptance: message on screen and no "jump to newest" control, even when other events were published after the one linked to. +### US-027b — Jump to the unread divider from a permalink + +As someone opening a push notification, I want the "New Messages" control to +take me to where my unread messages start, so that I can read what I missed. + +Acceptance: + +- Landing on a permalink and then asking for new messages scrolls to the unread + divider, and does not return to the message the permalink landed on. + ### US-028 — Share a message somewhere else As alice, I want to forward a message into another room in the same space, so diff --git a/e2e/harness/app/boot.ts b/e2e/harness/app/boot.ts index f9e55162..0353f823 100644 --- a/e2e/harness/app/boot.ts +++ b/e2e/harness/app/boot.ts @@ -2,7 +2,7 @@ import type {BrowserContext} from "@playwright/test" import {ms} from "@welshman/lib" import type {TrustedEvent} from "@welshman/util" import type {TestUser} from "../keys" -import {injectEvents, injectSession} from "./session" +import {injectChecked, injectEvents, injectSession} from "./session" // Must match TEST_ENV_KEY in src/lib/test/env.ts. const TEST_ENV_KEY = "__TEST_ENV__" @@ -19,6 +19,8 @@ export type BootOptions = { user?: TestUser // What this user's client already has in local storage, e.g. their room list. events?: TrustedEvent[] + // Which paths this user has already read, keyed the way src/app/notifications.ts keys them. + checked?: Record path?: string // VITE_ values the scenario sets for itself. Anything named here has to be something the test owns. env?: Record @@ -26,7 +28,16 @@ export type BootOptions = { export const boot = async ( context: BrowserContext, - {relays, indexers = relays, spaces = [], user, events = [], path = "/", env = {}}: BootOptions, + { + relays, + indexers = relays, + spaces = [], + user, + events = [], + checked, + path = "/", + env = {}, + }: BootOptions, ) => { const urls = relays.join(",") @@ -66,6 +77,10 @@ export const boot = async ( await injectEvents(context, events) } + if (checked) { + await injectChecked(context, checked) + } + const page = await context.newPage() await page.goto(path) diff --git a/e2e/harness/app/session.ts b/e2e/harness/app/session.ts index cf2a8dab..803e0c6d 100644 --- a/e2e/harness/app/session.ts +++ b/e2e/harness/app/session.ts @@ -24,3 +24,15 @@ export const injectEvents = (context: BrowserContext, events: TrustedEvent[]) => }, [TEST_EVENTS_KEY, events] as const, ) + +// Read state as an earlier visit left it, so a room can open with unread messages already in it. +export const injectChecked = (context: BrowserContext, checked: Record) => + context.addInitScript( + ([key, value]) => { + // An init script also runs on about:blank, where reading localStorage is a SecurityError. + if (location.protocol.startsWith("http")) { + localStorage.setItem(key, value) + } + }, + ["CapacitorStorage.checked", JSON.stringify(checked)] as const, + ) diff --git a/e2e/harness/index.ts b/e2e/harness/index.ts index e78ddb2a..1c3b0571 100644 --- a/e2e/harness/index.ts +++ b/e2e/harness/index.ts @@ -139,6 +139,8 @@ export type PageOptions = { relayInfo?: RelayInfoOverrides // What the hosting backend already knows about this user; getHosting changes it mid-test. hosting?: HostingFixtures + // Which paths this user has already read, for a spec that needs unread messages at first paint. + checked?: Record // Relay urls that take the socket and answer nothing; silenceRelay does the same mid-test. silent?: string[] // Relay urls that serve events and never eose, so every request runs out its own deadline. @@ -276,6 +278,7 @@ export const test = base.extend({ user, path, env: options.env, + checked: options.checked, relays: urls, indexers: indexerUrls, spaces: urls, diff --git a/e2e/specs/rooms.spec.ts b/e2e/specs/rooms.spec.ts index 70ecbc07..0354c526 100644 --- a/e2e/specs/rooms.spec.ts +++ b/e2e/specs/rooms.spec.ts @@ -1,5 +1,5 @@ import {neventEncode, npubEncode} from "nostr-tools/nip19" -import {DAY, HOUR, MINUTE, WEEK, bech32ToHex, now} from "@welshman/lib" +import {DAY, HOUR, MINUTE, WEEK, bech32ToHex, int, now} from "@welshman/lib" import {getLnUrl} from "@welshman/util" import {Profile} from "@welshman/domain" import type {Locator, Page} from "@playwright/test" @@ -671,6 +671,46 @@ test("US-027a a permalink near the newest end lands at the bottom", async ({seed await expect(jumpToNewest(page)).toHaveCount(0) }) +// A permalink pins the message it lands on, and the reader asking for the unread divider takes over. +test("US-027b jump to the unread divider from a permalink", async ({seed, as}) => { + let newest!: SeededEvent + + 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") + + // Deep enough that the jump centres what it lands on rather than clamping at the bottom. + for (let i = 0; i < 120; i++) { + space.message(user.bob, "general", `harbor watch note ${i}`, at(400 - i * 3, MINUTE)) + } + + newest = space.message(user.bob, "general", "and the tide is with us", at(2, MINUTE)) + }) + + const {url} = scenario.space("space") + const path = roomPath(url, "general") + + // Alice has read none of this, so the divider is screens above the message she lands on. + const page = await as(users.alice, `${path}?at=${newest.event.created_at}`, { + checked: {[path]: now() - int(12, HOUR)}, + }) + + await expect(page.locator(`[data-event="${newest.id}"]`)).toBeInViewport() + + const divider = page.locator("#new-messages") + + await page.getByRole("button", {name: "New Messages"}).click() + + await expect(divider).toBeInViewport() + + // The pin is what would pull her back down, a frame or two after the scroll starts. + await page.waitForTimeout(1000) + await expect(page.locator(`[data-event="${newest.id}"]`)).not.toBeInViewport() +}) + test("US-028 share a message somewhere else", async ({seed, as}) => { let shared!: SeededEvent diff --git a/src/app/components/RoomTranscript.svelte b/src/app/components/RoomTranscript.svelte index 2351fe20..8d8390f7 100644 --- a/src/app/components/RoomTranscript.svelte +++ b/src/app/components/RoomTranscript.svelte @@ -161,7 +161,11 @@ isProgrammaticScroll = false } - const scrollToNewMessages = () => scrollToRow("new-messages", {behavior: "smooth"}) + // Asking for the divider takes the scroll position over, so a permalink's pin lets go of it. + const scrollToNewMessages = () => { + release() + scrollToRow("new-messages", {behavior: "smooth"}) + } // Dropping the anchor remounts the transcript on the present, which puts the reader at the live end. const scrollToBottom = () => {