Files
dogfather-universe/workspace/assets/js/chat.js
T
DogFatherGitandClaude Opus 5 7de32a5ec7 Punkt 15: Der Chat bekommt Suche, Antworten mit Zitat und die Ungelesen-Linie
Wunsch Filipe: "ich will dass du diese seite viel krasser und
detaillierter machst, ich will dass du dich informierst und alles
reinsetzt was wir noch gebrauchen koennten."

NICHT ALLES, SONDERN WAS TAEGLICH FEHLT. Der Chat konnte schon Raeume,
Verlauf, Gelesen-Stand, Live-Zustellung, Gruppen und Zuruecknehmen.
Drei Dinge fehlten, und jedes davon kostet ohne es echte Zeit:

1. SUCHE IN DEN NACHRICHTEN. Ein Chat ohne sie ist ab dem zweiten
   Monat ein Archiv, in dem man nichts findet. Ein Suchfeld gab es --
   es durchsuchte aber nur die NAMEN der Gespraeche, also die kleinere
   Haelfte. Jetzt durchsucht dasselbe Feld beides und zeigt die
   Fundstellen UNTER der Gespraechsliste: Wer "Patrick" eingibt, will
   vielleicht das Gespraech und vielleicht die Nachricht -- ein
   Umschalter haette ihn zwingen wollen, das vorher zu wissen.

2. ANTWORTEN MIT ZITAT. Zu zweit weiss man meistens, worauf sich etwas
   bezieht. In einer Gruppe laufen drei Faeden parallel, und "ja, mach
   das" kann alles heissen. Das Zitat steht IN der Blase (es gehoert
   zur Antwort, nicht darueber) und fuehrt per Klick zur Stelle.

3. DIE LINIE "AB HIER NEU". Wer nach zwei Tagen zurueckkommt, sucht
   sonst die Stelle, an der er aufgehoert hat, indem er Uhrzeiten
   liest.

BEWUSST NICHT GEBAUT: Anhaenge (dafuer gibt es den Dateien-Bereich mit
Rechten und Ablauf), Reaktionen (eine vierte Sache, bevor die drei sich
bewaehrt haben) und Tipp-Anzeigen (dauernder Verkehr fuer eine
Auskunft, die man in zwei Sekunden ohnehin sieht).

DIE SICHERHEIT DER SUCHE STEHT IM JOIN, nicht in einer nachtraeglichen
Pruefung: `chat_teilnehmer` wird mit der eigenen Personenkennung
verbunden, und was dort nicht drinsteht, kommt gar nicht erst aus der
Datenbank. Ein Filter, der erst hinterher aussortiert, ist eine Zeile
davon entfernt, vergessen zu werden. Ebenso beim Zitat: Worauf
geantwortet wird, muss im SELBEN Raum liegen -- sonst koennte jemand
die Kennung aus einem fremden Gespraech mitschicken, und beim
Empfaenger stuende ein Zitat aus einem Raum, den er nie gesehen hat.

DREI FEHLER, DIE DER DURCHLAUF GEFUNDEN HAT:

1. `ESCAPE '\'` IN EINEM TEMPLATE-LITERAL. Dort ist `\'` eine
   Fluchtsequenz fuer das Anfuehrungszeichen -- SQLite bekam ein
   LEERES Fluchtzeichen und antwortete "ESCAPE expression must be a
   single character". Die Suche war damit komplett tot. Kein
   Syntaxfehler, kein Warnhinweis: Erst der Aufruf mit echten Daten
   hat es gezeigt.

2. DIE MASKIERUNG KANNTE ZWEI VON DREI ZEICHEN. `%` und `_` waren
   dabei, der Backslash nicht -- ausgerechnet das Fluchtzeichen selbst.
   Geprueft wird das jetzt an der ZEILE AUS DER DATEI, nicht an einem
   Nachbau: Mein erster Test hat die Maskierung nachgebaut und dabei
   die Shell-Maskierung mitgeschleppt -- er meldete einen Fehler, den
   nur er hatte.

3. DIE UNGELESEN-LINIE SCHIEN NICHT ZU FUNKTIONIEREN. Sie tat es --
   mein Testaufbau war falsch: Filipes Seite war noch offen, die neuen
   Nachrichten kamen ueber den Live-Strom an und wurden sofort als
   gelesen gemeldet. Es gab schlicht nichts Ungelesenes. Erst als er
   die Seite verlaesst, bevor Patrick schreibt, steht die Linie da --
   und zwar genau vor "Neu von Patrick, eins", und beim zweiten
   Oeffnen ist sie weg.

Der Gelesen-Stand wird deshalb beim OEFFNEN mitgeschickt, bevor er
gesetzt wird -- eine Zeile spaeter waere er immer die letzte Nachricht,
und die Linie staende nie irgendwo.

Ohne Volltextindex, mit Absicht: `LIKE` liest die Tabelle, und bei
einem Team dieser Groesse sind das einige tausend Zeilen. Ein
FTS5-Index waere eine zweite Tabelle, die synchron gehalten werden
muss -- genau daran gehen solche Sachen kaputt. Wenn der Verlauf
sechsstellig wird, ist das der Zeitpunkt dafuer, nicht heute.

