mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-06 06:41:56 +00:00
feat: per-user notification preference toggles
Add per-namespace notification preferences so users can choose which event types trigger push notifications (permission requests, session ready, task notifications, session completion). Adds the notification_preferences table (schema v20), GET/PUT /api/notification-preferences, POST /api/push/test for test pushes, and a Notifications settings page in the web app with a confirm dialog for disabling permission requests. Defaults keep all event types enabled. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import type { Session, SyncEvent, SyncEventListener, SyncEngine } from '../sync/syncEngine'
|
||||
import type { SessionEndReason } from '@hapi/protocol'
|
||||
import type { Store } from '../store'
|
||||
import type { NotificationChannel, TaskNotification } from './notificationTypes'
|
||||
import { NotificationHub } from './notificationHub'
|
||||
|
||||
@@ -245,4 +246,157 @@ describe('NotificationHub', () => {
|
||||
|
||||
hub.stop()
|
||||
})
|
||||
|
||||
it('suppresses all notification types when preferences are disabled', async () => {
|
||||
const engine = new FakeSyncEngine()
|
||||
const channel = new StubChannel()
|
||||
const store = {
|
||||
notificationPrefs: {
|
||||
getPreferenceFlags: () => ({
|
||||
permissionRequests: 0,
|
||||
sessionReady: 0,
|
||||
taskNotifications: 0,
|
||||
sessionCompletion: 0
|
||||
})
|
||||
}
|
||||
} as unknown as Store
|
||||
const hub = new NotificationHub(engine as unknown as SyncEngine, [channel], {
|
||||
permissionDebounceMs: 5,
|
||||
readyCooldownMs: 5
|
||||
}, store)
|
||||
|
||||
const session = createSession({
|
||||
agentState: {
|
||||
requests: {
|
||||
req1: { tool: 'Edit', arguments: {}, createdAt: 1 }
|
||||
}
|
||||
}
|
||||
})
|
||||
engine.setSession(session)
|
||||
engine.emit({ type: 'session-updated', sessionId: session.id })
|
||||
await sleep(25)
|
||||
|
||||
expect(channel.permissionSessions).toHaveLength(0)
|
||||
|
||||
const readyEvent: SyncEvent = {
|
||||
type: 'message-received',
|
||||
sessionId: session.id,
|
||||
message: {
|
||||
id: 'message-1',
|
||||
seq: 1,
|
||||
localId: null,
|
||||
createdAt: 0,
|
||||
content: {
|
||||
role: 'agent',
|
||||
content: {
|
||||
id: 'event-1',
|
||||
type: 'event',
|
||||
data: { type: 'ready' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
engine.emit(readyEvent)
|
||||
await sleep(5)
|
||||
expect(channel.readySessions).toHaveLength(0)
|
||||
|
||||
const taskEvent: SyncEvent = {
|
||||
type: 'message-received',
|
||||
sessionId: session.id,
|
||||
message: {
|
||||
id: 'message-task',
|
||||
seq: 2,
|
||||
localId: null,
|
||||
createdAt: 0,
|
||||
content: {
|
||||
role: 'agent',
|
||||
content: {
|
||||
type: 'output',
|
||||
data: {
|
||||
type: 'system',
|
||||
subtype: 'task_notification',
|
||||
status: 'completed',
|
||||
summary: 'Commit T4 finished'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
engine.emit(taskEvent)
|
||||
await sleep(5)
|
||||
expect(channel.taskNotifications).toHaveLength(0)
|
||||
|
||||
engine.emit({
|
||||
type: 'session-ended',
|
||||
sessionId: session.id,
|
||||
reason: 'completed' satisfies SessionEndReason
|
||||
})
|
||||
await sleep(5)
|
||||
expect(channel.sessionCompletions).toHaveLength(0)
|
||||
|
||||
hub.stop()
|
||||
})
|
||||
|
||||
it('filters per event type by namespace preferences', async () => {
|
||||
const engine = new FakeSyncEngine()
|
||||
const channel = new StubChannel()
|
||||
const store = {
|
||||
notificationPrefs: {
|
||||
getPreferenceFlags: () => ({
|
||||
permissionRequests: 0,
|
||||
sessionReady: 1,
|
||||
taskNotifications: 1,
|
||||
sessionCompletion: 0
|
||||
})
|
||||
}
|
||||
} as unknown as Store
|
||||
const hub = new NotificationHub(engine as unknown as SyncEngine, [channel], {
|
||||
permissionDebounceMs: 5,
|
||||
readyCooldownMs: 5
|
||||
}, store)
|
||||
|
||||
const session = createSession({
|
||||
agentState: {
|
||||
requests: {
|
||||
req1: { tool: 'Edit', arguments: {}, createdAt: 1 }
|
||||
}
|
||||
}
|
||||
})
|
||||
engine.setSession(session)
|
||||
engine.emit({ type: 'session-updated', sessionId: session.id })
|
||||
await sleep(25)
|
||||
expect(channel.permissionSessions).toHaveLength(0)
|
||||
|
||||
const readyEvent: SyncEvent = {
|
||||
type: 'message-received',
|
||||
sessionId: session.id,
|
||||
message: {
|
||||
id: 'message-1',
|
||||
seq: 1,
|
||||
localId: null,
|
||||
createdAt: 0,
|
||||
content: {
|
||||
role: 'agent',
|
||||
content: {
|
||||
id: 'event-1',
|
||||
type: 'event',
|
||||
data: { type: 'ready' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
engine.emit(readyEvent)
|
||||
await sleep(5)
|
||||
expect(channel.readySessions).toHaveLength(1)
|
||||
|
||||
engine.emit({
|
||||
type: 'session-ended',
|
||||
sessionId: session.id,
|
||||
reason: 'completed' satisfies SessionEndReason
|
||||
})
|
||||
await sleep(5)
|
||||
expect(channel.sessionCompletions).toHaveLength(0)
|
||||
|
||||
hub.stop()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Session, SyncEngine, SyncEvent } from '../sync/syncEngine'
|
||||
import type { SessionEndReason } from '@hapi/protocol'
|
||||
import type { Store } from '../store'
|
||||
import type { NotificationChannel, NotificationHubOptions, TaskNotification } from './notificationTypes'
|
||||
import type { NotificationSendContext } from './notificationSendContext'
|
||||
import { extractMessageEventType, extractTaskNotification } from './eventParsing'
|
||||
@@ -16,7 +17,8 @@ export class NotificationHub {
|
||||
constructor(
|
||||
private readonly syncEngine: SyncEngine,
|
||||
channels: NotificationChannel[],
|
||||
options?: NotificationHubOptions
|
||||
options?: NotificationHubOptions,
|
||||
private readonly store?: Store
|
||||
) {
|
||||
this.channels = channels
|
||||
this.readyCooldownMs = options?.readyCooldownMs ?? 5000
|
||||
@@ -182,7 +184,21 @@ export class NotificationHub {
|
||||
await this.notifySessionCompletion(session, reason)
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-namespace preference gate. Without a store (or a missing row) every
|
||||
* event type is enabled, preserving the pre-preferences behavior.
|
||||
*/
|
||||
private isEventEnabled(session: Session, flag: 'permissionRequests' | 'sessionReady' | 'taskNotifications' | 'sessionCompletion'): boolean {
|
||||
if (!this.store) {
|
||||
return true
|
||||
}
|
||||
return Boolean(this.store.notificationPrefs.getPreferenceFlags(session.namespace)[flag])
|
||||
}
|
||||
|
||||
private async notifyReady(session: Session): Promise<void> {
|
||||
if (!this.isEventEnabled(session, 'sessionReady')) {
|
||||
return
|
||||
}
|
||||
const ctx: NotificationSendContext = { nativeGate: { sent: false } }
|
||||
for (const channel of this.channels) {
|
||||
try {
|
||||
@@ -194,6 +210,9 @@ export class NotificationHub {
|
||||
}
|
||||
|
||||
private async notifyPermission(session: Session): Promise<void> {
|
||||
if (!this.isEventEnabled(session, 'permissionRequests')) {
|
||||
return
|
||||
}
|
||||
const ctx: NotificationSendContext = { nativeGate: { sent: false } }
|
||||
for (const channel of this.channels) {
|
||||
try {
|
||||
@@ -205,6 +224,9 @@ export class NotificationHub {
|
||||
}
|
||||
|
||||
private async notifyTask(session: Session, notification: TaskNotification): Promise<void> {
|
||||
if (!this.isEventEnabled(session, 'taskNotifications')) {
|
||||
return
|
||||
}
|
||||
const ctx: NotificationSendContext = { nativeGate: { sent: false } }
|
||||
for (const channel of this.channels) {
|
||||
try {
|
||||
@@ -216,6 +238,9 @@ export class NotificationHub {
|
||||
}
|
||||
|
||||
private async notifySessionCompletion(session: Session, reason: SessionEndReason): Promise<void> {
|
||||
if (!this.isEventEnabled(session, 'sessionCompletion')) {
|
||||
return
|
||||
}
|
||||
for (const channel of this.channels) {
|
||||
if (typeof channel.sendSessionCompletion !== 'function') {
|
||||
continue
|
||||
|
||||
+2
-1
@@ -246,7 +246,7 @@ export async function startHub(options: StartHubOptions = {}): Promise<HubInstan
|
||||
}
|
||||
}
|
||||
|
||||
notificationHub = new NotificationHub(syncEngine, notificationChannels)
|
||||
notificationHub = new NotificationHub(syncEngine, notificationChannels, undefined, store)
|
||||
|
||||
// Start HTTP service first (before tunnel, so tunnel has something to forward to)
|
||||
webServer = await startWebServer({
|
||||
@@ -256,6 +256,7 @@ export async function startHub(options: StartHubOptions = {}): Promise<HubInstan
|
||||
jwtSecret,
|
||||
store,
|
||||
vapidPublicKey: vapidKeys.publicKey,
|
||||
pushService,
|
||||
socketEngine: socketServer.engine,
|
||||
corsOrigins,
|
||||
relayMode: relayFlag.enabled,
|
||||
|
||||
+32
-2
@@ -8,6 +8,7 @@ import { addMessage } from './messages'
|
||||
import type { StoredMessage } from './types'
|
||||
import { PushStore } from './pushStore'
|
||||
import { FcmStore } from './fcmStore'
|
||||
import { NotificationPreferenceStore } from './notificationPreferenceStore'
|
||||
import { ScratchlistStore } from './scratchlistStore'
|
||||
import { SessionStore } from './sessionStore'
|
||||
import { UserStore } from './userStore'
|
||||
@@ -28,12 +29,13 @@ export { MachineStore } from './machineStore'
|
||||
export { MessageStore } from './messageStore'
|
||||
export { PushStore } from './pushStore'
|
||||
export { FcmStore } from './fcmStore'
|
||||
export { NotificationPreferenceStore } from './notificationPreferenceStore'
|
||||
export { ScratchlistStore } from './scratchlistStore'
|
||||
export { SessionStore } from './sessionStore'
|
||||
export { UserStore } from './userStore'
|
||||
export { UsageStore } from './usageStore'
|
||||
|
||||
const SCHEMA_VERSION: number = 19
|
||||
const SCHEMA_VERSION: number = 20
|
||||
const REQUIRED_TABLES = [
|
||||
'sessions',
|
||||
'machines',
|
||||
@@ -44,7 +46,8 @@ const REQUIRED_TABLES = [
|
||||
'fcm_devices',
|
||||
'session_scratchlist',
|
||||
'usage_events',
|
||||
'usage_scan_state'
|
||||
'usage_scan_state',
|
||||
'notification_preferences'
|
||||
] as const
|
||||
|
||||
export class Store {
|
||||
@@ -60,6 +63,7 @@ export class Store {
|
||||
readonly fcm: FcmStore
|
||||
readonly scratchlist: ScratchlistStore
|
||||
readonly usage: UsageStore
|
||||
readonly notificationPrefs: NotificationPreferenceStore
|
||||
|
||||
/**
|
||||
* Filesystem path of the underlying SQLite database, or ':memory:' for
|
||||
@@ -113,6 +117,7 @@ export class Store {
|
||||
this.fcm = new FcmStore(this.db)
|
||||
this.scratchlist = new ScratchlistStore(this.db)
|
||||
this.usage = new UsageStore(this.db)
|
||||
this.notificationPrefs = new NotificationPreferenceStore(this.db)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -287,6 +292,7 @@ export class Store {
|
||||
16: () => this.migrateFromV16ToV17(),
|
||||
17: () => this.migrateFromV17ToV18(),
|
||||
18: () => this.migrateFromV18ToV19(),
|
||||
19: () => this.migrateFromV19ToV20(),
|
||||
})
|
||||
|
||||
if (currentVersion === 0) {
|
||||
@@ -478,6 +484,15 @@ export class Store {
|
||||
last_seq INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification_preferences (
|
||||
namespace TEXT PRIMARY KEY,
|
||||
permission_requests INTEGER NOT NULL DEFAULT 1,
|
||||
session_ready INTEGER NOT NULL DEFAULT 1,
|
||||
task_notifications INTEGER NOT NULL DEFAULT 1,
|
||||
session_completion INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -809,6 +824,21 @@ export class Store {
|
||||
`)
|
||||
}
|
||||
|
||||
private migrateFromV19ToV20(): void {
|
||||
// Per-namespace notification preferences. Defaults are all-enabled so
|
||||
// existing namespaces keep the pre-preferences behavior.
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS notification_preferences (
|
||||
namespace TEXT PRIMARY KEY,
|
||||
permission_requests INTEGER NOT NULL DEFAULT 1,
|
||||
session_ready INTEGER NOT NULL DEFAULT 1,
|
||||
task_notifications INTEGER NOT NULL DEFAULT 1,
|
||||
session_completion INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`)
|
||||
}
|
||||
|
||||
private getSessionColumnNames(): Set<string> {
|
||||
const rows = this.db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }>
|
||||
return new Set(rows.map((row) => row.name))
|
||||
|
||||
@@ -10,7 +10,7 @@ describe('Store V12/V13→V14 schema reconciliation', () => {
|
||||
const store = new Store(':memory:')
|
||||
expect(tableExists(store, 'message_epochs')).toBe(true)
|
||||
expect(tableExists(store, 'session_scratchlist')).toBe(true)
|
||||
expect(getUserVersion(store)).toBe(19)
|
||||
expect(getUserVersion(store)).toBe(20)
|
||||
store.close()
|
||||
})
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('Store V12/V13→V14 schema reconciliation', () => {
|
||||
store = new Store(dbPath)
|
||||
expect(tableExists(store, 'message_epochs')).toBe(true)
|
||||
expect(tableExists(store, 'session_scratchlist')).toBe(true)
|
||||
expect(getUserVersion(store)).toBe(19)
|
||||
expect(getUserVersion(store)).toBe(20)
|
||||
expect(store.messages.getMessageEpoch('session-1')).toBe(0)
|
||||
expect(store.messages.getMessages('session-1')).toHaveLength(1)
|
||||
} finally {
|
||||
@@ -72,7 +72,7 @@ describe('Store V12/V13→V14 schema reconciliation', () => {
|
||||
store = new Store(dbPath)
|
||||
expect(tableExists(store, 'message_epochs')).toBe(true)
|
||||
expect(tableExists(store, 'session_scratchlist')).toBe(true)
|
||||
expect(getUserVersion(store)).toBe(19)
|
||||
expect(getUserVersion(store)).toBe(20)
|
||||
expect(store.messages.getMessages('session-1')).toHaveLength(1)
|
||||
} finally {
|
||||
store?.close()
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('Store V14→V15 migration: scratchlist attachments column', () => {
|
||||
expect(cols).toContain('attachments')
|
||||
expect(getColumns(store, 'usage_events')).toContain('last_input_tokens')
|
||||
expect(getColumns(store, 'usage_scan_state')).toContain('last_seq')
|
||||
expect(getUserVersion(store)).toBe(19)
|
||||
expect(getUserVersion(store)).toBe(20)
|
||||
store.close()
|
||||
})
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('Store V14→V15 migration: scratchlist attachments column', () => {
|
||||
expect(cols).toContain('attachments')
|
||||
expect(getColumns(store, 'usage_events')).toContain('last_input_tokens')
|
||||
expect(getColumns(store, 'usage_scan_state')).toContain('last_seq')
|
||||
expect(getUserVersion(store)).toBe(19)
|
||||
expect(getUserVersion(store)).toBe(20)
|
||||
} finally {
|
||||
store?.close()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
@@ -60,7 +60,7 @@ describe('Store V14→V15 migration: scratchlist attachments column', () => {
|
||||
store2 = new Store(dbPath)
|
||||
const cols2 = getColumns(store2, 'session_scratchlist')
|
||||
expect(cols2).toEqual(cols1)
|
||||
expect(getUserVersion(store2)).toBe(19)
|
||||
expect(getUserVersion(store2)).toBe(20)
|
||||
} finally {
|
||||
store2?.close()
|
||||
store1?.close()
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('Store V18->V19 migration: usage scan state', () => {
|
||||
const usageRows = internalDb.prepare('SELECT COUNT(*) AS count FROM usage_events').get() as { count: number }
|
||||
|
||||
expect(table?.name).toBe('usage_scan_state')
|
||||
expect(version.user_version).toBe(19)
|
||||
expect(version.user_version).toBe(20)
|
||||
expect(usageRows.count).toBe(0)
|
||||
} finally {
|
||||
store?.close()
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Database } from 'bun:sqlite'
|
||||
|
||||
import type { NotificationPreferenceFlags, NotificationPreferences } from './notificationPreferences'
|
||||
import { getPreferenceFlags, getPreferences, setPreferences } from './notificationPreferences'
|
||||
|
||||
export class NotificationPreferenceStore {
|
||||
private readonly db: Database
|
||||
|
||||
constructor(db: Database) {
|
||||
this.db = db
|
||||
}
|
||||
|
||||
getPreferences(namespace: string): NotificationPreferences {
|
||||
return getPreferences(this.db, namespace)
|
||||
}
|
||||
|
||||
getPreferenceFlags(namespace: string): NotificationPreferenceFlags {
|
||||
return getPreferenceFlags(this.db, namespace)
|
||||
}
|
||||
|
||||
setPreferences(
|
||||
namespace: string,
|
||||
partial: Partial<NotificationPreferenceFlags>
|
||||
): NotificationPreferences {
|
||||
return setPreferences(this.db, namespace, partial)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { Store } from './index'
|
||||
|
||||
describe('notificationPreferences', () => {
|
||||
it('returns all-enabled defaults when no row exists', () => {
|
||||
const store = new Store(':memory:')
|
||||
expect(store.notificationPrefs.getPreferences('ns-1')).toEqual({
|
||||
namespace: 'ns-1',
|
||||
permissionRequests: 1,
|
||||
sessionReady: 1,
|
||||
taskNotifications: 1,
|
||||
sessionCompletion: 1,
|
||||
updatedAt: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('upserts on first set, defaulting untouched fields', () => {
|
||||
const store = new Store(':memory:')
|
||||
const prefs = store.notificationPrefs.setPreferences('ns-1', { sessionReady: 0 })
|
||||
expect(prefs.sessionReady).toBe(0)
|
||||
expect(prefs.permissionRequests).toBe(1)
|
||||
expect(prefs.taskNotifications).toBe(1)
|
||||
expect(prefs.sessionCompletion).toBe(1)
|
||||
expect(prefs.updatedAt).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('updates only the provided fields on an existing row', () => {
|
||||
const store = new Store(':memory:')
|
||||
store.notificationPrefs.setPreferences('ns-1', { sessionReady: 0, taskNotifications: 0 })
|
||||
const after = store.notificationPrefs.setPreferences('ns-1', { permissionRequests: 0 })
|
||||
expect(after).toMatchObject({
|
||||
permissionRequests: 0,
|
||||
sessionReady: 0,
|
||||
taskNotifications: 0,
|
||||
sessionCompletion: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps namespaces independent', () => {
|
||||
const store = new Store(':memory:')
|
||||
store.notificationPrefs.setPreferences('ns-a', { sessionReady: 0 })
|
||||
expect(store.notificationPrefs.getPreferenceFlags('ns-a').sessionReady).toBe(0)
|
||||
expect(store.notificationPrefs.getPreferenceFlags('ns-b')).toEqual({
|
||||
permissionRequests: 1,
|
||||
sessionReady: 1,
|
||||
taskNotifications: 1,
|
||||
sessionCompletion: 1
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { Database } from 'bun:sqlite'
|
||||
|
||||
export type NotificationPreferenceFlags = {
|
||||
permissionRequests: number
|
||||
sessionReady: number
|
||||
taskNotifications: number
|
||||
sessionCompletion: number
|
||||
}
|
||||
|
||||
export type NotificationPreferences = NotificationPreferenceFlags & {
|
||||
namespace: string
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
type DbPreferenceRow = {
|
||||
namespace: string
|
||||
permission_requests: number
|
||||
session_ready: number
|
||||
task_notifications: number
|
||||
session_completion: number
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
// All event types default to enabled — new namespaces behave exactly like the
|
||||
// pre-preferences behavior (push everything).
|
||||
const DEFAULTS: NotificationPreferenceFlags = {
|
||||
permissionRequests: 1,
|
||||
sessionReady: 1,
|
||||
taskNotifications: 1,
|
||||
sessionCompletion: 1
|
||||
}
|
||||
|
||||
function rowToFlags(row: DbPreferenceRow): NotificationPreferenceFlags {
|
||||
return {
|
||||
permissionRequests: row.permission_requests,
|
||||
sessionReady: row.session_ready,
|
||||
taskNotifications: row.task_notifications,
|
||||
sessionCompletion: row.session_completion
|
||||
}
|
||||
}
|
||||
|
||||
function getRow(db: Database, namespace: string): DbPreferenceRow | undefined {
|
||||
return db.prepare(
|
||||
'SELECT * FROM notification_preferences WHERE namespace = ?'
|
||||
).get(namespace) as DbPreferenceRow | undefined
|
||||
}
|
||||
|
||||
export function getPreferences(db: Database, namespace: string): NotificationPreferences {
|
||||
const row = getRow(db, namespace)
|
||||
if (row) {
|
||||
return {
|
||||
namespace: row.namespace,
|
||||
...rowToFlags(row),
|
||||
updatedAt: row.updated_at
|
||||
}
|
||||
}
|
||||
return {
|
||||
namespace,
|
||||
...DEFAULTS,
|
||||
updatedAt: 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast path for the notification hot loop: flags only, no timestamp.
|
||||
*/
|
||||
export function getPreferenceFlags(db: Database, namespace: string): NotificationPreferenceFlags {
|
||||
const row = getRow(db, namespace)
|
||||
if (row) {
|
||||
return rowToFlags(row)
|
||||
}
|
||||
return { ...DEFAULTS }
|
||||
}
|
||||
|
||||
/**
|
||||
* Upserts a partial preference update. When no row exists yet, defaults are
|
||||
* merged with the provided flags before inserting.
|
||||
*/
|
||||
export function setPreferences(
|
||||
db: Database,
|
||||
namespace: string,
|
||||
partial: Partial<NotificationPreferenceFlags>
|
||||
): NotificationPreferences {
|
||||
const existing = getRow(db, namespace)
|
||||
const now = Date.now()
|
||||
if (existing) {
|
||||
const fields: string[] = ['updated_at = @updated_at']
|
||||
const params: Record<string, number | string> = { namespace, updated_at: now }
|
||||
for (const [key, column] of [
|
||||
['permissionRequests', 'permission_requests'],
|
||||
['sessionReady', 'session_ready'],
|
||||
['taskNotifications', 'task_notifications'],
|
||||
['sessionCompletion', 'session_completion']
|
||||
] as const) {
|
||||
if (partial[key] !== undefined) {
|
||||
fields.push(`${column} = @${key}`)
|
||||
params[key] = partial[key]
|
||||
}
|
||||
}
|
||||
db.prepare(
|
||||
`UPDATE notification_preferences SET ${fields.join(', ')} WHERE namespace = @namespace`
|
||||
).run(params)
|
||||
} else {
|
||||
const flags = { ...DEFAULTS, ...partial }
|
||||
db.prepare(`
|
||||
INSERT INTO notification_preferences (
|
||||
namespace, permission_requests, session_ready,
|
||||
task_notifications, session_completion, updated_at
|
||||
) VALUES (
|
||||
@namespace, @permissionRequests, @sessionReady,
|
||||
@taskNotifications, @sessionCompletion, @updatedAt
|
||||
)
|
||||
`).run({
|
||||
namespace,
|
||||
permissionRequests: flags.permissionRequests,
|
||||
sessionReady: flags.sessionReady,
|
||||
taskNotifications: flags.taskNotifications,
|
||||
sessionCompletion: flags.sessionCompletion,
|
||||
updatedAt: now
|
||||
})
|
||||
}
|
||||
return getPreferences(db, namespace)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { Hono } from 'hono'
|
||||
import { Store } from '../../store'
|
||||
import type { WebAppEnv } from '../middleware/auth'
|
||||
import { createNotificationPreferencesRoutes } from './notificationPreferences'
|
||||
|
||||
function createApp(): Hono<WebAppEnv> {
|
||||
const store = new Store(':memory:')
|
||||
const app = new Hono<WebAppEnv>()
|
||||
app.use('*', async (c, next) => {
|
||||
c.set('namespace', 'test-ns')
|
||||
await next()
|
||||
})
|
||||
app.route('/api', createNotificationPreferencesRoutes(store))
|
||||
return app
|
||||
}
|
||||
|
||||
describe('GET /api/notification-preferences', () => {
|
||||
it('returns all-enabled defaults for a namespace with no row', async () => {
|
||||
const app = createApp()
|
||||
const res = await app.request('/api/notification-preferences')
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
namespace: 'test-ns',
|
||||
permissionRequests: 1,
|
||||
sessionReady: 1,
|
||||
taskNotifications: 1,
|
||||
sessionCompletion: 1,
|
||||
updatedAt: 0
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /api/notification-preferences', () => {
|
||||
it('updates only the provided fields and returns the merged result', async () => {
|
||||
const app = createApp()
|
||||
const res = await app.request('/api/notification-preferences', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ sessionReady: 0 })
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toMatchObject({
|
||||
namespace: 'test-ns',
|
||||
permissionRequests: 1,
|
||||
sessionReady: 0,
|
||||
taskNotifications: 1,
|
||||
sessionCompletion: 1
|
||||
})
|
||||
|
||||
const getRes = await app.request('/api/notification-preferences')
|
||||
const body = await getRes.json() as { sessionReady: number }
|
||||
expect(body.sessionReady).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects out-of-range values', async () => {
|
||||
const app = createApp()
|
||||
const res = await app.request('/api/notification-preferences', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ permissionRequests: 2 })
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects invalid bodies', async () => {
|
||||
const app = createApp()
|
||||
const res = await app.request('/api/notification-preferences', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ sessionReady: 'yes' })
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Hono } from 'hono'
|
||||
import { z } from 'zod'
|
||||
import type { Store } from '../../store'
|
||||
import type { WebAppEnv } from '../middleware/auth'
|
||||
|
||||
const updateSchema = z.object({
|
||||
permissionRequests: z.number().min(0).max(1).optional(),
|
||||
sessionReady: z.number().min(0).max(1).optional(),
|
||||
taskNotifications: z.number().min(0).max(1).optional(),
|
||||
sessionCompletion: z.number().min(0).max(1).optional()
|
||||
})
|
||||
|
||||
export function createNotificationPreferencesRoutes(store: Store): Hono<WebAppEnv> {
|
||||
const app = new Hono<WebAppEnv>()
|
||||
|
||||
app.get('/notification-preferences', (c) => {
|
||||
const namespace = c.get('namespace')
|
||||
return c.json(store.notificationPrefs.getPreferences(namespace))
|
||||
})
|
||||
|
||||
app.put('/notification-preferences', async (c) => {
|
||||
const json = await c.req.json().catch(() => null)
|
||||
const parsed = updateSchema.safeParse(json)
|
||||
if (!parsed.success) {
|
||||
return c.json({ error: 'Invalid body', issues: parsed.error.flatten() }, 400)
|
||||
}
|
||||
|
||||
const namespace = c.get('namespace')
|
||||
return c.json(store.notificationPrefs.setPreferences(namespace, parsed.data))
|
||||
})
|
||||
|
||||
return app
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Hono } from 'hono'
|
||||
import { z } from 'zod'
|
||||
import type { PushService } from '../../push/pushService'
|
||||
import type { Store } from '../../store'
|
||||
import type { WebAppEnv } from '../middleware/auth'
|
||||
|
||||
@@ -15,7 +16,11 @@ const unsubscribeSchema = z.object({
|
||||
endpoint: z.string().min(1)
|
||||
})
|
||||
|
||||
export function createPushRoutes(store: Store, vapidPublicKey: string): Hono<WebAppEnv> {
|
||||
export function createPushRoutes(
|
||||
store: Store,
|
||||
vapidPublicKey: string,
|
||||
pushService: PushService
|
||||
): Hono<WebAppEnv> {
|
||||
const app = new Hono<WebAppEnv>()
|
||||
|
||||
app.get('/push/vapid-public-key', (c) => {
|
||||
@@ -52,5 +57,26 @@ export function createPushRoutes(store: Store, vapidPublicKey: string): Hono<Web
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
|
||||
// User-initiated test push: deliberately bypasses notification preferences.
|
||||
app.post('/push/test', async (c) => {
|
||||
const namespace = c.get('namespace')
|
||||
try {
|
||||
await pushService.sendToNamespace(namespace, {
|
||||
title: 'HAPI Test Notification',
|
||||
body: 'Your push notifications are working.',
|
||||
tag: 'test-push',
|
||||
data: {
|
||||
type: 'test',
|
||||
sessionId: '',
|
||||
url: '/settings/notifications'
|
||||
}
|
||||
})
|
||||
return c.json({ ok: true })
|
||||
} catch (error) {
|
||||
console.error('[PushRoutes] Test push failed:', error)
|
||||
return c.json({ error: 'Failed to send test push' }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { buildGeminiLiveSetupMessage, QWEN_REALTIME_MODEL } from '@hapi/protocol
|
||||
import { createQwenProxyWebSocketHandler } from './qwenProxyHandler'
|
||||
import { decodeVoiceSystemPromptParam } from '../voiceSystemPromptParam'
|
||||
import type { SyncEngine } from '../sync/syncEngine'
|
||||
import type { PushService } from '../push/pushService'
|
||||
import { createAuthMiddleware, type WebAppEnv } from './middleware/auth'
|
||||
import { createAuthRoutes } from './routes/auth'
|
||||
import { createBindRoutes } from './routes/bind'
|
||||
@@ -24,6 +25,7 @@ import { createGitRoutes } from './routes/git'
|
||||
import { createCliRoutes } from './routes/cli'
|
||||
import { createCodexDesktopRoutes } from './routes/codexDesktop'
|
||||
import { createPushRoutes } from './routes/push'
|
||||
import { createNotificationPreferencesRoutes } from './routes/notificationPreferences'
|
||||
import { createDevicesRoutes } from './routes/devices'
|
||||
import { createVoiceRoutes } from './routes/voice'
|
||||
import type { SSEManager } from '../sse/sseManager'
|
||||
@@ -216,6 +218,7 @@ function createWebApp(options: {
|
||||
jwtSecret: Uint8Array
|
||||
store: Store
|
||||
vapidPublicKey: string
|
||||
pushService: PushService
|
||||
corsOrigins?: string[]
|
||||
embeddedAssetMap: Map<string, EmbeddedWebAsset> | null
|
||||
relayMode?: boolean
|
||||
@@ -258,7 +261,8 @@ function createWebApp(options: {
|
||||
store: options.store,
|
||||
getSyncEngine: options.getSyncEngine
|
||||
}))
|
||||
app.route('/api', createPushRoutes(options.store, options.vapidPublicKey))
|
||||
app.route('/api', createPushRoutes(options.store, options.vapidPublicKey, options.pushService))
|
||||
app.route('/api', createNotificationPreferencesRoutes(options.store))
|
||||
app.route('/api', createDevicesRoutes(options.store))
|
||||
app.route('/api', createVoiceRoutes())
|
||||
|
||||
@@ -372,6 +376,7 @@ export async function startWebServer(options: {
|
||||
jwtSecret: Uint8Array
|
||||
store: Store
|
||||
vapidPublicKey: string
|
||||
pushService: PushService
|
||||
socketEngine: SocketEngine
|
||||
corsOrigins?: string[]
|
||||
relayMode?: boolean
|
||||
@@ -386,6 +391,7 @@ export async function startWebServer(options: {
|
||||
jwtSecret: options.jwtSecret,
|
||||
store: options.store,
|
||||
vapidPublicKey: options.vapidPublicKey,
|
||||
pushService: options.pushService,
|
||||
corsOrigins: options.corsOrigins,
|
||||
embeddedAssetMap,
|
||||
relayMode: options.relayMode,
|
||||
|
||||
@@ -14,9 +14,12 @@ import type {
|
||||
MachinesResponse,
|
||||
MessagesResponse,
|
||||
PermissionMode,
|
||||
NotificationPreferences,
|
||||
NotificationPreferencesUpdate,
|
||||
PushSubscriptionPayload,
|
||||
PushUnsubscribePayload,
|
||||
PushVapidPublicKeyResponse,
|
||||
TestPushResponse,
|
||||
SlashCommandsResponse,
|
||||
SkillsResponse,
|
||||
SpawnResponse,
|
||||
@@ -210,6 +213,26 @@ export class ApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
async getNotificationPreferences(): Promise<NotificationPreferences> {
|
||||
return await this.request<NotificationPreferences>('/api/notification-preferences')
|
||||
}
|
||||
|
||||
async updateNotificationPreferences(
|
||||
update: NotificationPreferencesUpdate
|
||||
): Promise<NotificationPreferences> {
|
||||
return await this.request<NotificationPreferences>('/api/notification-preferences', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(update)
|
||||
})
|
||||
}
|
||||
|
||||
async sendTestPush(): Promise<TestPushResponse> {
|
||||
return await this.request<TestPushResponse>('/api/push/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
}
|
||||
|
||||
async syncCodexSession(payload?: CodexDesktopSyncRequest): Promise<CodexDesktopScriptResponse> {
|
||||
// 中文注释:当前按钮语义已改为“从 Codex 导入到 Hapi”;这里提交的是本地 transcript 对应的 Codex thread ID 列表。
|
||||
return await this.request<CodexDesktopScriptResponse>('/api/codex/sync-session', {
|
||||
|
||||
@@ -34,6 +34,7 @@ export function SettingsNav(props: { activeId?: string; mobile?: boolean }) {
|
||||
display: `${t(`settings.display.appearance.${appearance}`)} · ${Math.round(fontScale * 100)}%`,
|
||||
chat: t(`settings.chat.enterBehavior.${composerEnterBehavior}`),
|
||||
voice: t('settings.hub.voice.summary'),
|
||||
notifications: t('settings.notifications.summary'),
|
||||
machines: t('settings.hub.machines.summary'),
|
||||
storage: t('settings.storage.summary'),
|
||||
usage: t('settings.usage.summary'),
|
||||
|
||||
@@ -916,6 +916,26 @@ export default {
|
||||
'settings.companion.copyLink': 'Copy link',
|
||||
'settings.companion.copied': 'Copied!',
|
||||
'settings.companion.hide': 'Hide',
|
||||
'settings.notifications.title': 'Notifications',
|
||||
'settings.notifications.description': 'Choose which events trigger push notifications on your devices.',
|
||||
'settings.notifications.summary': 'Push notification preferences',
|
||||
'settings.notifications.section': 'Notification types',
|
||||
'settings.notifications.permissionRequests': 'Permission requests',
|
||||
'settings.notifications.permissionRequestsDescription': 'When an agent needs your approval to run a tool.',
|
||||
'settings.notifications.sessionReady': 'Session ready',
|
||||
'settings.notifications.sessionReadyDescription': 'When an agent is waiting for your input.',
|
||||
'settings.notifications.taskNotifications': 'Task notifications',
|
||||
'settings.notifications.taskNotificationsDescription': 'When a task completes or fails.',
|
||||
'settings.notifications.sessionCompletion': 'Session completion',
|
||||
'settings.notifications.sessionCompletionDescription': 'When a session finishes.',
|
||||
'settings.notifications.disablePermissionTitle': 'Turn off permission request notifications?',
|
||||
'settings.notifications.disablePermissionDescription': 'You will no longer be notified on your phone when an agent needs your approval. Pending requests will only be visible in the web app.',
|
||||
'settings.notifications.disablePermissionConfirm': 'Turn off anyway',
|
||||
'settings.notifications.testPush': 'Send test push',
|
||||
'settings.notifications.testPushSending': 'Sending…',
|
||||
'settings.notifications.testPushSent': 'Test notification sent!',
|
||||
'settings.notifications.testPushError': 'Failed to send test notification',
|
||||
'settings.notifications.saveError': 'Failed to save preferences',
|
||||
'settings.machines.title': 'Machines',
|
||||
'settings.machines.description': 'Give your machines names of your own. Only machines that are currently online are listed.',
|
||||
'settings.machines.section': 'Your machines',
|
||||
|
||||
@@ -915,6 +915,26 @@ export default {
|
||||
'settings.companion.copyLink': '复制链接',
|
||||
'settings.companion.copied': '已复制!',
|
||||
'settings.companion.hide': '隐藏',
|
||||
'settings.notifications.title': '通知',
|
||||
'settings.notifications.description': '选择哪些事件会触发推送到你设备的通知。',
|
||||
'settings.notifications.summary': '推送通知偏好',
|
||||
'settings.notifications.section': '通知类型',
|
||||
'settings.notifications.permissionRequests': '权限请求',
|
||||
'settings.notifications.permissionRequestsDescription': '当 Agent 需要你批准执行某个工具时。',
|
||||
'settings.notifications.sessionReady': '会话就绪',
|
||||
'settings.notifications.sessionReadyDescription': '当 Agent 等待你的输入时。',
|
||||
'settings.notifications.taskNotifications': '任务通知',
|
||||
'settings.notifications.taskNotificationsDescription': '当任务完成或失败时。',
|
||||
'settings.notifications.sessionCompletion': '会话完成',
|
||||
'settings.notifications.sessionCompletionDescription': '当会话结束时。',
|
||||
'settings.notifications.disablePermissionTitle': '关闭权限请求通知?',
|
||||
'settings.notifications.disablePermissionDescription': '关闭后,当 Agent 需要你批准时手机将不再收到通知,待处理的请求只能通过网页查看。',
|
||||
'settings.notifications.disablePermissionConfirm': '仍然关闭',
|
||||
'settings.notifications.testPush': '发送测试推送',
|
||||
'settings.notifications.testPushSending': '发送中…',
|
||||
'settings.notifications.testPushSent': '测试通知已发送!',
|
||||
'settings.notifications.testPushError': '发送测试通知失败',
|
||||
'settings.notifications.saveError': '保存偏好设置失败',
|
||||
'settings.machines.title': '设备',
|
||||
'settings.machines.description': '给设备起自己的名字。这里只列出当前在线的设备。',
|
||||
'settings.machines.section': '我的设备',
|
||||
|
||||
@@ -4,6 +4,7 @@ export const queryKeys = {
|
||||
messages: (sessionId: string) => ['messages', sessionId] as const,
|
||||
machines: ['machines'] as const,
|
||||
sqliteStorage: ['sqlite-storage'] as const,
|
||||
notificationPreferences: ['notification-preferences'] as const,
|
||||
usageSummary: (range: string, timeZone: string) => ['usage-summary', range, timeZone] as const,
|
||||
machineCodexModels: (machineId: string) => ['machine-codex-models', machineId] as const,
|
||||
gitStatus: (sessionId: string) => ['git-status', sessionId] as const,
|
||||
|
||||
@@ -61,6 +61,7 @@ import SettingsVoicePage from '@/routes/settings/voice'
|
||||
import SettingsVoiceVoicesPage from '@/routes/settings/voice-voices'
|
||||
import SettingsVoiceAdvancedPage from '@/routes/settings/voice-advanced'
|
||||
import SettingsMachinesPage from '@/routes/settings/machines'
|
||||
import SettingsNotificationsPage from '@/routes/settings/notifications'
|
||||
import SettingsAboutPage from '@/routes/settings/about'
|
||||
import SettingsStoragePage from '@/routes/settings/storage'
|
||||
import SettingsUsagePage from '@/routes/settings/usage'
|
||||
@@ -1166,6 +1167,12 @@ const settingsMachinesRoute = createRoute({
|
||||
component: SettingsMachinesPage,
|
||||
})
|
||||
|
||||
const settingsNotificationsRoute = createRoute({
|
||||
getParentRoute: () => settingsRoute,
|
||||
path: 'notifications',
|
||||
component: SettingsNotificationsPage,
|
||||
})
|
||||
|
||||
const settingsAboutRoute = createRoute({
|
||||
getParentRoute: () => settingsRoute,
|
||||
path: 'about',
|
||||
@@ -1224,6 +1231,7 @@ export const routeTree = rootRoute.addChildren([
|
||||
settingsVoiceVoicesRoute,
|
||||
settingsVoiceAdvancedRoute,
|
||||
settingsMachinesRoute,
|
||||
settingsNotificationsRoute,
|
||||
settingsStorageRoute,
|
||||
settingsUsageRoute,
|
||||
settingsAboutRoute,
|
||||
|
||||
@@ -3,6 +3,7 @@ export const settingsCategories = [
|
||||
{ id: 'display', path: '/settings/display', titleKey: 'settings.display.title' },
|
||||
{ id: 'chat', path: '/settings/chat', titleKey: 'settings.chat.title' },
|
||||
{ id: 'voice', path: '/settings/voice', titleKey: 'settings.voice.title' },
|
||||
{ id: 'notifications', path: '/settings/notifications', titleKey: 'settings.notifications.title' },
|
||||
{ id: 'machines', path: '/settings/machines', titleKey: 'settings.machines.title' },
|
||||
{ id: 'storage', path: '/settings/storage', titleKey: 'settings.storage.title' },
|
||||
{ id: 'usage', path: '/settings/usage', titleKey: 'settings.usage.title' },
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { I18nProvider } from '@/lib/i18n-context'
|
||||
import SettingsNotificationsPage from './notifications'
|
||||
|
||||
const defaultPrefs = {
|
||||
namespace: 'default',
|
||||
permissionRequests: 1,
|
||||
sessionReady: 1,
|
||||
taskNotifications: 1,
|
||||
sessionCompletion: 1,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
const getNotificationPreferences = vi.fn()
|
||||
const updateNotificationPreferences = vi.fn()
|
||||
const sendTestPush = vi.fn()
|
||||
|
||||
vi.mock('@/lib/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
api: { getNotificationPreferences, updateNotificationPreferences, sendTestPush },
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('SettingsNotificationsPage', () => {
|
||||
beforeEach(() => {
|
||||
getNotificationPreferences.mockResolvedValue(defaultPrefs)
|
||||
updateNotificationPreferences.mockResolvedValue(defaultPrefs)
|
||||
sendTestPush.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function renderPage() {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider>
|
||||
<SettingsNotificationsPage />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
it('renders all four toggles from server preferences', async () => {
|
||||
renderPage()
|
||||
const permissionSwitch = await screen.findByLabelText('Permission requests')
|
||||
expect(permissionSwitch).toBeChecked()
|
||||
expect(screen.getByLabelText('Session ready')).toBeChecked()
|
||||
expect(screen.getByLabelText('Task notifications')).toBeChecked()
|
||||
expect(screen.getByLabelText('Session completion')).toBeChecked()
|
||||
})
|
||||
|
||||
it('saves a toggle change through the API', async () => {
|
||||
renderPage()
|
||||
const sessionReadySwitch = await screen.findByLabelText('Session ready')
|
||||
fireEvent.click(sessionReadySwitch)
|
||||
await waitFor(() => {
|
||||
expect(updateNotificationPreferences).toHaveBeenCalledWith({ sessionReady: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
it('asks for confirmation before disabling permission requests', async () => {
|
||||
renderPage()
|
||||
const permissionSwitch = await screen.findByLabelText('Permission requests')
|
||||
fireEvent.click(permissionSwitch)
|
||||
expect(updateNotificationPreferences).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Turn off permission request notifications?')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('applies the permission disable after confirming', async () => {
|
||||
renderPage()
|
||||
const permissionSwitch = await screen.findByLabelText('Permission requests')
|
||||
fireEvent.click(permissionSwitch)
|
||||
const confirmButton = screen.getByText('Turn off anyway')
|
||||
fireEvent.click(confirmButton)
|
||||
await waitFor(() => {
|
||||
expect(updateNotificationPreferences).toHaveBeenCalledWith({ permissionRequests: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
it('sends a test push and reports success', async () => {
|
||||
renderPage()
|
||||
const button = await screen.findByRole('button', { name: 'Send test push' })
|
||||
fireEvent.click(button)
|
||||
expect(sendTestPush).toHaveBeenCalled()
|
||||
expect(await screen.findByText('Test notification sent!')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
|
||||
import { SettingsPageContent, SettingsRow, SettingsSection, SettingsSwitch } from '@/components/settings/SettingsPrimitives'
|
||||
import { useAppContext } from '@/lib/app-context'
|
||||
import { queryKeys } from '@/lib/query-keys'
|
||||
import { useTranslation } from '@/lib/use-translation'
|
||||
import type { NotificationPreferencesUpdate } from '@/types/api'
|
||||
|
||||
type ToggleKey = keyof NotificationPreferencesUpdate
|
||||
|
||||
export default function SettingsNotificationsPage() {
|
||||
const { api } = useAppContext()
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [confirmDisablePermission, setConfirmDisablePermission] = useState(false)
|
||||
const [testPushLabel, setTestPushLabel] = useState<string | null>(null)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.notificationPreferences,
|
||||
queryFn: async () => {
|
||||
if (!api) throw new Error('API unavailable')
|
||||
return await api.getNotificationPreferences()
|
||||
},
|
||||
enabled: Boolean(api),
|
||||
staleTime: 0,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (update: NotificationPreferencesUpdate) => {
|
||||
if (!api) throw new Error('API unavailable')
|
||||
return await api.updateNotificationPreferences(update)
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(queryKeys.notificationPreferences, data)
|
||||
setSaveError(null)
|
||||
},
|
||||
onError: () => {
|
||||
setSaveError(t('settings.notifications.saveError'))
|
||||
},
|
||||
})
|
||||
|
||||
const handleToggle = (key: ToggleKey) => (checked: boolean) => {
|
||||
// Turning off permission requests needs explicit confirmation — it
|
||||
// disables phone-side approval, the core remote-control flow.
|
||||
if (key === 'permissionRequests' && !checked) {
|
||||
setConfirmDisablePermission(true)
|
||||
return
|
||||
}
|
||||
mutation.mutate({ [key]: checked ? 1 : 0 })
|
||||
}
|
||||
|
||||
const sendTestPush = async () => {
|
||||
if (!api) return
|
||||
setTestPushLabel(t('settings.notifications.testPushSending'))
|
||||
try {
|
||||
const result = await api.sendTestPush()
|
||||
setTestPushLabel('ok' in result
|
||||
? t('settings.notifications.testPushSent')
|
||||
: t('settings.notifications.testPushError'))
|
||||
} catch {
|
||||
setTestPushLabel(t('settings.notifications.testPushError'))
|
||||
}
|
||||
setTimeout(() => setTestPushLabel(null), 3000)
|
||||
}
|
||||
|
||||
const prefs = query.data
|
||||
|
||||
return (
|
||||
<SettingsPageContent description={t('settings.notifications.description')}>
|
||||
<SettingsSection title={t('settings.notifications.section')}>
|
||||
<SettingsSwitch
|
||||
label={t('settings.notifications.permissionRequests')}
|
||||
description={t('settings.notifications.permissionRequestsDescription')}
|
||||
checked={prefs ? Boolean(prefs.permissionRequests) : true}
|
||||
onChange={handleToggle('permissionRequests')}
|
||||
/>
|
||||
<SettingsSwitch
|
||||
label={t('settings.notifications.sessionReady')}
|
||||
description={t('settings.notifications.sessionReadyDescription')}
|
||||
checked={prefs ? Boolean(prefs.sessionReady) : true}
|
||||
onChange={handleToggle('sessionReady')}
|
||||
/>
|
||||
<SettingsSwitch
|
||||
label={t('settings.notifications.taskNotifications')}
|
||||
description={t('settings.notifications.taskNotificationsDescription')}
|
||||
checked={prefs ? Boolean(prefs.taskNotifications) : true}
|
||||
onChange={handleToggle('taskNotifications')}
|
||||
/>
|
||||
<SettingsSwitch
|
||||
label={t('settings.notifications.sessionCompletion')}
|
||||
description={t('settings.notifications.sessionCompletionDescription')}
|
||||
checked={prefs ? Boolean(prefs.sessionCompletion) : true}
|
||||
onChange={handleToggle('sessionCompletion')}
|
||||
/>
|
||||
{saveError ? <SettingsRow label={saveError} /> : null}
|
||||
</SettingsSection>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void sendTestPush()}
|
||||
disabled={testPushLabel !== null || !Boolean(api)}
|
||||
className="rounded-lg bg-[var(--app-button)] px-3 py-2 text-sm font-medium text-[var(--app-button-text)] disabled:opacity-50"
|
||||
>
|
||||
{testPushLabel ?? t('settings.notifications.testPush')}
|
||||
</button>
|
||||
<ConfirmDialog
|
||||
isOpen={confirmDisablePermission}
|
||||
onClose={() => setConfirmDisablePermission(false)}
|
||||
title={t('settings.notifications.disablePermissionTitle')}
|
||||
description={t('settings.notifications.disablePermissionDescription')}
|
||||
confirmLabel={t('settings.notifications.disablePermissionConfirm')}
|
||||
confirmingLabel={t('settings.notifications.disablePermissionConfirm')}
|
||||
onConfirm={async () => {
|
||||
mutation.mutate({ permissionRequests: 0 })
|
||||
}}
|
||||
isPending={mutation.isPending}
|
||||
destructive
|
||||
/>
|
||||
</SettingsPageContent>
|
||||
)
|
||||
}
|
||||
@@ -176,6 +176,23 @@ export type PushVapidPublicKeyResponse = {
|
||||
publicKey: string
|
||||
}
|
||||
|
||||
export type NotificationPreferences = {
|
||||
namespace: string
|
||||
permissionRequests: number
|
||||
sessionReady: number
|
||||
taskNotifications: number
|
||||
sessionCompletion: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export type NotificationPreferencesUpdate = Partial<
|
||||
Pick<NotificationPreferences, 'permissionRequests' | 'sessionReady' | 'taskNotifications' | 'sessionCompletion'>
|
||||
>
|
||||
|
||||
export type TestPushResponse =
|
||||
| { ok: true }
|
||||
| { error: string }
|
||||
|
||||
export type CodexDesktopScriptResponse = {
|
||||
success: boolean
|
||||
message?: string
|
||||
|
||||
Reference in New Issue
Block a user