Files
swyxweb/frontend/src/components/ContactsPanel.tsx
T
SvenandClaude Opus 5 01594a1f3d Adressdaten-Bereich, Mock hinter einem Profil, Webhook
Drei Stränge, die sich über dieselben Dateien ziehen (HomePage, index.css,
README, application.properties) und deshalb nicht getrennt committet werden
können, ohne einen nicht übersetzbaren Zwischenstand zu hinterlassen:

Adressdaten: Der Bereich zeigt den gesamten Bestand des globalen Telefon-
buchs - zuerst aus dem Adress-Cache der App (Protokoll 8), sonst über das
Kommando "addresses" und ersatzweise aus rund 110 Einzelabfragen hinter
einem Wartedialog. Der Mock gibt denselben Bestand heraus und lässt sich
über /api/mock/address-cache leeren, um die Rückfallebene zu prüfen.

Mock hinter dem Profil "mock": Seine Bohnen (/ws und /api/mock/**) hängen
jetzt an @Profile, sind ohne das Profil also nicht vorhanden. Damit kann
der Mock nicht versehentlich in einer Produktivumgebung mitlaufen; das
Container-Image setzt das Profil nicht. Je ein Test hält beide Richtungen
fest.

Webhook: POST /api/webhook nimmt beliebiges JSON eines fremden Systems an
und reicht es über Server-Sent Events an die offenen Browser weiter, wo es
der neue Bereich "Webhook" unverändert anzeigt. Der WebSocket kam dafür
nicht in Frage - er gehört der SwyxTray-App. Das Backend hält die letzten
50 Nachrichten vor und liefert sie beim Wiederverbinden anhand der
Last-Event-ID nach; ein Heartbeat und X-Accel-Buffering: no halten die
Verbindung durch Reverse Proxys hindurch offen. Ein Token (app.webhook.token)
ist vorgesehen, aber nicht voreingestellt.

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

263 lines
9.1 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 { describeContact, type Contact } from '../swyx/protocol'
import {
filterContacts,
sortContacts,
type Directory,
type DirectoryProgress,
type SweepOptions,
} from '../swyx/directory'
import LoadingDialog from './LoadingDialog'
interface Props {
/** Panel ist sichtbar erst dann wird geladen. */
active: boolean
disabled: boolean
/**
* Adress-Cache, den die App unaufgefordert schickt. `null` heißt „noch keine
* Cache-Nachricht"; eine leere Liste heißt „Cache ist leer".
*/
cache: Contact[] | null
onLoad: (options: SweepOptions) => Promise<Directory | null>
onDial: (number: string) => void
}
const STORAGE_FILTER = 'swyxweb.contacts.filter'
function stored(key: string): string {
try {
return localStorage.getItem(key) ?? ''
} catch {
// Privater Modus o. Ä. dann eben ohne Gedächtnis.
return ''
}
}
function remember(key: string, value: string): void {
try {
localStorage.setItem(key, value)
} catch {
// absichtlich still
}
}
/**
* Adressdaten des Swyx-Clients der **gesamte** Bestand des globalen
* Telefonbuchs.
*
* Drei Wege, in dieser Reihenfolge:
*
* 1. Der **Cache**, den die App seit Protokoll 8 beim Verbinden von selbst
* schickt. Er liegt dann schon vor, bevor der Bereich geöffnet wird kein
* Laden, kein Wartedialog.
* 2. Sonst beim Öffnen das Kommando `addresses`: derselbe Cache auf Anfrage.
* 3. Kennt die App das Kommando nicht oder ist der Cache leer, werden rund 110
* Einzelabfragen zusammengesetzt. Das dauert einige Sekunden und läuft hinter
* einem Wartedialog.
*
* Danach liegt der Bestand im Browser: gefiltert wird ohne weitere Abfrage.
*/
export default function ContactsPanel({ active, disabled, cache, onLoad, onDial }: Props) {
const [directory, setDirectory] = useState<Directory | null>(null)
const [progress, setProgress] = useState<DirectoryProgress | null>(null)
const [filter, setFilter] = useState(() => stored(STORAGE_FILTER))
// Nach einem Verbindungsabbruch soll beim nächsten Öffnen neu geladen werden.
const loadedRef = useRef(false)
const abortRef = useRef<AbortController | null>(null)
// Zählt die Ladevorgänge. Trifft der Cache ein, während die Einzelabfragen
// noch laufen, darf deren abgebrochenes Ergebnis den Cache nicht überschreiben.
const loadIdRef = useRef(0)
/** Macht einen laufenden Ladevorgang ungültig und beendet ihn. */
const supersede = useCallback(() => {
loadIdRef.current += 1
abortRef.current?.abort()
abortRef.current = null
setProgress(null)
}, [])
const load = useCallback(async () => {
// Ein zweiter Durchlauf würde nur dieselben Abfragen doppelt stellen.
supersede()
const ticket = loadIdRef.current
const controller = new AbortController()
abortRef.current = controller
setProgress({ done: 0, total: 0, found: 0 })
try {
const result = await onLoad({
signal: controller.signal,
onProgress: (next) => {
if (ticket === loadIdRef.current) setProgress(next)
},
})
if (ticket !== loadIdRef.current) return
// null heißt: Fehler der steht bereits in der Fehleranzeige der Seite.
if (result) setDirectory(result)
} finally {
if (ticket === loadIdRef.current) {
abortRef.current = null
setProgress(null)
}
}
}, [onLoad, supersede])
// Der Cache der App hat Vorrang: Er kommt unaufgefordert und ist damit oft
// schon da, bevor der Bereich überhaupt geöffnet wird. Ein leerer Cache ist
// dagegen keine Antwort dann bleibt es beim Laden weiter unten.
useEffect(() => {
if (!cache || cache.length === 0) return
loadedRef.current = true
supersede()
setDirectory({
contacts: sortContacts(cache),
source: 'cache',
queries: 0,
complete: true,
aborted: false,
})
}, [cache, supersede])
useEffect(() => {
if (disabled) {
// Ohne Verbindung ist der Bestand veraltet; er wird beim nächsten Mal neu geholt.
supersede()
loadedRef.current = false
setDirectory(null)
return
}
if (!active || loadedRef.current) return
loadedRef.current = true
void load()
}, [active, disabled, load, supersede])
// Ein laufender Durchlauf soll nicht weiterfragen, wenn die Seite verschwindet.
useEffect(() => () => abortRef.current?.abort(), [])
const visible = useMemo(
() => (directory ? filterContacts(directory.contacts, filter) : []),
[directory, filter],
)
const loading = progress !== null
return (
<>
<div className="card__header">
<h2>Adressdaten</h2>
<button
type="button"
className="button button--ghost"
onClick={() => void load()}
disabled={disabled || loading}
>
{loading ? 'Lädt …' : 'Neu laden'}
</button>
</div>
{disabled ? (
<p className="note">Erst mit der SwyxTray-App verbinden.</p>
) : (
<>
{/* Die `.row` ist nicht bloß Zierde: `.field` wächst (`flex: 1 1 260px`)
und würde als direktes Kind der Karte einer Spalte in die *Höhe*
wachsen und den freien Platz aufsaugen. In der Zeile wächst es in
die Breite, wie in allen anderen Bereichen auch. */}
<div className="row">
<label className="field">
<span className="field__label">Filtern</span>
<input
className="field__input"
type="search"
value={filter}
spellCheck={false}
autoComplete="off"
placeholder="Name, Rufnummer oder Kürzel"
onChange={(e) => {
setFilter(e.target.value)
remember(STORAGE_FILTER, e.target.value.trim())
}}
/>
</label>
</div>
{directory === null ? (
<p className="note">{loading ? 'Adressdaten werden geladen …' : 'Noch nicht geladen.'}</p>
) : (
<>
<p className="note">
{filter.trim()
? `${visible.length} von ${directory.contacts.length} Einträgen`
: `${directory.contacts.length} Einträge`}
{directory.source === 'cache'
? ' · aus dem Adress-Cache der App'
: ` · aus ${directory.queries} Einzelabfragen zusammengesetzt`}
</p>
{!directory.complete && (
<p className="note note--error">
{directory.aborted
? 'Abgebrochen der Bestand ist unvollständig.'
: 'Die App hat mindestens eine Abfrage gekürzt; es können Einträge fehlen.'}{' '}
Neu laden" versucht es erneut.
</p>
)}
{visible.length === 0 ? (
<p className="note">Kein Eintrag zu „{filter.trim()}".</p>
) : (
<ul className="contacts">
{visible.map((contact, index) => (
// Namen sind nicht eindeutig dieselbe Person kommt mit
// mehreren Durchwahlen vor; deshalb die Position mit hinein.
<li
key={`${contact.number ?? ''}-${contact.name ?? ''}-${index}`}
className="contacts__item"
>
<span className="contacts__info">
<span className="contacts__name">{describeContact(contact)}</span>
{contact.description && (
<span className="contacts__description">{contact.description}</span>
)}
</span>
{contact.number && <span className="contacts__number">{contact.number}</span>}
{contact.number && (
<button
type="button"
className="button button--small"
onClick={() => onDial(contact.number!)}
>
Anrufen
</button>
)}
</li>
))}
</ul>
)}
</>
)}
</>
)}
<p className="note">
Der Bestand kommt aus dem Telefonbuch des Swyx-Clients auf dem Rechner der SwyxTray-App
aus deren Adress-Cache, den sie beim Verbinden von selbst schickt. Kennt die App den Cache
noch nicht oder ist er leer, wird der Bestand ersatzweise aus rund 110 Suchabfragen
zusammengesetzt. Danach liegt er vollständig im Browser; das Filtern läuft ohne weitere
Abfrage.
</p>
{loading && (
<LoadingDialog
title="Adressdaten werden geladen"
done={progress.done}
total={progress.total}
detail={progress.found > 0 ? `${progress.found} Einträge bisher` : undefined}
onCancel={() => abortRef.current?.abort()}
/>
)}
</>
)
}