Compare commits
2
Commits
97c3778180
...
a9b40c7e6a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9b40c7e6a | ||
|
|
715720fbb3 |
@@ -1,6 +1,5 @@
|
|||||||
package de.appcreation.swyxweb.storage;
|
package de.appcreation.swyxweb.storage;
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -32,20 +31,15 @@ import org.springframework.data.mongodb.core.mapping.Document;
|
|||||||
*
|
*
|
||||||
* @param id Kennung des Dokuments, vergibt die MongoDB
|
* @param id Kennung des Dokuments, vergibt die MongoDB
|
||||||
* @param number Rufnummer, so wie sie gewählt wird – erkennt die Dublette
|
* @param number Rufnummer, so wie sie gewählt wird – erkennt die Dublette
|
||||||
* @param receivedAt Eingangszeit im Backend, bei bekannten Einträgen die des
|
|
||||||
* letzten Eingangs
|
|
||||||
* @param source woher der Eintrag stammt: {@code kunden}, {@code kuriere},
|
|
||||||
* {@code webhook} (generischer Webhook) oder {@code swyxtray}
|
|
||||||
* @param name Anzeigename, z. B. "SYSGEN GmbH" oder "Rainer Peters (HH1003)"
|
* @param name Anzeigename, z. B. "SYSGEN GmbH" oder "Rainer Peters (HH1003)"
|
||||||
* @param description Zusatz, z. B. "Kunde | SYSGEN, SYSTEME UND | NL Bremen | csc 100164"
|
* @param description Zusatz, z. B. "Kunde | SYSGEN, SYSTEME UND | NL Bremen | csc 100164"
|
||||||
* oder "Kurier | NL Hamburg | PKW | cr 2024"
|
* oder "Kurier | NL Hamburg | PKW | cr 2024" – die Herkunft
|
||||||
|
* eines Eintrags steht damit im Eintrag selbst
|
||||||
*/
|
*/
|
||||||
@Document("addresses")
|
@Document("addresses")
|
||||||
public record AddressEntry(
|
public record AddressEntry(
|
||||||
@Id String id,
|
@Id String id,
|
||||||
String number,
|
String number,
|
||||||
Instant receivedAt,
|
|
||||||
String source,
|
|
||||||
String name,
|
String name,
|
||||||
String description) {
|
String description) {
|
||||||
|
|
||||||
@@ -72,7 +66,7 @@ public record AddressEntry(
|
|||||||
if (!node.isObject()) return;
|
if (!node.isObject()) return;
|
||||||
String number = text(node, "number");
|
String number = text(node, "number");
|
||||||
if (number == null) return;
|
if (number == null) return;
|
||||||
entries.add(new AddressEntry(null, number, null, null, text(node, "name"), text(node, "description")));
|
entries.add(new AddressEntry(null, number, text(node, "name"), text(node, "description")));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Leere Zeichenketten schickt die SwyxTray-App als {@code ""}; hier werden sie {@code null}. */
|
/** Leere Zeichenketten schickt die SwyxTray-App als {@code ""}; hier werden sie {@code null}. */
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package de.appcreation.swyxweb.storage;
|
package de.appcreation.swyxweb.storage;
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
import jakarta.annotation.PreDestroy;
|
import jakarta.annotation.PreDestroy;
|
||||||
@@ -50,32 +50,46 @@ public class ArchiveService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legt Adressdaten-Einträge in der Sammlung {@code addresses} ab. Die
|
* Legt Adressdaten-Einträge in der Sammlung {@code addresses} ab – alle
|
||||||
* Sammlung ist ein Verzeichnis, kein Protokoll: Ein Eintrag zu einer
|
* Quellen (Webhooks Kunden/Kuriere, SwyxTray-Telefonbuch) in derselben
|
||||||
* bekannten Rufnummer wird aktualisiert statt verdoppelt – die
|
* Sammlung, je Eintrag nur Rufnummer, Name und Beschreibung. Die Sammlung
|
||||||
* SwyxTray-App schickt bei jeder Cache-Änderung das ganze Telefonbuch.
|
* ist ein Verzeichnis, kein Protokoll: Ein Eintrag zu einer bekannten
|
||||||
*
|
* Rufnummer wird aktualisiert statt verdoppelt – die SwyxTray-App schickt
|
||||||
* @param receivedAt Eingangszeit im Backend
|
* bei jeder Cache-Änderung das ganze Telefonbuch.
|
||||||
* @param source {@code kunden}, {@code kuriere}, {@code webhook}
|
|
||||||
* (generischer Webhook) oder {@code swyxtray}
|
|
||||||
*/
|
*/
|
||||||
public void saveAddresses(Instant receivedAt, String source, List<AddressEntry> entries) {
|
public void saveAddresses(List<AddressEntry> entries) {
|
||||||
if (entries.isEmpty()) return;
|
if (entries.isEmpty()) return;
|
||||||
// Upsert über die Rufnummer: aktualisiert den bekannten Eintrag oder
|
// Upsert über die Rufnummer: aktualisiert den bekannten Eintrag oder
|
||||||
// legt ihn an; die Dokument-Kennung vergibt dabei die MongoDB.
|
// legt ihn an; die Dokument-Kennung vergibt dabei die MongoDB.
|
||||||
submit("Adressdaten", () -> entries.forEach(entry -> mongo.upsert(
|
submit("Adressdaten", () -> entries.forEach(entry -> mongo.upsert(
|
||||||
new Query(Criteria.where("number").is(entry.number())),
|
new Query(Criteria.where("number").is(entry.number())),
|
||||||
new Update()
|
new Update()
|
||||||
.set("receivedAt", receivedAt)
|
|
||||||
.set("source", source)
|
|
||||||
.set("name", entry.name())
|
.set("name", entry.name())
|
||||||
.set("description", entry.description()),
|
.set("description", entry.description())
|
||||||
|
// Altbestand angleichen: Diese Felder wurden früher
|
||||||
|
// mitgeschrieben und sollen aus der Sammlung verschwinden.
|
||||||
|
.unset("receivedAt")
|
||||||
|
.unset("source"),
|
||||||
AddressEntry.class)));
|
AddressEntry.class)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Die gespeicherten Adressdaten, nach Namen sortiert. Liest direkt aus der MongoDB. */
|
/**
|
||||||
public List<AddressEntry> addresses() {
|
* Die gespeicherten Adressdaten, nach Namen sortiert – gelesen direkt aus
|
||||||
return mongo.find(new Query().with(Sort.by("name")), AddressEntry.class);
|
* der MongoDB, ohne Zwischenstand im Backend oder Browser. Ein Suchbegriff
|
||||||
|
* wird als Teilzeichenkette in Name, Rufnummer und Beschreibung gesucht,
|
||||||
|
* ohne Beachtung der Groß-/Kleinschreibung.
|
||||||
|
*/
|
||||||
|
public List<AddressEntry> addresses(String query) {
|
||||||
|
Query find = new Query().with(Sort.by("name"));
|
||||||
|
if (query != null && !query.isBlank()) {
|
||||||
|
// Der Begriff ist Text, kein Muster – Sonderzeichen werden zitiert.
|
||||||
|
String regex = Pattern.quote(query.trim());
|
||||||
|
find.addCriteria(new Criteria().orOperator(
|
||||||
|
Criteria.where("name").regex(regex, "i"),
|
||||||
|
Criteria.where("number").regex(regex, "i"),
|
||||||
|
Criteria.where("description").regex(regex, "i")));
|
||||||
|
}
|
||||||
|
return mongo.find(find, AddressEntry.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.appcreation.swyxweb.web;
|
package de.appcreation.swyxweb.web;
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import tools.jackson.databind.JsonNode;
|
import tools.jackson.databind.JsonNode;
|
||||||
@@ -11,6 +10,7 @@ import org.springframework.web.bind.annotation.GetMapping;
|
|||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
@@ -53,13 +53,17 @@ public class AddressController {
|
|||||||
if (entries.isEmpty()) {
|
if (entries.isEmpty()) {
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Keine Adressdaten in der Nutzlast.");
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Keine Adressdaten in der Nutzlast.");
|
||||||
}
|
}
|
||||||
archive.saveAddresses(Instant.now(), "swyxtray", entries);
|
archive.saveAddresses(entries);
|
||||||
return entries.size();
|
return entries.size();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Die gespeicherten Adressdaten – gelesen direkt aus der MongoDB. */
|
/**
|
||||||
|
* Die gespeicherten Adressdaten – gelesen direkt aus der MongoDB, jede
|
||||||
|
* Abfrage frisch. Mit {@code ?q=…} wird in der Datenbank gesucht
|
||||||
|
* (Teilzeichenkette in Name, Rufnummer und Beschreibung).
|
||||||
|
*/
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<AddressEntry> list() {
|
public List<AddressEntry> list(@RequestParam(name = "q", required = false) String query) {
|
||||||
return archive.addresses();
|
return archive.addresses(query);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ public class WebhookController {
|
|||||||
return accept(payload, token, null);
|
return accept(payload, token, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Adressdaten der Kunden; abgelegt mit {@code source: "kunden"}. */
|
/** Adressdaten der Kunden; abgelegt in der Sammlung {@code addresses}. */
|
||||||
@PostMapping(path = "/kunden", consumes = MediaType.APPLICATION_JSON_VALUE)
|
@PostMapping(path = "/kunden", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||||
public Receipt receiveKunden(
|
public Receipt receiveKunden(
|
||||||
@RequestBody JsonNode payload,
|
@RequestBody JsonNode payload,
|
||||||
@@ -89,7 +89,7 @@ public class WebhookController {
|
|||||||
return accept(payload, token, "kunden");
|
return accept(payload, token, "kunden");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Adressdaten der Kuriere; abgelegt mit {@code source: "kuriere"}. */
|
/** Adressdaten der Kuriere; abgelegt in der Sammlung {@code addresses}. */
|
||||||
@PostMapping(path = "/kuriere", consumes = MediaType.APPLICATION_JSON_VALUE)
|
@PostMapping(path = "/kuriere", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||||
public Receipt receiveKuriere(
|
public Receipt receiveKuriere(
|
||||||
@RequestBody JsonNode payload,
|
@RequestBody JsonNode payload,
|
||||||
|
|||||||
@@ -133,22 +133,21 @@ public class WebhookService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Die URL bestimmt die Ablage: Kunden und Kuriere sind Adressdaten (die
|
* Die URL bestimmt die Ablage: Kunden und Kuriere sind Adressdaten, Jobs
|
||||||
* Herkunft wird als {@code source} vermerkt), Jobs sind Jobs. Beim
|
* sind Jobs. Beim generischen Webhook ({@code channel == null}) entscheidet
|
||||||
* generischen Webhook ({@code channel == null}) entscheidet die Nutzlast
|
* die Nutzlast selbst: {@code "type":"job"} ist ein Job, alles andere sind
|
||||||
* selbst: {@code "type":"job"} ist ein Job, alles andere sind Adressdaten.
|
* Adressdaten. Alle Adressdaten landen in derselben Sammlung.
|
||||||
*/
|
*/
|
||||||
private void archiveByChannel(WebhookEvent event, String channel) {
|
private void archiveByChannel(WebhookEvent event, String channel) {
|
||||||
JsonNode payload = event.payload();
|
JsonNode payload = event.payload();
|
||||||
switch (channel == null ? "" : channel) {
|
switch (channel == null ? "" : channel) {
|
||||||
case "kunden", "kuriere" ->
|
case "kunden", "kuriere" -> archive.saveAddresses(AddressEntry.manyFrom(payload));
|
||||||
archive.saveAddresses(event.receivedAt(), channel, AddressEntry.manyFrom(payload));
|
|
||||||
case "jobs" -> archive.saveJob(JobEntry.from(payload, mapper, event.receivedAt()));
|
case "jobs" -> archive.saveJob(JobEntry.from(payload, mapper, event.receivedAt()));
|
||||||
default -> {
|
default -> {
|
||||||
if (JobEntry.isJob(payload)) {
|
if (JobEntry.isJob(payload)) {
|
||||||
archive.saveJob(JobEntry.from(payload, mapper, event.receivedAt()));
|
archive.saveJob(JobEntry.from(payload, mapper, event.receivedAt()));
|
||||||
} else {
|
} else {
|
||||||
archive.saveAddresses(event.receivedAt(), "webhook", AddressEntry.manyFrom(payload));
|
archive.saveAddresses(AddressEntry.manyFrom(payload));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
package de.appcreation.swyxweb;
|
package de.appcreation.swyxweb;
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
|
||||||
import static org.mockito.ArgumentMatchers.argThat;
|
import static org.mockito.ArgumentMatchers.argThat;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||||
@@ -16,6 +18,7 @@ import org.springframework.http.MediaType;
|
|||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import de.appcreation.swyxweb.storage.AddressEntry;
|
||||||
import de.appcreation.swyxweb.storage.ArchiveService;
|
import de.appcreation.swyxweb.storage.ArchiveService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,7 +47,21 @@ class AddressControllerTests {
|
|||||||
.andExpect(status().isAccepted())
|
.andExpect(status().isAccepted())
|
||||||
.andExpect(jsonPath("$").value(2));
|
.andExpect(jsonPath("$").value(2));
|
||||||
|
|
||||||
verify(archive).saveAddresses(any(), eq("swyxtray"), argThat(entries -> entries.size() == 2));
|
verify(archive).saveAddresses(argThat(entries -> entries.size() == 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Suche läuft in der Datenbank – der Begriff wird durchgereicht. */
|
||||||
|
@Test
|
||||||
|
void listPassesQueryToArchive() throws Exception {
|
||||||
|
when(archive.addresses("muster"))
|
||||||
|
.thenReturn(List.of(new AddressEntry("1", "+493012345", "Muster GmbH", null)));
|
||||||
|
|
||||||
|
mvc.perform(get("/api/addresses").param("q", "muster"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$[0].name").value("Muster GmbH"))
|
||||||
|
.andExpect(jsonPath("$[0].number").value("+493012345"));
|
||||||
|
|
||||||
|
verify(archive).addresses("muster");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
package de.appcreation.swyxweb;
|
package de.appcreation.swyxweb;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
|
||||||
import static org.mockito.ArgumentMatchers.argThat;
|
import static org.mockito.ArgumentMatchers.argThat;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
@@ -118,8 +116,10 @@ class WebhookControllerTests {
|
|||||||
.containsExactly("kunden", "kuriere", "jobs");
|
.containsExactly("kunden", "kuriere", "jobs");
|
||||||
|
|
||||||
// Kunden und Kuriere landen als Adressdaten in der Ablage, der Job als Job.
|
// Kunden und Kuriere landen als Adressdaten in der Ablage, der Job als Job.
|
||||||
verify(archive).saveAddresses(any(), eq("kunden"), argThat(entries -> entries.size() == 1));
|
verify(archive).saveAddresses(argThat(entries ->
|
||||||
verify(archive).saveAddresses(any(), eq("kuriere"), argThat(entries -> entries.size() == 1));
|
entries.size() == 1 && "SYSGEN GmbH".equals(entries.getFirst().name())));
|
||||||
|
verify(archive).saveAddresses(argThat(entries ->
|
||||||
|
entries.size() == 1 && "Rainer Peters (HH1003)".equals(entries.getFirst().name())));
|
||||||
verify(archive).saveJob(argThat(job -> job.id() == 4711));
|
verify(archive).saveJob(argThat(job -> job.id() == 4711));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +132,7 @@ class WebhookControllerTests {
|
|||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
assertThat(service.history().getFirst().channel()).isNull();
|
assertThat(service.history().getFirst().channel()).isNull();
|
||||||
verify(archive).saveAddresses(any(), eq("webhook"), argThat(entries -> entries.size() == 1));
|
verify(archive).saveAddresses(argThat(entries -> entries.size() == 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ohne Token in der Konfiguration darf der Kopf fehlen. */
|
/** Ohne Token in der Konfiguration darf der Kopf fehlen. */
|
||||||
|
|||||||
@@ -8,6 +8,34 @@ import type { Contact } from './swyx/protocol'
|
|||||||
* diese Adressdaten sonst nicht heran. Fire-and-forget wie bei den
|
* diese Adressdaten sonst nicht heran. Fire-and-forget wie bei den
|
||||||
* Anrufdaten: Ist das Backend nicht erreichbar, läuft die Anzeige weiter.
|
* Anrufdaten: Ist das Backend nicht erreichbar, läuft die Anzeige weiter.
|
||||||
*/
|
*/
|
||||||
|
/** Ein Eintrag der Sammlung `addresses`, wie ihn `GET /api/addresses` liefert. */
|
||||||
|
interface StoredAddress {
|
||||||
|
number?: string | null
|
||||||
|
name?: string | null
|
||||||
|
description?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fragt die Adress-Ablage (MongoDB-Sammlung `addresses`) über das Backend ab –
|
||||||
|
* den zusammengeführten Bestand aus den Webhooks (Kunden, Kuriere) und dem
|
||||||
|
* SwyxTray-Telefonbuch. Gesucht wird **in der Datenbank** (Teilzeichenkette in
|
||||||
|
* Name, Rufnummer und Beschreibung); im Browser wird nichts vorgehalten.
|
||||||
|
*/
|
||||||
|
export async function fetchAddresses(query = ''): Promise<Contact[]> {
|
||||||
|
const wanted = query.trim()
|
||||||
|
const url = wanted ? `/api/addresses?q=${encodeURIComponent(wanted)}` : '/api/addresses'
|
||||||
|
const response = await fetch(url)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Adress-Ablage nicht abrufbar (/api/addresses antwortete mit HTTP ${response.status}).`)
|
||||||
|
}
|
||||||
|
const entries = (await response.json()) as StoredAddress[]
|
||||||
|
return entries.map((entry) => ({
|
||||||
|
name: entry.name ?? undefined,
|
||||||
|
number: entry.number ?? undefined,
|
||||||
|
description: entry.description ?? undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
export function reportAddresses(contacts: Contact[]): void {
|
export function reportAddresses(contacts: Contact[]): void {
|
||||||
if (contacts.length === 0) return
|
if (contacts.length === 0) return
|
||||||
void fetch('/api/addresses', {
|
void fetch('/api/addresses', {
|
||||||
|
|||||||
@@ -1,145 +1,142 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { describeContact, type Contact } from '../swyx/protocol'
|
import { describeContact, type Contact } from '../swyx/protocol'
|
||||||
import {
|
import type { Directory, DirectoryProgress, SweepOptions } from '../swyx/directory'
|
||||||
filterContacts,
|
import { fetchAddresses } from '../addresses'
|
||||||
sortContacts,
|
|
||||||
type Directory,
|
|
||||||
type DirectoryProgress,
|
|
||||||
type SweepOptions,
|
|
||||||
} from '../swyx/directory'
|
|
||||||
import LoadingDialog from './LoadingDialog'
|
import LoadingDialog from './LoadingDialog'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Panel ist sichtbar – erst dann wird geladen. */
|
/** Panel ist sichtbar – erst dann wird abgefragt. */
|
||||||
active: boolean
|
active: boolean
|
||||||
|
/** Keine Verbindung zur SwyxTray-App – Anrufen und Telefonbuch-Abgleich entfallen. */
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
/**
|
/**
|
||||||
* Adress-Cache, den die App unaufgefordert schickt. `null` heißt „noch keine
|
* Adress-Cache, den die App unaufgefordert schickt. Er wird hier nicht
|
||||||
* Cache-Nachricht"; eine leere Liste heißt „Cache ist leer".
|
* angezeigt, sondern ist das Signal, die Ablage neu abzufragen – das Backend
|
||||||
|
* hat ihn gerade hineingeschrieben.
|
||||||
*/
|
*/
|
||||||
cache: Contact[] | null
|
cache: Contact[] | null
|
||||||
onLoad: (options: SweepOptions) => Promise<Directory | null>
|
onLoad: (options: SweepOptions) => Promise<Directory | null>
|
||||||
onDial: (number: string) => void
|
onDial: (number: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_FILTER = 'swyxweb.contacts.filter'
|
/** So lange bekommt das Backend Zeit, den Telefonbuch-Push wegzuschreiben. */
|
||||||
|
const ARCHIVE_WRITE_DELAY_MS = 1500
|
||||||
|
|
||||||
function stored(key: string): string {
|
/** Entprellung der Eingabe – erst dann geht die Abfrage an die Datenbank. */
|
||||||
try {
|
const SEARCH_DEBOUNCE_MS = 300
|
||||||
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
|
* Adressdaten aus der **Adress-Ablage des Backends** (MongoDB-Sammlung
|
||||||
* Telefonbuchs.
|
* `addresses`) – dem zusammengeführten Bestand aus den Webhooks (Kunden,
|
||||||
|
* Kuriere) und dem Telefonbuch des Swyx-Clients.
|
||||||
*
|
*
|
||||||
* Drei Wege, in dieser Reihenfolge:
|
* Es gibt keinen Bestand im Browser: Jede Eingabe im Suchfeld fragt –
|
||||||
|
* entprellt – die Datenbank ab, gesucht wird dort als Teilzeichenkette in
|
||||||
|
* Name, Rufnummer und Beschreibung. Angezeigt wird immer das frische
|
||||||
|
* Abfrageergebnis; der Bereich funktioniert damit auch ohne Verbindung zur
|
||||||
|
* SwyxTray-App.
|
||||||
*
|
*
|
||||||
* 1. Der **Cache**, den die App seit Protokoll 8 beim Verbinden von selbst
|
* Das Telefonbuch der App fließt auf zwei Wegen in die Ablage:
|
||||||
* 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.
|
* 1. Der **Cache-Push** beim Verbinden (Protokoll 8) – die Startseite meldet
|
||||||
|
* ihn ans Backend; das Panel fragt die Ablage danach neu ab.
|
||||||
|
* 2. „Neu laden" bei bestehender Verbindung: erst das Telefonbuch der App
|
||||||
|
* holen (Cache oder rund 110 Einzelabfragen), das Backend legt es ab,
|
||||||
|
* dann die Ablage abfragen.
|
||||||
*/
|
*/
|
||||||
export default function ContactsPanel({ active, disabled, cache, onLoad, onDial }: Props) {
|
export default function ContactsPanel({ active, disabled, cache, onLoad, onDial }: Props) {
|
||||||
const [directory, setDirectory] = useState<Directory | null>(null)
|
// Das jeweils letzte Abfrageergebnis – kein Bestand, nur die Anzeige.
|
||||||
|
const [results, setResults] = useState<Contact[] | null>(null)
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
// Warnung aus dem letzten Telefonbuch-Abgleich: dort fehlen evtl. Einträge.
|
||||||
|
const [incomplete, setIncomplete] = useState(false)
|
||||||
const [progress, setProgress] = useState<DirectoryProgress | null>(null)
|
const [progress, setProgress] = useState<DirectoryProgress | null>(null)
|
||||||
const [filter, setFilter] = useState(() => stored(STORAGE_FILTER))
|
// Bewusst nicht gemerkt: Nach einem Neustart beginnt die Suche leer.
|
||||||
|
const [filter, setFilter] = useState('')
|
||||||
|
|
||||||
// Nach einem Verbindungsabbruch soll beim nächsten Öffnen neu geladen werden.
|
|
||||||
const loadedRef = useRef(false)
|
|
||||||
const abortRef = useRef<AbortController | null>(null)
|
const abortRef = useRef<AbortController | null>(null)
|
||||||
// Zählt die Ladevorgänge. Trifft der Cache ein, während die Einzelabfragen
|
// Zählt die Abfragen: Eine überholte darf das Ergebnis einer neueren nicht
|
||||||
// noch laufen, darf deren abgebrochenes Ergebnis den Cache nicht überschreiben.
|
// mehr überschreiben – Antworten kommen nicht zwingend in Reihenfolge.
|
||||||
const loadIdRef = useRef(0)
|
const queryIdRef = useRef(0)
|
||||||
|
// Der aktuelle Suchbegriff für Abfragen außerhalb des Eingabe-Effekts
|
||||||
|
// (Cache-Push, „Neu laden") – ohne den Effekt neu anzustoßen.
|
||||||
|
const filterRef = useRef(filter)
|
||||||
|
filterRef.current = filter
|
||||||
|
|
||||||
/** Macht einen laufenden Ladevorgang ungültig und beendet ihn. */
|
/** Fragt die Ablage mit dem aktuellen Suchbegriff ab. */
|
||||||
const supersede = useCallback(() => {
|
const search = useCallback(async () => {
|
||||||
loadIdRef.current += 1
|
const ticket = ++queryIdRef.current
|
||||||
abortRef.current?.abort()
|
try {
|
||||||
abortRef.current = null
|
const entries = await fetchAddresses(filterRef.current)
|
||||||
setProgress(null)
|
if (ticket !== queryIdRef.current) return
|
||||||
|
setResults(entries)
|
||||||
|
setLoadError(null)
|
||||||
|
} catch (e) {
|
||||||
|
if (ticket !== queryIdRef.current) return
|
||||||
|
setLoadError(e instanceof Error ? e.message : String(e))
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
// Jede Eingabe fragt die Datenbank ab – entprellt; die erste Abfrage nach
|
||||||
// Ein zweiter Durchlauf würde nur dieselben Abfragen doppelt stellen.
|
// dem Öffnen läuft sofort. Beim erneuten Öffnen des Bereichs ebenfalls
|
||||||
supersede()
|
// frisch abfragen, damit nie ein alter Stand stehen bleibt.
|
||||||
const ticket = loadIdRef.current
|
useEffect(() => {
|
||||||
|
if (!active) return
|
||||||
|
const timer = setTimeout(() => void search(), results === null ? 0 : SEARCH_DEBOUNCE_MS)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
// `results` absichtlich nicht in den Abhängigkeiten: Es würde nach jeder
|
||||||
|
// Antwort eine weitere Abfrage anstoßen.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [active, filter, search])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „Neu laden": Bei bestehender Verbindung zuerst das Telefonbuch der App
|
||||||
|
* holen – das Backend schreibt es dabei in die Ablage –, danach in jedem
|
||||||
|
* Fall die Ablage frisch abfragen.
|
||||||
|
*/
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
abortRef.current?.abort()
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
abortRef.current = controller
|
abortRef.current = controller
|
||||||
|
|
||||||
setProgress({ done: 0, total: 0, found: 0 })
|
|
||||||
try {
|
try {
|
||||||
|
if (!disabled) {
|
||||||
|
setProgress({ done: 0, total: 0, found: 0 })
|
||||||
const result = await onLoad({
|
const result = await onLoad({
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
onProgress: (next) => {
|
onProgress: (next) => {
|
||||||
if (ticket === loadIdRef.current) setProgress(next)
|
if (abortRef.current === controller) setProgress(next)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (ticket !== loadIdRef.current) return
|
if (abortRef.current !== controller) return
|
||||||
// null heißt: Fehler – der steht bereits in der Fehleranzeige der Seite.
|
// null heißt: Fehler – der steht bereits in der Fehleranzeige der Seite.
|
||||||
if (result) setDirectory(result)
|
setIncomplete(result !== null && !result.complete)
|
||||||
|
// Das Backend schreibt nebenbei; kurz warten, damit die Abfrage den
|
||||||
|
// frischen Stand schon sieht.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, ARCHIVE_WRITE_DELAY_MS))
|
||||||
|
if (abortRef.current !== controller) return
|
||||||
|
}
|
||||||
|
await search()
|
||||||
} finally {
|
} finally {
|
||||||
if (ticket === loadIdRef.current) {
|
if (abortRef.current === controller) {
|
||||||
abortRef.current = null
|
abortRef.current = null
|
||||||
setProgress(null)
|
setProgress(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [onLoad, supersede])
|
}, [disabled, onLoad, search])
|
||||||
|
|
||||||
// Der Cache der App hat Vorrang: Er kommt unaufgefordert und ist damit oft
|
// Telefonbuch-Push der App: Die Startseite hat ihn ans Backend gemeldet;
|
||||||
// schon da, bevor der Bereich überhaupt geöffnet wird. Ein leerer Cache ist
|
// nach einer kurzen Schreibfrist die Ablage neu abfragen.
|
||||||
// dagegen keine Antwort – dann bleibt es beim Laden weiter unten.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!cache || cache.length === 0) return
|
if (!cache || cache.length === 0) return
|
||||||
loadedRef.current = true
|
const timer = setTimeout(() => void search(), ARCHIVE_WRITE_DELAY_MS)
|
||||||
supersede()
|
return () => clearTimeout(timer)
|
||||||
setDirectory({
|
}, [cache, search])
|
||||||
contacts: sortContacts(cache),
|
|
||||||
source: 'cache',
|
|
||||||
queries: 0,
|
|
||||||
complete: true,
|
|
||||||
aborted: false,
|
|
||||||
})
|
|
||||||
}, [cache, supersede])
|
|
||||||
|
|
||||||
useEffect(() => {
|
// Ein laufender Telefonbuch-Abgleich soll nicht weiterfragen, wenn die
|
||||||
if (disabled) {
|
// Seite verschwindet.
|
||||||
// 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(), [])
|
useEffect(() => () => abortRef.current?.abort(), [])
|
||||||
|
|
||||||
const visible = useMemo(
|
|
||||||
() => (directory ? filterContacts(directory.contacts, filter) : []),
|
|
||||||
[directory, filter],
|
|
||||||
)
|
|
||||||
|
|
||||||
const loading = progress !== null
|
const loading = progress !== null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -149,24 +146,20 @@ export default function ContactsPanel({ active, disabled, cache, onLoad, onDial
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="button button--ghost"
|
className="button button--ghost"
|
||||||
onClick={() => void load()}
|
onClick={() => void reload()}
|
||||||
disabled={disabled || loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? 'Lädt …' : 'Neu laden'}
|
{loading ? 'Lädt …' : 'Neu laden'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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`)
|
{/* 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*
|
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
|
wachsen und den freien Platz aufsaugen. In der Zeile wächst es in
|
||||||
die Breite, wie in allen anderen Bereichen auch. */}
|
die Breite, wie in allen anderen Bereichen auch. */}
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span className="field__label">Filtern</span>
|
<span className="field__label">Suchen</span>
|
||||||
<input
|
<input
|
||||||
className="field__input"
|
className="field__input"
|
||||||
type="search"
|
type="search"
|
||||||
@@ -174,41 +167,38 @@ export default function ContactsPanel({ active, disabled, cache, onLoad, onDial
|
|||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
placeholder="Name, Rufnummer oder Kürzel"
|
placeholder="Name, Rufnummer oder Kürzel"
|
||||||
onChange={(e) => {
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
setFilter(e.target.value)
|
|
||||||
remember(STORAGE_FILTER, e.target.value.trim())
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{directory === null ? (
|
{loadError && <p className="note note--error">{loadError}</p>}
|
||||||
<p className="note">{loading ? 'Adressdaten werden geladen …' : 'Noch nicht geladen.'}</p>
|
|
||||||
|
{results === null ? (
|
||||||
|
!loadError && <p className="note">Adressdaten werden abgefragt …</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="note">
|
<p className="note">
|
||||||
{filter.trim()
|
{filter.trim() ? `${results.length} Treffer` : `${results.length} Einträge`}
|
||||||
? `${visible.length} von ${directory.contacts.length} Einträgen`
|
{' · direkt aus der Adress-Ablage des Backends'}
|
||||||
: `${directory.contacts.length} Einträge`}
|
|
||||||
{directory.source === 'cache'
|
|
||||||
? ' · aus dem Adress-Cache der App'
|
|
||||||
: ` · aus ${directory.queries} Einzelabfragen zusammengesetzt`}
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{!directory.complete && (
|
{incomplete && (
|
||||||
<p className="note note--error">
|
<p className="note note--error">
|
||||||
{directory.aborted
|
Der letzte Telefonbuch-Abgleich war unvollständig; es können Einträge fehlen.
|
||||||
? '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.
|
„Neu laden" versucht es erneut.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{visible.length === 0 ? (
|
{results.length === 0 ? (
|
||||||
<p className="note">Kein Eintrag zu „{filter.trim()}".</p>
|
<p className="note">
|
||||||
|
{filter.trim()
|
||||||
|
? `Kein Eintrag zu „${filter.trim()}".`
|
||||||
|
: 'Die Ablage ist noch leer – sie füllt sich über die Webhooks und über das Telefonbuch der SwyxTray-App.'}
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="contacts">
|
<ul className="contacts">
|
||||||
{visible.map((contact, index) => (
|
{results.map((contact, index) => (
|
||||||
// Namen sind nicht eindeutig – dieselbe Person kommt mit
|
// Namen sind nicht eindeutig – dieselbe Person kommt mit
|
||||||
// mehreren Durchwahlen vor; deshalb die Position mit hinein.
|
// mehreren Durchwahlen vor; deshalb die Position mit hinein.
|
||||||
<li
|
<li
|
||||||
@@ -227,6 +217,7 @@ export default function ContactsPanel({ active, disabled, cache, onLoad, onDial
|
|||||||
type="button"
|
type="button"
|
||||||
className="button button--small"
|
className="button button--small"
|
||||||
onClick={() => onDial(contact.number!)}
|
onClick={() => onDial(contact.number!)}
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
Anrufen
|
Anrufen
|
||||||
</button>
|
</button>
|
||||||
@@ -237,20 +228,18 @@ export default function ContactsPanel({ active, disabled, cache, onLoad, onDial
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="note">
|
<p className="note">
|
||||||
Der Bestand kommt aus dem Telefonbuch des Swyx-Clients auf dem Rechner der SwyxTray-App –
|
Jede Suche fragt die Adress-Ablage des Backends (MongoDB) direkt ab – die per Webhook
|
||||||
aus deren Adress-Cache, den sie beim Verbinden von selbst schickt. Kennt die App den Cache
|
gemeldeten Kunden und Kuriere sowie das Telefonbuch des Swyx-Clients; im Browser wird
|
||||||
noch nicht oder ist er leer, wird der Bestand ersatzweise aus rund 110 Suchabfragen
|
kein Bestand vorgehalten. Das Telefonbuch fließt bei bestehender Verbindung zur
|
||||||
zusammengesetzt. Danach liegt er vollständig im Browser; das Filtern läuft ohne weitere
|
SwyxTray-App in die Ablage ein – beim Verbinden von selbst, bei „Neu laden" auf
|
||||||
Abfrage.
|
Anforderung.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<LoadingDialog
|
<LoadingDialog
|
||||||
title="Adressdaten werden geladen"
|
title="Telefonbuch wird abgeglichen"
|
||||||
done={progress.done}
|
done={progress.done}
|
||||||
total={progress.total}
|
total={progress.total}
|
||||||
detail={progress.found > 0 ? `${progress.found} Einträge bisher` : undefined}
|
detail={progress.found > 0 ? `${progress.found} Einträge bisher` : undefined}
|
||||||
|
|||||||
@@ -65,10 +65,7 @@ export default function WebhookPanel({ events, status, error, onClear }: Props)
|
|||||||
{error && <p className="note note--error">{error}</p>}
|
{error && <p className="note note--error">{error}</p>}
|
||||||
|
|
||||||
{events.length === 0 ? (
|
{events.length === 0 ? (
|
||||||
<p className="note">
|
<p className="note">Noch keine Nachricht eingegangen.</p>
|
||||||
Noch keine Nachricht eingegangen. Ein fremdes System schickt Adressdaten an{' '}
|
|
||||||
<code>POST /api/webhook</code>; sie erscheinen hier ohne Zutun.
|
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
<ul className="webhook">
|
<ul className="webhook">
|
||||||
{events.map((event) => (
|
{events.map((event) => (
|
||||||
@@ -83,12 +80,6 @@ export default function WebhookPanel({ events, status, error, onClear }: Props)
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="note">
|
|
||||||
Der Webhook nimmt beliebiges JSON an – Objekt wie Liste – und zeigt es hier unverändert.
|
|
||||||
Der Verlauf liegt im Speicher des Backends und ist nach dessen Neustart leer; „Leeren"
|
|
||||||
verwirft ihn auch dort.
|
|
||||||
</p>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,36 @@ export const DEFAULT_WS_PATH = '/ws'
|
|||||||
export const DEFAULT_WS_URL =
|
export const DEFAULT_WS_URL =
|
||||||
import.meta.env.VITE_WS_URL ?? `ws://${DEFAULT_WS_HOST}:${DEFAULT_WS_PORT}${DEFAULT_WS_PATH}`
|
import.meta.env.VITE_WS_URL ?? `ws://${DEFAULT_WS_HOST}:${DEFAULT_WS_PORT}${DEFAULT_WS_PATH}`
|
||||||
|
|
||||||
|
const SAVED_WS_URL_KEY = 'swyxweb.wsUrl'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zuletzt erfolgreich verbundene Adresse. localStorage kann fehlen oder gesperrt
|
||||||
|
* sein (Privatmodus, Browser-Einstellung) – dann wird schlicht nichts gemerkt.
|
||||||
|
*/
|
||||||
|
export function loadSavedWsUrl(): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(SAVED_WS_URL_KEY)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveWsUrl(url: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SAVED_WS_URL_KEY, url)
|
||||||
|
} catch {
|
||||||
|
// Ohne Speicher kein Wiederverbinden nach Neustart – mehr geht nicht verloren.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSavedWsUrl(): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(SAVED_WS_URL_KEY)
|
||||||
|
} catch {
|
||||||
|
// Siehe saveWsUrl.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface ClientConfig {
|
export interface ClientConfig {
|
||||||
websocketUrl: string
|
websocketUrl: string
|
||||||
host: string
|
host: string
|
||||||
|
|||||||
@@ -131,11 +131,6 @@ code {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page__footer {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react'
|
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react'
|
||||||
import { DEFAULT_WS_URL, fetchClientConfig } from '../config'
|
import { DEFAULT_WS_URL, fetchClientConfig, clearSavedWsUrl, loadSavedWsUrl, saveWsUrl } from '../config'
|
||||||
import { useSwyxTray } from '../hooks/useSwyxTray'
|
import { useSwyxTray } from '../hooks/useSwyxTray'
|
||||||
import { useCallNotifications } from '../hooks/useCallNotifications'
|
import { useCallNotifications } from '../hooks/useCallNotifications'
|
||||||
import { useWebhook } from '../hooks/useWebhook'
|
import { useWebhook } from '../hooks/useWebhook'
|
||||||
@@ -68,13 +68,25 @@ export default function HomePage() {
|
|||||||
|
|
||||||
// Der Benutzer soll seine eingetippte Adresse nicht durch die Backend-Antwort verlieren.
|
// Der Benutzer soll seine eingetippte Adresse nicht durch die Backend-Antwort verlieren.
|
||||||
const urlTouched = useRef(false)
|
const urlTouched = useRef(false)
|
||||||
|
// Adresse des laufenden Verbindungsversuchs; gemerkt wird sie erst, wenn die
|
||||||
|
// Verbindung tatsächlich zustande kommt.
|
||||||
|
const pendingUrl = useRef<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Mit der zuletzt erfolgreich verbundenen Adresse direkt wieder verbinden –
|
||||||
|
// die Backend-Konfiguration muss dann nicht gefragt werden.
|
||||||
|
const saved = loadSavedWsUrl()
|
||||||
|
if (saved) {
|
||||||
|
urlTouched.current = true
|
||||||
|
setUrl(saved)
|
||||||
|
pendingUrl.current = saved
|
||||||
|
tray.connect(saved)
|
||||||
|
return
|
||||||
|
}
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
fetchClientConfig(controller.signal)
|
fetchClientConfig(controller.signal)
|
||||||
.then((backendUrl) => {
|
.then((backendUrl) => {
|
||||||
if (controller.signal.aborted) return
|
if (controller.signal.aborted) return
|
||||||
setConfigNote(`Adresse vom Backend übernommen: ${backendUrl}`)
|
|
||||||
if (!urlTouched.current) setUrl(backendUrl)
|
if (!urlTouched.current) setUrl(backendUrl)
|
||||||
})
|
})
|
||||||
.catch((e: unknown) => {
|
.catch((e: unknown) => {
|
||||||
@@ -83,12 +95,28 @@ export default function HomePage() {
|
|||||||
setConfigNote(`Backend-Konfiguration nicht abrufbar (${message}) – Standardadresse wird verwendet.`)
|
setConfigNote(`Backend-Konfiguration nicht abrufbar (${message}) – Standardadresse wird verwendet.`)
|
||||||
})
|
})
|
||||||
return () => controller.abort()
|
return () => controller.abort()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- nur beim Laden der Seite
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Erst die zustande gekommene Verbindung zählt: dann die Adresse merken, damit
|
||||||
|
// sie nach Verbindungsverlust oder Neustart automatisch wieder aufgebaut wird.
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === 'open' && pendingUrl.current) saveWsUrl(pendingUrl.current)
|
||||||
|
}, [status])
|
||||||
|
|
||||||
function handleConnect(event: FormEvent) {
|
function handleConnect(event: FormEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const target = url.trim()
|
const target = url.trim()
|
||||||
if (target) tray.connect(target)
|
if (!target) return
|
||||||
|
pendingUrl.current = target
|
||||||
|
tray.connect(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manuelles Trennen heißt auch: nach einem Neustart nicht wieder verbinden.
|
||||||
|
function handleDisconnect() {
|
||||||
|
clearSavedWsUrl()
|
||||||
|
pendingUrl.current = null
|
||||||
|
tray.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSendRaw(event: FormEvent) {
|
function handleSendRaw(event: FormEvent) {
|
||||||
@@ -104,7 +132,7 @@ export default function HomePage() {
|
|||||||
<StadtboteSignet className="brand__signet" />
|
<StadtboteSignet className="brand__signet" />
|
||||||
<div>
|
<div>
|
||||||
<h1 className="brand__name">Stadtbote</h1>
|
<h1 className="brand__name">Stadtbote</h1>
|
||||||
<p className="page__subtitle">SwyxWeb · Telefonie über die SwyxTray-App</p>
|
<p className="page__subtitle">SwyxWeb</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={status} />
|
<StatusBadge status={status} />
|
||||||
@@ -228,7 +256,7 @@ export default function HomePage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="button"
|
className="button"
|
||||||
onClick={tray.disconnect}
|
onClick={handleDisconnect}
|
||||||
disabled={!isBusyConnection}
|
disabled={!isBusyConnection}
|
||||||
>
|
>
|
||||||
Trennen
|
Trennen
|
||||||
@@ -292,13 +320,6 @@ export default function HomePage() {
|
|||||||
</section>
|
</section>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="page__footer">
|
|
||||||
SwyxTray · Kommandos <code>call</code> / <code>answer</code> / <code>hangup</code> /{' '}
|
|
||||||
<code>tabs</code> / <code>opentab</code> / <code>closetab</code> / <code>contacts</code> ·
|
|
||||||
Zustand über{' '}
|
|
||||||
<code>snapshot</code> · Standardziel <code>{DEFAULT_WS_URL}</code>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user