From 76bbacdbd63ba040435741a0a5a2380af48c5eaf Mon Sep 17 00:00:00 2001 From: Coracle-Bot Date: Wed, 9 Sep 2026 16:59:54 +0000 Subject: [PATCH] Preview a notification by what the message says rather than by the entity it quotes --- e2e/USER_STORIES.md | 12 +++++ e2e/specs/notifications.spec.ts | 89 +++++++++++++++++++++++++++++++++ src/app/push/adapters/web.ts | 11 +--- src/app/render.ts | 77 ++++++++++++++++++++++++++++ src/app/speech.ts | 49 +----------------- 5 files changed, 182 insertions(+), 56 deletions(-) create mode 100644 src/app/render.ts diff --git a/e2e/USER_STORIES.md b/e2e/USER_STORIES.md index 43f0d070..2c9078db 100644 --- a/e2e/USER_STORIES.md +++ b/e2e/USER_STORIES.md @@ -1522,6 +1522,18 @@ Acceptance: room in it raises an unread dot. - Turning either back on restores unread indicators for subsequent activity. +### US-120 — Read what a notification says + +As alice, I want a notification to say what was written, so that I can tell +from it whether the message is worth opening. + +Acceptance: + +- With push notifications on and the tab in the background, a reply from bob in + a room alice is in raises one naming her as mentioned. +- Its body is the words bob wrote rather than the quote his reply is prepended + with, and a url in it is named by its host instead of spelled out. + ### US-105 — Land on the home page As a new user, I want the home page to route me somewhere useful, so that I'm diff --git a/e2e/specs/notifications.spec.ts b/e2e/specs/notifications.spec.ts index 7276943c..765e138d 100644 --- a/e2e/specs/notifications.spec.ts +++ b/e2e/specs/notifications.spec.ts @@ -81,6 +81,43 @@ const send = async (page: Page, content: string) => { await expect(message(page, content)).toBeVisible() } +// RoomItem gives its hover actions no accessible names — every one is an icon. Their order is +// fixed by the component: zap, emoji, reply, edit (only on your own recent message), menu. +const replyToMessage = (page: Page, text: string) => + message(page, text).locator(".room__item-actions button").nth(2).click() + +// Chromium's own notifications are invisible to a test, and a tab playwright drives is never +// hidden, so both of the things the adapter reads are stubbed on the page. It takes the global at +// notify time, which is what lets this land after boot. +const captureNotifications = async (page: Page) => { + const notifications: {title: string; body: string}[] = [] + + await page.exposeFunction("onTestNotification", (title: string, body: string) => { + notifications.push({title, body}) + }) + + await page.evaluate(() => { + Object.defineProperty(document, "hidden", {get: () => true}) + Object.defineProperty(document, "visibilityState", {get: () => "hidden"}) + + window.Notification = class { + static permission = "granted" + + constructor(title: string, options: NotificationOptions = {}) { + const record = window as unknown as { + onTestNotification: (title: string, body: string) => void + } + + record.onTestNotification(title, options.body || "") + } + + close() {} + } as unknown as typeof Notification + }) + + return notifications +} + // The page bar names the room, so waiting for it is what says the composer below now belongs to // the room that was just opened rather than to the one being torn down. const postTo = async (page: Page, room: string, content: string) => { @@ -643,3 +680,55 @@ test("US-114 see which listings are unread", async ({seed, as}) => { await expect(unreadDot(hers)).toBeVisible() await expect(unreadDot(his)).toHaveCount(0) }) + +test("US-120 read what a notification says", 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") + space.profile(user.alice, {name: "Alice Anchor"}) + space.profile(user.bob, {name: "Bob Barnacle"}) + space.message(user.alice, "general", "when does the dock close?", at(2, HOUR)) + }) + + const space = scenario.space("space") + + // Push notifications are off until they are asked for, and the tab has to be in the background + // before one is raised at all. + const alice = await as(users.alice, "/settings/alerts", { + context: {permissions: ["notifications"]}, + }) + + await settingRow(alice, "Enable push notifications").getByRole("checkbox").check() + await alice.getByRole("button", {name: "Save Changes"}).click() + + await expect(alice.getByRole("alert")).toContainText("Your settings have been saved!") + + await spaceNavItem(alice, space.name).click() + await roomLink(alice, "General").click() + + await expect(alice).toHaveURL(pattern(roomPath(space.url, "general"))) + await expect(message(alice, "when does the dock close?")).toBeVisible() + + const notifications = await captureNotifications(alice) + + const bob = await as(users.bob, roomPath(space.url, "general")) + + await replyToMessage(bob, "when does the dock close?") + await composer(bob).pressSequentially("sunday, the notice is at https://harbor.example/dock") + await composer(bob).press("Enter") + + await expect(message(bob, "sunday, the notice is at")).toBeVisible() + + // A reply prepends the message it answers, so its first line is an entity and says nothing about + // the reply. The preview is the words bob wrote, with the url named by its host rather than + // spelled out, and the quote of alice's message tags her, so it reads as a mention. + await expect + .poll(() => notifications.at(-1)) + .toEqual({ + title: "Someone mentioned you", + body: "sunday, the notice is at a link to harbor.example", + }) +}) diff --git a/src/app/push/adapters/web.ts b/src/app/push/adapters/web.ts index 575947a1..f6b8a3ee 100644 --- a/src/app/push/adapters/web.ts +++ b/src/app/push/adapters/web.ts @@ -1,5 +1,4 @@ import type {Unsubscriber} from "svelte/store" -import {parse, renderAsText} from "@welshman/content" import {ellipsize, maybe} from "@welshman/lib" import {hexTags, matchFilter, tagValues, type TrustedEvent} from "@welshman/util" import {DM_KINDS} from "@app/content" @@ -7,17 +6,11 @@ import {app} from "@app/core" import {notificationSettings} from "@app/settings" import type {IPushAdapter} from "@app/push/adapters/common" import {onNotification} from "@app/push/adapters/common" +import {renderEventAsSummary} from "@app/render" import {goToEvent} from "@app/routes" const PREVIEW_LENGTH = 120 -const makePreview = (event: TrustedEvent) => { - const lines = renderAsText(parse(event)).toString().split("\n") - const line = lines.map(line => line.trim()).find(line => line.length > 0) - - return line && ellipsize(line, PREVIEW_LENGTH, "…") -} - export class WebNotifications implements IPushAdapter { _unsubscriber = maybe() @@ -62,7 +55,7 @@ export class WebNotifications implements IPushAdapter { const $pubkey = app.get().user?.pubkey if (push && document.hidden && Notification?.permission === "granted") { - const preview = makePreview(event) + const preview = ellipsize(renderEventAsSummary(event), PREVIEW_LENGTH, "…") if (messages && matchFilter({kinds: DM_KINDS}, event)) { this._notify( diff --git a/src/app/render.ts b/src/app/render.ts new file mode 100644 index 00000000..2f345674 --- /dev/null +++ b/src/app/render.ts @@ -0,0 +1,77 @@ +import {ParsedType, isImage, parse} from "@welshman/content" +import type {Parsed} from "@welshman/content" +import type {TrustedEvent} from "@welshman/util" +import {profiles} from "@app/core" + +// Welshman's text renderer resolves an entity or a link to the string it links to, so anything +// reading a message as text rather than rendering it as components gets a hundred characters of +// bech32 where a quote was. Everything that is not prose is named instead. +export const renderNode = (parsed: Parsed): string => { + switch (parsed.type) { + case ParsedType.Address: + return "another post" + case ParsedType.Cashu: + return "a cashu token" + case ParsedType.Code: + return parsed.value + case ParsedType.Command: + return parsed.raw + case ParsedType.Ellipsis: + return "…" + case ParsedType.Email: + return parsed.value + case ParsedType.Emoji: + return parsed.value.name + case ParsedType.Event: + return "another message" + case ParsedType.Invoice: + return "a lightning invoice" + case ParsedType.Link: { + const {host} = parsed.value.url + + return isImage(parsed) ? "an image" : `a link to ${host}` + } + case ParsedType.LinkGrid: + return "some images" + case ParsedType.Newline: + return parsed.value + case ParsedType.Profile: + return profiles.get().display(parsed.value.pubkey).get() + case ParsedType.Room: + return parsed.value.room + case ParsedType.Text: + return parsed.value + case ParsedType.Topic: + return parsed.value.slice(1) + } +} + +export const renderNodes = (parsed: Parsed[]) => parsed.map(renderNode).join("") + +export const renderEventAsText = (event: TrustedEvent) => renderNodes(parse(event)).trim() + +const splitLines = (parsed: Parsed[]) => { + const lines: Parsed[][] = [[]] + + for (const node of parsed) { + if (node.type === ParsedType.Newline) { + lines.push([]) + } else { + lines[lines.length - 1].push(node) + } + } + + return lines.filter(line => line.length > 0) +} + +const hasProse = (line: Parsed[]) => + line.some(node => node.type === ParsedType.Text && node.value.trim().length > 0) + +// A reply prepends the message it answers, so the first line of one describes the quote and says +// nothing about the reply. The first line carrying words of its own is what the message is about. +export const renderEventAsSummary = (event: TrustedEvent) => { + const lines = splitLines(parse(event)) + const line = lines.find(hasProse) || lines[0] || [] + + return renderNodes(line).trim() +} diff --git a/src/app/speech.ts b/src/app/speech.ts index c747f38c..367cd0dc 100644 --- a/src/app/speech.ts +++ b/src/app/speech.ts @@ -1,6 +1,4 @@ import {get, writable} from "svelte/store" -import {ParsedType, isImage, parse} from "@welshman/content" -import type {Parsed} from "@welshman/content" import type {Maybe} from "@welshman/lib" import type {TrustedEvent} from "@welshman/util" import {errorMessage} from "@lib/util" @@ -9,6 +7,7 @@ import OpenRouterEnable from "@app/components/OpenRouterEnable.svelte" import {endCall, isCallActive} from "@app/call" import {profiles} from "@app/core" import {pushModal} from "@app/modal" +import {renderEventAsText} from "@app/render" import {getSetting} from "@app/settings" import {pushToast} from "@app/toast" @@ -65,50 +64,6 @@ const toWav = (pcm: ArrayBuffer) => { return new Blob([wav], {type: "audio/wav"}) } -// An entity or a url read a character at a time is unintelligible, so anything that is not prose -// is named rather than spelled out. -const speakOne = (parsed: Parsed): string => { - switch (parsed.type) { - case ParsedType.Address: - return "another post" - case ParsedType.Cashu: - return "a cashu token" - case ParsedType.Code: - return parsed.value - case ParsedType.Command: - return parsed.raw - case ParsedType.Ellipsis: - return "\u2026" - case ParsedType.Email: - return parsed.value - case ParsedType.Emoji: - return parsed.value.name - case ParsedType.Event: - return "another message" - case ParsedType.Invoice: - return "a lightning invoice" - case ParsedType.Link: { - const {host} = parsed.value.url - - return isImage(parsed) ? "an image" : `a link to ${host}` - } - case ParsedType.LinkGrid: - return "some images" - case ParsedType.Newline: - return parsed.value - case ParsedType.Profile: - return profiles.get().display(parsed.value.pubkey).get() - case ParsedType.Room: - return parsed.value.room - case ParsedType.Text: - return parsed.value - case ParsedType.Topic: - return parsed.value.slice(1) - } -} - -const speakable = (event: TrustedEvent) => parse(event).map(speakOne).join("").trim() - export const synthesize = async (text: string) => { const response = await fetch("https://openrouter.ai/api/v1/audio/speech", { method: "POST", @@ -171,7 +126,7 @@ const play = async (event: TrustedEvent, text: string) => { } export const readAloud = (event: TrustedEvent) => { - const text = speakable(event) + const text = renderEventAsText(event) if (getSetting("openrouter_key")) { if (get(isCallActive)) {