Release the permalink pin when the reader jumps to the unread divider

This commit is contained in:
Coracle-Bot 2026-09-29 12:52:30 +00:00
parent 0e694dc2ea
commit 3b8c217a25
6 changed files with 88 additions and 4 deletions

View file

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

View file

@ -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<string, number>
path?: string
// VITE_ values the scenario sets for itself. Anything named here has to be something the test owns.
env?: Record<string, string>
@ -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)

View file

@ -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<string, number>) =>
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,
)

View file

@ -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<string, number>
// 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<HarnessFixtures, HarnessWorkerFixtures>({
user,
path,
env: options.env,
checked: options.checked,
relays: urls,
indexers: indexerUrls,
spaces: urls,

View file

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

View file

@ -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 = () => {