DIE SYMBOLE IN DER TAGESKACHEL WAREN DIESELBEN -- OHNE IHRE GESTALTUNG
Sie kommen vom selben Bauer wie alle anderen, bekamen aber nie dessen
Aussehen: Alle Lagenregeln waren auf `.kachel__svg` eingegrenzt, und
dieses Zeichen heisst `.dran__svg`. Uebrig blieb eine flache Kontur --
daneben, auf derselben Seite, dieselben Zeichen mit drei Lichtern,
Tiefe und Randlicht. Die Regeln gelten jetzt fuer beide Traeger, und
das Feld darum ist dasselbe gefasste Schild wie auf den Kacheln.
"UNDEFINED" IM CHAT -- DERSELBE FEHLER WIE HEUTE FRUEH, NUR ALS OBJEKT
Ueber Cigdems Namen stand woertlich "UNDEFINED". Die Rollennamen
standen als Objekt in FUENF Skripten (chat.js zweimal, dateien.js,
kalender.js zweimal), in keinem davon 'spicy'.
Heute Frueh war es dieselbe Sache als Menge (`new Set([...])`), und
seitdem sucht `pruef-css-klassen.mjs` danach. Ein Muster, das nur eine
Schreibweise kennt, findet auch nur eine -- die Pruefung sucht jetzt
auch nach Rollen-OBJEKTEN, mit Gegenprobe. Die Namen stehen an EINER
Stelle in bereiche.js.
DOGFATHER UND SPICY MEDIA SIND IM CHAT FUER JEDEN ERREICHBAR
DogFather kam bisher als Nebeneffekt ueber die Betreuungskette mit
hinein, Spicy Media gar nicht -- die Rolle steht in keiner Kette, sie
steht daneben. Beide werden jetzt ausdruecklich hinzugefuegt: Eine
Zustaendigkeit, die nur zufaellig aus einer anderen Regel herausfaellt,
faellt beim naechsten Umbau genauso zufaellig wieder heraus.
Gemessen aus der Sicht eines Creators -- wer bei ihm ankommt, kommt
ueberall an.
DIE PERSONENLISTE: SPICY MEDIA SIEHT ALLES AUSSER DOGFATHER
Der Abschnitt "Spicy Media" fehlte in der Liste komplett -- die Rolle
gibt es seit heute Frueh, die Personenseite kannte sie nicht. Und Spicy
Media selbst sah dort bisher nur das Anlegen-Formular; sie bekommt
jetzt die Liste, ohne DogFathers Zeile, und weiterhin ohne Codes,
Sperren, Loeschen und Protokoll. Ueberblick ist nicht Verwaltung.
ZWEI FOLGEFEHLER, BEIDE VON DEN PRUEFUNGEN GEFUNDEN
* `next("route")` TUT DAS GEGENTEIL VON DEM, WONACH ES KLINGT. Mein
erster Versuch war eine Ausnahme-Route VOR der Schranke, die mit
`next("route")` weiterreicht -- das ueberspringt aber die restlichen
Handler DIESER Route und geht zur naechsten Schicht, also genau zur
Schranke. Spicy bekam weiter 404, die Oberflaeche verstand das als
"nicht erlaubt" und sprang zur Startseite. Gemessen: Auf
personen.html standen die Kategorien der STARTSEITE.
* DAS PROTOKOLL WARF SIE VON DER SEITE. Es bleibt bei DogFather und
antwortet ihr mit 404 -- und `hole()` versteht ein 404 unter
`/verwaltung/` als "nicht erlaubt". Die Seite baute sich auf und
sprang im naechsten Atemzug weg. Eine Abfrage, von der man weiss,
dass sie 404 gibt, stellt man nicht.
UND EINE MEINER EIGENEN NEUEN PRUEFUNGEN WAR WERTLOS
"bei ihr fehlt der Abschnitt DogFather" -- gruen, mit dem Zusatz
"(keine Abschnitte)". Sie war gruen, weil die Liste bei ihr GAR NICHT
DA war. Genau der Haken, der nichts beweist. Er steht jetzt neben einem
Ergebnis, das etwas enthaelt: "Spicy Media | Manager | ...".
18 Pruefungen gelaufen, alle gruen. pruef-spicy von 49 auf 57.
Co-Authored-By: Claude Opus 5 <[email protected]>
695 lines
26 KiB
JavaScript
695 lines
26 KiB
JavaScript
/* =====================================================================
|
||
chat.js — Gespräche im Workspace.
|
||
|
||
Wunsch Filipe, 06.09.2026: *"jeder kann dann eine nachricht
|
||
reinschreiben, die andere person bekommt eine benachrichtigung und
|
||
kann dann antworten."*
|
||
|
||
---------------------------------------------------------------------
|
||
DREI ENTSCHEIDUNGEN, die den Rest erklären
|
||
|
||
1. NEUE NACHRICHTEN KOMMEN VON SELBST (EventSource / SSE). Kein
|
||
Nachfragen im Takt: Das wäre fünf Sekunden langsam und stellte bei
|
||
zehn Angemeldeten 7200 Anfragen in der Stunde für Nachrichten, die
|
||
es meistens nicht gibt. Reisst die Verbindung ab, verbindet der
|
||
Browser sich von selbst neu -- und wir holen die Lücke über den
|
||
normalen Verlauf nach.
|
||
|
||
2. DIE EIGENE NACHRICHT STEHT SOFORT DA, bevor der Server antwortet.
|
||
Ein Chat, in dem man nach dem Abschicken eine halbe Sekunde ins
|
||
Leere sieht, fühlt sich kaputt an. Kommt der Server nicht mit,
|
||
wird sie sichtbar als "nicht angekommen" markiert -- nicht still
|
||
entfernt.
|
||
|
||
3. GELESEN WIRD GEMELDET, wenn man wirklich hinsieht. Nicht schon
|
||
beim Laden der Seite: Wer die Seite im Hintergrund offen hat, hat
|
||
nichts gelesen, und eine Zahl, die von selbst verschwindet, ist
|
||
schlimmer als keine.
|
||
===================================================================== */
|
||
(() => {
|
||
'use strict';
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
const el = (tag, klasse, text) => {
|
||
const k = document.createElement(tag);
|
||
if (klasse) k.className = klasse;
|
||
if (text !== undefined) k.textContent = text;
|
||
return k;
|
||
};
|
||
|
||
/* Aus der einen Quelle. Stand hier als eigenes Objekt und hatte
|
||
'spicy' nicht -- siehe bereiche.js. */
|
||
const ROLLENKURZ = window.Bereiche.ROLLEN_NAME;
|
||
|
||
let ich = null;
|
||
let raeume = [];
|
||
let offen = null; // der gerade angesehene Raum
|
||
let nachrichten = [];
|
||
let strom = null; // die EventSource
|
||
let gewaehlt = new Set(); // im Neu-Dialog
|
||
|
||
/* ---------- Kleinkram ---------------------------------------------------- */
|
||
|
||
async function hole(weg, einst) {
|
||
const a = await fetch(weg, { credentials: 'same-origin', ...einst });
|
||
if (a.status === 401) { location.assign('/workspace/'); throw new Error('weg'); }
|
||
return a;
|
||
}
|
||
|
||
/** Ein bis zwei Buchstaben für den Kreis. Dieselbe Regel wie bei der
|
||
* Teilnehmerwahl im Kalender -- Anfang UND Ende, weil "Patrick von
|
||
* Hohenstein" sonst zu "PV" würde. */
|
||
function initialen(name) {
|
||
const teile = String(name || '').trim().split(/\s+/).filter(Boolean);
|
||
if (!teile.length) return '?';
|
||
const erst = [...teile[0]][0] || '';
|
||
const letzt = teile.length > 1 ? ([...teile[teile.length - 1]][0] || '') : '';
|
||
return (erst + letzt).toLocaleUpperCase('de-DE');
|
||
}
|
||
|
||
/** "14:32" heute, "Gestern", sonst "3. Sep".
|
||
*
|
||
* ORTSZEIT, nicht UTC: Die Zeitstempel kommen als ISO-Text vom
|
||
* Server. `new Date(text)` liest sie richtig, `slice()` darauf wäre
|
||
* UTC und läge nachts einen Tag daneben. */
|
||
function wann(iso, lang = false) {
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return '';
|
||
const uhr = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||
if (lang) return `${d.toLocaleDateString('de-DE', { day: 'numeric', month: 'long' })}, ${uhr}`;
|
||
|
||
const heute = new Date();
|
||
const gleich = (a, b) => a.getFullYear() === b.getFullYear()
|
||
&& a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||
if (gleich(d, heute)) return uhr;
|
||
const gestern = new Date(heute.getTime() - 86400000);
|
||
if (gleich(d, gestern)) return 'Gestern';
|
||
return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' });
|
||
}
|
||
|
||
const melde = (t) => { $('fehler').textContent = t || ''; };
|
||
|
||
/* ---------- Die Gesprächsliste ------------------------------------------- */
|
||
|
||
async function raeumeLaden() {
|
||
try {
|
||
const a = await hole('/workspace/api/chat/raeume');
|
||
if (!a.ok) return;
|
||
raeume = (await a.json()).raeume || [];
|
||
} catch { return; }
|
||
raeumeZeichnen();
|
||
ungelesenMelden();
|
||
}
|
||
|
||
function raeumeZeichnen() {
|
||
const ziel = $('raeume');
|
||
const suchtext = ($('suche').value || '').trim().toLowerCase();
|
||
const zeigen = suchtext
|
||
? raeume.filter((r) => r.name.toLowerCase().includes(suchtext))
|
||
: raeume;
|
||
|
||
ziel.textContent = '';
|
||
$('liste-leer').hidden = raeume.length > 0;
|
||
/* Das Suchfeld erst, wenn die Liste lang genug ist, um darin zu
|
||
suchen. Vorher ist es ein Feld ohne Zweck. */
|
||
$('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;
|
||
if (gegen.bild) {
|
||
const b = document.createElement('img');
|
||
b.src = gegen.bild; b.alt = '';
|
||
b.addEventListener('error', () => b.remove());
|
||
zeichen.append(b);
|
||
}
|
||
}
|
||
}
|
||
knopf.append(zeichen);
|
||
|
||
const mitte = el('span', 'chat-raum__mitte');
|
||
const oben = el('span', 'chat-raum__oben');
|
||
oben.append(el('span', 'chat-raum__name', r.name));
|
||
if (r.letzte) oben.append(el('span', 'chat-raum__zeit', wann(r.letzte.erstellt)));
|
||
mitte.append(oben);
|
||
|
||
const vorschau = el('span', 'chat-raum__vorschau');
|
||
if (r.letzte) {
|
||
const wer = r.letzte.von === ich.name ? 'Du: ' : (r.art === 'gruppe' ? `${r.letzte.von}: ` : '');
|
||
vorschau.textContent = r.letzte.zurueckgenommen
|
||
? `${wer}Nachricht zurückgenommen`
|
||
: wer + r.letzte.text;
|
||
if (r.letzte.zurueckgenommen) vorschau.dataset.weg = 'ja';
|
||
} else {
|
||
vorschau.textContent = 'Noch nichts geschrieben';
|
||
vorschau.dataset.weg = 'ja';
|
||
}
|
||
mitte.append(vorschau);
|
||
knopf.append(mitte);
|
||
|
||
if (r.ungelesen) {
|
||
const zahl = el('span', 'chat-raum__zahl', String(r.ungelesen));
|
||
zahl.setAttribute('aria-label', `${r.ungelesen} ungelesen`);
|
||
knopf.append(zahl);
|
||
}
|
||
|
||
knopf.addEventListener('click', () => raumOeffnen(r.id));
|
||
li.append(knopf);
|
||
ziel.append(li);
|
||
}
|
||
|
||
if (suchtext && !zeigen.length) {
|
||
const li = el('li', 'chat__leer', 'Kein Gespräch passt dazu.');
|
||
ziel.append(li);
|
||
}
|
||
}
|
||
|
||
/* ---------- 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) {
|
||
/* DAS GESICHT STATT DER INITIALEN (07.09.2026).
|
||
|
||
Der Anfangsbuchstabe bleibt als Unterlage LIEGEN und wird
|
||
nicht ersetzt: Laedt das Bild nicht, steht dort weiterhin
|
||
etwas Sinnvolles statt eines kaputten Bildsymbols. Dieselbe
|
||
Bauweise wie in der Kopfleiste und in der Personenauswahl --
|
||
drei Stellen, ein Verhalten. */
|
||
const z = el('span', 'chat-nachricht__zeichen', initialen(n.von));
|
||
z.setAttribute('aria-hidden', 'true');
|
||
z.dataset.rolle = n.rolle || '';
|
||
if (n.von_bild) {
|
||
const b = document.createElement('img');
|
||
b.src = n.von_bild; b.alt = '';
|
||
b.addEventListener('error', () => b.remove());
|
||
z.append(b);
|
||
}
|
||
zeile.append(z);
|
||
}
|
||
|
||
const blase = el('div', 'chat-nachricht__blase');
|
||
if (!gleicheFolge && !n.selbst && offen.art === 'gruppe') {
|
||
blase.append(el('p', 'chat-nachricht__von', n.von));
|
||
}
|
||
|
||
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. */
|
||
/* Die Rollenfolge kommt aus bereiche.js. Stand sie hier selbst,
|
||
fehlte 'spicy' -- und wer nicht in der Schleife vorkommt, taucht
|
||
in der Gesprächsliste gar nicht auf. Kein Fehler, nur eine
|
||
Person, die es fuer die Anderen nicht gibt. */
|
||
for (const rolle of window.Bereiche.ROLLENFOLGE) {
|
||
const leute = personen.filter((p) => p.rolle === rolle);
|
||
if (!leute.length) continue;
|
||
|
||
const gruppe = el('div', 'chat-wahl__gruppe');
|
||
gruppe.dataset.rolle = rolle;
|
||
const titel = el('p', 'chat-wahl__titel');
|
||
const punkt = el('span', 'chat-wahl__punkt');
|
||
punkt.setAttribute('aria-hidden', 'true');
|
||
titel.append(punkt, document.createTextNode(
|
||
window.Bereiche.ROLLEN_GRUPPE[rolle]));
|
||
gruppe.append(titel);
|
||
|
||
const liste = el('div', 'chat-wahl__liste');
|
||
liste.setAttribute('role', 'group');
|
||
for (const p of leute) {
|
||
const b = el('button', 'chat-wahl__knopf');
|
||
b.type = 'button';
|
||
b.dataset.id = String(p.id);
|
||
b.dataset.rolle = p.rolle;
|
||
b.setAttribute('aria-pressed', 'false');
|
||
|
||
const z = el('span', 'chat-wahl__zeichen', initialen(p.name));
|
||
z.setAttribute('aria-hidden', 'true');
|
||
b.append(z, el('span', 'chat-wahl__name', p.name));
|
||
|
||
b.addEventListener('click', () => {
|
||
if (gewaehlt.has(p.id)) { gewaehlt.delete(p.id); delete b.dataset.an; }
|
||
else { gewaehlt.add(p.id); b.dataset.an = 'ja'; }
|
||
b.setAttribute('aria-pressed', gewaehlt.has(p.id) ? 'true' : 'false');
|
||
wahlGeaendert();
|
||
});
|
||
liste.append(b);
|
||
}
|
||
gruppe.append(liste);
|
||
ziel.append(gruppe);
|
||
}
|
||
}
|
||
|
||
function wahlGeaendert() {
|
||
const n = gewaehlt.size;
|
||
/* Ab zwei ist es eine Gruppe -- und die braucht einen Namen. */
|
||
$('gruppenname-zeile').hidden = n < 2;
|
||
$('neu-los').disabled = n === 0;
|
||
$('neu-los').textContent = n >= 2 ? 'Gruppe anlegen' : 'Schreiben';
|
||
}
|
||
|
||
async function neuAbschicken(e) {
|
||
e.preventDefault();
|
||
const ids = [...gewaehlt];
|
||
if (!ids.length) return;
|
||
const knopf = $('neu-los');
|
||
knopf.disabled = true;
|
||
|
||
try {
|
||
let raumId;
|
||
if (ids.length === 1) {
|
||
const a = await hole('/workspace/api/chat/direkt', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ mit: ids[0] }),
|
||
});
|
||
if (!a.ok) {
|
||
$('neu-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.';
|
||
return;
|
||
}
|
||
raumId = (await a.json()).raum_id;
|
||
} else {
|
||
const name = $('gruppenname').value.trim();
|
||
if (name.length < 2) {
|
||
$('neu-fehler').textContent = 'Die Gruppe braucht einen Namen.';
|
||
$('gruppenname').focus();
|
||
return;
|
||
}
|
||
const a = await hole('/workspace/api/chat/gruppe', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name, mit: ids }),
|
||
});
|
||
if (!a.ok) {
|
||
$('neu-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.';
|
||
return;
|
||
}
|
||
raumId = (await a.json()).raum_id;
|
||
}
|
||
$('neu-dialog').close();
|
||
await raeumeLaden();
|
||
await raumOeffnen(raumId);
|
||
} catch { /* umgeleitet */ } finally { knopf.disabled = false; }
|
||
}
|
||
|
||
/* ---------- Die Höhe der Kopfleiste MESSEN, nicht raten ------------------
|
||
|
||
Die Chatfläche ist so hoch wie das Fenster minus die Kopfleiste.
|
||
Deren Höhe stand zuerst als fester Wert im CSS (62 px) -- am Handy
|
||
ist sie zweizeilig und gut doppelt so hoch. Folge: Die Seite war zu
|
||
lang, und das Eingabefeld stand halb unter dem Bildschirmrand.
|
||
|
||
Ein fester Wert ist hier immer falsch: Die Leiste wächst mit der
|
||
Schriftgröße, mit der Glocke, mit einem längeren Namen. Also wird
|
||
sie gemessen -- einmal beim Start und wieder, wenn sich etwas an
|
||
der Fenstergröße ändert. */
|
||
function kopfMessen() {
|
||
const kopf = document.querySelector('.kopfleiste');
|
||
if (!kopf) return;
|
||
const h = Math.ceil(kopf.getBoundingClientRect().height);
|
||
document.documentElement.style.setProperty('--kopf-hoehe', `${h}px`);
|
||
}
|
||
kopfMessen();
|
||
window.addEventListener('resize', kopfMessen);
|
||
/* Auch wenn die Leiste selbst sich ändert (Glocke erscheint, Name
|
||
wird eingesetzt) -- ohne das bliebe der erste Messwert stehen. */
|
||
if (window.ResizeObserver) {
|
||
const kopf = document.querySelector('.kopfleiste');
|
||
if (kopf) new ResizeObserver(kopfMessen).observe(kopf);
|
||
}
|
||
|
||
/* ---------- Start -------------------------------------------------------- */
|
||
|
||
$('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 */ }
|
||
})();
|
||
})();
|