/* =====================================================================
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
/* KANAELE UND ANGEHEFTETES (10.09.2026, Kapitel 7.2).
`darfKanal` und `darfAnheftenHier` kommen vom SERVER, nicht aus
einem Rollenvergleich hier. Diese Datei bekommt jeder, der die
Seite oeffnet -- ein `rolle === 'admin'` darin waere eine Auskunft
ueber den Aufbau des Hauses an alle. Ein Ja/Nein ist keine. */
let angeheftet = [];
let darfAnheftenHier = false;
let darfKanal = false;
let kanalFrei = [];
let neuArt = 'gespraech'; // 'gespraech' | 'kanal'
let kanalUmbau = null; // Raumnummer, wenn die Besetzung geaendert wird
/* ---------- 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' });
}
/* Dieselbe Zeile trägt zwei Sorten Nachricht: eine Störung (rot) und
einen Zwischenstand wie "wird geschickt …" (ruhig). Beides rot zu
zeigen hiesse, dass man Rot irgendwann nicht mehr ernst nimmt --
und Rot ist hier das Einzige, was Aufmerksamkeit verlangen darf.
Die Farbe entscheidet das CSS über data-art, nicht diese Zeile. */
const melde = (t, art = 'fehler') => {
const z = $('fehler');
z.textContent = t || '';
z.dataset.art = t ? art : 'fehler';
};
/* ---------- Die Gesprächsliste ------------------------------------------- */
async function raeumeLaden() {
try {
const a = await hole('/workspace/api/chat/raeume');
if (!a.ok) return;
const daten = await a.json();
raeume = daten.raeume || [];
darfKanal = !!daten.darf_kanal;
kanalFrei = daten.kanal_frei || [];
/* Der Knopf erscheint nur, wenn der Server etwas zu bieten hat.
Ein Knopf, der beim Druecken "darfst du nicht" sagt, ist eine
Enttaeuschung mit Ansage. */
$('neu-art').hidden = !darfKanal;
/* Abgeleitet, nicht zweimal entschieden: Die Erklaerung gehoert
genau dann hin, wenn es auch etwas zu entscheiden gibt. */
$('neu-erklaerung').hidden = $('neu-art').hidden;
if (!darfKanal) neuArt = 'gespraech';
} 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 === 'kanal') {
/* Das Rautenzeichen ist die Schreibweise, die jeder aus
anderen Messengern kennt -- es muss nicht erklaert werden. */
zeichen.dataset.kanal = 'ja';
zeichen.textContent = '#';
} else 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) {
/* Wer geschrieben hat, gehoert ueberall dorthin, wo mehr als
zwei Leute schreiben koennen -- in einer Gruppe wie in einem
Kanal. Hier stand `=== 'gruppe'`, und ein Kanal waere damit
die einzige Stelle ohne Absender in der Vorschau gewesen. */
const wer = r.letzte.von === ich.name ? 'Du: '
: (r.art !== 'direkt' ? `${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;
angeheftet = daten.angeheftet || [];
darfAnheftenHier = !!daten.darf_anheften;
/* 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;
/* Bei allem, was mehr als zwei Leute hat, stehen die Namen darunter
-- bei einem Zweier-Gespraech die Rolle des Gegenuebers. */
$('verlauf-unter').textContent = offen.art !== 'direkt'
? offen.teilnehmer.map((t) => t.name).join(' · ')
: (ROLLENKURZ[offen.teilnehmer.find((t) => t.id !== ich.id)?.rolle] || '');
/* Die Besetzung aendern kann nur, wer Kanaele fuehrt -- und nur in
einem Kanal. In einer Gruppe waere es ein Eingriff in fremde
Runden; siehe die Route dazu im Server. */
$('kanal-leute').hidden = !(darfKanal && offen.art === 'kanal');
$('schreiben').hidden = false;
$('verlauf-leer').hidden = true;
$('chat').dataset.ansicht = 'verlauf';
/* Erst jetzt gibt es etwas zum Wegräumen. */
$('gespraech-weg').hidden = false;
/* AUFLOESEN: nur beim Gruender, und nur bei Gruppe oder Kanal.
Die Antwort kommt vom Server (`darf_aufloesen`) -- hier wird
nichts nachgerechnet. Der Browser kennt `erstellt_von` gar
nicht, und eine zweite Rechenvorschrift waere die, die
irgendwann von der ersten abweicht. */
$('raum-aufloesen').hidden = !offen.darf_aufloesen;
/* Ein frisch geoeffnetes Gespraech steht immer am Ende -- man will
das Letzte sehen, nicht das Erste. */
unten = true; neuUnten = 0;
pinsZeichnen();
verlaufZeichnen();
/* Der Entwurf von vorhin, falls einer da ist. */
$('text').value = entwurfHolen(id);
hoeheAnpassen();
zaehlerZeigen();
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();
}
/* =======================================================================
DIE ANGEHEFTETEN (10.09.2026, Kapitel 7.2)
Sie stehen UEBER dem Verlauf und rollen nicht mit. Eine Ankuendigung,
die man erst suchen muss, ist keine.
Jeder Aushang fuehrt zu seiner Stelle im Verlauf. Ohne das waere er
ein Bild von einer Nachricht: Man liest den Anfang und kann nicht
nachsehen, was darauf geantwortet wurde.
======================================================================= */
function pinNadel() {
const raum = 'http://www.w3.org/2000/svg';
const s = document.createElementNS(raum, 'svg');
s.setAttribute('viewBox', '0 0 24 24');
s.setAttribute('aria-hidden', 'true');
s.setAttribute('class', 'chat-pin__nadel');
const pfad = document.createElementNS(raum, 'path');
pfad.setAttribute('d', 'M9 3h6l-1 5 3 3v2h-5v6l-1 2-1-2v-6H5v-2l3-3z');
pfad.setAttribute('fill', 'currentColor');
s.append(pfad);
return s;
}
function pinsZeichnen() {
const ziel = $('pins');
ziel.textContent = '';
ziel.hidden = !angeheftet.length;
if (!angeheftet.length) return;
for (const a of angeheftet) {
const kasten = el('div', 'chat-pin');
const auf = el('button', 'chat-pin__auf');
auf.type = 'button';
auf.title = 'Zur Nachricht springen';
const kopf = el('span', 'chat-pin__kopf');
kopf.append(pinNadel());
kopf.append(el('span', 'chat-pin__von', a.von));
if (a.heftete) kopf.append(el('span', 'chat-pin__wer', `angeheftet von ${a.heftete}`));
auf.append(kopf);
auf.append(el('span', 'chat-pin__text', a.text || 'Ohne Text'));
auf.addEventListener('click', () => zurStelle(a.id));
kasten.append(auf);
if (darfAnheftenHier) {
const los = el('button', 'chat-pin__los', 'lösen');
los.type = 'button';
los.setAttribute('aria-label', `„${(a.text || '').slice(0, 40)}“ lösen`);
los.addEventListener('click', () => anheften(a.id, false));
kasten.append(los);
}
ziel.append(kasten);
}
}
async function anheften(id, an) {
try {
const a = await hole(`/workspace/api/chat/nachrichten/${id}/anheften`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ an }),
});
if (!a.ok) {
melde((await a.json().catch(() => ({}))).fehler || 'Ging nicht.');
return;
}
/* Neu holen statt hier nachzurechnen: Der Server kennt die Grenze
von drei, das Abfallen zurueckgenommener Nachrichten und die
Reihenfolge. Zwei Rechnungen darueber liefen irgendwann
auseinander. */
if (offen) await raumOeffnen(offen.id, { sanft: true });
} catch { /* umgeleitet */ }
}
/* ===================================================================
REAKTIONEN (14.09.2026)
Filipe: "ich will dass man auf die nachrichten auch reagieren kann
mit einem emoji, die nachricht selbst ohne zu antworten."
DIE AUSWAHL KOMMT VOM SERVER. Stuende sie hier, gaebe es sie
zweimal -- und die zweite Fassung ist die, in der ein Zeichen
fehlt, das der Server noch annimmt. Faellt der Abruf aus, gibt es
keinen Ersatz aus dem Aermel: Dann erscheint die Auswahl nicht,
statt Zeichen anzubieten, die abgewiesen werden.
=================================================================== */
let auswahlDaten = null; /* vom Server: Vorschlag, Katalog, Favoriten */
let offeneAuswahl = null;
let favoritenModus = false;
async function auswahlHolen(neuLaden = false) {
if (auswahlDaten && !neuLaden) return auswahlDaten;
try {
const a = await hole('/workspace/api/chat/reaktionen/auswahl');
if (!a.ok) return null;
const d = await a.json();
if (!Array.isArray(d.zeichen) || !d.zeichen.length) return null;
auswahlDaten = d;
return d;
} catch { return null; }
}
function auswahlSchliessen() {
if (offeneAuswahl) { offeneAuswahl.remove(); offeneAuswahl = null; }
favoritenModus = false;
}
/** Ein Zeichen in der Tafel. Was ein Klick tut, haengt am Modus --
* reagieren oder merken. */
function zeichenKnopf(z, nachrichtId) {
const b = el('button', 'reagieren-tafel__zeichen', z);
b.type = 'button';
b.dataset.zeichen = z;
if (auswahlDaten?.favoriten?.includes(z)) b.dataset.favorit = 'ja';
b.setAttribute('aria-label', `Mit ${z} reagieren`);
b.addEventListener('click', async () => {
if (favoritenModus) { await favoritSetzen(z); return; }
auswahlSchliessen();
reagieren(nachrichtId, z);
});
return b;
}
async function favoritSetzen(zeichen) {
try {
const a = await hole('/workspace/api/chat/reaktionen/favorit', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ zeichen }),
});
const d = await a.json().catch(() => ({}));
const h = offeneAuswahl?.querySelector('.reagieren-tafel__hinweis');
if (!a.ok) {
/* DIE ABSAGE STEHT IN DER TAFEL, nicht irgendwo anders: Wer
gerade Favoriten festlegt, schaut genau dorthin. */
if (h) h.textContent = d.fehler || 'Ging nicht.';
return;
}
auswahlDaten.favoriten = d.favoriten;
auswahlDaten.zeichen = d.zeichen;
if (h && favoritenModus) {
h.textContent = `${d.favoriten.length} von ${auswahlDaten.favoriten_max} gemerkt.`;
}
/* Die Tafel bleibt offen und zeigt den neuen Stand. Sie zu
schliessen waere bei acht Favoriten acht Mal Aufmachen. */
for (const b of offeneAuswahl?.querySelectorAll('.reagieren-tafel__zeichen') || []) {
if (d.favoriten.includes(b.dataset.zeichen)) b.dataset.favorit = 'ja';
else delete b.dataset.favorit;
}
offeneAuswahl?._schnellBauen?.();
} catch { /* stumm -- die Tafel bleibt, wie sie ist */ }
}
/** Die Tafel. Oben die Vorschlagszeile, darunter Suche und Katalog.
*
* WARUM ALLES IN EINER TAFEL und nicht "sechs Zeichen, dann ein
* Fenster fuer den Rest": Wer etwas anderes will, soll nicht erst
* ein zweites Mal klicken. Die Vorschlagszeile ist die Abkuerzung,
* nicht die Grenze. */
async function auswahlZeigen(knopf, nachrichtId) {
const schonOffen = offeneAuswahl && offeneAuswahl.dataset.fuer === String(nachrichtId);
auswahlSchliessen();
if (schonOffen) return;
const daten = await auswahlHolen();
if (!daten) { melde('Die Auswahl ließ sich nicht laden.'); return; }
const tafel = el('div', 'reagieren-tafel');
tafel.dataset.fuer = String(nachrichtId);
tafel.setAttribute('role', 'dialog');
tafel.setAttribute('aria-label', 'Reagieren');
const hinweis = el('p', 'reagieren-tafel__hinweis');
/* ---- Kopf: Vorschlaege + Favoriten-Schalter --------------------- */
const kopf = el('div', 'reagieren-tafel__kopf');
const schnell = el('div', 'reagieren-tafel__schnell');
const schnellBauen = () => {
schnell.textContent = '';
for (const z of auswahlDaten.zeichen) schnell.append(zeichenKnopf(z, nachrichtId));
};
schnellBauen();
kopf.append(schnell);
/* Der Stern schaltet um: Klicken heisst dann MERKEN statt
REAGIEREN. Ein Modus statt eines zweiten Knopfes an jedem
Zeichen -- am Handy gibt es kein "daneben zeigen". */
const stern = el('button', 'reagieren-tafel__stern', '★');
stern.type = 'button';
stern.title = 'Favoriten festlegen';
stern.setAttribute('aria-pressed', 'false');
stern.addEventListener('click', () => {
favoritenModus = !favoritenModus;
tafel.dataset.merken = favoritenModus ? 'ja' : '';
stern.setAttribute('aria-pressed', favoritenModus ? 'true' : 'false');
hinweis.textContent = favoritenModus
? `Antippen merkt ein Zeichen (bis ${auswahlDaten.favoriten_max}). Noch einmal nimmt es weg.`
: '';
});
kopf.append(stern);
tafel.append(kopf);
tafel.append(hinweis);
/* ---- Suche ------------------------------------------------------ */
const suche = document.createElement('input');
suche.type = 'search';
suche.className = 'reagieren-tafel__suche';
suche.placeholder = 'Suchen … z. B. feuer, herz, danke';
suche.setAttribute('aria-label', 'Zeichen suchen');
tafel.append(suche);
/* ---- Katalog ---------------------------------------------------- */
const gitter = el('div', 'reagieren-tafel__katalog');
tafel.append(gitter);
const katalogBauen = (suchwort) => {
gitter.textContent = '';
const w = String(suchwort || '').trim().toLowerCase();
let gefunden = 0;
for (const gruppe of auswahlDaten.katalog) {
const treffer = gruppe.zeichen.filter(([z, woerter]) =>
!w || woerter.includes(w) || z === w);
if (!treffer.length) continue;
gefunden += treffer.length;
gitter.append(el('p', 'reagieren-tafel__gruppe', gruppe.name));
const reihe = el('div', 'reagieren-tafel__reihe');
for (const [z] of treffer) reihe.append(zeichenKnopf(z, nachrichtId));
gitter.append(reihe);
}
if (!gefunden) {
/* KEIN LEERES FELD. "Nichts gefunden" ist eine Auskunft, eine
leere Flaeche sieht nach einem Fehler aus. */
gitter.append(el('p', 'reagieren-tafel__leer',
`Nichts zu „${suchwort}“ gefunden.`));
}
};
katalogBauen('');
let tippTakt = null;
suche.addEventListener('input', () => {
clearTimeout(tippTakt);
tippTakt = setTimeout(() => katalogBauen(suche.value), 90);
});
knopf.closest('.chat-nachricht')?.append(tafel);
offeneAuswahl = tafel;
/* Die Tafel merkt sich, wie sie ihre Schnellzeile neu baut -- nach
einem Favoriten-Klick muss sie sich aendern, ohne dass man sie
schliesst und wieder oeffnet. */
tafel._schnellBauen = schnellBauen;
}
/* Wegklicken und Escape schliessen die Tafel. Ohne das bleibt sie
stehen, wenn man es sich anders ueberlegt -- und sie sieht dann
aus wie ein Fehler. */
document.addEventListener('click', (e) => {
if (!offeneAuswahl) return;
if (e.target.closest('.reagieren-tafel, .chat-nachricht__reag-knopf')) return;
auswahlSchliessen();
});
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') auswahlSchliessen(); });
async function reagieren(nachrichtId, zeichen) {
try {
const a = await hole(`/workspace/api/chat/nachrichten/${nachrichtId}/reaktion`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ zeichen }),
});
if (!a.ok) {
const d = await a.json().catch(() => ({}));
melde(d.fehler === 'zurueckgenommen'
? 'Auf eine zurückgenommene Nachricht lässt sich nicht reagieren.'
: 'Das Zeichen kam nicht an.');
setTimeout(() => melde(''), 4000);
return;
}
const d = await a.json();
/* Sofort im Bild -- der Ereignisstrom bringt dasselbe gleich noch
einmal fuer die anderen. Wer selbst klickt, soll nicht auf das
Netz warten muessen. */
standSetzen(d.id, d.reaktionen);
} catch { melde('Keine Verbindung.'); }
}
/** Die Reaktionen EINER Nachricht neu setzen -- in der Liste und auf
* dem Bildschirm. An einer Stelle, damit der Klick und das Ereignis
* nicht zwei verschiedene Wege nehmen. */
function standSetzen(id, reaktionen) {
const i = nachrichten.findIndex((x) => x.id === id);
if (i >= 0) nachrichten[i].reaktionen = reaktionen;
verlaufZeichnen();
}
/** Die Zeile unter der Nachricht. */
function reaktionenBauen(n) {
const reihe = el('div', 'reaktionen');
for (const r of n.reaktionen) {
const b = el('button', 'reaktionen__stueck');
b.type = 'button';
if (r.selbst) b.dataset.selbst = 'ja';
b.append(el('span', 'reaktionen__zeichen', r.zeichen));
b.append(el('span', 'reaktionen__zahl', String(r.anzahl)));
/* WER ES WAR, steht im Titel. "Drei Daumen" sagt weniger als die
Namen -- und es sind Leute aus demselben Raum. */
b.title = `${r.wer.join(', ')} · ${r.selbst ? 'noch einmal klicken nimmt deins zurück'
: 'klicken, um dich anzuschließen'}`;
b.setAttribute('aria-label', `${r.zeichen} von ${r.wer.join(', ')}`);
b.addEventListener('click', () => reagieren(n.id, r.zeichen));
reihe.append(b);
}
return reihe;
}
function verlaufZeichnen() {
const ziel = $('verlauf');
/* NUR DIE NACHRICHTEN WEG — NICHT DER GANZE KASTEN.
------------------------------------------------------------------
Hier stand `ziel.textContent = ''`. Das loeschte auch
`#verlauf-leer`, den Absatz "Links ein Gespraech auswaehlen", denn
der liegt IN diesem Kasten. Nach dem ersten Zeichnen gab es ihn
also nicht mehr — und `raumOeffnen` faellt sechs Zeilen weiter
genau darueber:
$('verlauf-leer').hidden = true; -> null
Folge, und die ist die eigentliche: `raumOeffnen` bricht an dieser
Stelle ab. Damit laufen `history.replaceState`, `gelesenMelden()`
und `raeumeZeichnen()` beim ZWEITEN Aufruf nie. Der zweite Aufruf
ist aber der Normalfall — der Ereignisstrom ruft nach jedem
Verbindungsaufbau genau das (`{ sanft: true }`), um nachzuholen,
was waehrend der Pause geschrieben wurde. Dieses Nachholen hat
nie funktioniert; der Absturz stand still in der Konsole.
Gefunden hat das nicht das Auge, sondern der Lauscher auf
`pageerror` in pruef-chat-ausbau.mjs. Ohne ihn waere es geblieben:
Die Seite sieht danach voellig normal aus. */
for (const alt of [...ziel.children]) if (alt.id !== 'verlauf-leer') alt.remove();
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);
}
if (n.angeheftet) zeile.dataset.angeheftet = 'ja';
const blase = el('div', 'chat-nachricht__blase');
if (!gleicheFolge && !n.selbst && offen.art !== 'direkt') {
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 {
/* DER ANHANG STEHT ÜBER DEM TEXT. Ein Begleitsatz gehört unter
das Bild, nicht darüber -- man sieht zuerst, worum es geht. */
if (n.anhang) blase.append(anhangBauen(n.anhang));
/* textContent, nie innerHTML: Eine Nachricht darf keine
Auszeichnung einschleusen. Zeilenumbrüche macht das CSS
(white-space: pre-wrap).
NUR WENN WIRKLICH TEXT DA IST: Eine Nachricht, die nur aus
einem Foto besteht, hätte sonst einen leeren Absatz darunter
-- und der nimmt Platz, den niemand erklären kann. */
if (n.text) blase.append(mitLinks(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);
/* REAGIEREN (14.09.2026).
Filipe: "ich will dass man auf die nachrichten auch reagieren
kann mit einem emoji, die nachricht selbst ohne zu
antworten."
Der Knopf steht neben "antworten", weil es dieselbe Sorte
Handgriff ist -- nur der kleinere. Die Auswahl klappt an der
Nachricht auf und nicht in einem Fenster: Wer ein Zeichen
anklickt, ist in zwei Sekunden fertig; ein Dialog dafuer
waere mehr Weg als Zweck. */
const reag = el('button', 'chat-nachricht__reag-knopf', 'reagieren');
reag.type = 'button';
reag.addEventListener('click', () => auswahlZeigen(reag, n.id));
fuss.append(reag);
}
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);
}
/* ANHEFTEN steht an derselben Stelle wie "antworten" -- dieselbe
Sorte Handgriff, dieselbe Zeile. Nur fuer die, die es duerfen,
und nie an einer zurueckgenommenen oder noch unterwegs
befindlichen Nachricht (`n.id > 0`): Die haette noch keine
Nummer, unter der man sie anheften koennte. */
if (darfAnheftenHier && !n.zurueckgenommen && n.id > 0) {
const pin = el('button', 'chat-nachricht__pin-knopf',
n.angeheftet ? 'lösen' : 'anheften');
pin.type = 'button';
pin.addEventListener('click', () => anheften(n.id, !n.angeheftet));
fuss.append(pin);
}
/* KOPIEREN. Eine Adresse, ein Code, eine Uhrzeit -- man will sie
weitergeben, nicht abtippen. Der Knopf bestaetigt sich selbst
und faellt danach zurueck; eine Meldung anderswo waere ein
zweiter Ort, an dem man nachsehen muesste. */
if (!n.zurueckgenommen && n.text) {
const kopie = el('button', 'chat-nachricht__kopie-knopf', 'kopieren');
kopie.type = 'button';
kopie.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(n.text);
kopie.textContent = 'kopiert';
} catch {
/* Ohne Zwischenablage-Recht (aeltere Browser, kein HTTPS)
den Text wenigstens auswaehlen -- dann geht Strg+C. */
const p = kopie.closest('.chat-nachricht')?.querySelector('.chat-nachricht__text');
if (p) { const r = document.createRange(); r.selectNodeContents(p);
const sel = getSelection(); sel.removeAllRanges(); sel.addRange(r); }
kopie.textContent = 'markiert';
}
setTimeout(() => { kopie.textContent = 'kopieren'; }, 1600);
});
fuss.append(kopie);
}
blase.append(fuss);
/* DIE REAKTIONEN STEHEN UNTER DER NACHRICHT, nicht in der
Fusszeile: Sie sind Inhalt, keine Bedienung. Jedes Zeichen ein
eigener Knopf -- ein Klick darauf setzt oder nimmt die eigene
Reaktion, genau wie in der Auswahl. */
if (Array.isArray(n.reaktionen) && n.reaktionen.length) {
blase.append(reaktionenBauen(n));
}
zeile.append(blase);
ziel.append(zeile);
}
/* NUR NACH UNTEN SPRINGEN, WENN MAN SCHON UNTEN WAR.
Vorher sprang der Verlauf bei JEDEM Neuzeichnen ans Ende. Wer
weiter oben etwas nachlas und dabei eine neue Nachricht bekam,
wurde mitten im Satz weggerissen -- und das passiert im Live-
Strom staendig. Jetzt gilt: Wer unten steht, bleibt unten
(das ist der Normalfall). Wer liest, bleibt stehen und bekommt
einen Knopf, der sagt, wie viel unten wartet. */
if (unten) {
ziel.scrollTop = ziel.scrollHeight;
neuUnten = 0;
}
springKnopfZeigen();
}
/* Steht der Verlauf am Ende? 40 Pixel Nachsicht -- ein Bildlauf trifft
die letzte Zeile selten auf den Punkt, und ein Knopf, der bei einem
Pixel Abstand erscheint, ist ein Flackern. */
let unten = true;
let neuUnten = 0;
function amEnde(e) { return e.scrollHeight - e.scrollTop - e.clientHeight < 40; }
function springKnopfZeigen() {
const k = $('zum-neuesten');
if (!k) return;
k.hidden = unten || !nachrichten.length;
k.textContent = neuUnten
? (neuUnten === 1 ? '1 neue Nachricht' : `${neuUnten} neue Nachrichten`)
: 'Zum neuesten';
}
/* ---------- Anhänge: Fotos und PDF (09.09.2026) -------------------------
Filipe: "pdfs und fotos".
Ein Foto wird GEZEIGT, ein PDF wird ANGEBOTEN. Das ist kein
Schönheitsunterschied: Ein Bild erkennt man in einer Zehntelsekunde,
ein PDF muss man ohnehin öffnen -- eine Vorschau davon wäre ein
grauer Kasten, der so tut, als könnte man etwas lesen.
`width`/`height` stehen am Bild, obwohl das CSS die Größe macht.
Sie sind das Seitenverhältnis: Damit hält der Browser den Platz
frei, BEVOR das Bild da ist. Ohne sie springt der ganze Verlauf,
sobald ein Bild fertig geladen ist -- und zwar genau dann, wenn
man gerade anfängt zu lesen. */
function grosse(bytes) {
const b = Number(bytes) || 0;
if (b < 1024) return `${b} B`;
if (b < 1024 * 1024) return `${Math.round(b / 1024)} kB`;
return `${(b / 1048576).toFixed(1)} MB`;
}
function anhangBauen(a) {
if (a.art === 'bild') {
const kasten = el('a', 'chat-bild');
kasten.href = a.weg;
kasten.target = '_blank';
kasten.rel = 'noopener noreferrer';
kasten.title = `${a.name} · ${grosse(a.groesse)} – in neuem Tab öffnen`;
/* DER PLATZ WIRD AM KASTEN RESERVIERT, NICHT AM BILD.
------------------------------------------------------------------
Erster Anlauf: width- und height-Attribut ans
, wie man es
überall liest. Gemessen war der Verlauf danach 323 px zu hoch --
genau die Höhe des Fotos.
Der Grund: Die Attribute geben dem Browser nur das SEITEN-
VERHÄLTNIS. Damit daraus eine Höhe wird, muss die Breite
feststehen. Bei `width: auto` und einem noch nicht geladenen
Bild ist sie 0 -- das Verhältnis hat nichts, womit es rechnen
könnte, der Kasten ist 0 px hoch, und wenn das Foto ankommt,
schiebt es alles darunter weg. Man springt beim Öffnen ans
Ende und steht eine Sekunde später mittendrin.
Deshalb bekommt der KASTEN das Verhältnis und eine Höchstbreite
(die Breite, die das Bild nach dem Deckel von 340 px Höhe
hätte). Damit steht die Breite von Anfang an fest, die Höhe
folgt daraus, und es wackelt nichts.
Gefunden hat das keine Überlegung, sondern eine Zahl: In der
Prüfung war der Rest 0 px -- dort lag das Bild schon im
Zwischenspeicher. Erst die Messung im Aufnahmeskript, beim
ERSTEN Laden, zeigte die 323. */
if (a.breite > 0 && a.hoehe > 0) {
const DECKEL = 340;
const breit = a.hoehe > DECKEL ? Math.round(a.breite * DECKEL / a.hoehe) : a.breite;
kasten.style.aspectRatio = `${a.breite} / ${a.hoehe}`;
kasten.style.maxWidth = `${breit}px`;
}
const bild = document.createElement('img');
bild.src = a.weg;
bild.alt = a.name || 'Foto';
bild.decoding = 'async';
if (a.breite > 0 && a.hoehe > 0) { bild.width = a.breite; bild.height = a.hoehe; }
/* KEIN loading="lazy". In einem Verlauf, der beim Öffnen ganz nach
unten springt, sind die sichtbaren Bilder die zuletzt
geschriebenen -- lazy verzögert genau die, die man sehen will,
und spart nichts, weil ein Chat selten hundert Fotos hat.
WENN EIN MASS FEHLT (seltene WebP-Formen), ist oben nichts
reserviert. Dann ist dieser Zuhörer die Sicherung: War man
unten, bleibt man unten. Er wird beim Bauen festgehalten, nicht
beim Laden gelesen -- bis dahin hat ein Bildlauf-Ereignis den
Zustand längst umgeworfen. */
const warUnten = unten;
bild.addEventListener('load', () => {
if (!warUnten) return;
const v = $('verlauf');
v.scrollTop = v.scrollHeight;
unten = true; neuUnten = 0;
springKnopfZeigen();
});
/* Ein Bild, das nicht lädt, hinterlässt sonst ein kaputtes
Symbol ohne Erklärung. */
bild.addEventListener('error', () => {
kasten.dataset.kaputt = 'ja';
kasten.textContent = 'Foto lässt sich gerade nicht laden';
});
kasten.append(bild);
return kasten;
}
const karte = el('a', 'chat-datei');
karte.href = a.weg;
karte.target = '_blank';
karte.rel = 'noopener noreferrer';
const zeichen = el('span', 'chat-datei__zeichen', 'PDF');
zeichen.setAttribute('aria-hidden', 'true');
const mitte = el('span', 'chat-datei__mitte');
mitte.append(el('span', 'chat-datei__name', a.name || 'Dokument'));
mitte.append(el('span', 'chat-datei__mass', `${grosse(a.groesse)} · öffnen`));
karte.append(zeichen, mitte);
karte.title = `${a.name} · ${grosse(a.groesse)}`;
return karte;
}
/* ---------- Links in Nachrichten (09.09.2026) ---------------------------
Eine Adresse im Text soll anklickbar sein. Gebaut wird sie als
ECHTER Knoten, nie ueber innerHTML: Sonst koennte ein Absender
Auszeichnung einschleusen, und in einem Chat schreibt jeder.
Erkannt wird bewusst wenig -- http, https und www. Wer mehr
erkennt (Mailadressen, nackte Domains), erwischt irgendwann
"3.stelle" oder "z.b." und macht daraus einen Link ins Nichts.
`rel="noopener noreferrer"` ist Pflicht: Ohne `noopener` kann die
geoeffnete Seite auf das Fenster zurueckgreifen, aus dem sie kam. */
const LINKMUSTER = /(https?:\/\/[^\s<>"']+|www\.[^\s<>"']+)/g;
function mitLinks(text) {
const p = el('p', 'chat-nachricht__text');
let letzte = 0;
for (const treffer of String(text).matchAll(LINKMUSTER)) {
const roh = treffer[0];
/* Satzzeichen am Ende gehoeren zum Satz, nicht zur Adresse. */
const sauber = roh.replace(/[.,;:!?)\]]+$/, '');
if (treffer.index > letzte) p.append(document.createTextNode(text.slice(letzte, treffer.index)));
const a = el('a', 'chat-nachricht__link', sauber);
a.href = sauber.startsWith('www.') ? 'https://' + sauber : sauber;
a.target = '_blank';
a.rel = 'noopener noreferrer';
p.append(a);
if (sauber.length < roh.length) p.append(document.createTextNode(roh.slice(sauber.length)));
letzte = treffer.index + roh.length;
}
if (letzte < text.length) p.append(document.createTextNode(text.slice(letzte)));
return p;
}
/* ---------- 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);
/* Wer selbst schreibt, will das Ergebnis sehen -- auch wenn er
vorher weiter oben gelesen hat. */
unten = true; neuUnten = 0;
verlaufZeichnen();
feld.value = '';
entwurfWeg(offen.id);
zaehlerZeigen();
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 });
/* Wer gerade weiter oben liest, soll sehen, WIE VIEL unten
dazugekommen ist -- nicht nur, DASS etwas da ist. */
if (!unten && n.von_id !== ich.id) neuUnten++;
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();
});
/* DER RAUM IST AUFGELOEST (11.09.2026).
Kommt beim Gruender genauso an wie bei allen anderen -- er
braucht keinen Sonderweg, und dadurch gibt es keinen zweiten
Ablauf, der irgendwann anders aussieht.
Wer ihn gerade offen hat, bekommt einen Satz dazu. Ohne den
spraenge die Seite wortlos auf die Liste zurueck, und man
haelt es fuer einen Fehler. Wer ihn NICHT offen hat, merkt nur,
dass ein Eintrag aus der Liste verschwindet -- das reicht. */
strom.addEventListener('raum-weg', (e) => {
let n;
try { n = JSON.parse(e.data); } catch { return; }
if (offen && n.raum_id === offen.id) {
verlaufSchliessen();
melde('Dieses Gespräch wurde von der Person aufgelöst, die es '
+ 'angelegt hat.');
setTimeout(() => melde(''), 8000);
}
raeumeLaden();
});
/* EINE REAKTION -- alle im Raum sehen sie sofort (14.09.2026).
Betroffen ist nur, wer denselben Raum offen hat. Wer ihn nicht
offen hat, bekommt NICHTS: kein Ungelesen-Zeichen, keine Meldung
aufs Handy. Ein Daumen ist keine Nachricht; wer dafuer geweckt
wird, schaltet Meldungen ab. */
strom.addEventListener('reaktion', (e) => {
let n;
try { n = JSON.parse(e.data); } catch { return; }
if (!offen || n.raum_id !== offen.id) return;
standSetzen(n.id, n.reaktionen || []);
});
/* ANGEHEFTET ODER GELOEST -- alle im Raum sehen es sofort.
Betroffen ist nur, wer denselben Raum offen hat; sonst genuegt
die Liste. */
strom.addEventListener('pin', (e) => {
let n;
try { n = JSON.parse(e.data); } catch { return; }
if (offen && n.raum_id === offen.id) raumOeffnen(offen.id, { sanft: true });
});
/* 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(vorbelegt = []) {
const ziel = $('partner');
ziel.textContent = '';
gewaehlt = new Set(vorbelegt);
$('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. */
function gruppeBauen(schluessel, ueberschrift, leute) {
const gruppe = el('div', 'chat-wahl__gruppe');
gruppe.dataset.rolle = schluessel;
const titel = el('p', 'chat-wahl__titel');
const punkt = el('span', 'chat-wahl__punkt');
punkt.setAttribute('aria-hidden', 'true');
titel.append(punkt, document.createTextNode(ueberschrift));
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;
if (gewaehlt.has(p.id)) { b.dataset.an = 'ja'; }
b.setAttribute('aria-pressed', gewaehlt.has(p.id) ? 'true' : '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);
}
/* EINE SCHLEIFE, NICHT ZWEI (11.09.2026).
Bis heute standen hier zwei: erst die fünf Rollen aus
ROLLENFOLGE, dann „alle übrigen". Das hielt zwar niemanden mehr
verborgen — aber es sortierte Team Dogi zwangsläufig ans Ende,
egal was der Server sagte. Die rechte Hand stand damit hinter
jedem Creator.
Jetzt zeichnet der Browser in der Reihenfolge, die er bekommt.
Wer die Rangfolge ändern will, ändert sie an EINER Stelle
(ROLLEN_REIHE im Server) — und nicht in drei Auswahllisten. */
const gruppen = window.Bereiche?.gruppieren?.(personen)
|| [{ titel: 'Alle', rolle: '', leute: personen }];
for (const g of gruppen) gruppeBauen(g.rolle, g.titel, g.leute);
wahlGeaendert();
}
/* Welche Zustaendigkeiten noch keinen Kanal haben. Die Liste kommt vom
Server; steht nichts darin, gibt es alle schon. */
function kanalWahlFuellen() {
const feld = $('kanal-kategorie');
feld.textContent = '';
for (const k of kanalFrei) {
const o = document.createElement('option');
o.value = k.wert; o.textContent = k.name;
feld.append(o);
}
$('kanal-alle').hidden = kanalFrei.length > 0;
feld.hidden = kanalFrei.length === 0;
}
/* Umschalten zwischen "Gespräch" und "Kanal". Ein zweiter Dialog
waere dieselbe Personenwahl ein zweites Mal -- und die zweite
Fassung waere die, in der eine Regel fehlt. */
function neuArtSetzen(art) {
neuArt = art;
for (const b of document.querySelectorAll('#neu-art [data-art]')) {
const an = b.dataset.art === art;
b.dataset.an = an ? 'ja' : '';
b.setAttribute('aria-pressed', an ? 'true' : 'false');
}
$('kanal-zeile').hidden = art !== 'kanal' || !!kanalUmbau;
$('neu-titel').textContent = kanalUmbau ? 'Wer ist im Kanal?'
: art === 'kanal' ? 'Neuer Kanal' : 'Neues Gespräch';
/* Der Satz sagt, was JETZT zu tun ist -- der Unterschied zwischen
den beiden Arten steht darueber in #neu-erklaerung und muss hier
nicht noch einmal stehen. Zwei Texte fuer dieselbe Auskunft
laufen auseinander, sobald einer geaendert wird. */
$('neu-unter').textContent = kanalUmbau
? 'Antippen, um jemanden hinein- oder herauszunehmen. Die Leitung bleibt immer dabei.'
: art === 'kanal'
? 'Zuständigkeit wählen – der Rest ergibt sich daraus. Leute kannst du gleich '
+ 'mitnehmen oder später hinzufügen.'
: 'Eine Person antippen, um ihr zu schreiben. Mehrere auswählen ergibt eine '
+ 'Gruppe – die bekommt einen Namen und bleibt bestehen.';
wahlGeaendert();
}
function wahlGeaendert() {
const n = gewaehlt.size;
if (neuArt === 'kanal') {
/* Ein Kanal braucht keinen Namen (er kommt aus der Kategorie) und
auch niemanden -- man legt ihn an und fuellt ihn spaeter. Nur
eine Kategorie muss es geben, sonst gibt es nichts anzulegen. */
$('gruppenname-zeile').hidden = true;
$('neu-los').disabled = !kanalUmbau && !$('kanal-kategorie').value;
$('neu-los').textContent = kanalUmbau ? 'Übernehmen' : 'Kanal anlegen';
return;
}
/* 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];
const knopf = $('neu-los');
/* KANAL: anlegen oder umbesetzen. Steht zuerst, weil hier auch eine
LEERE Auswahl erlaubt ist -- ein Kanal ohne Leute ist ein
gueltiger Zwischenstand, ein Gespraech ohne Gegenueber nicht. */
if (neuArt === 'kanal') {
knopf.disabled = true;
try {
if (kanalUmbau) {
const a = await hole(`/workspace/api/chat/kanal/${kanalUmbau}/mitglieder`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mit: ids }),
});
if (!a.ok) {
$('neu-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.';
return;
}
const raumId = kanalUmbau;
$('neu-dialog').close();
await raeumeLaden();
await raumOeffnen(raumId, { sanft: true });
return;
}
const kategorie = $('kanal-kategorie').value;
if (!kategorie) { $('neu-fehler').textContent = 'Wähle eine Kategorie.'; return; }
const a = await hole('/workspace/api/chat/kanal', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kategorie, mit: ids }),
});
if (!a.ok) {
$('neu-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.';
return;
}
const raumId = (await a.json()).raum_id;
$('neu-dialog').close();
await raeumeLaden();
await raumOeffnen(raumId);
} catch { /* umgeleitet */ } finally { knopf.disabled = false; }
return;
}
if (!ids.length) return;
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. */
/* SEIT DEM 09.09.2026 GEHOERT DAS BAND DAZU.
Ueber der Chatflaeche steht in einer fremden Sicht ein zweites
Element: das Band mit dem Namen (.fremd-band, kopf.js). Wer nur die
Kopfleiste misst, rechnet dessen Hoehe nicht ab -- und das
Eingabefeld steht genau so weit unter dem Bildschirmrand, wie das
Band hoch ist. Derselbe Fehler wie mit den festen 62 px, nur mit
einem anderen Element.
Gezaehlt wird deshalb ALLES, was ueber der Flaeche liegt. */
const UEBER_DER_FLAECHE = ['.kopfleiste', '.fremd-band'];
function kopfMessen() {
const teile = UEBER_DER_FLAECHE.map((w) => document.querySelector(w)).filter(Boolean);
if (!teile.length) return;
const h = Math.ceil(teile.reduce((s, e) => s + e.getBoundingClientRect().height, 0));
document.documentElement.style.setProperty('--kopf-hoehe', `${h}px`);
}
kopfMessen();
window.addEventListener('resize', kopfMessen);
/* Das Band entsteht erst, wenn kopf.js weiss, wer angemeldet ist --
also nach dieser Messung. Ohne diesen Beobachter bliebe der erste
Wert stehen, und die Flaeche waere genau um das Band zu lang. */
if (window.MutationObserver) {
new MutationObserver(kopfMessen).observe(document.body, { childList: true });
}
/* 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();
entwurfMerken();
zaehlerZeigen();
});
/* ---------- Entwurf je Gespraech (09.09.2026) ----------------------------
Wer mitten im Satz das Gespraech wechselt, findet den Satz beim
Zurueckkommen wieder. Ohne das ist jeder Wechsel eine Entscheidung:
"wegwerfen oder erst fertig schreiben?"
Im localStorage und nicht auf dem Server: Ein Entwurf ist nichts,
was jemand anders sehen soll, und er gehoert an dieses Geraet.
Abgeschickte oder geleerte Entwuerfe werden geloescht -- eine
Ablage, die nur waechst, ist ein Leck. */
const ENTWURF = (id) => `dfw-chat-entwurf-${id}`;
function entwurfMerken() {
if (!offen) return;
const t = $('text').value;
try {
if (t.trim()) localStorage.setItem(ENTWURF(offen.id), t);
else localStorage.removeItem(ENTWURF(offen.id));
} catch { /* privater Modus: dann eben nicht */ }
}
function entwurfHolen(raumId) {
try { return localStorage.getItem(ENTWURF(raumId)) || ''; } catch { return ''; }
}
function entwurfWeg(raumId) {
try { localStorage.removeItem(ENTWURF(raumId)); } catch { /* egal */ }
}
/* ---------- Zeichenzaehler ------------------------------------------------
Er erscheint erst, wenn es eng wird. Ein Zaehler, der von Anfang an
mitlaeuft, zaehlt die meiste Zeit etwas, das niemanden interessiert --
und wird dann auch dann uebersehen, wenn er zaehlt. */
/* Die Zahl steht NICHT hier. Sie steht im maxlength des Feldes, und
dort steht sie, weil der Server sie so haben will (TEXT_MAX = 4000
in workspace-chat.js). Zwei Stellen fuer dieselbe Zahl laufen
auseinander -- dann zaehlt der Zaehler bis 4000 herunter, waehrend
das Feld schon bei 2000 dichtmacht, und niemand versteht warum. */
const GRENZE = Number($('text').getAttribute('maxlength')) || 4000;
function zaehlerZeigen() {
const z = $('zeichen');
if (!z) return;
const n = $('text').value.length;
const rest = GRENZE - n;
z.hidden = rest > 400;
z.textContent = `noch ${rest}`;
z.dataset.eng = rest <= 100 ? 'ja' : 'nein';
}
/* ---------- Emoji-Auswahl -------------------------------------------------
Eine kleine, feste Auswahl statt einer vollstaendigen Tafel: Diese
dreissig decken den Alltag ab, und sie laden nichts nach. Eine
Emoji-Bibliothek waere ein Megabyte fuer etwas, das die
Bildschirmtastatur ohnehin kann -- am Rechner aber nicht.
Eingefuegt wird an der CURSORSTELLE, nicht am Ende: Wer mitten im
Satz eines setzen will, soll nicht erst umsortieren. */
const EMOJIS = ['😀','😂','🥹','😍','😎','🤔','😅','🙃','😴','🤝',
'👍','👏','🙌','💪','🙏','🔥','✨','⭐','💯','🎉',
'❤️','💙','💚','🖤','👀','🎮','🎧','📈','⏰','✅'];
function emojiTafelBauen() {
const tafel = $('emoji-tafel');
if (!tafel || tafel.childElementCount) return;
for (const e of EMOJIS) {
const b = el('button', 'emoji-tafel__knopf', e);
b.type = 'button';
b.setAttribute('aria-label', `Emoji ${e} einfügen`);
b.addEventListener('click', () => {
const f = $('text');
const a = f.selectionStart ?? f.value.length;
const b2 = f.selectionEnd ?? a;
f.value = f.value.slice(0, a) + e + f.value.slice(b2);
f.focus();
f.selectionStart = f.selectionEnd = a + e.length;
hoeheAnpassen(); entwurfMerken(); zaehlerZeigen();
});
tafel.append(b);
}
}
$('emoji-auf')?.addEventListener('click', () => {
const tafel = $('emoji-tafel');
emojiTafelBauen();
const auf = tafel.hidden;
tafel.hidden = !auf;
$('emoji-auf').setAttribute('aria-expanded', String(auf));
if (auf) tafel.querySelector('button')?.focus();
});
/* Ein Klick daneben schliesst die Tafel -- sonst steht sie im Weg,
und man sucht den Knopf, mit dem man sie aufgemacht hat. */
document.addEventListener('click', (e) => {
const tafel = $('emoji-tafel');
if (!tafel || tafel.hidden) return;
if (tafel.contains(e.target) || e.target.closest('#emoji-auf')) return;
tafel.hidden = true;
$('emoji-auf')?.setAttribute('aria-expanded', 'false');
});
/* Escape schliesst sie -- und zwar von ueberall her, nicht nur wenn
die Schreibmarke zufaellig noch in der Tafel steht.
Erster Anlauf hing der Zuhoerer an der Tafel selbst. Das geht gut,
solange man sie gerade aufgemacht hat (dann liegt der Fokus auf dem
ersten Zeichen). Wer danach ins Schreibfeld zurueckklickt, drueckt
Escape aber ins Leere -- und die Tafel steht weiter im Weg. Am
Dokument gilt es immer; ist die Tafel zu, tut die Zeile nichts. */
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
const tafel = $('emoji-tafel');
if (!tafel || tafel.hidden) return;
tafel.hidden = true;
$('emoji-auf')?.setAttribute('aria-expanded', 'false');
$('text').focus();
});
/* ---------- Zum neuesten springen ---------------------------------------- */
$('verlauf')?.addEventListener('scroll', () => {
const v = $('verlauf');
const jetztUnten = amEnde(v);
if (jetztUnten !== unten) {
unten = jetztUnten;
if (unten) neuUnten = 0;
springKnopfZeigen();
}
});
$('zum-neuesten')?.addEventListener('click', () => {
const v = $('verlauf');
v.scrollTop = v.scrollHeight;
unten = true; neuUnten = 0;
springKnopfZeigen();
});
/* 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 () => {
kanalUmbau = null;
kanalWahlFuellen();
neuArtSetzen('gespraech');
await partnerLaden();
$('neu-dialog').showModal();
});
for (const b of document.querySelectorAll('#neu-art [data-art]')) {
b.addEventListener('click', () => neuArtSetzen(b.dataset.art));
}
$('kanal-kategorie').addEventListener('change', wahlGeaendert);
/* WER IST IM KANAL. Derselbe Dialog, vorbelegt mit denen, die schon
drin sind -- so sieht man beim Oeffnen den Stand und muss ihn nicht
erst aus der Kopfzeile abschreiben. */
$('kanal-leute').addEventListener('click', async () => {
if (!offen || offen.art !== 'kanal') return;
kanalUmbau = offen.id;
kanalWahlFuellen();
neuArtSetzen('kanal');
await partnerLaden(offen.teilnehmer.map((t) => t.id));
$('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');
});
/* =======================================================================
ANHÄNGE SCHICKEN (09.09.2026)
Filipe: "am besten wäre es auch wenn man da auch im chat pdfs
schicken könnte. pdfs und fotos."
DREI WEGE ZU DERSELBEN SACHE, weil Leute unterschiedlich arbeiten:
die Büroklammer (immer da, auch am Handy), Hineinziehen (am
Rechner der schnellste Weg) und Einfügen mit Strg+V (der Weg für
einen Screenshot -- der liegt in der Zwischenablage und nirgendwo
als Datei).
Alle drei landen in derselben Funktion. Drei Fassungen desselben
Ablaufs wären drei Gelegenheiten, dass eine davon die Prüfung
vergisst.
GEPRÜFT WIRD TROTZDEM AUCH HIER, obwohl der Server es noch einmal
tut: Eine Absage nach zwölf Megabyte Wartezeit ist keine Absage,
das ist eine Strafe. Die Prüfung im Browser ist die Höflichkeit,
die auf dem Server ist die Sicherheit -- keine ersetzt die andere. */
const ANHANG_MAX = 12 * 1024 * 1024;
const ANHANG_TYPEN = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'application/pdf']);
let schicktGerade = false;
async function anhangSchicken(datei) {
if (!offen || !datei) return;
if (schicktGerade) { melde('Einen Moment – der letzte Anhang ist noch unterwegs.'); return; }
if (!ANHANG_TYPEN.has(datei.type)) {
melde('Das geht nicht – erlaubt sind Fotos (PNG, JPEG, WebP, GIF) und PDF.');
return;
}
if (datei.size > ANHANG_MAX) {
melde(`Zu groß – erlaubt sind ${Math.round(ANHANG_MAX / 1048576)} MB je Datei.`);
return;
}
schicktGerade = true;
$('anhang-auf').disabled = true;
melde(`„${datei.name}“ wird geschickt …`, 'lauf');
/* Ein angefangener Begleitsatz geht mit -- wer erst tippt und dann
das Bild anhängt, will beides in einer Nachricht. */
const begleittext = $('text').value.trim();
try {
const a = await hole(`/workspace/api/chat/raeume/${offen.id}/anhang`, {
method: 'POST',
headers: {
'Content-Type': datei.type,
/* Kopfzeilen sind streng genommen nur ASCII -- ohne die
Kodierung käme aus "Größe.pdf" unterwegs Unsinn. */
'X-Dateiname': encodeURIComponent(datei.name || 'anhang'),
'X-Text': encodeURIComponent(begleittext),
},
body: datei,
});
if (!a.ok) {
let grund = 'Ging nicht.';
try { grund = (await a.json()).fehler || grund; } catch { /* nichts Lesbares */ }
melde(grund);
return;
}
const { nachricht } = await a.json();
/* Nur eintragen, wenn sie nicht schon über den Strom kam --
sonst stünde dasselbe Foto zweimal da. */
if (!nachrichten.some((x) => x.id === nachricht.id)) {
nachrichten.push({ ...nachricht, selbst: true });
}
$('text').value = '';
entwurfWeg(offen.id);
hoeheAnpassen();
zaehlerZeigen();
unten = true; neuUnten = 0;
verlaufZeichnen();
melde('');
raeumeLaden();
} catch {
melde('Keine Verbindung – der Anhang wurde nicht geschickt.');
} finally {
schicktGerade = false;
$('anhang-auf').disabled = false;
}
}
$('anhang-auf').addEventListener('click', () => $('anhang-feld').click());
$('anhang-feld').addEventListener('change', () => {
const datei = $('anhang-feld').files?.[0];
/* Zurücksetzen, BEVOR geschickt wird: Sonst löst dieselbe Datei
beim zweiten Mal kein `change` aus, und man wundert sich, warum
nichts passiert. */
$('anhang-feld').value = '';
if (datei) void anhangSchicken(datei);
});
/* ---- Hineinziehen ------------------------------------------------------
`dragover` MUSS abgefangen werden, sonst öffnet der Browser die
Datei einfach als Seite -- der Verlauf wäre weg und das Bild
bildschirmfüllend da. */
{
const flaeche = document.querySelector('.chat__spalte--verlauf');
let tiefe = 0; /* dragenter/-leave feuert je Kind */
const zeigen = (ja) => {
const k = $('ablage');
if (k) k.hidden = !ja || !offen;
};
flaeche.addEventListener('dragenter', (e) => {
if (!e.dataTransfer?.types?.includes('Files')) return;
tiefe++; zeigen(true);
});
flaeche.addEventListener('dragover', (e) => {
if (!e.dataTransfer?.types?.includes('Files')) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
});
flaeche.addEventListener('dragleave', () => { if (--tiefe <= 0) { tiefe = 0; zeigen(false); } });
flaeche.addEventListener('drop', (e) => {
if (!e.dataTransfer?.files?.length) return;
e.preventDefault();
tiefe = 0; zeigen(false);
if (!offen) { melde('Zuerst ein Gespräch öffnen.'); return; }
void anhangSchicken(e.dataTransfer.files[0]);
});
/* Fällt etwas neben dem Fenster, darf der Browser es trotzdem nicht
öffnen. */
window.addEventListener('dragover', (e) => e.preventDefault());
window.addEventListener('drop', (e) => e.preventDefault());
}
/* ---- Einfügen (Strg+V) --------------------------------------------------
Der Weg für Screenshots. Ein kopiertes Bild hat keinen Namen --
deshalb bekommt es hier einen mit Datum, sonst hiesse jede
Bildschirmaufnahme "image.png". */
$('text').addEventListener('paste', (e) => {
const stuecke = [...(e.clipboardData?.items || [])];
const bild = stuecke.find((s) => s.kind === 'file' && ANHANG_TYPEN.has(s.type));
if (!bild) return; /* normaler Text: nichts tun */
const datei = bild.getAsFile();
if (!datei) return;
e.preventDefault();
const endung = (datei.type.split('/')[1] || 'png').replace('jpeg', 'jpg');
const stempel = new Date().toISOString().slice(0, 16).replace(/[-:]/g, '').replace('T', '-');
void anhangSchicken(new File([datei], datei.name && datei.name !== 'image.png'
? datei.name : `bildschirmfoto-${stempel}.${endung}`, { type: datei.type }));
});
/* =======================================================================
GESPRÄCH WEGRÄUMEN — NUR BEI MIR
Die Rückfrage sagt ausdrücklich, was NICHT passiert. "Wirklich
löschen?" liesse offen, ob der andere seinen Verlauf verliert --
und das ist genau die Frage, die man in dem Moment hat. */
/* ZURUECK AUF "nichts offen" -- an EINER Stelle (11.09.2026).
Diese zehn Zeilen standen beim Wegraeumen. Mit dem Aufloesen und
dem Ereignis "der Raum ist weg" braeuchte es sie dreimal, und die
dritte Abschrift waere die, in der eine Zeile fehlt: ein Knopf,
der stehen bleibt, oder ein Entwurf zu einem Raum, den es nicht
mehr gibt. */
function verlaufSchliessen() {
if (offen) entwurfWeg(offen.id);
offen = null;
nachrichten = [];
$('schreiben').hidden = true;
$('gespraech-weg').hidden = true;
$('raum-aufloesen').hidden = true;
$('kanal-leute').hidden = true;
$('verlauf-titel').textContent = 'Wähle ein Gespräch';
$('verlauf-unter').textContent = '';
$('verlauf-leer').hidden = false;
$('chat').dataset.ansicht = 'liste';
verlaufZeichnen();
history.replaceState(null, '', 'chat.html');
}
$('gespraech-weg').addEventListener('click', async () => {
if (!offen) return;
const wer = offen.name;
if (!window.confirm(
`Gespräch mit ${wer} wegräumen?\n\n`
+ 'Es verschwindet nur bei dir. Der Verlauf beim Gegenüber bleibt '
+ 'vollständig.\n\nSchreibt euch jemand wieder, kommt das Gespräch '
+ 'mit dem Neuen zurück – das Alte bleibt weg.')) return;
try {
const a = await hole(`/workspace/api/chat/raeume/${offen.id}`, { method: 'DELETE' });
if (!a.ok) { melde('Ging nicht.'); return; }
verlaufSchliessen();
await raeumeLaden();
melde('');
} catch { melde('Keine Verbindung.'); }
});
/* =====================================================================
FUER ALLE AUFLOESEN (11.09.2026)
Filipe: "die person die ihn oeffnet soll auch das recht haben das
zu loeschen und so dass es dan fuer jeden geloescht ist. aber nur
die person die es gruendet."
DIE RUECKFRAGE NENNT ZAHLEN, KEINE FLOSKEL. "Wirklich loeschen?"
beantwortet die Frage nicht, die man in dem Moment hat -- naemlich
WAS genau verschwindet und bei WEM. Deshalb stehen der Name, die
Zahl der Beteiligten und das Wort "endgueltig" darin.
Und sie sagt ausdruecklich, was der Unterschied zum Knopf daneben
ist. Zwei Loeschknoepfe nebeneinander sind sonst eine Falle. */
$('raum-aufloesen').addEventListener('click', async () => {
if (!offen || !offen.darf_aufloesen) return;
const wasIstEs = offen.art === 'kanal' ? 'Kanal' : 'Gruppe';
const andere = Math.max(0, (offen.teilnehmer?.length || 1) - 1);
if (!window.confirm(
`${wasIstEs} „${offen.name}" für alle auflösen?\n\n`
+ `Der ganze Verlauf wird gelöscht – auch bei ${andere === 1
? 'der anderen Person' : `den ${andere} anderen`}, samt aller `
+ 'Anhänge. Das lässt sich nicht rückgängig machen.\n\n'
+ 'Wenn du sie nur bei dir loswerden willst, nimm den Knopf '
+ 'daneben – dann behalten die anderen alles.')) return;
const knopf = $('raum-aufloesen');
knopf.disabled = true;
try {
const a = await hole(`/workspace/api/chat/raeume/${offen.id}/ganz`,
{ method: 'DELETE' });
if (!a.ok) {
/* Der Server ist die Wahrheit, nicht der Knopf. Sagt er nein,
steht der Grund da -- nicht "Ging nicht." */
const d = await a.json().catch(() => ({}));
melde(d.fehler === 'nur_gruender'
? `Nur wer ${wasIstEs === 'Kanal' ? 'den Kanal' : 'die Gruppe'} `
+ 'angelegt hat, kann sie auflösen.'
: 'Ging nicht.');
return;
}
const d = await a.json().catch(() => ({}));
verlaufSchliessen();
await raeumeLaden();
melde(`${wasIstEs} aufgelöst – ${d.nachrichten ?? 0} Nachrichten `
+ 'für alle gelöscht.', 'gut');
setTimeout(() => melde(''), 6000);
} catch { melde('Keine Verbindung.'); }
finally { knopf.disabled = false; }
});
/* 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= -- 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 */ }
})();
})();