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 message on screen and no "jump to newest" control, even when other events were
published after the one linked to. 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 ### US-028 — Share a message somewhere else
As alice, I want to forward a message into another room in the same space, so 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 {ms} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import type {TestUser} from "../keys" 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. // Must match TEST_ENV_KEY in src/lib/test/env.ts.
const TEST_ENV_KEY = "__TEST_ENV__" const TEST_ENV_KEY = "__TEST_ENV__"
@ -19,6 +19,8 @@ export type BootOptions = {
user?: TestUser user?: TestUser
// What this user's client already has in local storage, e.g. their room list. // What this user's client already has in local storage, e.g. their room list.
events?: TrustedEvent[] events?: TrustedEvent[]
// Which paths this user has already read, keyed the way src/app/notifications.ts keys them.
checked?: Record<string, number>
path?: string path?: string
// VITE_ values the scenario sets for itself. Anything named here has to be something the test owns. // VITE_ values the scenario sets for itself. Anything named here has to be something the test owns.
env?: Record<string, string> env?: Record<string, string>
@ -26,7 +28,16 @@ export type BootOptions = {
export const boot = async ( export const boot = async (
context: BrowserContext, 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(",") const urls = relays.join(",")
@ -66,6 +77,10 @@ export const boot = async (
await injectEvents(context, events) await injectEvents(context, events)
} }
if (checked) {
await injectChecked(context, checked)
}
const page = await context.newPage() const page = await context.newPage()
await page.goto(path) await page.goto(path)

View file

@ -24,3 +24,15 @@ export const injectEvents = (context: BrowserContext, events: TrustedEvent[]) =>
}, },
[TEST_EVENTS_KEY, events] as const, [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 relayInfo?: RelayInfoOverrides
// What the hosting backend already knows about this user; getHosting changes it mid-test. // What the hosting backend already knows about this user; getHosting changes it mid-test.
hosting?: HostingFixtures 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. // Relay urls that take the socket and answer nothing; silenceRelay does the same mid-test.
silent?: string[] silent?: string[]
// Relay urls that serve events and never eose, so every request runs out its own deadline. // 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, user,
path, path,
env: options.env, env: options.env,
checked: options.checked,
relays: urls, relays: urls,
indexers: indexerUrls, indexers: indexerUrls,
spaces: urls, spaces: urls,

View file

@ -1,5 +1,5 @@
import {neventEncode, npubEncode} from "nostr-tools/nip19" 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 {getLnUrl} from "@welshman/util"
import {Profile} from "@welshman/domain" import {Profile} from "@welshman/domain"
import type {Locator, Page} from "@playwright/test" 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) 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}) => { test("US-028 share a message somewhere else", async ({seed, as}) => {
let shared!: SeededEvent let shared!: SeededEvent

View file

@ -161,7 +161,11 @@
isProgrammaticScroll = false 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. // Dropping the anchor remounts the transcript on the present, which puts the reader at the live end.
const scrollToBottom = () => { const scrollToBottom = () => {