From 803c2f65a98fe033e65386edc140b2dc4adc860f Mon Sep 17 00:00:00 2001 From: weishu Date: Wed, 7 Jan 2026 18:58:02 +0800 Subject: [PATCH] feat: add notification optimization with visibility tracking and toast messages Implement a visibility-aware notification system that delivers toast messages via SSE to visible browser tabs instead of sending push notifications, reducing unnecessary push requests. Includes VisibilityTracker for monitoring connection visibility, toast UI components, and enhanced SSEManager with toast delivery capability. --- server/src/index.ts | 8 +- server/src/push/pushNotificationChannel.ts | 36 ++++++ server/src/sse/sseManager.test.ts | 59 +++++++++- server/src/sse/sseManager.ts | 50 ++++++++- server/src/visibility/visibilityTracker.ts | 75 +++++++++++++ server/src/web/routes/events.ts | 48 +++++++- server/src/web/server.ts | 6 +- shared/src/schemas.ts | 12 +- web/src/App.tsx | 33 +++++- web/src/api/client.ts | 8 ++ web/src/components/ToastContainer.tsx | 42 +++++++ web/src/components/ui/Toast.tsx | 52 +++++++++ web/src/hooks/useSSE.ts | 52 ++++++++- web/src/hooks/useVisibilityReporter.ts | 122 +++++++++++++++++++++ web/src/lib/toast-context.tsx | 77 +++++++++++++ web/src/types/api.ts | 5 + 16 files changed, 672 insertions(+), 13 deletions(-) create mode 100644 server/src/visibility/visibilityTracker.ts create mode 100644 web/src/components/ToastContainer.tsx create mode 100644 web/src/components/ui/Toast.tsx create mode 100644 web/src/hooks/useVisibilityReporter.ts create mode 100644 web/src/lib/toast-context.tsx diff --git a/server/src/index.ts b/server/src/index.ts index 0b90b357..e3c7df12 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -21,6 +21,7 @@ import { SSEManager } from './sse/sseManager' import { getOrCreateVapidKeys } from './config/vapidKeys' import { PushService } from './push/pushService' import { PushNotificationChannel } from './push/pushNotificationChannel' +import { VisibilityTracker } from './visibility/visibilityTracker' import type { Server as BunServer } from 'bun' import type { WebSocketData } from '@socket.io/bun-engine' @@ -42,6 +43,7 @@ let syncEngine: SyncEngine | null = null let happyBot: HappyBot | null = null let webServer: BunServer | null = null let sseManager: SSEManager | null = null +let visibilityTracker: VisibilityTracker | null = null let notificationHub: NotificationHub | null = null async function main() { @@ -87,7 +89,8 @@ async function main() { const vapidSubject = process.env.VAPID_SUBJECT ?? 'mailto:admin@hapi.run' const pushService = new PushService(vapidKeys, vapidSubject, store) - sseManager = new SSEManager(30_000) + visibilityTracker = new VisibilityTracker() + sseManager = new SSEManager(30_000, visibilityTracker) const socketServer = createSocketServer({ store, @@ -102,7 +105,7 @@ async function main() { syncEngine = new SyncEngine(store, socketServer.io, socketServer.rpcRegistry, sseManager) const notificationChannels: NotificationChannel[] = [ - new PushNotificationChannel(pushService, config.miniAppUrl) + new PushNotificationChannel(pushService, sseManager, visibilityTracker, config.miniAppUrl) ] // Initialize Telegram bot (optional) @@ -125,6 +128,7 @@ async function main() { webServer = await startWebServer({ getSyncEngine: () => syncEngine, getSseManager: () => sseManager, + getVisibilityTracker: () => visibilityTracker, jwtSecret, store, vapidPublicKey: vapidKeys.publicKey, diff --git a/server/src/push/pushNotificationChannel.ts b/server/src/push/pushNotificationChannel.ts index 29076fa4..76e73d24 100644 --- a/server/src/push/pushNotificationChannel.ts +++ b/server/src/push/pushNotificationChannel.ts @@ -1,11 +1,15 @@ import type { Session } from '../sync/syncEngine' import type { NotificationChannel } from '../notifications/notificationTypes' import { getAgentName, getSessionName } from '../notifications/sessionInfo' +import type { SSEManager } from '../sse/sseManager' +import type { VisibilityTracker } from '../visibility/visibilityTracker' import type { PushPayload, PushService } from './pushService' export class PushNotificationChannel implements NotificationChannel { constructor( private readonly pushService: PushService, + private readonly sseManager: SSEManager, + private readonly visibilityTracker: VisibilityTracker, _appUrl: string ) {} @@ -31,6 +35,22 @@ export class PushNotificationChannel implements NotificationChannel { } } + const url = payload.data?.url ?? this.buildSessionPath(session.id) + if (this.visibilityTracker.hasVisibleConnection(session.namespace)) { + const delivered = await this.sseManager.sendToast(session.namespace, { + type: 'toast', + data: { + title: payload.title, + body: payload.body, + sessionId: session.id, + url + } + }) + if (delivered > 0) { + return + } + } + await this.pushService.sendToNamespace(session.namespace, payload) } @@ -53,6 +73,22 @@ export class PushNotificationChannel implements NotificationChannel { } } + const url = payload.data?.url ?? this.buildSessionPath(session.id) + if (this.visibilityTracker.hasVisibleConnection(session.namespace)) { + const delivered = await this.sseManager.sendToast(session.namespace, { + type: 'toast', + data: { + title: payload.title, + body: payload.body, + sessionId: session.id, + url + } + }) + if (delivered > 0) { + return + } + } + await this.pushService.sendToNamespace(session.namespace, payload) } diff --git a/server/src/sse/sseManager.test.ts b/server/src/sse/sseManager.test.ts index e624cee8..209c278f 100644 --- a/server/src/sse/sseManager.test.ts +++ b/server/src/sse/sseManager.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from 'bun:test' import { SSEManager } from './sseManager' import type { SyncEvent } from '../sync/syncEngine' +import { VisibilityTracker } from '../visibility/visibilityTracker' describe('SSEManager namespace filtering', () => { it('routes events to matching namespace', () => { - const manager = new SSEManager(0) + const manager = new SSEManager(0, new VisibilityTracker()) const receivedAlpha: SyncEvent[] = [] const receivedBeta: SyncEvent[] = [] @@ -35,7 +36,7 @@ describe('SSEManager namespace filtering', () => { }) it('broadcasts connection-changed to all namespaces', () => { - const manager = new SSEManager(0) + const manager = new SSEManager(0, new VisibilityTracker()) const received: Array<{ id: string; event: SyncEvent }> = [] manager.subscribe({ @@ -63,4 +64,58 @@ describe('SSEManager namespace filtering', () => { expect(received).toHaveLength(2) expect(received.map((entry) => entry.id).sort()).toEqual(['alpha', 'beta']) }) + + it('sends toast only to visible connections in a namespace', async () => { + const manager = new SSEManager(0, new VisibilityTracker()) + const received: Array<{ id: string; event: SyncEvent }> = [] + + manager.subscribe({ + id: 'visible', + namespace: 'alpha', + all: true, + visibility: 'visible', + send: (event) => { + received.push({ id: 'visible', event }) + }, + sendHeartbeat: () => {} + }) + + manager.subscribe({ + id: 'hidden', + namespace: 'alpha', + all: true, + visibility: 'hidden', + send: (event) => { + received.push({ id: 'hidden', event }) + }, + sendHeartbeat: () => {} + }) + + manager.subscribe({ + id: 'other', + namespace: 'beta', + all: true, + visibility: 'visible', + send: (event) => { + received.push({ id: 'other', event }) + }, + sendHeartbeat: () => {} + }) + + const toastEvent: Extract = { + type: 'toast', + data: { + title: 'Test', + body: 'Toast body', + sessionId: 'session-1', + url: '/sessions/session-1' + } + } + + const delivered = await manager.sendToast('alpha', toastEvent) + + expect(delivered).toBe(1) + expect(received).toHaveLength(1) + expect(received[0]?.id).toBe('visible') + }) }) diff --git a/server/src/sse/sseManager.ts b/server/src/sse/sseManager.ts index 740f9ea9..2bd267b9 100644 --- a/server/src/sse/sseManager.ts +++ b/server/src/sse/sseManager.ts @@ -1,4 +1,6 @@ import type { SyncEvent } from '../sync/syncEngine' +import type { VisibilityState } from '../visibility/visibilityTracker' +import type { VisibilityTracker } from '../visibility/visibilityTracker' export type SSESubscription = { id: string @@ -17,9 +19,11 @@ export class SSEManager { private readonly connections: Map = new Map() private heartbeatTimer: NodeJS.Timeout | null = null private readonly heartbeatMs: number + private readonly visibilityTracker: VisibilityTracker - constructor(heartbeatMs = 30_000) { + constructor(heartbeatMs = 30_000, visibilityTracker: VisibilityTracker) { this.heartbeatMs = heartbeatMs + this.visibilityTracker = visibilityTracker } subscribe(options: { @@ -28,6 +32,7 @@ export class SSEManager { all?: boolean sessionId?: string | null machineId?: string | null + visibility?: VisibilityState send: (event: SyncEvent) => void | Promise sendHeartbeat: () => void | Promise }): SSESubscription { @@ -42,6 +47,11 @@ export class SSEManager { } this.connections.set(subscription.id, subscription) + this.visibilityTracker.registerConnection( + subscription.id, + subscription.namespace, + options.visibility ?? 'hidden' + ) this.ensureHeartbeat() return { id: subscription.id, @@ -54,11 +64,46 @@ export class SSEManager { unsubscribe(id: string): void { this.connections.delete(id) + this.visibilityTracker.removeConnection(id) if (this.connections.size === 0) { this.stopHeartbeat() } } + async sendToast(namespace: string, event: Extract): Promise { + const deliveries: Array> = [] + for (const connection of this.connections.values()) { + if (connection.namespace !== namespace) { + continue + } + if (!this.visibilityTracker.isVisibleConnection(connection.id)) { + continue + } + + deliveries.push( + Promise.resolve(connection.send(event)) + .then(() => ({ id: connection.id, ok: true })) + .catch(() => ({ id: connection.id, ok: false })) + ) + } + + if (deliveries.length === 0) { + return 0 + } + + const results = await Promise.all(deliveries) + let successCount = 0 + for (const result of results) { + if (result.ok) { + successCount += 1 + continue + } + this.unsubscribe(result.id) + } + + return successCount + } + broadcast(event: SyncEvent): void { for (const connection of this.connections.values()) { if (!this.shouldSend(connection, event)) { @@ -73,6 +118,9 @@ export class SSEManager { stop(): void { this.stopHeartbeat() + for (const id of this.connections.keys()) { + this.visibilityTracker.removeConnection(id) + } this.connections.clear() } diff --git a/server/src/visibility/visibilityTracker.ts b/server/src/visibility/visibilityTracker.ts new file mode 100644 index 00000000..9752946e --- /dev/null +++ b/server/src/visibility/visibilityTracker.ts @@ -0,0 +1,75 @@ +export type VisibilityState = 'visible' | 'hidden' + +export class VisibilityTracker { + private readonly visibleConnections = new Map>() + private readonly subscriptionToNamespace = new Map() + + registerConnection(subscriptionId: string, namespace: string, state: VisibilityState): void { + this.removeConnection(subscriptionId) + this.subscriptionToNamespace.set(subscriptionId, namespace) + if (state === 'visible') { + this.addVisibleConnection(namespace, subscriptionId) + } + } + + setVisibility(subscriptionId: string, namespace: string, state: VisibilityState): boolean { + const trackedNamespace = this.subscriptionToNamespace.get(subscriptionId) + if (!trackedNamespace || trackedNamespace !== namespace) { + return false + } + + if (state === 'visible') { + this.addVisibleConnection(trackedNamespace, subscriptionId) + return true + } + + this.removeVisibleConnection(trackedNamespace, subscriptionId) + return true + } + + removeConnection(subscriptionId: string): void { + const namespace = this.subscriptionToNamespace.get(subscriptionId) + if (!namespace) { + return + } + + this.subscriptionToNamespace.delete(subscriptionId) + this.removeVisibleConnection(namespace, subscriptionId) + } + + hasVisibleConnection(namespace: string): boolean { + const visible = this.visibleConnections.get(namespace) + return Boolean(visible && visible.size > 0) + } + + isVisibleConnection(subscriptionId: string): boolean { + const namespace = this.subscriptionToNamespace.get(subscriptionId) + if (!namespace) { + return false + } + const visible = this.visibleConnections.get(namespace) + return Boolean(visible && visible.has(subscriptionId)) + } + + private addVisibleConnection(namespace: string, subscriptionId: string): void { + const existing = this.visibleConnections.get(namespace) + if (existing) { + existing.add(subscriptionId) + return + } + + this.visibleConnections.set(namespace, new Set([subscriptionId])) + } + + private removeVisibleConnection(namespace: string, subscriptionId: string): void { + const existing = this.visibleConnections.get(namespace) + if (!existing) { + return + } + + existing.delete(subscriptionId) + if (existing.size === 0) { + this.visibleConnections.delete(namespace) + } + } +} diff --git a/server/src/web/routes/events.ts b/server/src/web/routes/events.ts index 337afd6b..d2dd2281 100644 --- a/server/src/web/routes/events.ts +++ b/server/src/web/routes/events.ts @@ -1,8 +1,11 @@ import { Hono } from 'hono' import { streamSSE } from 'hono/streaming' import { randomUUID } from 'node:crypto' +import { z } from 'zod' import type { SSEManager } from '../../sse/sseManager' import type { SyncEngine } from '../../sync/syncEngine' +import type { VisibilityState } from '../../visibility/visibilityTracker' +import type { VisibilityTracker } from '../../visibility/visibilityTracker' import type { WebAppEnv } from '../middleware/auth' function parseOptionalId(value: string | undefined): string | null { @@ -19,9 +22,19 @@ function parseBoolean(value: string | undefined): boolean { return value === 'true' || value === '1' } +function parseVisibility(value: string | undefined): VisibilityState { + return value === 'visible' ? 'visible' : 'hidden' +} + +const visibilitySchema = z.object({ + subscriptionId: z.string().min(1), + visibility: z.enum(['visible', 'hidden']) +}) + export function createEventsRoutes( getSseManager: () => SSEManager | null, - getSyncEngine: () => SyncEngine | null + getSyncEngine: () => SyncEngine | null, + getVisibilityTracker: () => VisibilityTracker | null ): Hono { const app = new Hono() @@ -36,6 +49,7 @@ export function createEventsRoutes( const sessionId = parseOptionalId(query.sessionId) const machineId = parseOptionalId(query.machineId) const subscriptionId = randomUUID() + const visibility = parseVisibility(query.visibility) const namespace = c.get('namespace') if (sessionId || machineId) { @@ -70,12 +84,23 @@ export function createEventsRoutes( all, sessionId, machineId, + visibility, send: (event) => stream.writeSSE({ data: JSON.stringify(event) }), sendHeartbeat: async () => { await stream.write(': heartbeat\n\n') } }) + await stream.writeSSE({ + data: JSON.stringify({ + type: 'connection-changed', + data: { + status: 'connected', + subscriptionId + } + }) + }) + await new Promise((resolve) => { const done = () => resolve() c.req.raw.signal.addEventListener('abort', done, { once: true }) @@ -86,5 +111,26 @@ export function createEventsRoutes( }) }) + app.post('/visibility', async (c) => { + const tracker = getVisibilityTracker() + if (!tracker) { + return c.json({ error: 'Not connected' }, 503) + } + + const json = await c.req.json().catch(() => null) + const parsed = visibilitySchema.safeParse(json) + if (!parsed.success) { + return c.json({ error: 'Invalid body' }, 400) + } + + const namespace = c.get('namespace') + const updated = tracker.setVisibility(parsed.data.subscriptionId, namespace, parsed.data.visibility) + if (!updated) { + return c.json({ error: 'Subscription not found' }, 404) + } + + return c.json({ ok: true }) + }) + return app } diff --git a/server/src/web/server.ts b/server/src/web/server.ts index 506207d2..4224a2cd 100644 --- a/server/src/web/server.ts +++ b/server/src/web/server.ts @@ -18,6 +18,7 @@ import { createGitRoutes } from './routes/git' import { createCliRoutes } from './routes/cli' import { createPushRoutes } from './routes/push' import type { SSEManager } from '../sse/sseManager' +import type { VisibilityTracker } from '../visibility/visibilityTracker' import type { Server as BunServer } from 'bun' import type { Server as SocketEngine } from '@socket.io/bun-engine' import type { WebSocketData } from '@socket.io/bun-engine' @@ -54,6 +55,7 @@ function serveEmbeddedAsset(asset: EmbeddedWebAsset): Response { function createWebApp(options: { getSyncEngine: () => SyncEngine | null getSseManager: () => SSEManager | null + getVisibilityTracker: () => VisibilityTracker | null jwtSecret: Uint8Array store: Store vapidPublicKey: string @@ -82,7 +84,7 @@ function createWebApp(options: { app.route('/api', createBindRoutes(options.jwtSecret, options.store)) app.use('/api/*', createAuthMiddleware(options.jwtSecret)) - app.route('/api', createEventsRoutes(options.getSseManager, options.getSyncEngine)) + app.route('/api', createEventsRoutes(options.getSseManager, options.getSyncEngine, options.getVisibilityTracker)) app.route('/api', createSessionsRoutes(options.getSyncEngine)) app.route('/api', createMessagesRoutes(options.getSyncEngine)) app.route('/api', createPermissionsRoutes(options.getSyncEngine)) @@ -171,6 +173,7 @@ function createWebApp(options: { export async function startWebServer(options: { getSyncEngine: () => SyncEngine | null getSseManager: () => SSEManager | null + getVisibilityTracker: () => VisibilityTracker | null jwtSecret: Uint8Array store: Store vapidPublicKey: string @@ -181,6 +184,7 @@ export async function startWebServer(options: { const app = createWebApp({ getSyncEngine: options.getSyncEngine, getSseManager: options.getSseManager, + getVisibilityTracker: options.getVisibilityTracker, jwtSecret: options.jwtSecret, store: options.store, vapidPublicKey: options.vapidPublicKey, diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index a868bbb4..f9ccf570 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -154,10 +154,20 @@ export const SyncEventSchema = z.discriminatedUnion('type', [ type: z.literal('machine-updated'), data: z.unknown().optional() }), + SessionEventBaseSchema.extend({ + type: z.literal('toast'), + data: z.object({ + title: z.string(), + body: z.string(), + sessionId: z.string(), + url: z.string() + }) + }), SessionEventBaseSchema.extend({ type: z.literal('connection-changed'), data: z.object({ - status: z.string() + status: z.string(), + subscriptionId: z.string().optional() }).optional() }) ]) diff --git a/web/src/App.tsx b/web/src/App.tsx index b672835b..dfdd9903 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -9,6 +9,7 @@ import { useServerUrl } from '@/hooks/useServerUrl' import { useSSE } from '@/hooks/useSSE' import { useSyncingState } from '@/hooks/useSyncingState' import { usePushNotifications } from '@/hooks/usePushNotifications' +import { useVisibilityReporter } from '@/hooks/useVisibilityReporter' import { queryKeys } from '@/lib/query-keys' import { AppContextProvider } from '@/lib/app-context' import { fetchLatestMessages } from '@/lib/message-window-store' @@ -18,14 +19,28 @@ import { InstallPrompt } from '@/components/InstallPrompt' import { OfflineBanner } from '@/components/OfflineBanner' import { SyncingBanner } from '@/components/SyncingBanner' import { LoadingState } from '@/components/LoadingState' +import { ToastContainer } from '@/components/ToastContainer' +import { ToastProvider, useToast } from '@/lib/toast-context' +import type { SyncEvent } from '@/types/api' + +type ToastEvent = Extract export function App() { + return ( + + + + ) +} + +function AppInner() { const { serverUrl, baseUrl, setServerUrl, clearServerUrl } = useServerUrl() const { authSource, isLoading: isAuthSourceLoading, setAccessToken } = useAuthSource(baseUrl) const { token, api, isLoading: isAuthLoading, error: authError, needsBinding, bind } = useAuth(authSource, baseUrl) const goBack = useAppGoBack() const pathname = useLocation({ select: (location) => location.pathname }) const matchRoute = useMatchRoute() + const { addToast } = useToast() useEffect(() => { const tg = getTelegramWebApp() @@ -172,6 +187,14 @@ export function App() { }, [api, queryClient, selectedSessionId, startSync, endSync]) const handleSseEvent = useCallback(() => {}, []) + const handleToast = useCallback((event: ToastEvent) => { + addToast({ + title: event.data.title, + body: event.data.body, + sessionId: event.data.sessionId, + url: event.data.url + }) + }, [addToast]) const eventSubscription = useMemo(() => { if (selectedSessionId) { @@ -180,13 +203,20 @@ export function App() { return { all: true } }, [selectedSessionId]) - useSSE({ + const { subscriptionId } = useSSE({ enabled: Boolean(api && token), token: token ?? '', baseUrl, subscription: eventSubscription, onConnect: handleSseConnect, onEvent: handleSseEvent, + onToast: handleToast + }) + + useVisibilityReporter({ + api, + subscriptionId, + enabled: Boolean(api && token) }) // Loading auth source @@ -271,6 +301,7 @@ export function App() {
+ ) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index c3dd7ff7..ab4c2ac3 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -13,6 +13,7 @@ import type { PushVapidPublicKeyResponse, SlashCommandsResponse, SpawnResponse, + VisibilityPayload, SessionResponse, SessionsResponse } from '@/types/api' @@ -172,6 +173,13 @@ export class ApiClient { }) } + async setVisibility(payload: VisibilityPayload): Promise { + await this.request('/api/visibility', { + method: 'POST', + body: JSON.stringify(payload) + }) + } + async getSession(sessionId: string): Promise { return await this.request(`/api/sessions/${encodeURIComponent(sessionId)}`) } diff --git a/web/src/components/ToastContainer.tsx b/web/src/components/ToastContainer.tsx new file mode 100644 index 00000000..719dc86e --- /dev/null +++ b/web/src/components/ToastContainer.tsx @@ -0,0 +1,42 @@ +import { useNavigate } from '@tanstack/react-router' +import { Toast } from '@/components/ui/Toast' +import { useToast } from '@/lib/toast-context' + +export function ToastContainer() { + const navigate = useNavigate() + const { toasts, removeToast } = useToast() + + if (toasts.length === 0) { + return null + } + + return ( +
+ {toasts.map((toast) => ( + { + removeToast(toast.id) + if (toast.sessionId) { + void navigate({ + to: '/sessions/$sessionId', + params: { sessionId: toast.sessionId } + }) + return + } + if (toast.url) { + void navigate({ to: toast.url }) + } + }} + onClose={() => removeToast(toast.id)} + /> + ))} +
+ ) +} diff --git a/web/src/components/ui/Toast.tsx b/web/src/components/ui/Toast.tsx new file mode 100644 index 00000000..31263cc4 --- /dev/null +++ b/web/src/components/ui/Toast.tsx @@ -0,0 +1,52 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from '@/lib/utils' + +const toastVariants = cva( + 'pointer-events-auto w-full max-w-sm rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] text-[var(--app-fg)] shadow-lg', + { + variants: { + variant: { + default: 'border-[var(--app-border)] bg-[var(--app-bg)]' + } + }, + defaultVariants: { + variant: 'default' + } + } +) + +export type ToastProps = React.HTMLAttributes & + VariantProps & { + title: string + body: string + onClose?: () => void +} + +export function Toast({ title, body, onClose, className, variant, ...props }: ToastProps) { + const handleClose = (event: React.MouseEvent) => { + event.stopPropagation() + onClose?.() + } + + return ( +
+
+
+
{title}
+
{body}
+
+ {onClose ? ( + + ) : null} +
+
+ ) +} diff --git a/web/src/hooks/useSSE.ts b/web/src/hooks/useSSE.ts index bdd675e9..c131a1a5 100644 --- a/web/src/hooks/useSSE.ts +++ b/web/src/hooks/useSSE.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useQueryClient } from '@tanstack/react-query' import type { SyncEvent } from '@/types/api' import { queryKeys } from '@/lib/query-keys' @@ -14,9 +14,26 @@ type SSESubscription = { machineId?: string } -function buildEventsUrl(baseUrl: string, token: string, subscription: SSESubscription): string { +type VisibilityState = 'visible' | 'hidden' + +type ToastEvent = Extract + +function getVisibilityState(): VisibilityState { + if (typeof document === 'undefined') { + return 'hidden' + } + return document.visibilityState === 'visible' ? 'visible' : 'hidden' +} + +function buildEventsUrl( + baseUrl: string, + token: string, + subscription: SSESubscription, + visibility: VisibilityState +): string { const params = new URLSearchParams() params.set('token', token) + params.set('visibility', visibility) if (subscription.all) { params.set('all', 'true') } @@ -44,13 +61,16 @@ export function useSSE(options: { onConnect?: () => void onDisconnect?: (reason: string) => void onError?: (error: unknown) => void -}): void { + onToast?: (event: ToastEvent) => void +}): { subscriptionId: string | null } { const queryClient = useQueryClient() const onEventRef = useRef(options.onEvent) const onConnectRef = useRef(options.onConnect) const onDisconnectRef = useRef(options.onDisconnect) const onErrorRef = useRef(options.onError) + const onToastRef = useRef(options.onToast) const eventSourceRef = useRef(null) + const [subscriptionId, setSubscriptionId] = useState(null) useEffect(() => { onEventRef.current = options.onEvent @@ -68,6 +88,10 @@ export function useSSE(options: { onDisconnectRef.current = options.onDisconnect }, [options.onDisconnect]) + useEffect(() => { + onToastRef.current = options.onToast + }, [options.onToast]) + const subscription = options.subscription ?? {} const subscriptionKey = useMemo(() => { return `${subscription.all ? '1' : '0'}|${subscription.sessionId ?? ''}|${subscription.machineId ?? ''}` @@ -77,14 +101,31 @@ export function useSSE(options: { if (!options.enabled) { eventSourceRef.current?.close() eventSourceRef.current = null + setSubscriptionId(null) return } - const url = buildEventsUrl(options.baseUrl, options.token, subscription) + setSubscriptionId(null) + const url = buildEventsUrl(options.baseUrl, options.token, subscription, getVisibilityState()) const eventSource = new EventSource(url) eventSourceRef.current = eventSource const handleSyncEvent = (event: SyncEvent) => { + if (event.type === 'connection-changed') { + const data = event.data + if (data && typeof data === 'object' && 'subscriptionId' in data) { + const nextId = (data as { subscriptionId?: unknown }).subscriptionId + if (typeof nextId === 'string' && nextId.length > 0) { + setSubscriptionId(nextId) + } + } + } + + if (event.type === 'toast') { + onToastRef.current?.(event) + return + } + if (event.type === 'message-received') { ingestIncomingMessages(event.sessionId, [event.message]) } @@ -145,6 +186,9 @@ export function useSSE(options: { if (eventSourceRef.current === eventSource) { eventSourceRef.current = null } + setSubscriptionId(null) } }, [options.baseUrl, options.enabled, options.token, subscriptionKey, queryClient]) + + return { subscriptionId } } diff --git a/web/src/hooks/useVisibilityReporter.ts b/web/src/hooks/useVisibilityReporter.ts new file mode 100644 index 00000000..03d4533b --- /dev/null +++ b/web/src/hooks/useVisibilityReporter.ts @@ -0,0 +1,122 @@ +import { useEffect, useRef } from 'react' +import type { ApiClient } from '@/api/client' + +type VisibilityState = 'visible' | 'hidden' + +function getVisibilityState(): VisibilityState { + if (typeof document === 'undefined') { + return 'hidden' + } + return document.visibilityState === 'visible' ? 'visible' : 'hidden' +} + +export function useVisibilityReporter(options: { + api: ApiClient | null + subscriptionId: string | null + enabled?: boolean +}): void { + const lastStateRef = useRef(null) + const lastSubscriptionRef = useRef(null) + const pendingStateRef = useRef(null) + const inFlightRef = useRef(false) + const retryTimerRef = useRef | null>(null) + + const clearRetry = () => { + if (retryTimerRef.current) { + clearTimeout(retryTimerRef.current) + retryTimerRef.current = null + } + } + + useEffect(() => { + if (options.enabled === false) { + clearRetry() + return + } + if (!options.api || !options.subscriptionId) { + lastStateRef.current = null + lastSubscriptionRef.current = options.subscriptionId ?? null + pendingStateRef.current = null + clearRetry() + return + } + + const api = options.api + const subscriptionId = options.subscriptionId + if (lastSubscriptionRef.current !== subscriptionId) { + lastSubscriptionRef.current = subscriptionId + lastStateRef.current = null + pendingStateRef.current = null + clearRetry() + } + + const flush = () => { + if (lastSubscriptionRef.current !== subscriptionId) { + return + } + const desired = pendingStateRef.current + if (!desired) { + return + } + if (inFlightRef.current) { + return + } + if (retryTimerRef.current) { + return + } + if (lastStateRef.current === desired) { + pendingStateRef.current = null + return + } + + inFlightRef.current = true + let hadError = false + const activeSubscription = subscriptionId + void api.setVisibility({ + subscriptionId, + visibility: desired + }).then(() => { + if (lastSubscriptionRef.current !== activeSubscription) { + return + } + lastStateRef.current = desired + pendingStateRef.current = null + clearRetry() + }).catch((error) => { + if (lastSubscriptionRef.current !== activeSubscription) { + return + } + hadError = true + console.error('Failed to update visibility:', error) + if (!retryTimerRef.current) { + retryTimerRef.current = setTimeout(() => { + retryTimerRef.current = null + flush() + }, 2000) + } + }).finally(() => { + inFlightRef.current = false + if (hadError || retryTimerRef.current) { + return + } + if (pendingStateRef.current && pendingStateRef.current !== lastStateRef.current) { + flush() + } + }) + } + + const report = () => { + const state = getVisibilityState() + pendingStateRef.current = state + flush() + } + + report() + document.addEventListener('visibilitychange', report) + return () => { + document.removeEventListener('visibilitychange', report) + clearRetry() + inFlightRef.current = false + } + }, [options.api, options.enabled, options.subscriptionId]) +} diff --git a/web/src/lib/toast-context.tsx b/web/src/lib/toast-context.tsx new file mode 100644 index 00000000..4386d9d2 --- /dev/null +++ b/web/src/lib/toast-context.tsx @@ -0,0 +1,77 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' + +export type Toast = { + id: string + title: string + body: string + sessionId: string + url: string +} + +export type ToastContextValue = { + toasts: Toast[] + addToast: (toast: Omit) => void + removeToast: (id: string) => void +} + +const ToastContext = createContext(null) +const TOAST_DURATION_MS = 6000 + +function createToastId(): string { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return crypto.randomUUID() + } + return `toast_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` +} + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]) + const timersRef = useRef>>(new Map()) + + useEffect(() => { + return () => { + for (const timer of timersRef.current.values()) { + clearTimeout(timer) + } + timersRef.current.clear() + } + }, []) + + const removeToast = useCallback((id: string) => { + setToasts((prev) => prev.filter((toast) => toast.id !== id)) + const timer = timersRef.current.get(id) + if (timer) { + clearTimeout(timer) + timersRef.current.delete(id) + } + }, []) + + const addToast = useCallback((toast: Omit) => { + const id = createToastId() + setToasts((prev) => [...prev, { id, ...toast }]) + const timer = setTimeout(() => { + removeToast(id) + }, TOAST_DURATION_MS) + timersRef.current.set(id, timer) + }, [removeToast]) + + const value = useMemo(() => ({ + toasts, + addToast, + removeToast + }), [toasts, addToast, removeToast]) + + return ( + + {children} + + ) +} + +export function useToast(): ToastContextValue { + const ctx = useContext(ToastContext) + if (!ctx) { + throw new Error('useToast must be used within ToastProvider') + } + return ctx +} diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 047c2b4c..1327c508 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -154,4 +154,9 @@ export type PushVapidPublicKeyResponse = { publicKey: string } +export type VisibilityPayload = { + subscriptionId: string + visibility: 'visible' | 'hidden' +} + export type SyncEvent = ProtocolSyncEvent