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>
263 lines
9.1 KiB
TypeScript
263 lines
9.1 KiB
TypeScript
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()}
|
||
/>
|
||
)}
|
||
</>
|
||
)
|
||
}
|