Preview a notification by what the message says rather than by the entity it quotes
This commit is contained in:
parent
78472f58b0
commit
76bbacdbd6
5 changed files with 182 additions and 56 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
77
src/app/render.ts
Normal 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()
|
||||
}
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue