✨ feat(client): 弹窗可设自动关闭时间,完成提醒描述只显示标题

This commit is contained in:
pyh
2026-10-05 00:29:36 +08:00
parent 9218521dcb
commit faa3930ae8
12 changed files with 369 additions and 102 deletions
+17 -2
View File
@@ -6,7 +6,7 @@
*/
import { notificationApi, notificationPermission } from '../platform.js'
import { writeStoredKinds } from './storage.js'
import { writeStoredDismissMs, writeStoredKinds } from './storage.js'
import { text } from './log.js'
/**
@@ -83,5 +83,20 @@ export function createActions({ store, t, delivery, system, toasts, publish }) {
store.set({ ...snapshot, kinds })
}
return { sendTest, sendTestSystem, requestPermission, setKindEnabled }
/**
* Remember how long an in-app popup lives.
*
* The stack reads this at the moment it arms a card, and the cards already on
* screen are re-armed so the new choice applies to them too — except a card the
* pointer is resting on, which nothing may close.
* @param ms - milliseconds, or `0` for "never auto-close".
*/
const setDismissAfter = (ms) => {
writeStoredDismissMs(ms)
const snapshot = store.getSnapshot()
store.set({ ...snapshot, dismissAfterMs: ms })
toasts.rearmAll()
}
return { sendTest, sendTestSystem, requestPermission, setKindEnabled, setDismissAfter }
}
+4 -1
View File
@@ -16,7 +16,10 @@ export function createCopy(t) {
const copyFor = (candidate) => {
const title = candidate.title === '' ? t('body.untitled') : candidate.title
if (candidate.kind === 'completion') {
return { kind: 'completion', title: t('notification.completion'), body: t('body.completion', { title }) }
// The heading already says what happened ("conversation finished"), so the
// description is the conversation itself — naming it twice would only make
// the card longer.
return { kind: 'completion', title: t('notification.completion'), body: title }
}
// A plan review arrives as a question with its own discriminator, so it is
// answered before the question branch below — a plan review says what it is,
+62 -18
View File
@@ -1,43 +1,87 @@
/**
* This package's browser-local preferences.
* This package's browser-local preferences: the three trigger switches and the
* in-app popup lifetime.
*
* They are browser preferences rather than cordis configuration, so they live in
* this origin's own storage instead of the profile's patch file: the plugin
* writes nothing into DSH's configuration. Every read is defensive, because
* storage can be unavailable or hold what an older version wrote, and every
* write merges into the stored object so that setting one preference never
* clears another.
*
* @module @dsh-plugin/session-notify/client/core/storage
*/
import { KINDS } from '../constants.js'
import { DISMISS_CHOICES, KINDS, TOAST_DURATION_MS } from '../constants.js'
/** Storage key for this package's own preferences. */
export const STORAGE_KEY = 'dsh-plugin/session-notify'
/** Every preference, at the value a browser that has never seen this plugin uses. */
function defaults() {
return { completion: true, approval: true, question: true, dismissAfterMs: TOAST_DURATION_MS }
}
/**
* Read this package's stored trigger switches.
* Read the stored preferences, one field at a time.
*
* These are browser preferences rather than cordis configuration, so they live
* in this origin's own storage instead of the profile's patch file: the plugin
* writes nothing into DSH's configuration, and every read is defensive because
* storage can be unavailable or hold something an older version wrote.
* @returns the stored switches, defaulting to everything on.
* A field is only taken when it has the shape this version understands: a
* boolean per trigger, and a popup lifetime from {@link DISMISS_CHOICES}. Anything
* else keeps the default, so a foreign or older value can never produce a state
* the plugin cannot render.
* @returns the effective preferences.
*/
export function readStoredKinds() {
const kinds = { completion: true, approval: true, question: true }
function readStored() {
const preferences = defaults()
try {
const raw = globalThis.localStorage?.getItem(STORAGE_KEY)
if (typeof raw !== 'string' || raw === '') return kinds
if (typeof raw !== 'string' || raw === '') return preferences
const stored = JSON.parse(raw)
for (const kind of KINDS) {
if (typeof stored?.[kind] === 'boolean') kinds[kind] = stored[kind]
if (typeof stored?.[kind] === 'boolean') preferences[kind] = stored[kind]
}
if (DISMISS_CHOICES.includes(stored?.dismissAfterMs)) preferences.dismissAfterMs = stored.dismissAfterMs
} catch {
/* unreadable or foreign storage keeps the defaults */
}
return kinds
return preferences
}
/** Write a patch over the stored preferences, keeping every field the patch does not name. */
function writeStored(patch) {
try {
globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify({ ...readStored(), ...patch }))
} catch {
/* a refused write leaves the preference live for this page only */
}
}
/**
* Read this package's stored trigger switches.
* @returns the stored switches, defaulting to everything on.
*/
export function readStoredKinds() {
const { completion, approval, question } = readStored()
return { completion, approval, question }
}
/** Store one trigger switch, ignoring storage that refuses to write. */
export function writeStoredKinds(kinds) {
try {
globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(kinds))
} catch {
/* a refused write leaves the switches live for this page only */
}
writeStored(kinds)
}
/**
* Read the stored in-app popup lifetime.
* @returns milliseconds before a card closes itself, or `0` for "never".
*/
export function readStoredDismissMs() {
return readStored().dismissAfterMs
}
/**
* Store the popup lifetime the user picked.
* @param ms - milliseconds, or `0` for "never auto-close".
*/
export function writeStoredDismissMs(ms) {
writeStored({ dismissAfterMs: ms })
}
+40 -3
View File
@@ -1,6 +1,11 @@
/**
* The light in-app popup stack: its lifetime, its ordering, and its bound.
*
* The lifetime is the user's, read from the store at the moment a card is armed:
* the settings page offers a few delays plus "never", and re-arming after a hover
* picks the current choice up. `0` means the card waits for the user instead of a
* timer.
*
* @module @dsh-plugin/session-notify/client/core/toasts
*/
@@ -13,10 +18,19 @@ import { TOAST_DURATION_MS, TOAST_LIMIT } from '../constants.js'
*/
export function createToastStack({ store, copy }) {
const timers = new Map()
/** Cards the pointer is resting on: they carry no timer until it leaves. */
const held = new Set()
let seq = 0
/** The configured lifetime, falling back to the default for a value this version cannot use. */
const lifetime = () => {
const configured = store.getSnapshot().dismissAfterMs
return typeof configured === 'number' && configured >= 0 ? configured : TOAST_DURATION_MS
}
/** Drop one popup and cancel its lifetime timer. */
const closeToast = (id) => {
held.delete(id)
const timer = timers.get(id)
if (timer !== undefined) {
clearTimeout(timer)
@@ -27,21 +41,42 @@ export function createToastStack({ store, copy }) {
store.set({ ...snapshot, toasts: snapshot.toasts.filter((toast) => toast.id !== id) })
}
/** Arm one popup's lifetime; hovering calls `hold` first, then this again. */
/**
* Arm one popup's lifetime; hovering calls `hold` first, then this again.
* A lifetime of `0` arms nothing: the card stays until it is dismissed.
*/
const armToast = (id) => {
held.delete(id)
const timer = timers.get(id)
if (timer !== undefined) clearTimeout(timer)
timers.set(id, setTimeout(() => closeToast(id), TOAST_DURATION_MS))
timers.delete(id)
const duration = lifetime()
if (duration === 0) return
timers.set(id, setTimeout(() => closeToast(id), duration))
}
/** Pause one popup's lifetime while the pointer rests on it. */
const holdToast = (id) => {
held.add(id)
const timer = timers.get(id)
if (timer === undefined) return
clearTimeout(timer)
timers.delete(id)
}
/**
* Apply the current lifetime to the cards already on screen.
*
* Changing the setting restarts the countdown for what is visible — otherwise
* a card raised under the old lifetime would still close on the old schedule.
* A held card is left alone: the pointer is on it, so nothing may close it.
*/
const rearmAll = () => {
for (const toast of store.getSnapshot().toasts) {
if (!held.has(toast.id)) armToast(toast.id)
}
}
/**
* Show one light in-app popup: the trigger as the title, the conversation
* and its detail as the description, so the heading says what happened and
@@ -65,6 +100,7 @@ export function createToastStack({ store, copy }) {
clearTimeout(timer)
timers.delete(dropped.id)
}
held.delete(dropped.id)
}
store.set({ ...snapshot, toasts: next.slice(0, TOAST_LIMIT) })
armToast(toast.id)
@@ -74,7 +110,8 @@ export function createToastStack({ store, copy }) {
const dispose = () => {
for (const timer of timers.values()) clearTimeout(timer)
timers.clear()
held.clear()
}
return { showToast, closeToast, armToast, holdToast, dispose }
return { showToast, closeToast, armToast, holdToast, rearmAll, dispose }
}