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 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(null) const [progress, setProgress] = useState(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(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 ( <>

Adressdaten

{disabled ? (

Erst mit der SwyxTray-App verbinden.

) : ( <> {/* 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. */}
{directory === null ? (

{loading ? 'Adressdaten werden geladen …' : 'Noch nicht geladen.'}

) : ( <>

{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`}

{!directory.complete && (

{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.

)} {visible.length === 0 ? (

Kein Eintrag zu „{filter.trim()}".

) : (
    {visible.map((contact, index) => ( // Namen sind nicht eindeutig – dieselbe Person kommt mit // mehreren Durchwahlen vor; deshalb die Position mit hinein.
  • {describeContact(contact)} {contact.description && ( {contact.description} )} {contact.number && {contact.number}} {contact.number && ( )}
  • ))}
)} )} )}

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.

{loading && ( 0 ? `${progress.found} Einträge bisher` : undefined} onCancel={() => abortRef.current?.abort()} /> )} ) }