SwyxWeb: React-Frontend und Spring-Boot-Backend mit SwyxTray-Mock. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
175 lines
5.6 KiB
TypeScript
175 lines
5.6 KiB
TypeScript
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,
|
||
}
|
||
}
|