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.
|
room in it raises an unread dot.
|
||||||
- Turning either back on restores unread indicators for subsequent activity.
|
- 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
|
### 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
|
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()
|
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 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.
|
// the room that was just opened rather than to the one being torn down.
|
||||||
const postTo = async (page: Page, room: string, content: string) => {
|
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(hers)).toBeVisible()
|
||||||
await expect(unreadDot(his)).toHaveCount(0)
|
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 type {Unsubscriber} from "svelte/store"
|
||||||
import {parse, renderAsText} from "@welshman/content"
|
|
||||||
import {ellipsize, maybe} from "@welshman/lib"
|
import {ellipsize, maybe} from "@welshman/lib"
|
||||||
import {hexTags, matchFilter, tagValues, type TrustedEvent} from "@welshman/util"
|
import {hexTags, matchFilter, tagValues, type TrustedEvent} from "@welshman/util"
|
||||||
import {DM_KINDS} from "@app/content"
|
import {DM_KINDS} from "@app/content"
|
||||||
|
|
@ -7,17 +6,11 @@ import {app} from "@app/core"
|
||||||
import {notificationSettings} from "@app/settings"
|
import {notificationSettings} from "@app/settings"
|
||||||
import type {IPushAdapter} from "@app/push/adapters/common"
|
import type {IPushAdapter} from "@app/push/adapters/common"
|
||||||
import {onNotification} from "@app/push/adapters/common"
|
import {onNotification} from "@app/push/adapters/common"
|
||||||
|
import {renderEventAsSummary} from "@app/render"
|
||||||
import {goToEvent} from "@app/routes"
|
import {goToEvent} from "@app/routes"
|
||||||
|
|
||||||
const PREVIEW_LENGTH = 120
|
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 {
|
export class WebNotifications implements IPushAdapter {
|
||||||
_unsubscriber = maybe<Unsubscriber>()
|
_unsubscriber = maybe<Unsubscriber>()
|
||||||
|
|
||||||
|
|
@ -62,7 +55,7 @@ export class WebNotifications implements IPushAdapter {
|
||||||
const $pubkey = app.get().user?.pubkey
|
const $pubkey = app.get().user?.pubkey
|
||||||
|
|
||||||
if (push && document.hidden && Notification?.permission === "granted") {
|
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)) {
|
if (messages && matchFilter({kinds: DM_KINDS}, event)) {
|
||||||
this._notify(
|
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 {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 {Maybe} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {errorMessage} from "@lib/util"
|
import {errorMessage} from "@lib/util"
|
||||||
|
|
@ -9,6 +7,7 @@ import OpenRouterEnable from "@app/components/OpenRouterEnable.svelte"
|
||||||
import {endCall, isCallActive} from "@app/call"
|
import {endCall, isCallActive} from "@app/call"
|
||||||
import {profiles} from "@app/core"
|
import {profiles} from "@app/core"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
|
import {renderEventAsText} from "@app/render"
|
||||||
import {getSetting} from "@app/settings"
|
import {getSetting} from "@app/settings"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
|
|
||||||
|
|
@ -65,50 +64,6 @@ const toWav = (pcm: ArrayBuffer) => {
|
||||||
return new Blob([wav], {type: "audio/wav"})
|
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) => {
|
export const synthesize = async (text: string) => {
|
||||||
const response = await fetch("https://openrouter.ai/api/v1/audio/speech", {
|
const response = await fetch("https://openrouter.ai/api/v1/audio/speech", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -171,7 +126,7 @@ const play = async (event: TrustedEvent, text: string) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const readAloud = (event: TrustedEvent) => {
|
export const readAloud = (event: TrustedEvent) => {
|
||||||
const text = speakable(event)
|
const text = renderEventAsText(event)
|
||||||
|
|
||||||
if (getSetting("openrouter_key")) {
|
if (getSetting("openrouter_key")) {
|
||||||
if (get(isCallActive)) {
|
if (get(isCallActive)) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue