Preview a notification by what the message says rather than by the entity it quotes

This commit is contained in:
Coracle-Bot 2026-09-09 16:59:54 +00:00
parent 78472f58b0
commit 76bbacdbd6
5 changed files with 182 additions and 56 deletions

View file

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

View file

@ -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",
})
})

View file

@ -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<Unsubscriber>()
@ -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(

77
src/app/render.ts Normal file
View file

@ -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()
}

View file

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