Files
swyxweb/frontend/src/hooks/useSwyxTray.ts
T
SvenandClaude Opus 5 ae397e1ea3 first commit
SwyxWeb: React-Frontend und Spring-Boot-Backend mit SwyxTray-Mock.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 11:03:34 +02:00

175 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { SwyxTrayClient, type ConnectionStatus, type RawMessage } from '../swyx/SwyxTrayClient'
import { mergeSnapshot, type CallEvent, type SnapshotMessage } from '../swyx/protocol'
export interface LogEntry extends RawMessage {
id: number
}
/** Zustand der App selbst, aus dem Snapshot unabhängig von den Leitungen. */
export interface TrayState {
connected?: boolean
serverUp?: boolean
overall?: string
statusText?: string
user?: string
server?: string
}
const MAX_LOG_ENTRIES = 300
const MAX_HISTORY = 50
/**
* Bindet den SwyxTrayClient an React: Verbindungsstatus, Zustand der Leitungen,
* Ereignisverlauf und die Telefonie-Kommandos.
*/
export function useSwyxTray() {
const [status, setStatus] = useState<ConnectionStatus>('idle')
const [error, setError] = useState<string | null>(null)
const [log, setLog] = useState<LogEntry[]>([])
const [history, setHistory] = useState<CallEvent[]>([])
// Belegte Leitungen, nach Leitungsnummer; jeder Snapshot schreibt sie neu.
const [lines, setLines] = useState<Record<number, CallEvent>>({})
const [tray, setTray] = useState<TrayState>({})
const [busy, setBusy] = useState(false)
const clientRef = useRef<SwyxTrayClient | null>(null)
const logIdRef = useRef(0)
// Stand der Leitungen für den Snapshot-Vergleich.
const linesRef = useRef<Record<number, CallEvent>>({})
if (clientRef.current === null) {
clientRef.current = new SwyxTrayClient()
}
const client = clientRef.current
useEffect(() => {
const unsubscribe = [
client.on('status', (next) => {
setStatus(next)
if (next === 'open') setError(null)
// Der Leitungszustand der alten Verbindung ist nach dem Abbruch wertlos;
// die App schickt beim Verbinden ohnehin einen Snapshot.
if (next === 'closed') {
linesRef.current = {}
setLines({})
setTray({})
}
}),
client.on('error', (message) => setError(message)),
client.on('raw', (message) => {
setLog((entries) => {
const next = [...entries, { ...message, id: logIdRef.current++ }]
return next.length > MAX_LOG_ENTRIES ? next.slice(next.length - MAX_LOG_ENTRIES) : next
})
}),
client.on('snapshot', (snapshot: SnapshotMessage) => {
setTray({
connected: snapshot.connected,
serverUp: snapshot.serverUp,
overall: snapshot.overall,
statusText: snapshot.statusText,
user: snapshot.user,
server: snapshot.server,
})
// Der Vollzustand ersetzt den bisherigen Stand; die Anruf-Ereignisse für
// Verlauf und Benachrichtigung entstehen aus dem Vergleich mit ihm.
// Der Vergleich läuft über die Ref, nicht im State-Updater der wird
// im StrictMode doppelt ausgeführt und würde den Verlauf verdoppeln.
const { lines: next, events } = mergeSnapshot(linesRef.current, snapshot.lines)
linesRef.current = next
setLines(next)
if (events.length > 0) {
setHistory((entries) => [...[...events].reverse(), ...entries].slice(0, MAX_HISTORY))
}
}),
]
return () => {
unsubscribe.forEach((off) => off())
}
}, [client])
// Beim Verlassen der Seite Verbindung und Timer aufräumen.
useEffect(() => () => client.dispose(), [client])
const connect = useCallback((url: string) => client.connect(url), [client])
const disconnect = useCallback(() => client.disconnect(), [client])
/** Führt ein Kommando aus und übernimmt Fehleranzeige und Busy-Zustand. */
const run = useCallback(async <T,>(action: () => Promise<T>): Promise<T | null> => {
setBusy(true)
setError(null)
try {
return await action()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
return null
} finally {
setBusy(false)
}
}, [])
const dial = useCallback((number: string) => run(() => client.dial(number)), [client, run])
const answer = useCallback((line: number) => run(() => client.answer(line)), [client, run])
const hangup = useCallback((line: number) => run(() => client.hangup(line)), [client, run])
const refresh = useCallback(() => run(() => client.requestStatus()), [client, run])
const focusWindow = useCallback(
(title: string, url?: string) => run(() => client.focusWindow(title, url)),
[client, run],
)
// Tabs des Firefox-Plugins; die App reicht diese drei Kommandos nur durch.
const listTabs = useCallback(() => run(() => client.listTabs()), [client, run])
const openTab = useCallback((url: string) => run(() => client.openTab(url)), [client, run])
const closeTab = useCallback((tabId: number) => run(() => client.closeTab(tabId)), [client, run])
const sendRaw = useCallback(
(text: string) => {
try {
client.sendRaw(text)
return true
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
return false
}
},
[client],
)
const clearLog = useCallback(() => setLog([]), [])
const calls = useMemo(
() => Object.values(lines).sort((a, b) => a.line - b.line),
[lines],
)
/** Erste klingelnde Leitung eines eingehenden Anrufs. */
const ringingCall = useMemo(
() => calls.find((call) => call.event === 'incoming') ?? null,
[calls],
)
return {
status,
error,
log,
history,
calls,
ringingCall,
tray,
busy,
connect,
disconnect,
dial,
answer,
hangup,
refresh,
focusWindow,
listTabs,
openTab,
closeTab,
sendRaw,
clearLog,
}
}