1 Commits

Author SHA1 Message Date
5c76e12142 chore(prototype): state-model prototype for the realtime frontend
Throwaway, kept as a primary source rather than on main. Single self-contained
HTML file: free-play buttons plus six guided walkthroughs over a pure reducer.

It settled two things the spec had wrong or missing:

- The client must supply the message id. With a server-generated id there is
  nothing to reconcile the optimistically rendered message against when it
  comes back over the realtime channel, and it renders twice. Both cases are
  reproducible via the toggle at the top.
- An unread *count* cannot be derived without fetching, because seq counts
  events and not messages. Hence: fetch for DMs, a plain dot for channels.
2026-08-14 18:19:18 +02:00
14 changed files with 524 additions and 7692 deletions

5088
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,493 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Prototyp — Frontend-Zustandsmodell nach dem WebSocket-Umbau</title>
<style>
:root {
--akzent: #b4491f;
--grund: #fbfaf8;
--papier: #ffffff;
--linie: #e3ded6;
--text: #23201d;
--leise: #7d7469;
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 2.5rem 1.5rem 6rem;
background: var(--grund); color: var(--text);
font: 16px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
}
.huelle { max-width: 66rem; margin: 0 auto; }
h1 { font-size: 1.55rem; margin: 0 0 .4rem; letter-spacing: -.01em; }
h2 { font-size: .82rem; text-transform: uppercase; letter-spacing: .09em;
color: var(--leise); margin: 2.4rem 0 .9rem; font-weight: 600; }
p { margin: 0 0 1rem; }
.frage { background: var(--papier); border: 1px solid var(--linie);
border-left: 3px solid var(--akzent); padding: 1.1rem 1.3rem;
border-radius: 3px; margin-bottom: .5rem; }
.frage strong { display: block; margin-bottom: .35rem; }
.frage p:last-child { margin-bottom: 0; }
.klein { font-size: .87rem; color: var(--leise); }
.schalter { display: flex; gap: 1.6rem; flex-wrap: wrap; margin: 1rem 0 0; }
.schalter label { display: flex; gap: .5rem; align-items: flex-start;
font-size: .88rem; cursor: pointer; max-width: 30rem; }
.schalter input { margin-top: .35rem; }
.raster { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start; }
@media (max-width: 62rem) { .raster { grid-template-columns: 1fr; } }
.tafel { background: var(--papier); border: 1px solid var(--linie);
border-radius: 3px; padding: 1.1rem 1.3rem; }
.server { border-bottom: 1px solid var(--linie); padding: .7rem 0; }
.server:last-child { border-bottom: 0; padding-bottom: 0; }
.server:first-child { padding-top: 0; }
.serverkopf { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; }
.name { font-weight: 600; }
.zustand { font-size: .78rem; padding: .1rem .5rem; border-radius: 2rem;
border: 1px solid var(--linie); color: var(--leise); white-space: nowrap; }
.zustand.verbunden { color: #1f6b3a; border-color: #bfe0cb; background: #f2faf5; }
.zustand.neuverbinden { color: #96601a; border-color: #ecd8b4; background: #fdf7ec; }
.zustand.getrennt { color: #8f2f2f; border-color: #eec8c8; background: #fdf3f3; }
.zustand.abgemeldet { color: var(--leise); background: #f3f1ed; }
.conv { display: flex; justify-content: space-between; gap: .8rem;
padding: .28rem 0 .28rem .9rem; font-size: .9rem; }
.conv.offen { border-left: 2px solid var(--akzent); padding-left: .75rem; font-weight: 600; }
.marke { font-size: .78rem; color: var(--akzent); white-space: nowrap; }
.msgs { margin: .35rem 0 .2rem 1.6rem; padding: 0; list-style: none;
font-size: .84rem; color: var(--leise); }
.msgs li { padding: .1rem 0; }
.msgs .pending { opacity: .5; font-style: italic; }
.msgs .deleted { text-decoration: line-through; opacity: .45; }
.msgs .seq { display: inline-block; min-width: 2.4rem; color: #b3aa9d; }
button { font: inherit; font-size: .87rem; padding: .42rem .85rem;
border: 1px solid var(--linie); background: var(--papier);
border-radius: 3px; cursor: pointer; color: var(--text); }
button:hover:not(:disabled) { border-color: var(--akzent); color: var(--akzent); }
button:disabled { opacity: .35; cursor: default; }
button.jetzt { border-color: var(--akzent); background: var(--akzent); color: #fff; }
.knopfreihe { display: flex; flex-wrap: wrap; gap: .5rem; }
.reiter { display: flex; flex-wrap: wrap; gap: .4rem; margin-bottom: 1rem; }
.reiter button.aktiv { border-color: var(--akzent); color: var(--akzent); font-weight: 600; }
ol.schritte { margin: 1rem 0 0; padding: 0; list-style: none; }
ol.schritte li { display: flex; gap: .7rem; align-items: baseline;
padding: .3rem 0; border-top: 1px dotted var(--linie); }
ol.schritte li:first-child { border-top: 0; }
ol.schritte .nr { color: #b3aa9d; font-size: .8rem; min-width: 1.2rem; }
ol.schritte .erledigt { color: var(--leise); text-decoration: line-through; }
.protokoll { margin-top: 1.4rem; font-size: .84rem; color: var(--leise);
max-height: 13rem; overflow-y: auto; }
.protokoll div { padding: .16rem 0; border-top: 1px dotted var(--linie); }
.protokoll div:first-child { border-top: 0; }
.protokoll .neu { color: var(--akzent); }
footer { margin-top: 3rem; font-size: .82rem; color: var(--leise);
border-top: 1px solid var(--linie); padding-top: 1rem; }
</style>
</head>
<body>
<div class="huelle">
<h1>Frontend-Zustandsmodell nach dem WebSocket-Umbau</h1>
<p class="klein">Wegwerf-Prototyp zu Ticket 10 der Map <em>websocket-realtime</em>. Nur zum Durchklicken — kein Produktionscode.</p>
<div class="frage">
<strong>Die Frage, die dieser Prototyp beantwortet</strong>
<p>Der Client hält Verbindungen zu <strong>mehreren unabhängigen Servern</strong>. Der Rust-Layer besitzt die Verbindungen, führt die Cursor und lädt selbst nach; das Frontend bekommt <strong>fertige Entitäten</strong> geliefert und hat keine Cursor-Logik. Trägt dieses Zustandsmodell die unangenehmen Fälle — Nachricht in einer nicht geöffneten Conversation, optimistisch gesendete Nachricht, die gleich nochmal vom Server kommt, Bearbeitung, Löschung, erzwungener Neuabgleich, und ein einzelner ausgefallener Server neben funktionierenden?</p>
<p class="klein">Die beiden Schalter unten sind die eigentlich offenen Entscheidungen. Stell sie um und fahr dieselben Durchläufe nochmal — der Unterschied ist der Punkt.</p>
<div class="schalter">
<label><input type="checkbox" id="s-immer">
<span><strong>Rust lädt auch für nicht geöffnete Conversations nach.</strong> Aus: es kommt nur ein Signal, der Inhalt wird erst beim Öffnen geholt.</span></label>
<label><input type="checkbox" id="s-clientid" checked>
<span><strong>Der Client vergibt die Nachrichten-ID.</strong> Aus: der Server vergibt sie, der Client muss die eigene Nachricht am Echo wiedererkennen.</span></label>
</div>
</div>
<div class="raster">
<div>
<h2>Aktueller Zustand</h2>
<div class="tafel" id="zustand"></div>
<h2>Was gerade passiert ist</h2>
<div class="tafel protokoll" id="protokoll"></div>
</div>
<div>
<h2>Freies Ausprobieren</h2>
<div class="tafel">
<div class="knopfreihe" id="freispiel"></div>
</div>
<h2>Geführte Durchläufe</h2>
<div class="tafel">
<div class="reiter" id="reiter"></div>
<p id="szenario-text" class="klein"></p>
<ol class="schritte" id="schritte"></ol>
</div>
</div>
</div>
<footer>
Prototyp — wird nicht ausgeliefert. Der Reducer im ersten <code>&lt;script&gt;</code>-Block ist der Teil, der in echten Code übernommen werden kann; die Seite drumherum nicht.
</footer>
</div>
<script>
/* ------------------------------------------------------------------ *
* REINER REDUCER — dieser Block ist der übernehmbare Teil.
* Kein DOM, keine Knöpfe, keine Seite. (Zustand, Aktion) => Zustand.
* ------------------------------------------------------------------ */
const A = "server-a", B = "server-b";
function anfang() {
return {
optionen: { rustLaedtImmer: false, clientVergibtId: true },
offen: { serverId: A, conversationId: "allgemein" },
naechsteSeq: { "server-a/allgemein": 3, "server-a/design": 8, "server-b/team": 5 },
server: {
[A]: {
name: "chat.example.org", verbindung: "verbunden",
conversations: {
allgemein: { name: "#allgemein", geladen: true, ungelesen: 0, nachrichten: [
{ id: "m1", seq: 1, autor: "Ida", text: "Moin.", zustand: "ok" },
{ id: "m2", seq: 2, autor: "Ruben", text: "Läuft der Server?", zustand: "ok" },
]},
design: { name: "#design", geladen: false, ungelesen: 0, nachrichten: [] },
},
},
[B]: {
name: "intern.firma.de", verbindung: "verbunden",
conversations: {
team: { name: "#team", geladen: false, ungelesen: 0, nachrichten: [] },
},
},
},
};
}
const istOffen = (s, srv, conv) =>
s.offen.serverId === srv && s.offen.conversationId === conv;
function sortiert(liste) {
// Anzeigereihenfolge ist ausschliesslich seq. Optimistische Nachrichten
// haben noch keine und hängen hinten an.
return [...liste].sort((x, y) => (x.seq ?? Infinity) - (y.seq ?? Infinity));
}
function reduce(zustand, aktion) {
const s = structuredClone(zustand);
const notiz = (t) => { s.letzteNotiz = t; };
switch (aktion.art) {
case "option": {
s.optionen[aktion.name] = aktion.wert;
notiz(`Option „${aktion.name}“ ist jetzt ${aktion.wert ? "an" : "aus"}.`);
return s;
}
case "conversationGeoeffnet": {
const { serverId, conversationId } = aktion;
s.offen = { serverId, conversationId };
const c = s.server[serverId].conversations[conversationId];
if (!c.geladen) {
// Beim Öffnen wird geholt, was noch fehlt.
c.geladen = true;
c.nachrichten = aktion.inhalt ?? c.nachrichten;
notiz(`${c.name} geöffnet — Inhalt wurde beim Öffnen nachgeladen.`);
} else {
notiz(`${c.name} geöffnet.`);
}
c.ungelesen = 0;
return s;
}
/* Rust hat ein Signal bekommen, nachgeladen und liefert Entitäten hoch.
Ob überhaupt nachgeladen wurde, entscheidet die Option. */
case "entitaetenEingetroffen": {
const { serverId, conversationId, entitaeten } = aktion;
const srv = s.server[serverId];
const c = srv.conversations[conversationId];
const sichtbar = istOffen(s, serverId, conversationId);
if (!sichtbar && !s.optionen.rustLaedtImmer) {
// Nur ein Signal: wir wissen DASS sich etwas geändert hat, nicht WAS.
c.ungelesen = "•";
notiz(`${srv.name} · ${c.name}: Signal ohne Inhalt — Markierung gesetzt, Anzahl unbekannt.`);
return s;
}
for (const e of entitaeten) {
const i = c.nachrichten.findIndex((m) => m.id === e.id);
if (i === -1) {
c.nachrichten.push({ ...e, zustand: e.zustand ?? "ok" });
} else {
// Ersetzen, nicht anhängen. seq der Nachricht bleibt, also
// bewegt sich eine bearbeitete Nachricht NICHT im Verlauf.
c.nachrichten[i] = { ...c.nachrichten[i], ...e };
}
}
c.geladen = true;
c.nachrichten = sortiert(c.nachrichten);
if (sichtbar) {
c.ungelesen = 0;
notiz(`${c.name}: ${entitaeten.length} Entität(en) übernommen — offen, also direkt sichtbar.`);
} else {
c.ungelesen = (typeof c.ungelesen === "number" ? c.ungelesen : 0) + entitaeten.length;
notiz(`${srv.name} · ${c.name}: nachgeladen im Hintergrund, Anzahl bekannt (${c.ungelesen}).`);
}
return s;
}
/* Optimistisches Senden. */
case "gesendet": {
const { serverId, conversationId, text } = aktion;
const c = s.server[serverId].conversations[conversationId];
const id = s.optionen.clientVergibtId ? aktion.clientId : null;
c.nachrichten.push({
id, clientId: aktion.clientId, seq: null,
autor: "ich", text, zustand: "pending",
});
notiz(`Gesendet: „${text}“ — erscheint sofort, noch ohne seq.`);
return s;
}
/* Die eigene Nachricht kommt über den Realtime-Weg zurück. */
case "eigeneNachrichtZurueck": {
const { serverId, conversationId, clientId, text } = aktion;
const c = s.server[serverId].conversations[conversationId];
const schluessel = `${serverId}/${conversationId}`;
const seq = s.naechsteSeq[schluessel]++;
// Zusammenführen: nur möglich, wenn es etwas gibt, woran man
// die eigene Nachricht wiedererkennt.
const i = s.optionen.clientVergibtId
? c.nachrichten.findIndex((m) => m.clientId === clientId)
: -1;
if (i === -1) {
c.nachrichten.push({
id: `srv-${seq}`, seq, autor: "ich", text, zustand: "ok",
});
notiz(s.optionen.clientVergibtId
? `Zurück vom Server — nichts zum Zusammenführen gefunden, angehängt.`
: `DOPPELT: Server vergab eine eigene ID, die optimistische Nachricht bleibt daneben stehen.`);
} else {
c.nachrichten[i] = { ...c.nachrichten[i], id: clientId, seq, zustand: "ok" };
notiz(`Zurück vom Server — über die Client-ID zusammengeführt, kein Duplikat.`);
}
c.nachrichten = sortiert(c.nachrichten);
return s;
}
case "bearbeitet": {
const { serverId, conversationId, id, text } = aktion;
const c = s.server[serverId].conversations[conversationId];
const m = c.nachrichten.find((x) => x.id === id);
if (m) { m.text = text; m.bearbeitet = true; }
notiz(`Bearbeitet — seq bleibt ${m ? m.seq : "?"}, die Nachricht springt also nicht.`);
return s;
}
case "geloescht": {
const { serverId, conversationId, id } = aktion;
const c = s.server[serverId].conversations[conversationId];
const m = c.nachrichten.find((x) => x.id === id);
if (m) { m.zustand = "deleted"; m.text = "(gelöscht)"; }
notiz(`Gelöscht — kommt als Grabstein mit, nicht als Auslassung.`);
return s;
}
/* Server kann kein Delta bilden: Zwischenspeicher wegwerfen. */
case "resync": {
const { serverId, conversationId, inhalt } = aktion;
const srv = s.server[serverId];
const c = srv.conversations[conversationId];
const sichtbar = istOffen(s, serverId, conversationId);
c.nachrichten = sichtbar ? inhalt : [];
c.geladen = sichtbar;
c.ungelesen = sichtbar ? 0 : "•";
notiz(sichtbar
? `${c.name}: Neuabgleich — Zwischenspeicher verworfen und frisch geladen.`
: `${srv.name} · ${c.name}: Neuabgleich — Zwischenspeicher verworfen, Laden beim Öffnen.`);
return s;
}
case "verbindungVerloren": {
s.server[aktion.serverId].verbindung = "neuverbinden";
notiz(`${s.server[aktion.serverId].name} getrennt — versucht neu zu verbinden. Andere Server unberührt.`);
return s;
}
case "verbindungZurueck": {
const srv = s.server[aktion.serverId];
srv.verbindung = "verbunden";
notiz(`${srv.name} wieder verbunden — Rust zieht das Manifest und gleicht ab.`);
return s;
}
case "verdraengt": {
s.server[aktion.serverId].verbindung = "abgemeldet";
notiz(`${s.server[aktion.serverId].name}: von neuerer Verbindung verdrängt (4002) — es wird NICHT neu verbunden.`);
return s;
}
case "zuruecksetzen":
return { ...anfang(), optionen: zustand.optionen, letzteNotiz: "Zurückgesetzt." };
default:
return s;
}
}
/* ------------------------------------------------------------------ *
* Ende des übernehmbaren Teils. Alles darunter ist Wegwerf-Seite.
* ------------------------------------------------------------------ */
</script>
<script>
let zustand = anfang();
let protokoll = [];
let aktivesSzenario = 0;
let schrittIndex = 0;
const el = (id) => document.getElementById(id);
function schicke(aktion) {
zustand = reduce(zustand, aktion);
if (zustand.letzteNotiz) protokoll.unshift(zustand.letzteNotiz);
protokoll = protokoll.slice(0, 40);
zeichne();
}
/* ---- Aktionen für das freie Ausprobieren ---- */
let zaehler = 0;
const neuerText = () => ["Kurze Frage.", "Bin gleich da.", "Sieht gut aus.", "Passt so.", "Melde mich später."][zaehler++ % 5];
const freispiel = [
{ titel: "#allgemein öffnen", tu: () => schicke({ art: "conversationGeoeffnet", serverId: A, conversationId: "allgemein" }) },
{ titel: "#design öffnen", tu: () => schicke({ art: "conversationGeoeffnet", serverId: A, conversationId: "design",
inhalt: [{ id: "d1", seq: 6, autor: "Ida", text: "Entwurf liegt bereit.", zustand: "ok" },
{ id: "d2", seq: 7, autor: "Nour", text: "Schaue ich mir an.", zustand: "ok" }] }) },
{ titel: "#team öffnen (Server B)", tu: () => schicke({ art: "conversationGeoeffnet", serverId: B, conversationId: "team",
inhalt: [{ id: "t1", seq: 4, autor: "Milan", text: "Standup um zehn.", zustand: "ok" }] }) },
{ titel: "Neue Nachricht in #allgemein", tu: () => schicke({ art: "entitaetenEingetroffen", serverId: A, conversationId: "allgemein",
entitaeten: [{ id: `x${++zaehler}`, seq: zustand.naechsteSeq["server-a/allgemein"]++, autor: "Ruben", text: neuerText() }] }) },
{ titel: "Neue Nachricht in #design", tu: () => schicke({ art: "entitaetenEingetroffen", serverId: A, conversationId: "design",
entitaeten: [{ id: `y${++zaehler}`, seq: zustand.naechsteSeq["server-a/design"]++, autor: "Nour", text: neuerText() }] }) },
{ titel: "Neue Nachricht in #team (Server B)", tu: () => schicke({ art: "entitaetenEingetroffen", serverId: B, conversationId: "team",
entitaeten: [{ id: `z${++zaehler}`, seq: zustand.naechsteSeq["server-b/team"]++, autor: "Milan", text: neuerText() }] }) },
{ titel: "Ich sende etwas", tu: () => { const cid = `c${++zaehler}`; letzteEigene = { cid, text: neuerText() };
schicke({ art: "gesendet", serverId: zustand.offen.serverId, conversationId: zustand.offen.conversationId, clientId: cid, text: letzteEigene.text }); } },
{ titel: "…und sie kommt zurück", tu: () => { if (!letzteEigene) return;
schicke({ art: "eigeneNachrichtZurueck", serverId: zustand.offen.serverId, conversationId: zustand.offen.conversationId, clientId: letzteEigene.cid, text: letzteEigene.text }); } },
{ titel: "Älteste Nachricht bearbeiten", tu: () => { const c = aktuelleConv(); const m = c.nachrichten.find(x => x.zustand === "ok");
if (m) schicke({ art: "bearbeitet", serverId: zustand.offen.serverId, conversationId: zustand.offen.conversationId, id: m.id, text: m.text + " (korrigiert)" }); } },
{ titel: "Älteste Nachricht löschen", tu: () => { const c = aktuelleConv(); const m = c.nachrichten.find(x => x.zustand === "ok");
if (m) schicke({ art: "geloescht", serverId: zustand.offen.serverId, conversationId: zustand.offen.conversationId, id: m.id }); } },
{ titel: "Server verlangt Neuabgleich", tu: () => schicke({ art: "resync", serverId: zustand.offen.serverId, conversationId: zustand.offen.conversationId,
inhalt: [{ id: "r1", seq: 20, autor: "Ida", text: "Frisch geladen nach Neuabgleich.", zustand: "ok" }] }) },
{ titel: "Server A verliert Verbindung", tu: () => schicke({ art: "verbindungVerloren", serverId: A }) },
{ titel: "Server A ist zurück", tu: () => schicke({ art: "verbindungZurueck", serverId: A }) },
{ titel: "Server A: verdrängt (4002)", tu: () => schicke({ art: "verdraengt", serverId: A }) },
{ titel: "Alles zurücksetzen", tu: () => { protokoll = []; letzteEigene = null; schicke({ art: "zuruecksetzen" }); } },
];
let letzteEigene = null;
const aktuelleConv = () => zustand.server[zustand.offen.serverId].conversations[zustand.offen.conversationId];
/* ---- Geführte Durchläufe ---- */
const szenarien = [
{ name: "Nachricht kommt an",
text: "Der einfache Fall. Eine Nachricht trifft in der geöffneten Conversation ein. Achte darauf, dass das Frontend nur fertige Entitäten einsetzt — es rechnet nichts aus und fragt nichts nach.",
schritte: [freispiel[0], freispiel[3], freispiel[3]] },
{ name: "Nachricht anderswo",
text: "Die Nachricht kommt in einer Conversation an, die NICHT geöffnet ist. Fahr diesen Durchlauf zweimal — einmal mit dem Schalter „Rust lädt auch für nicht geöffnete Conversations nach“ aus, einmal an. Aus bekommst du nur einen Punkt, weil niemand die Anzahl kennt; an bekommst du eine Zahl, aber Requests für Inhalt, den vielleicht niemand ansieht.",
schritte: [freispiel[0], freispiel[4], freispiel[4], freispiel[1]] },
{ name: "Optimistisch senden",
text: "Die eigene Nachricht erscheint sofort und kommt gleich darauf über den Realtime-Weg zurück. Mit Client-ID wird zusammengeführt. Schalte „Der Client vergibt die Nachrichten-ID“ AUS und fahr den Durchlauf erneut — dann steht dieselbe Nachricht zweimal da.",
schritte: [freispiel[0], freispiel[6], freispiel[7]] },
{ name: "Bearbeiten und Löschen",
text: "Eine bereits sichtbare Nachricht wird geändert und dann gelöscht. Achte auf die Position: die Sortierung folgt der seq der Nachricht, nicht dem Änderungszeitpunkt — sie springt also nicht ans Ende. Und Löschen erzeugt einen Grabstein, keine Lücke.",
schritte: [freispiel[0], freispiel[8], freispiel[9]] },
{ name: "Neuabgleich",
text: "Der Server kann kein Delta bilden — Cursor zu alt, Limit überschritten, oder der Listener hatte einen Aussetzer. Der Zwischenspeicher dieser Conversation wird verworfen und frisch geladen. Das ist derselbe Pfad, den auch die Erstverbindung nimmt.",
schritte: [freispiel[0], freispiel[3], freispiel[10]] },
{ name: "Ein Server fällt aus",
text: "Server A verliert die Verbindung, während Server B weiterläuft — der Zustand ist pro Server, nicht global. Danach wird Server A verdrängt (Close-Code 4002): dieser Zustand ist endgültig, es wird bewusst NICHT neu verbunden, sonst verdrängen sich zwei Geräte im Kreis.",
schritte: [freispiel[0], freispiel[11], freispiel[5], freispiel[12], freispiel[13]] },
];
/* ---- Zeichnen ---- */
function zeichne() {
el("s-immer").checked = zustand.optionen.rustLaedtImmer;
el("s-clientid").checked = zustand.optionen.clientVergibtId;
el("zustand").innerHTML = Object.entries(zustand.server).map(([sid, srv]) => {
const zk = { verbunden: "verbunden", neuverbinden: "verbindet neu", getrennt: "getrennt", abgemeldet: "abgemeldet (4002)" }[srv.verbindung];
const convs = Object.entries(srv.conversations).map(([cid, c]) => {
const offen = istOffen(zustand, sid, cid);
const marke = c.ungelesen === "•" ? "• ungelesen"
: (typeof c.ungelesen === "number" && c.ungelesen > 0) ? `${c.ungelesen} neu` : "";
const liste = offen && c.nachrichten.length
? `<ul class="msgs">${sortiert(c.nachrichten).map(m =>
`<li class="${m.zustand === "pending" ? "pending" : m.zustand === "deleted" ? "deleted" : ""}">` +
`<span class="seq">${m.seq ?? "—"}</span>${m.autor}: ${m.text}${m.bearbeitet ? " ✎" : ""}</li>`).join("")}</ul>`
: (offen ? `<ul class="msgs"><li>— leer —</li></ul>` : "");
return `<div class="conv ${offen ? "offen" : ""}"><span>${c.name}${c.geladen ? "" : ' <span class="klein">(nicht geladen)</span>'}</span><span class="marke">${marke}</span></div>${liste}`;
}).join("");
return `<div class="server"><div class="serverkopf"><span class="name">${srv.name}</span>` +
`<span class="zustand ${srv.verbindung}">${zk}</span></div>${convs}</div>`;
}).join("");
el("protokoll").innerHTML = protokoll.length
? protokoll.map((t, i) => `<div class="${i === 0 ? "neu" : ""}">${t}</div>`).join("")
: `<div class="klein">Noch nichts passiert. Drück einen Knopf.</div>`;
el("freispiel").innerHTML = "";
freispiel.forEach((a, i) => {
const b = document.createElement("button");
b.textContent = a.titel;
b.onclick = a.tu;
el("freispiel").appendChild(b);
});
el("reiter").innerHTML = "";
szenarien.forEach((sz, i) => {
const b = document.createElement("button");
b.textContent = sz.name;
if (i === aktivesSzenario) b.className = "aktiv";
b.onclick = () => { aktivesSzenario = i; schrittIndex = 0; protokoll = [];
letzteEigene = null; schicke({ art: "zuruecksetzen" }); };
el("reiter").appendChild(b);
});
const sz = szenarien[aktivesSzenario];
el("szenario-text").textContent = sz.text;
el("schritte").innerHTML = "";
sz.schritte.forEach((schritt, i) => {
const li = document.createElement("li");
const nr = document.createElement("span");
nr.className = "nr"; nr.textContent = `${i + 1}.`;
const b = document.createElement("button");
b.textContent = schritt.titel;
b.disabled = i !== schrittIndex;
if (i === schrittIndex) b.className = "jetzt";
if (i < schrittIndex) b.classList.add("erledigt");
b.onclick = () => { schritt.tu(); schrittIndex = i + 1; zeichne(); };
li.append(nr, b);
el("schritte").appendChild(li);
});
}
el("s-immer").onchange = (e) => schicke({ art: "option", name: "rustLaedtImmer", wert: e.target.checked });
el("s-clientid").onchange = (e) => schicke({ art: "option", name: "clientVergibtId", wert: e.target.checked });
zeichne();
</script>
</body>
</html>

View File

@@ -16,7 +16,3 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
uuid = { version = "1.24.0", features = ["v4"] }
futures-util = "0.3.34"
tokio-tungstenite = { version = "0.30.0", features = ["rustls-tls-webpki-roots"] }
rand = "0.10.2"

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -1,33 +1,19 @@
use crate::models::Message;
use crate::state::{AppState, BASE_URL};
#[derive(serde::Deserialize)]
struct History {
cursor: i64,
messages: Vec<Message>,
}
#[tauri::command]
pub async fn list_messages(
state: tauri::State<'_, AppState>,
conversation_id: String,
) -> Result<Vec<Message>, String> {
let history: History = state
.authed_get(&format!(
"{}/conversations/{}/messages",
BASE_URL, conversation_id
))
state
.authed_get(&format!("{}/conversations/{}/messages", BASE_URL, conversation_id))
.send()
.await
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())?;
// Loading the history *is* a resync: adopt the cursor it came with, so the
// next realtime signal asks for a delta from the right place.
state.cursors.set(&conversation_id, history.cursor);
Ok(history.messages)
.map_err(|e| e.to_string())
}
#[tauri::command]
@@ -43,12 +29,7 @@ pub async fn send_message(
.clone()
.ok_or_else(|| "Not logged in".to_string())?;
// The id comes from here, not from the server. Without it the optimistically
// rendered message cannot be matched against the copy that arrives over the
// realtime channel, and the same message shows up twice. It also makes a
// retry after an unclear network error a no-op instead of a duplicate.
let body = serde_json::json!({
"id": uuid::Uuid::new_v4().to_string(),
"conversation_id": conversation_id,
"user_id": user_id,
"content": content,

View File

@@ -2,11 +2,7 @@ use crate::models::{AuthTokenResponse, User};
use crate::state::{AppState, BASE_URL};
#[tauri::command]
pub async fn login(
app: tauri::AppHandle,
state: tauri::State<'_, AppState>,
email: String,
) -> Result<User, String> {
pub async fn login(state: tauri::State<'_, AppState>, email: String) -> Result<User, String> {
let users: Vec<User> = state
.client
.get(format!("{}/users", BASE_URL))
@@ -42,7 +38,5 @@ pub async fn login(
*state.token.lock().unwrap() = Some(token_resp.token);
*state.user_id.lock().unwrap() = Some(user.id.clone());
crate::realtime::spawn(app);
Ok(user)
}

View File

@@ -1,6 +1,5 @@
mod commands;
mod models;
mod realtime;
mod state;
use state::AppState;

View File

@@ -28,16 +28,12 @@ pub struct Conversation {
pub created_by: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Serialize, Deserialize)]
pub struct Message {
pub id: String,
pub conversation_id: String,
pub user_id: String,
pub content: String,
/// Sort key within the conversation. Display order follows this, never
/// `created_at` — that is transaction start time on the server, so it is
/// neither unique nor commit-ordered.
pub seq: i64,
pub created_at: String,
}

View File

@@ -1,219 +0,0 @@
//! The realtime connection, owned by Rust rather than by the WebView.
//!
//! Two reasons it lives here. The handshake can carry a real
//! `Authorization: Bearer` header — the only channel that proxy defaults do not
//! write to their access logs — and `tungstenite` surfaces the full HTTP
//! response on a rejected handshake, whereas the browser API is required by spec
//! to collapse every failure into close code 1006. "Token expired" and "server
//! down" would be indistinguishable in the WebView.
//!
//! The socket only ever says "conversation X is at seq N". This module then
//! pulls the actual entities over REST and hands them to the frontend, so the
//! frontend keeps no cursor logic at all.
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, Manager};
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::Message;
use crate::models::Message as ChatMessage;
use crate::state::{AppState, BASE_URL};
pub const EVENT_CHANGED: &str = "realtime://changed";
pub const EVENT_RESYNC: &str = "realtime://resync";
// TODO: a connection-state event per server once the UI shows it — ticket 10
// wants "connected / reconnecting / signed out" visible per server. Left out
// until something listens, rather than emitting into the void.
#[derive(Debug, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum Inbound {
Changed { conversation: String, seq: i64 },
Resync,
}
#[derive(Debug, Serialize, Clone)]
pub struct Changed {
pub conversation_id: String,
pub messages: Vec<ChatMessage>,
}
#[derive(Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum Changes {
Delta {
cursor: i64,
messages: Vec<ChatMessage>,
},
Resync {
cursor: i64,
},
}
/// Cursor per conversation. Lives next to the connection and the token, so the
/// frontend never has to know it exists.
#[derive(Default)]
pub struct Cursors(Mutex<HashMap<String, i64>>);
impl Cursors {
fn get(&self, conversation: &str) -> i64 {
*self.0.lock().unwrap().get(conversation).unwrap_or(&0)
}
pub fn set(&self, conversation: &str, seq: i64) {
self.0.lock().unwrap().insert(conversation.to_string(), seq);
}
fn clear(&self) {
self.0.lock().unwrap().clear();
}
}
/// Close codes the server may send. 4002 and 4003 are terminal: reconnecting
/// after being displaced would just displace somebody else, and two devices
/// would push each other around in circles.
const CLOSE_TOKEN_EXPIRED: u16 = 4001;
const CLOSE_DISPLACED: u16 = 4002;
const CLOSE_UNAUTHORIZED: u16 = 4003;
pub fn spawn(app: AppHandle) {
tauri::async_runtime::spawn(async move {
// TODO: one connection per configured server once the server list
// replaces BASE_URL. The structure below is per-connection already; what
// is missing is the list and the per-server auth UI.
let mut attempt: u32 = 0;
loop {
match connect_once(&app).await {
Ok(terminal) => {
if terminal {
return;
}
attempt = 0;
}
Err(e) => eprintln!("realtime: {e}"),
}
// Full jitter. The random part is not optional: without it every
// client comes back at the same instant after a server restart.
let ceiling = (500u64 << attempt.min(6)).min(30_000);
let wait = rand::random_range(0..=ceiling);
tokio::time::sleep(Duration::from_millis(wait)).await;
attempt = attempt.saturating_add(1);
}
});
}
/// Returns Ok(true) when the server told us to stay down.
async fn connect_once(app: &AppHandle) -> Result<bool, String> {
let state = app.state::<AppState>();
let token = state
.token
.lock()
.unwrap()
.clone()
.ok_or_else(|| "not signed in".to_string())?;
let url = BASE_URL.replacen("http", "ws", 1) + "/ws";
let mut request = url.into_client_request().map_err(|e| e.to_string())?;
request.headers_mut().insert(
"Authorization",
format!("Bearer {token}")
.parse()
.map_err(|_| "bad token".to_string())?,
);
let (socket, _) = tokio_tungstenite::connect_async(request)
.await
.map_err(|e| e.to_string())?;
let (mut sink, mut stream) = socket.split();
// The client drives the heartbeat: it sits behind the NAT whose path needs
// keeping open, and it can slow down on battery without the server caring.
let mut ping = tokio::time::interval(Duration::from_secs(15));
loop {
tokio::select! {
_ = ping.tick() => {
if sink.send(Message::Ping(Vec::new().into())).await.is_err() {
return Ok(false);
}
}
received = stream.next() => {
let Some(received) = received else { return Ok(false) };
let message = received.map_err(|e| e.to_string())?;
match message {
Message::Text(text) => match serde_json::from_str::<Inbound>(&text) {
Ok(Inbound::Changed { conversation, seq }) => {
// The server already collapses bursts per connection,
// so there is nothing left to debounce here.
if seq > state.cursors.get(&conversation) {
if let Err(e) = backfill(app, &conversation).await {
eprintln!("realtime: backfill failed: {e}");
}
}
}
Ok(Inbound::Resync) => {
state.cursors.clear();
let _ = app.emit(EVENT_RESYNC, ());
}
Err(e) => eprintln!("realtime: undecodable frame: {e}"),
},
Message::Close(frame) => {
let code = frame.map(|f| u16::from(f.code)).unwrap_or(0);
// TOKEN_EXPIRED counts as terminal only because there is
// no refresh flow yet — reconnecting with the same dead
// token would just spin against 401.
// TODO: refresh over REST and push the new token back
// through the socket as a `reauth` frame (the server
// already accepts one), then treat 4001 as recoverable.
return Ok(matches!(
code,
CLOSE_DISPLACED | CLOSE_UNAUTHORIZED | CLOSE_TOKEN_EXPIRED
));
}
_ => {}
}
}
}
}
}
async fn backfill(app: &AppHandle, conversation: &str) -> Result<(), String> {
let state = app.state::<AppState>();
let since = state.cursors.get(conversation);
let response: Changes = state
.authed_get(&format!(
"{BASE_URL}/conversations/{conversation}/changes?since={since}"
))
.send()
.await
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())?;
match response {
Changes::Delta { cursor, messages } => {
state.cursors.set(conversation, cursor);
app.emit(
EVENT_CHANGED,
Changed {
conversation_id: conversation.to_string(),
messages,
},
)
.map_err(|e| e.to_string())?;
}
Changes::Resync { cursor } => {
// Not an error — this is the normal answer on a first connection or
// after a long absence. The frontend drops its cache and reloads.
state.cursors.set(conversation, cursor);
app.emit(EVENT_RESYNC, ()).map_err(|e| e.to_string())?;
}
}
Ok(())
}

View File

@@ -6,9 +6,6 @@ pub struct AppState {
pub client: reqwest::Client,
pub token: Mutex<Option<String>>,
pub user_id: Mutex<Option<String>>,
/// Cursor per conversation, kept here rather than in the frontend so the UI
/// has no realtime bookkeeping at all.
pub cursors: crate::realtime::Cursors,
}
impl AppState {
@@ -17,7 +14,6 @@ impl AppState {
client: reqwest::Client::new(),
token: Mutex::new(None),
user_id: Mutex::new(None),
cursors: Default::default(),
}
}

View File

@@ -3,9 +3,10 @@ import { invoke } from "@tauri-apps/api/core";
interface Props {
conversationId: string;
onSent: () => void;
}
export default function MessageInput({ conversationId }: Props) {
export default function MessageInput({ conversationId, onSent }: Props) {
const [content, setContent] = useState("");
const [error, setError] = useState<string | null>(null);
@@ -15,10 +16,7 @@ export default function MessageInput({ conversationId }: Props) {
await invoke("send_message", { conversationId, content });
setContent("");
setError(null);
// Nothing to trigger: the message comes back over the realtime channel.
// TODO: render it optimistically instead of waiting for the round trip —
// the client already supplies the id, which is what makes reconciling it
// possible without duplicates.
onSent();
} catch (e) {
setError(String(e));
}

View File

@@ -1,66 +1,42 @@
import { useState, useEffect, useRef } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
interface Message {
id: string;
user_id: string;
content: string;
/** Sort key. Never sort by created_at — see the Rust model for why. */
seq: number;
created_at: string;
}
interface Changed {
conversation_id: string;
messages: Message[];
}
interface Props {
conversationId: string;
refreshKey: number;
}
export default function MessageList({ conversationId }: Props) {
export default function MessageList({ conversationId, refreshKey }: Props) {
const [messages, setMessages] = useState<Message[]>([]);
const [error, setError] = useState<string | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const prevLengthRef = useRef(0);
// Full load. Also the resync path: the Rust side adopts the cursor that comes
// with the history, so the next realtime signal asks for a delta from the
// right place.
const load = () =>
invoke<Message[]>("list_messages", { conversationId })
.then(setMessages)
.catch((e) => setError(String(e)));
// Fix 3: cancellation flag prevents stale responses from overwriting new conversation
useEffect(() => {
setMessages([]);
let cancelled = false;
const load = () =>
invoke<Message[]>("list_messages", { conversationId })
.then((msgs) => {
if (!cancelled) setMessages(msgs);
})
.catch((e) => { if (!cancelled) setError(String(e)); });
load();
return () => { prevLengthRef.current = 0; };
}, [conversationId]);
// Realtime. Rust owns the connection and the cursor and hands over resolved
// entities, so there is nothing to compute here — replace by id, sort by seq.
useEffect(() => {
const changed = listen<Changed>("realtime://changed", (event) => {
if (event.payload.conversation_id !== conversationId) return;
setMessages((current) => {
const byId = new Map(current.map((m) => [m.id, m]));
for (const message of event.payload.messages) byId.set(message.id, message);
return [...byId.values()].sort((a, b) => a.seq - b.seq);
});
});
// The server could not build a delta — throw the cache away and reload.
const resync = listen("realtime://resync", load);
return () => {
changed.then((un) => un());
resync.then((un) => un());
};
}, [conversationId]);
// ponytail: polling every 3s — replace with WebSocket when server supports it
const id = setInterval(load, 3000);
return () => { cancelled = true; clearInterval(id); prevLengthRef.current = 0; };
// Fix 2: refreshKey in deps triggers immediate reload after send
}, [conversationId, refreshKey]);
// Fix 4: scroll only when message count increases, not on every poll
useEffect(() => {
if (messages.length > prevLengthRef.current) {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });

View File

@@ -7,6 +7,7 @@ import MessageInput from "../components/MessageInput";
export default function ChatPage() {
const { workspaceId } = useParams<{ workspaceId: string }>();
const [conversationId, setConversationId] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
return (
<div style={{ display: "flex", height: "100vh" }}>
@@ -18,10 +19,11 @@ export default function ChatPage() {
<div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
{conversationId ? (
<>
<MessageList conversationId={conversationId} />
{/* No onSent callback: the sent message arrives back through the
realtime channel like any other. */}
<MessageInput conversationId={conversationId} />
<MessageList conversationId={conversationId} refreshKey={refreshKey} />
<MessageInput
conversationId={conversationId}
onSent={() => setRefreshKey((k) => k + 1)}
/>
</>
) : (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", color: "#888" }}>