Geprueft: pruef-chat, pruef-chat-optik, pruef-css-klassen,
pruef-workspace-seiten, pruef-handy -- alle in Ordnung. Dazu im
Browser durchgespielt: Zitat gesetzt und gelesen, Suche nach
"Bitrate" (1 Treffer), nach "100 %" (1 Treffer -- die Maskierung
haelt), nach Unsinn (0), einbuchstabige Suche (zu kurz), Antwortleiste
mit dem richtigen Namen, Ungelesen-Linie an der richtigen Stelle und
beim zweiten Oeffnen weg.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-09 02:18:59 +02:00

865 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
chat.js — Gespräche im Workspace.
Wunsch Filipe, 06.09.2026: *"jeder kann dann eine nachricht
reinschreiben, die andere person bekommt eine benachrichtigung und
kann dann antworten."*
---------------------------------------------------------------------
DREI ENTSCHEIDUNGEN, die den Rest erklären
1. NEUE NACHRICHTEN KOMMEN VON SELBST (EventSource / SSE). Kein
Nachfragen im Takt: Das wäre fünf Sekunden langsam und stellte bei
zehn Angemeldeten 7200 Anfragen in der Stunde für Nachrichten, die
es meistens nicht gibt. Reisst die Verbindung ab, verbindet der
Browser sich von selbst neu -- und wir holen die Lücke über den
normalen Verlauf nach.
2. DIE EIGENE NACHRICHT STEHT SOFORT DA, bevor der Server antwortet.
Ein Chat, in dem man nach dem Abschicken eine halbe Sekunde ins
Leere sieht, fühlt sich kaputt an. Kommt der Server nicht mit,
wird sie sichtbar als "nicht angekommen" markiert -- nicht still
entfernt.
3. GELESEN WIRD GEMELDET, wenn man wirklich hinsieht. Nicht schon
beim Laden der Seite: Wer die Seite im Hintergrund offen hat, hat
nichts gelesen, und eine Zahl, die von selbst verschwindet, ist
schlimmer als keine.
===================================================================== */
(() => {
'use strict';
const $ = (id) => document.getElementById(id);
const el = (tag, klasse, text) => {
const k = document.createElement(tag);
if (klasse) k.className = klasse;
if (text !== undefined) k.textContent = text;
return k;
};
/* Aus der einen Quelle. Stand hier als eigenes Objekt und hatte
'spicy' nicht -- siehe bereiche.js. */
const ROLLENKURZ = window.Bereiche.ROLLEN_NAME;
let ich = null;
let raeume = [];
let offen = null; // der gerade angesehene Raum
let nachrichten = [];
let strom = null; // die EventSource
let gewaehlt = new Set(); // im Neu-Dialog
/* ---------- Kleinkram ---------------------------------------------------- */
async function hole(weg, einst) {
const a = await fetch(weg, { credentials: 'same-origin', ...einst });
if (a.status === 401) { location.assign('/workspace/'); throw new Error('weg'); }
return a;
}
/** Ein bis zwei Buchstaben für den Kreis. Dieselbe Regel wie bei der
* Teilnehmerwahl im Kalender -- Anfang UND Ende, weil "Patrick von
* Hohenstein" sonst zu "PV" würde. */
function initialen(name) {
const teile = String(name || '').trim().split(/\s+/).filter(Boolean);
if (!teile.length) return '?';
const erst = [...teile[0]][0] || '';
const letzt = teile.length > 1 ? ([...teile[teile.length - 1]][0] || '') : '';
return (erst + letzt).toLocaleUpperCase('de-DE');
}
/** "14:32" heute, "Gestern", sonst "3. Sep".
*
* ORTSZEIT, nicht UTC: Die Zeitstempel kommen als ISO-Text vom
* Server. `new Date(text)` liest sie richtig, `slice()` darauf wäre
* UTC und läge nachts einen Tag daneben. */
function wann(iso, lang = false) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
const uhr = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
if (lang) return `${d.toLocaleDateString('de-DE', { day: 'numeric', month: 'long' })}, ${uhr}`;
const heute = new Date();
const gleich = (a, b) => a.getFullYear() === b.getFullYear()
&& a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
if (gleich(d, heute)) return uhr;
const gestern = new Date(heute.getTime() - 86400000);
if (gleich(d, gestern)) return 'Gestern';
return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' });
}
const melde = (t) => { $('fehler').textContent = t || ''; };
/* ---------- Die Gesprächsliste ------------------------------------------- */
async function raeumeLaden() {
try {
const a = await hole('/workspace/api/chat/raeume');
if (!a.ok) return;
raeume = (await a.json()).raeume || [];
} catch { return; }
raeumeZeichnen();
ungelesenMelden();
}
function raeumeZeichnen() {
const ziel = $('raeume');
const suchtext = ($('suche').value || '').trim().toLowerCase();
const zeigen = suchtext
? raeume.filter((r) => r.name.toLowerCase().includes(suchtext))
: raeume;
ziel.textContent = '';
$('liste-leer').hidden = raeume.length > 0;
/* Das Suchfeld erst, wenn die Liste lang genug ist, um darin zu
suchen. Vorher ist es ein Feld ohne Zweck. */
/* IMMER SICHTBAR SEIT DEM 09.09.2026.
Hier stand `raeume.length < 8` mit der Begruendung, ein Suchfeld
ueber drei Eintraegen sei Zierde. Das stimmte, solange es nur
NAMEN durchsuchte. Seit es auch die Nachrichten durchsucht, ist
es das Gegenteil: Zwei Gespraeche koennen tausend Nachrichten
haben, und genau dann braucht man es am dringendsten. */
$('suche-zeile').hidden = false;
for (const r of zeigen) {
const li = el('li', 'chat-raum');
li.dataset.id = String(r.id);
if (offen && r.id === offen.id) li.dataset.offen = 'ja';
if (r.ungelesen) li.dataset.neu = 'ja';
const knopf = el('button', 'chat-raum__knopf');
knopf.type = 'button';
const zeichen = el('span', 'chat-raum__zeichen');
zeichen.setAttribute('aria-hidden', 'true');
if (r.art === 'gruppe') {
zeichen.dataset.gruppe = 'ja';
zeichen.textContent = String(r.teilnehmer.length);
} else {
zeichen.textContent = initialen(r.name);
const gegen = r.teilnehmer.find((t) => t.id !== ich.id);
if (gegen) {
zeichen.dataset.rolle = gegen.rolle;
if (gegen.bild) {
const b = document.createElement('img');
b.src = gegen.bild; b.alt = '';
b.addEventListener('error', () => b.remove());
zeichen.append(b);
}
}
}
knopf.append(zeichen);
const mitte = el('span', 'chat-raum__mitte');
const oben = el('span', 'chat-raum__oben');
oben.append(el('span', 'chat-raum__name', r.name));
if (r.letzte) oben.append(el('span', 'chat-raum__zeit', wann(r.letzte.erstellt)));
mitte.append(oben);
const vorschau = el('span', 'chat-raum__vorschau');
if (r.letzte) {
const wer = r.letzte.von === ich.name ? 'Du: ' : (r.art === 'gruppe' ? `${r.letzte.von}: ` : '');
vorschau.textContent = r.letzte.zurueckgenommen
? `${wer}Nachricht zurückgenommen`
: wer + r.letzte.text;
if (r.letzte.zurueckgenommen) vorschau.dataset.weg = 'ja';
} else {
vorschau.textContent = 'Noch nichts geschrieben';
vorschau.dataset.weg = 'ja';
}
mitte.append(vorschau);
knopf.append(mitte);
if (r.ungelesen) {
const zahl = el('span', 'chat-raum__zahl', String(r.ungelesen));
zahl.setAttribute('aria-label', `${r.ungelesen} ungelesen`);
knopf.append(zahl);
}
knopf.addEventListener('click', () => raumOeffnen(r.id));
li.append(knopf);
ziel.append(li);
}
if (suchtext && !zeigen.length) {
const li = el('li', 'chat__leer', 'Kein Gespräch passt dazu.');
ziel.append(li);
}
}
/* Bis wohin war beim Oeffnen gelesen -- daraus entsteht die Linie
"ab hier ist es neu". Und worauf gerade geantwortet wird. */
let gelesenBeimOeffnen = 0;
let antwortAuf = null;
/* ---------- In Nachrichten suchen (09.09.2026, Punkt 15) ----------------- */
let suchLauf = 0;
async function nachrichtenSuchen() {
const frage = ($('suche').value || '').trim();
const kasten = $('funde');
const liste = $('funde-liste');
if (!kasten) return;
if (frage.length < 2) { kasten.hidden = true; liste.textContent = ''; return; }
/* JEDE ANFRAGE BEKOMMT EINE NUMMER, und nur die neueste darf
zeichnen. Beim Tippen laufen mehrere gleichzeitig; ohne das
koennte die Antwort auf "Pa" nach der auf "Patrick" ankommen und
die richtige Liste ueberschreiben. Das sieht dann aus, als
fuende die Suche das Falsche. */
const meine = ++suchLauf;
let daten;
try {
const a = await hole(`/workspace/api/chat/suche?q=${encodeURIComponent(frage)}`);
if (!a.ok) return;
daten = await a.json();
} catch { return; }
if (meine !== suchLauf) return;
liste.textContent = '';
kasten.hidden = false;
$('funde-schild').textContent = daten.treffer.length
? `In Nachrichten · ${daten.treffer.length}`
: 'In Nachrichten';
if (!daten.treffer.length) {
/* Ein leeres Ergebnis wird GESAGT. Ein Kasten, der einfach leer
bleibt, sieht aus, als laufe die Suche noch. */
liste.append(el('li', 'chat-funde__leer', 'Nichts gefunden.'));
return;
}
for (const t of daten.treffer) {
const li = el('li');
const b = el('button', 'chat-fund');
b.type = 'button';
const kopf = el('span', 'chat-fund__kopf');
kopf.append(el('span', 'chat-fund__raum', t.raum));
kopf.append(el('span', 'chat-fund__wann', wann(t.erstellt)));
b.append(kopf);
b.append(el('span', 'chat-fund__von', t.von));
b.append(el('span', 'chat-fund__text', t.text));
b.addEventListener('click', async () => {
await raumOeffnen(t.raum_id);
/* Erst oeffnen, dann springen -- vorher gibt es die Zeile
nicht. Steht sie nicht im geladenen Ausschnitt, sagt
`zurStelle` das. */
zurStelle(t.id);
});
li.append(b);
liste.append(li);
}
}
/* ---------- Ein Gespräch öffnen ------------------------------------------ */
async function raumOeffnen(id, { sanft = false } = {}) {
try {
const a = await hole(`/workspace/api/chat/raeume/${id}/nachrichten`);
if (!a.ok) { melde('Dieses Gespräch gibt es nicht mehr.'); return; }
const daten = await a.json();
offen = daten.raum;
nachrichten = daten.nachrichten;
/* DER STAND VON VORHIN, nicht der von jetzt. `gelesenMelden()`
weiter unten setzt ihn gleich auf die letzte Nachricht -- die
Linie muss also aus dem Wert gezogen werden, der beim OEFFNEN
galt. Danach wuerde sie nie irgendwo stehen. */
gelesenBeimOeffnen = daten.gelesen_bis ?? 0;
antwortAuf = null;
} catch { return; }
$('verlauf-titel').textContent = offen.name;
$('verlauf-unter').textContent = offen.art === 'gruppe'
? offen.teilnehmer.map((t) => t.name).join(' · ')
: (ROLLENKURZ[offen.teilnehmer.find((t) => t.id !== ich.id)?.rolle] || '');
$('schreiben').hidden = false;
$('verlauf-leer').hidden = true;
$('chat').dataset.ansicht = 'verlauf';
verlaufZeichnen();
if (!sanft) $('text').focus();
/* Die Adresszeile merkt sich das Gespräch -- so führt die
Benachrichtigung direkt hierher, und ein Neuladen bleibt am
richtigen Ort. */
history.replaceState(null, '', `chat.html?raum=${id}`);
gelesenMelden();
raeumeZeichnen();
}
function verlaufZeichnen() {
const ziel = $('verlauf');
ziel.textContent = '';
if (!nachrichten.length) {
ziel.append(el('p', 'chat__leer', 'Noch nichts geschrieben. Fang an.'));
return;
}
let letzterTag = '';
let letzterVon = null;
let linieGesetzt = false;
for (const n of nachrichten) {
/* DIE LINIE "AB HIER IST ES NEU" (09.09.2026, Punkt 15).
Sie steht vor der ersten Nachricht, die beim Oeffnen noch
nicht gelesen war -- und nur EINMAL. Wer nach zwei Tagen
zurueckkommt, sucht sonst die Stelle, an der er aufgehoert
hat, indem er Uhrzeiten liest.
Nicht bei eigenen Nachrichten: Was man selbst geschrieben hat,
ist nie "neu fuer einen". */
if (!linieGesetzt && gelesenBeimOeffnen && n.id > gelesenBeimOeffnen && !n.selbst) {
linieGesetzt = true;
const linie = el('div', 'chat-neu');
linie.append(el('span', 'chat-neu__text', 'Ab hier neu'));
ziel.append(linie);
}
/* Ein Datumstrenner, wenn ein neuer Tag beginnt. Ohne ihn steht
die Antwort von vorgestern direkt unter der von heute. */
const tag = new Date(n.erstellt).toDateString();
if (tag !== letzterTag) {
letzterTag = tag;
letzterVon = null;
const trenner = el('div', 'chat-tag');
trenner.append(el('span', 'chat-tag__text', tagName(n.erstellt)));
ziel.append(trenner);
}
const zeile = el('article', 'chat-nachricht');
zeile.dataset.id = String(n.id);
if (n.selbst) zeile.dataset.selbst = 'ja';
if (n.zurueckgenommen) zeile.dataset.weg = 'ja';
/* Mehrere Nachrichten derselben Person hintereinander rücken
zusammen und wiederholen den Namen nicht -- sonst liest man
fünfmal "Patrick" und dreimal dasselbe Gesicht. */
const gleicheFolge = letzterVon === n.von_id;
if (gleicheFolge) zeile.dataset.folge = 'ja';
letzterVon = n.von_id;
if (!gleicheFolge && !n.selbst) {
/* DAS GESICHT STATT DER INITIALEN (07.09.2026).
Der Anfangsbuchstabe bleibt als Unterlage LIEGEN und wird
nicht ersetzt: Laedt das Bild nicht, steht dort weiterhin
etwas Sinnvolles statt eines kaputten Bildsymbols. Dieselbe
Bauweise wie in der Kopfleiste und in der Personenauswahl --
drei Stellen, ein Verhalten. */
const z = el('span', 'chat-nachricht__zeichen', initialen(n.von));
z.setAttribute('aria-hidden', 'true');
z.dataset.rolle = n.rolle || '';
if (n.von_bild) {
const b = document.createElement('img');
b.src = n.von_bild; b.alt = '';
b.addEventListener('error', () => b.remove());
z.append(b);
}
zeile.append(z);
}
const blase = el('div', 'chat-nachricht__blase');
if (!gleicheFolge && !n.selbst && offen.art === 'gruppe') {
blase.append(el('p', 'chat-nachricht__von', n.von));
}
/* DAS ZITAT STEHT IN DER BLASE, NICHT DARUEBER. So wandert es beim
Rollen mit und gehoert sichtbar zur Antwort -- eine Zeile
darueber liest sich als eigene Nachricht. */
if (n.antwort) {
const z = el('button', 'chat-zitat');
z.type = 'button';
z.append(el('span', 'chat-zitat__von', n.antwort.von));
z.append(el('span', 'chat-zitat__text', n.antwort.zurueckgenommen
? 'Nachricht zurückgenommen' : n.antwort.text));
z.title = 'Zur zitierten Nachricht springen';
/* Ein Zitat, das nicht zur Stelle fuehrt, ist ein Bild von
einer Nachricht. Der Klick springt hin und hebt sie kurz
hervor. */
z.addEventListener('click', () => zurStelle(n.antwort.id));
blase.append(z);
}
if (n.zurueckgenommen) {
blase.append(el('p', 'chat-nachricht__text chat-nachricht__weg',
'Nachricht zurückgenommen'));
} else {
/* textContent, nie innerHTML: Eine Nachricht darf keine
Auszeichnung einschleusen. Zeilenumbrüche macht das CSS
(white-space: pre-wrap). */
blase.append(el('p', 'chat-nachricht__text', n.text));
}
const fuss = el('div', 'chat-nachricht__fuss');
const zeit = el('time', 'chat-nachricht__zeit', wann(n.erstellt));
zeit.dateTime = n.erstellt;
zeit.title = wann(n.erstellt, true);
fuss.append(zeit);
/* Antworten geht auf JEDE Nachricht, auch die eigene -- in einer
Gruppe bezieht man sich durchaus auf sich selbst. Nur auf eine
zurueckgenommene nicht: Das Zitat waere leer. */
if (!n.zurueckgenommen && n.id > 0) {
const ant = el('button', 'chat-nachricht__antwort-knopf', 'antworten');
ant.type = 'button';
ant.addEventListener('click', () => antwortSetzen(n));
fuss.append(ant);
}
if (n.selbst && !n.zurueckgenommen) {
const weg = el('button', 'chat-nachricht__weg-knopf', 'zurücknehmen');
weg.type = 'button';
weg.addEventListener('click', () => zuruecknehmen(n.id));
fuss.append(weg);
}
blase.append(fuss);
zeile.append(blase);
ziel.append(zeile);
}
ziel.scrollTop = ziel.scrollHeight;
}
/* ---------- Antworten und Springen (09.09.2026, Punkt 15) ---------------- */
function antwortSetzen(n) {
antwortAuf = { id: n.id, von: n.von, text: n.text };
const leiste = $('antwort-leiste');
if (!leiste) return;
leiste.hidden = false;
$('antwort-von').textContent = n.von;
$('antwort-text').textContent = n.text;
$('text').focus();
}
function antwortLoeschen() {
antwortAuf = null;
const leiste = $('antwort-leiste');
if (leiste) leiste.hidden = true;
}
/** Zur zitierten Nachricht springen und sie kurz hervorheben.
*
* Steht sie nicht (mehr) im geladenen Ausschnitt, wird das GESAGT --
* ein Klick, der nichts tut, laesst einen glauben, der Knopf sei
* kaputt. */
function zurStelle(id) {
const el2 = document.querySelector(`.chat-nachricht[data-id="${id}"]`);
if (!el2) { melde('Diese Nachricht liegt weiter oben – bitte nach oben blättern.'); return; }
el2.scrollIntoView({ block: 'center', behavior: 'smooth' });
el2.dataset.hervor = 'ja';
setTimeout(() => { delete el2.dataset.hervor; }, 2200);
}
function tagName(iso) {
const d = new Date(iso);
const heute = new Date();
const gleich = (a, b) => a.toDateString() === b.toDateString();
if (gleich(d, heute)) return 'Heute';
if (gleich(d, new Date(heute.getTime() - 86400000))) return 'Gestern';
return d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' });
}
/* ---------- Schreiben ---------------------------------------------------- */
async function abschicken(e) {
e.preventDefault();
const feld = $('text');
const text = feld.value.trim();
if (!text || !offen) return;
/* SOFORT ANZEIGEN. Die vorläufige Nachricht bekommt eine negative
Nummer -- damit lässt sie sich später zweifelsfrei durch die
echte ersetzen, ohne mit einer Server-Nummer zu kollidieren. */
/* Das Antwortziel wird HIER festgehalten, nicht erst im
`await`. Wer waehrend des Sendens auf eine andere Nachricht
antwortet, wuerde sonst das falsche Zitat mitschicken -- die
Anfrage laeuft, die Variable hat sich schon geaendert. */
const zielAntwort = antwortAuf;
const vorlaeufig = {
id: -Date.now(), von_id: ich.id, von: ich.name, rolle: ich.rolle,
text, erstellt: new Date().toISOString(), selbst: true, unterwegs: true,
antwort: zielAntwort
? { id: zielAntwort.id, von: zielAntwort.von, text: zielAntwort.text }
: null,
};
nachrichten.push(vorlaeufig);
verlaufZeichnen();
feld.value = '';
antwortLoeschen();
hoeheAnpassen();
melde('');
try {
const a = await hole(`/workspace/api/chat/raeume/${offen.id}/nachrichten`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, antwort_auf: zielAntwort?.id ?? null }),
});
if (!a.ok) {
const d = await a.json().catch(() => ({}));
markiereAlsGescheitert(vorlaeufig.id, d.fehler || 'Nicht angekommen.');
return;
}
const { nachricht } = await a.json();
const i = nachrichten.findIndex((n) => n.id === vorlaeufig.id);
if (i >= 0) nachrichten[i] = { ...nachricht, selbst: true };
verlaufZeichnen();
raeumeLaden();
} catch {
markiereAlsGescheitert(vorlaeufig.id, 'Keine Verbindung.');
}
}
/** NICHT STILL VERSCHWINDEN LASSEN. Wer etwas schreibt und es sieht,
* glaubt, es sei angekommen. Verschwindet es wortlos, merkt er es
* im schlimmsten Fall Tage später. */
function markiereAlsGescheitert(id, grund) {
const i = nachrichten.findIndex((n) => n.id === id);
if (i >= 0) { nachrichten[i].gescheitert = true; delete nachrichten[i].unterwegs; }
verlaufZeichnen();
melde(`${grund} Die Nachricht steht noch da – kopiere sie und versuch es erneut.`);
}
async function zuruecknehmen(id) {
if (!window.confirm('Diese Nachricht zurücknehmen? Der Text ist dann weg, '
+ 'im Verlauf bleibt ein Hinweis stehen.')) return;
try {
const a = await hole(`/workspace/api/chat/nachrichten/${id}`, { method: 'DELETE' });
if (!a.ok) { melde('Ging nicht.'); return; }
const i = nachrichten.findIndex((n) => n.id === id);
if (i >= 0) { nachrichten[i].zurueckgenommen = true; nachrichten[i].text = null; }
verlaufZeichnen();
raeumeLaden();
} catch { /* umgeleitet */ }
}
/** Das Feld wächst mit dem Text -- bis zu einer Grenze, sonst
* verdrängt eine lange Nachricht den ganzen Verlauf. */
function hoeheAnpassen() {
const f = $('text');
f.style.height = 'auto';
f.style.height = Math.min(f.scrollHeight, 160) + 'px';
}
/* ---------- Gelesen ------------------------------------------------------ */
function gelesenMelden() {
if (!offen || !nachrichten.length) return;
/* NUR WENN MAN WIRKLICH HINSIEHT. Eine Seite im Hintergrund hat
niemand gelesen. */
if (document.hidden) return;
const echte = nachrichten.filter((n) => n.id > 0);
if (!echte.length) return;
const bis = echte[echte.length - 1].id;
fetch(`/workspace/api/chat/raeume/${offen.id}/gelesen`, {
method: 'PUT', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bis }),
}).then(() => {
const r = raeume.find((x) => x.id === offen.id);
if (r) { r.ungelesen = 0; raeumeZeichnen(); }
ungelesenMelden();
}).catch(() => {});
}
/** Sagt der Kopfleiste, wie viele insgesamt warten. */
async function ungelesenMelden() {
try {
const a = await fetch('/workspace/api/chat/ungelesen', { credentials: 'same-origin' });
if (!a.ok) return;
const { ungelesen } = await a.json();
window.chatZahlZeigen?.(ungelesen);
} catch { /* die Zahl ist Zugabe */ }
}
/* ---------- Neue Nachrichten kommen von selbst --------------------------- */
function stromOeffnen() {
if (strom) strom.close();
strom = new EventSource('/workspace/api/chat/strom');
strom.addEventListener('neu', (e) => {
let n;
try { n = JSON.parse(e.data); } catch { return; }
if (offen && n.raum_id === offen.id) {
/* SCHON DA? Zwei Wege führen hierher, und beide sind richtig:
die Antwort auf das eigene POST und der Strom. */
if (nachrichten.some((x) => x.id === n.id)) return;
/* DER WETTLAUF (gefunden 06.09.2026, im Zwei-Browser-Test):
Beim Abschicken steht die eigene Nachricht sofort da -- mit
einer vorläufigen negativen Nummer. Ist der Strom SCHNELLER
als die Antwort auf das POST, greift die Prüfung darüber
nicht (die Nummern sind verschieden), und die Nachricht
stand zweimal im Verlauf.
Deshalb wird eine eigene Nachricht, die noch unterwegs ist
und denselben Text trägt, hier ERSETZT statt ergänzt. Damit
gewinnt, wer zuerst kommt, und das Ergebnis ist dasselbe. */
if (n.von_id === ich.id) {
const i = nachrichten.findIndex(
(x) => x.unterwegs && x.text === n.text);
if (i >= 0) {
nachrichten[i] = { ...n, selbst: true };
verlaufZeichnen();
return;
}
}
nachrichten.push({ ...n, selbst: n.von_id === ich.id });
verlaufZeichnen();
gelesenMelden();
}
raeumeLaden();
});
strom.addEventListener('weg', (e) => {
let n;
try { n = JSON.parse(e.data); } catch { return; }
const i = nachrichten.findIndex((x) => x.id === n.id);
if (i >= 0) {
nachrichten[i].zurueckgenommen = true;
nachrichten[i].text = null;
verlaufZeichnen();
}
raeumeLaden();
});
/* Bei einem Abriss verbindet EventSource von selbst neu. Wir holen
danach den Verlauf nach -- sonst fehlte, was während der Pause
geschrieben wurde. */
strom.addEventListener('open', () => {
if (offen) raumOeffnen(offen.id, { sanft: true });
else raeumeLaden();
});
}
/* ---------- Neues Gespräch ----------------------------------------------- */
async function partnerLaden() {
const ziel = $('partner');
ziel.textContent = '';
gewaehlt = new Set();
$('gruppenname-zeile').hidden = true;
$('neu-los').disabled = true;
$('neu-fehler').textContent = '';
let personen = [];
try {
const a = await hole('/workspace/api/chat/partner');
if (a.ok) personen = (await a.json()).personen || [];
} catch { return; }
if (!personen.length) {
ziel.append(el('p', 'chat__leer',
'Dir ist noch niemand zugeordnet – melde dich bei DogFather.'));
return;
}
/* Nach Rollen gruppiert, wie bei der Teilnehmerwahl im Kalender.
Die Rolle steht einmal über der Gruppe statt an jedem Namen. */
/* Die Rollenfolge kommt aus bereiche.js. Stand sie hier selbst,
fehlte 'spicy' -- und wer nicht in der Schleife vorkommt, taucht
in der Gesprächsliste gar nicht auf. Kein Fehler, nur eine
Person, die es fuer die Anderen nicht gibt. */
for (const rolle of window.Bereiche.ROLLENFOLGE) {
const leute = personen.filter((p) => p.rolle === rolle);
if (!leute.length) continue;
const gruppe = el('div', 'chat-wahl__gruppe');
gruppe.dataset.rolle = rolle;
const titel = el('p', 'chat-wahl__titel');
const punkt = el('span', 'chat-wahl__punkt');
punkt.setAttribute('aria-hidden', 'true');
titel.append(punkt, document.createTextNode(
window.Bereiche.ROLLEN_GRUPPE[rolle]));
gruppe.append(titel);
const liste = el('div', 'chat-wahl__liste');
liste.setAttribute('role', 'group');
for (const p of leute) {
const b = el('button', 'chat-wahl__knopf');
b.type = 'button';
b.dataset.id = String(p.id);
b.dataset.rolle = p.rolle;
b.setAttribute('aria-pressed', 'false');
const z = el('span', 'chat-wahl__zeichen', initialen(p.name));
z.setAttribute('aria-hidden', 'true');
b.append(z, el('span', 'chat-wahl__name', p.name));
b.addEventListener('click', () => {
if (gewaehlt.has(p.id)) { gewaehlt.delete(p.id); delete b.dataset.an; }
else { gewaehlt.add(p.id); b.dataset.an = 'ja'; }
b.setAttribute('aria-pressed', gewaehlt.has(p.id) ? 'true' : 'false');
wahlGeaendert();
});
liste.append(b);
}
gruppe.append(liste);
ziel.append(gruppe);
}
}
function wahlGeaendert() {
const n = gewaehlt.size;
/* Ab zwei ist es eine Gruppe -- und die braucht einen Namen. */
$('gruppenname-zeile').hidden = n < 2;
$('neu-los').disabled = n === 0;
$('neu-los').textContent = n >= 2 ? 'Gruppe anlegen' : 'Schreiben';
}
async function neuAbschicken(e) {
e.preventDefault();
const ids = [...gewaehlt];
if (!ids.length) return;
const knopf = $('neu-los');
knopf.disabled = true;
try {
let raumId;
if (ids.length === 1) {
const a = await hole('/workspace/api/chat/direkt', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mit: ids[0] }),
});
if (!a.ok) {
$('neu-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.';
return;
}
raumId = (await a.json()).raum_id;
} else {
const name = $('gruppenname').value.trim();
if (name.length < 2) {
$('neu-fehler').textContent = 'Die Gruppe braucht einen Namen.';
$('gruppenname').focus();
return;
}
const a = await hole('/workspace/api/chat/gruppe', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, mit: ids }),
});
if (!a.ok) {
$('neu-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.';
return;
}
raumId = (await a.json()).raum_id;
}
$('neu-dialog').close();
await raeumeLaden();
await raumOeffnen(raumId);
} catch { /* umgeleitet */ } finally { knopf.disabled = false; }
}
/* ---------- Die Höhe der Kopfleiste MESSEN, nicht raten ------------------
Die Chatfläche ist so hoch wie das Fenster minus die Kopfleiste.
Deren Höhe stand zuerst als fester Wert im CSS (62 px) -- am Handy
ist sie zweizeilig und gut doppelt so hoch. Folge: Die Seite war zu
lang, und das Eingabefeld stand halb unter dem Bildschirmrand.
Ein fester Wert ist hier immer falsch: Die Leiste wächst mit der
Schriftgröße, mit der Glocke, mit einem längeren Namen. Also wird
sie gemessen -- einmal beim Start und wieder, wenn sich etwas an
der Fenstergröße ändert. */
function kopfMessen() {
const kopf = document.querySelector('.kopfleiste');
if (!kopf) return;
const h = Math.ceil(kopf.getBoundingClientRect().height);
document.documentElement.style.setProperty('--kopf-hoehe', `${h}px`);
}
kopfMessen();
window.addEventListener('resize', kopfMessen);
/* Auch wenn die Leiste selbst sich ändert (Glocke erscheint, Name
wird eingesetzt) -- ohne das bliebe der erste Messwert stehen. */
if (window.ResizeObserver) {
const kopf = document.querySelector('.kopfleiste');
if (kopf) new ResizeObserver(kopfMessen).observe(kopf);
}
/* ---------- Start -------------------------------------------------------- */
/* Der Verwerfen-Knopf der Antwortleiste. Zusaetzlich beendet Escape
die Antwort -- wer im Eingabefeld steht, greift eher zur Taste als
zur Maus. */
$('antwort-weg')?.addEventListener('click', antwortLoeschen);
$('text')?.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && antwortAuf) { e.preventDefault(); antwortLoeschen(); }
});
$('schreiben').addEventListener('submit', abschicken);
$('text').addEventListener('input', hoeheAnpassen);
/* Enter schickt, Umschalt+Enter macht eine neue Zeile. Am Handy
NICHT -- dort ist Enter die Zeilenschaltung der Bildschirmtastatur,
und wer eine zweite Zeile will, käme sonst nie dazu. */
$('text').addEventListener('keydown', (e) => {
const handy = window.matchMedia('(hover: none)').matches;
if (e.key === 'Enter' && !e.shiftKey && !handy) {
e.preventDefault();
$('schreiben').requestSubmit();
}
});
$('suche').addEventListener('input', () => { raeumeZeichnen(); nachrichtenSuchen(); });
$('neu-oeffnen').addEventListener('click', async () => {
await partnerLaden();
$('neu-dialog').showModal();
});
$('neu-zu').addEventListener('click', () => $('neu-dialog').close());
$('neu-form').addEventListener('submit', neuAbschicken);
$('zur-liste').addEventListener('click', () => {
$('chat').dataset.ansicht = 'liste';
history.replaceState(null, '', 'chat.html');
});
/* Zurück an den Bildschirm: nachsehen, was inzwischen kam, und melden,
dass jetzt wirklich jemand hinsieht. */
document.addEventListener('visibilitychange', () => {
if (document.hidden) return;
gelesenMelden();
raeumeLaden();
});
(async () => {
try {
const a = await hole('/workspace/api/ich');
if (!a.ok) { location.assign('/workspace/'); return; }
ich = await a.json();
} catch { return; }
window.werZeigen?.(ich);
await raeumeLaden();
stromOeffnen();
/* Kommt man aus einer Benachrichtigung, steht das Gespräch in der
Adresse -- dann direkt hinein. */
const suche = new URLSearchParams(location.search);
const wunsch = Number(suche.get('raum'));
if (Number.isInteger(wunsch) && wunsch > 0) { await raumOeffnen(wunsch); return; }
/* ?mit=<Person> -- der Weg von woanders her (06.09.2026).
Die Frühwarnung im Dashboard schlägt vor nachzufassen; ohne
diesen Einstieg müsste man danach die Gesprächsliste durchsuchen
oder ein Gespräch von Hand anlegen, das vielleicht schon
existiert. `/direkt` legt beim ersten Mal an und findet beim
zweiten dasselbe wieder -- ein Aufruf für beide Fälle.
Die Berechtigung prüft der Server (darfSchreibenMit). Eine von
Hand getippte Personennummer bringt hier nichts. */
const mit = Number(suche.get('mit'));
if (!Number.isInteger(mit) || mit < 1) return;
try {
const a = await hole('/workspace/api/chat/direkt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mit }),
});
if (!a.ok) return; /* die Liste steht ja */
const { raum_id: raumId } = await a.json();
await raeumeLaden();
await raumOeffnen(raumId);
} catch { /* die Liste steht ja */ }
})();
})();