Files
dogfather-universe/workspace/assets/js/chat.js
T
DogFatherGitandClaude Opus 5 953c3f5721 Zahlen bekommen eine Oberflaeche, das Dashboard bekommt Zahlen
Der Server konnte seit gestern rechnen -- und niemand konnte etwas
eintragen. Ein Rechenwerk ohne Oberflaeche ist kein Modul, sondern ein
Versprechen. Das ist jetzt eingeloest:

  * leistung.html: Wochenkarten (Wert, Vergleich zur Vorwoche,
    Verlaufslinie), Ziele als Fortschrittsbalken, die letzten 14 Tage
    zum Eintragen -- auch die LEEREN, denn eine Luecke ist selbst die
    Auskunft. Import aus dem TikTok-Export mit Vorschau VOR dem
    Schreiben.
  * Kachel "Zahlen" als erste der Gruppe "Rund um den Creator", eigenes
    Zeichen (steigende Linie, kein zweites Balkendiagramm).
  * Dashboard: jede Creator-Karte traegt jetzt Diamanten, LIVE-Tage und
    Verweildauer. Bis heute zeigte sie ausschliesslich ARBEIT -- ein
    Creator konnte null ueberfaellige Aufgaben haben und gleichzeitig
    seit drei Wochen einbrechen, und die Karte sah tadellos aus.
  * Die Fruehwarnung steht oben im Dashboard. Sechs benannte Signale im
    Klartext mit Beleg, bewusst OHNE Punktzahl -- ein Wert wie
    "Abwanderungsrisiko 73" klingt nach Wissenschaft und ist eine
    Behauptung, der man nicht widersprechen kann.
  * chat.html?mit=<person>: der Weg von der Warnung ins Gespraech. Gab
    es vorher nicht; ein Hinweis ohne Weg dahin ist nur ein schlechtes
    Gewissen.

GEFUNDEN VON DEN PRUEFUNGEN, nicht von der Theorie:

  1. Die Verlaufslinie zeichnete fuer vier der sieben Groessen NICHTS.
     Der Server liefert im Verlauf nur drei Reihen; fuer den Rest kam
     undefined an. `=== null` faengt das nicht, Number(undefined) ist
     NaN, und ein <polyline points="NaN,NaN"> ist im DOM vorhanden und
     auf dem Bildschirm unsichtbar -- ohne Fehlermeldung. Die Pruefung
     misst deshalb die PUNKTE, nicht die Anwesenheit des Elements.
  2. Fuenf Schriftgroessen im CHAT lagen unter 11,5 px (.68/.7 rem) und
     waren seit vorgestern drin. Die Grundlinie der Groessenpruefung
     stand auf 43, gemessen wurden 48. Nicht die Grundlinie angehoben,
     sondern die fuenf Stellen behoben: Eine Grundlinie, die mitwaechst,
     ist keine mehr.

pruef-leistung-optik.mjs: 57 Pruefungen, zwei echte Browser, Gegenprobe
zu jeder Aussage -- auch dazu, dass die Vorschau vor dem Bestaetigen
wirklich noch nichts geschrieben hat und dass ein Creator seine Zahlen
zwar SIEHT, sie aber weder eintragen noch die Fruehwarnung ueber sich
lesen kann (auch nicht ueber eine von Hand gebaute Anfrage).

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

668 lines
25 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;
};
const ROLLENKURZ = { admin: 'DogFather', manager: 'Manager', scout: 'Scout', creator: 'Creator' };
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. */
$('suche-zeile').hidden = raeume.length < 8;
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;
}
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);
}
}
/* ---------- 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;
} 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;
for (const n of nachrichten) {
/* 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) {
const z = el('span', 'chat-nachricht__zeichen', initialen(n.von));
z.setAttribute('aria-hidden', 'true');
z.dataset.rolle = n.rolle || '';
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));
}
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);
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;
}
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. */
const vorlaeufig = {
id: -Date.now(), von_id: ich.id, von: ich.name, rolle: ich.rolle,
text, erstellt: new Date().toISOString(), selbst: true, unterwegs: true,
};
nachrichten.push(vorlaeufig);
verlaufZeichnen();
feld.value = '';
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 }),
});
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. */
for (const rolle of ['admin', 'manager', 'scout', 'creator']) {
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(
{ admin: 'DogFather', manager: 'Manager', scout: 'Scouts', creator: 'Creator' }[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 -------------------------------------------------------- */
$('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);
$('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 */ }
})();
})();