Files
dogfather-universe/workspace/assets/js/chat.js
T
DogFatherGitandClaude Opus 5 bf3c7bd718 Einen Aushang loest jeder fuer sich -- fuer alle nur DogFather und die rechte Hand
Filipe: "jeder soll das fixierte individuel für sich lösen können aber
niemals so dass es sich für alle löst. außer dogfather macht es oder die
rechte hand dan ist es bei jedem weg ansonsten sollen alle anderen
rollen es individuell für sich lösen können. dogfather und die rechte
hand sollen die option haben für sich selbst oder für alle zu lösen."

BIS HEUTE GAB ES NUR EIN LOESEN, UND DAS GALT FUER ALLE. Wer den Knopf
sah, nahm damit jedem im Raum den Aushang weg; wer ihn nicht sah, musste
die Ansage vom Montag bis Freitag ueber jedem Gespraech stehen lassen.

JETZT ZWEI KNOEPFE AM AUSHANG:

  "lösen"      nimmt ihn nur bei MIR weg -- jeder darf das, ohne
               Rueckfrage. Umkehrbar: Das Menue an der Nachricht holt
               ihn mit "wieder oben" zurueck. Eine Rueckfrage vor etwas
               Umkehrbarem lernt man wegzuklicken, und danach klickt man
               auch die weg, die zaehlt.

  "bei allen"  nimmt ihn jedem weg -- nur fuer DogFather und die rechte
               Hand, und MIT Rueckfrage. Er traegt die Warnfarbe des
               Hauses: Zwei gleich aussehende Knoepfe nebeneinander
               waeren die schlechteste Loesung, man traefe den falschen
               und merkte es erst, wenn jemand fragt, wo die Ansage
               hin ist.

EIN EINZIGER KNOPF MIT AUSWAHLFENSTER waere kuerzer und schlechter: Der
haeufige Fall ("weg damit, kenne ich") braeuchte dann zwei Klicks, und
der seltene, folgenreiche waere genauso weit entfernt wie der harmlose.

WAS NICHT IN FILIPES SATZ STEHT UND TROTZDEM NOETIG IST: Wer einen
Aushang SELBST angeheftet hat, darf ihn auch selbst wieder fuer alle
loesen. Sonst entsteht eine Sackgasse -- es haengen hoechstens drei,
und eine Gruppenleitung, die drei angeheftet hat und keinen abnehmen
darf, koennte nie wieder etwas anheften. Sie nimmt damit nur zurueck,
was sie selbst getan hat; das ist die Kehrseite derselben Erlaubnis,
keine neue.

TECHNISCH: eine Tabelle `chat_pin_aus` (Nachricht, Person). Kein
Eintrag heisst sichtbar -- nicht umgekehrt, sonst muesste beim Anheften
fuer jeden Teilnehmer eine Zeile entstehen und wer spaeter dazukommt,
saehe den Aushang nie. Der Verbund steht in der Abfrage und nicht im
Browser: Eine Liste, die alles schickt und im Browser gefiltert wird,
ist eine Liste, die alles schickt.

GEMESSEN -- server/pruef-pin-fuer-mich.mjs (neu), 37 Pruefungen, 0 Fehler:
  - Der Modi nimmt sie bei sich weg. BEI DOGFATHER UND BEIM ZWEITEN
    MODI HAENGT SIE WEITER -- das ist der Kern des Auftrags, und er
    laesst sich nur mit mehreren Anmeldungen messen.
  - Er holt sie zurueck; zweimal wegnehmen ist kein Fehler.
  - Er kann NICHT fuer alle loesen (403), und die Absage sagt, was
    stattdessen geht.
  - Die rechte Hand loest fuer alle -- danach ist sie bei jedem weg.
  - Wer selbst angeheftet hat, loest seinen eigenen (200) und den von
    DogFather nicht (403).
  - Das Nachruecken stimmt: Wer einen von dreien weggenommen hat, sieht
    zwei, waehrend DogFather drei sieht.
  - Drei Gegenproben: fremder Raum 404, ohne Anmeldung 401, erfundene
    Nummer 404.

DIE PRUEFUNG MUSSTE AUF node:http UMGEBAUT WERDEN. Sie braucht
Team-Dogi-Rollen, die es nur auf der Crew-Adresse gibt -- und den
`Host`-Kopf laesst `fetch` nicht setzen (verbotener Kopf, undici
verwirft ihn stumm). Die Anfrage kam auf 127.0.0.1 an, waehrend
`Origin` die Crew-Adresse nannte; jede schreibende Anfrage bekam 403
"fremde_herkunft". Im ersten Lauf sah das aus, als sei die neue Route
kaputt.

AUSSERDEM IN DIESER RUNDE: Die drei Faecher im Chat (Personen, Gruppen,
Kanaele) waren 38 px hoch statt 44. Gefunden vom Handy-Rundgang bei
jeder Rolle -- aber erst, seit der Sammellauf auch die Zeile UNTER dem
Befund mitschreibt. Vorher stand dort nur "1 Befund".

Die Schemaaenderung auf einer Kopie der echten Datenbank durchgespielt:
72 Tabellen, keine Zeile und keine Spalte verloren, chat_pin_aus da.
pruef-chat 63/0, pruef-chat-optik 62/0, pruef-chat-kanaele 81/0,
pruef-treffchat 110/0, pruef-chat-neu 36/0, pruef-handy-teamdogi
0 Befunde, pruef-css-klassen gruen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 14:13:05 +02:00

4855 lines
209 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);
/* =====================================================================
MEINE KACHEL (20.09.2026)
Filipe: „jeder account soll seine kachel personalisieren koennen."
DIE TABELLE IST LEER UND WIRD GEFUELLT. Sie steht nicht als feste
Liste hier: Die zwoelf Toene gehoeren dem Server (CHAT_KACHELN in
workspace-chat.js), und eine zweite Fassung im Browser waere beim
dreizehnten die vergessene -- dieselbe Krankheit wie bei KANAL_WORT
auf dem Highlight-Brett, die heute behoben wurde.
WOZU SIE HIER GEBRAUCHT WIRD: Die Nachricht bringt einen
SCHLUESSEL mit („moos"), keine Farbe. Waere die Farbe selbst in der
Nachricht, stuende ein CSS-Wert aus der Datenbank in der Seite --
und ein vertippter oder boeswilliger Eintrag koennte faerben, was
er will. So kommt nur durch, was in der Tabelle steht. */
const KACHEL_FARBE = Object.create(null);
/* WELCHE SCHRIFT AUF WELCHER KACHEL -- vom Server gerechnet
(schriftFuer in workspace-chat.js). Hier wird sie nur
nachgeschlagen: Eine zweite Rechnung im Browser waere die
Fassung, die beim naechsten Ton aelter ist als die andere. */
const KACHEL_SCHRIFT = Object.create(null);
let kachelListe = [];
/* Die 360 Farben des Rings, Index = Winkel. Kommt vom Server; hier
wird nichts gerechnet (Begruendung steht an der Route). */
let kreisToene = [];
/* Die sieben Helligkeiten und welche davon die Mitte ist. Beides
kommt vom Server (siehe chat-kreis-stufen.js). */
let kreisStufen = [{ wert: 's4', misch: 0, name: 'normal' }];
let kreisMitte = 's4';
/** Einen Grundton heller oder dunkler mischen.
*
* `misch` ist negativ fuer dunkler (Richtung Schwarz) und positiv
* fuer heller (Richtung Weiss) -- dieselbe Rechnung wie
* `color-mix(in srgb, …)` und wie `tonFarbe()` im Server. Sie steht
* hier ein zweites Mal, und das ist Absicht: Die ZAHLEN kommen vom
* Server, nur das Mischen selbst passiert im Browser. Eine Farbe,
* die 2520-mal ueber die Leitung geht, waere teurer als drei Zeilen
* Rechnung.
*/
function tonMischen(grund, misch) {
const s = String(grund).replace('#', '');
if (s.length !== 6) return grund;
const teile = [0, 2, 4].map((i) => parseInt(s.slice(i, i + 2), 16));
const ziel = misch < 0 ? 0 : 255;
const t = Math.abs(misch);
return '#' + teile
.map((v) => Math.round(v * (1 - t) + ziel * t).toString(16).padStart(2, '0'))
.join('');
}
let meineKachel = null;
let autoKachel = null;
async function kachelnLaden() {
try {
const a2 = await fetch('/workspace/api/chat/kacheln', { credentials: 'same-origin' });
if (!a2.ok) return;
const d = await a2.json();
kachelListe = Array.isArray(d.kacheln) ? d.kacheln : [];
meineKachel = d.meine || null;
/* Was getragen wird, wenn man nichts waehlt. Der Server rechnet
das aus der Person aus; die Oberflaeche rechnet es NICHT nach.
Zwei Rechnungen fuer dieselbe Farbe waeren zwei Stellen, an
denen sie stimmen muss. */
autoKachel = d.automatisch || null;
for (const k of kachelListe) {
KACHEL_FARBE[k.wert] = k.farbe;
if (k.schrift) KACHEL_SCHRIFT[k.wert] = k.schrift;
}
/* DER KREIS KOMMT IN DIESELBE TABELLE.
Das ist der ganze Trick an dieser Stelle: Ab hier ist
"ton-214" ein Schluessel wie "veilchen" auch. blaseFaerben(),
der Knopf in der Kopfzeile und jede andere Stelle, die eine
Farbe nachschlaegt, funktionieren unveraendert weiter -- ohne
dass irgendwo eine zweite Art Wahl behandelt werden muesste.
Eine Sonderbehandlung an zehn Stellen waere neun Gelegenheiten
gewesen, eine davon zu vergessen. */
kreisToene = Array.isArray(d.kreis) ? d.kreis : [];
/* ==== DIE SIEBEN HELLIGKEITEN (25.09.2026) ==================
Filipe: „ich will dass die leute auch heller und dunkler
aussuchen koennen."
NICHT 2520 FERTIGE FARBEN, sondern 360 Grundtoene und sieben
Mischanteile -- die ganze Tabelle waere 20 kB fuer etwas, das
eine Zeile Rechnung ist.
DIE ZAHLEN KOMMEN VOM SERVER und stehen nicht hier: Sie sind
aus der Kontrastrechnung hergeleitet (chat-kreis-stufen.js),
und eine zweite Fassung im Browser waere ab der naechsten
Messung falsch -- unbemerkt, denn sie lieferte ja weiter
„irgendeine Farbe". Kommt nichts, bleibt es bei der einen
Mitte: der Kreis von gestern. */
kreisStufen = Array.isArray(d.kreis_stufen) && d.kreis_stufen.length
? d.kreis_stufen : [{ wert: 's4', misch: 0, name: 'normal' }];
kreisMitte = d.kreis_mitte || 's4';
kreisToene.forEach((farbe, winkel) => {
KACHEL_FARBE['ton-' + winkel] = farbe;
if (d.kreis_schrift) KACHEL_SCHRIFT['ton-' + winkel] = d.kreis_schrift;
/* Jede Stufe bekommt ihren eigenen Schluessel in derselben
Tabelle. Damit funktionieren ALLE Stellen, die eine Farbe
nachschlagen, unveraendert weiter -- dieselbe Ueberlegung
wie am 23.09., als der Kreis dazukam. */
for (const s of kreisStufen) {
if (!s.misch) continue;
KACHEL_FARBE['ton-' + winkel + '-' + s.wert] = tonMischen(farbe, s.misch);
}
/* Und der Schluessel MIT der Mitte zeigt auf dieselbe Farbe
wie der ohne -- sonst waere „ton-214" und „ton-214-s4"
zweierlei, und beide kaemen vor. */
KACHEL_FARBE['ton-' + winkel + '-' + kreisMitte] = farbe;
});
kachelKnopfZeigen();
} catch { /* ohne die Liste bleibt alles beim Standard -- kein Grund
zu scheitern, der Chat funktioniert weiter */ }
}
function kachelKnopfZeigen() {
const knopf = $('kachel-oeffnen');
if (!knopf) return;
/* OHNE LISTE KEIN KNOPF. Ein Knopf, der eine leere Auswahl oeffnet,
ist schlimmer als keiner -- man drueckt ihn und sucht den Fehler
bei sich. */
knopf.hidden = kachelListe.length === 0;
const probe = knopf.querySelector('.chat__kachelknopf-probe');
if (probe) {
/* Der Knopf zeigt, was WIRKLICH getragen wird -- also die eigene
Wahl, sonst die automatische. Zeigte er den neutralen Ton,
waere er eine Auskunft ueber etwas, das man auf dem Bildschirm
daneben anders sieht. */
probe.style.background = KACHEL_FARBE[meineKachel]
|| KACHEL_FARBE[autoKachel] || KACHEL_FARBE.grund || '#4f545c';
}
}
/* WELCHE KACHELN NEBEN DEM RING BLEIBEN -- abgeleitet, nicht
aufgezaehlt.
Auf dem Ring liegt alles, was einen Farbton hat und auf der
gemeinsamen Leuchtdichte sitzt. Zwei Kacheln koennen das nicht:
Grau hat keinen Farbton, und Babyblau ist die eine helle mit
eigener, dunkler Schrift. Beide gehoeren weiter dazu.
Eine Liste ["grund","babyblau"] waere die naheliegende Loesung und
genau die Falle vom 11.09.: Kommt morgen eine dritte Sonderkachel
dazu, fehlt sie stillschweigend im Waehler. Stattdessen wird
gemessen -- liegt die Farbe nahe an einer Ringfarbe, ist sie auf
dem Ring; sonst braucht sie einen eigenen Knopf.
DIE SCHWELLE IST NICHT GERATEN, sondern nachgemessen (23.09.2026):
Die elf bunten Kacheln liegen zwischen 0,0 und 2,4 von ihrer
Ringfarbe entfernt, Grau bei 75,4 und Babyblau bei 192,9. Zwischen
2,4 und 75 ist so viel Luft, dass jede Zahl darin dieselbe Antwort
gibt -- die Schwelle ist also keine knappe Entscheidung, die beim
naechsten Nachrechnen kippt. */
const RINGNAH = 12;
function abstand(a, b) {
if (!a || !b || a.length !== 7 || b.length !== 7) return Infinity;
let s = 0;
for (const i of [1, 3, 5]) {
const d2 = parseInt(a.slice(i, i + 2), 16) - parseInt(b.slice(i, i + 2), 16);
s += d2 * d2;
}
return Math.sqrt(s);
}
/** Der Winkel, dessen Farbe dieser am naechsten kommt -- samt Abstand. */
function naechsterWinkel(farbe) {
let best = Infinity, winkel = 0;
kreisToene.forEach((t, i) => {
const d2 = abstand(t, farbe);
if (d2 < best) { best = d2; winkel = i; }
});
return { winkel, abstand: best };
}
function kachelwahlBauen() {
radBauen();
const gitter = $('kachel-gitter');
if (!gitter) return;
gitter.textContent = '';
for (const k of kachelListe) {
/* Liegt sie auf dem Ring, hat sie dort schon ihren Platz. */
if (kreisToene.length && naechsterWinkel(k.farbe).abstand <= RINGNAH) continue;
const b = el('button', 'chat-kachelwahl__ton');
b.type = 'button';
b.dataset.wert = k.wert;
b.style.background = k.farbe;
b.setAttribute('role', 'radio');
/* DER NAME GEHOERT DAZU, nicht nur die Farbe. Wer Farben schlecht
unterscheidet, waehlt sonst zwoelf Mal dasselbe Kaestchen --
und ein Vorleseprogramm saehe zwoelf namenlose Knoepfe. */
b.setAttribute('aria-label', k.name);
b.title = k.name;
b.setAttribute('aria-checked', String((meineKachel || 'grund') === k.wert));
/* EINE VORSCHAU MIT ECHTEM TEXT, nicht nur ein Farbfleck: Gewaehlt
wird ja nicht die Farbe, sondern wie die eigene Nachricht
aussieht. */
/* DAS „Aa" TRAEGT DIE SCHRIFT DIESER KACHEL (22.09.2026).
Sonst stuende in der Auswahl ein helles Aa auf Babyblau --
also genau das Bild, das es zu vermeiden gilt, und zwar an der
Stelle, an der man sich entscheidet. */
const wort = el('span', 'chat-kachelwahl__wort', 'Aa');
if (k.schrift) wort.style.color = k.schrift.text;
b.append(wort);
b.addEventListener('click', () => kachelSetzen(k.wert, b));
gitter.append(b);
}
}
async function kachelSetzen(wert, knopf) {
const vorher = meineKachel;
/* SOFORT UMSTELLEN, dann speichern. Wer eine Farbe antippt und eine
halbe Sekunde nichts sieht, tippt noch einmal. Geht es schief,
wird es zurueckgenommen -- und zwar sichtbar. */
meineKachel = (wert === 'grund') ? null : wert;
/* OHNE DEN GEDRUECKTEN KNOPF ZU KENNEN. Vorher wurde ueber
knopf.parentElement markiert -- seit der Ring dazugekommen ist,
gibt es Aufrufe ganz ohne Knopf, und die waeren daran
abgestuerzt. Der Zustand steht in meineKachel; die Anzeige wird
daraus abgeleitet. */
auswahlMarkieren();
kachelKnopfZeigen();
eigeneKachelnFaerben();
try {
const a2 = await hole('/workspace/api/chat/kachel', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kachel: wert }),
});
if (!a2.ok) throw new Error('abgelehnt');
} catch {
meineKachel = vorher;
kachelKnopfZeigen();
eigeneKachelnFaerben();
kachelwahlBauen();
melde('Die Farbe konnte nicht gespeichert werden.');
}
}
/** Die eigenen Nachrichten im offenen Verlauf sofort umfaerben.
*
* OHNE NEU ZU LADEN: Der Verlauf neu zu holen hiesse, beim Faerben
* nach unten zu springen und die Stelle zu verlieren, an der man
* gerade liest. Die Farbe steht ohnehin nur an der Kachel. */
/** Flaeche UND Schrift in einem Griff.
*
* ZUSAMMEN UND NICHT NACHEINANDER (22.09.2026): Wer die Farbe an
* einer Stelle setzt und die Schrift an einer anderen, hat
* irgendwann eine helle Kachel mit heller Schrift -- und merkt es
* erst, wenn jemand nichts mehr lesen kann. Es gibt deshalb genau
* diesen einen Weg, eine Blase einzufaerben. */
function blaseFaerben(blase, wert) {
const farbe = KACHEL_FARBE[wert] || null;
const schrift = KACHEL_SCHRIFT[wert] || null;
if (farbe) blase.style.setProperty('--kachel', farbe);
else blase.style.removeProperty('--kachel');
/* ==== DIE SCHRIFTFARBE WIRD HIER NICHT MEHR GESETZT (25.09.2026)
Sie wechselte bisher mit der Kachel: Auf einer hellen Flaeche
dunkle Schrift, auf einer dunklen helle. Das war genau richtig,
SOLANGE der Text auf der Kachel stand.
Seit heute steht er das nicht mehr. Die Blase ist dunkles Glas
fuer alle; die persoenliche Farbe ist die KANTE, der NAME und
ein Schein darunter. Damit ist der Untergrund des Textes immer
derselbe -- und eine Schriftfarbe, die mit der Kachel wechselt,
waere ab jetzt eine Rechnung fuer einen Untergrund, den es nicht
mehr gibt. Auf einer hellen Kachel haette sie dunkle Schrift auf
dunkles Glas gesetzt: unlesbar.
`schrift` bleibt trotzdem stehen und wird weiter geholt -- der
Farbring in der Mitte zeigt damit weiter, wie die Farbe mit
Schrift wirkt. Nur die Blase fragt nicht mehr danach. */
void schrift;
}
function eigeneKachelnFaerben() {
for (const z of document.querySelectorAll('.chat-nachricht[data-selbst="ja"] .chat-nachricht__blase')) {
blaseFaerben(z, meineKachel);
}
}
/** Markiert im Waehler, was gerade gewaehlt ist.
*
* NUR DIE KNOEPFE, NICHT DER RING. Der Ring wird von radZeigen()
* gestellt -- riefe er hier zurueck, riefen sich die beiden
* gegenseitig auf. Es endete zwar nach zwei Schritten, aber wer
* spaeter eine Zeile dazwischensetzt, baut daraus eine Schleife,
* die den Browser anhaelt. Also gar nicht erst. */
function auswahlMarkieren() {
const gitter = $('kachel-gitter');
if (!gitter) return;
for (const b of gitter.querySelectorAll('.chat-kachelwahl__ton')) {
b.setAttribute('aria-checked', String((meineKachel || 'grund') === b.dataset.wert));
}
}
/* =====================================================================
DER FARBRING (23.09.2026)
Filipe: „kannst du nicht so eine art diagramm machen wo man mit
einem kreis herum gehen kann und sie die farbe ganz genau selber
auswaehlen kann?"
---------------------------------------------------------------------
GESPEICHERT WIRD ERST, WENN MAN LOSLAESST
Wer einmal um den Ring fuehrt, erzeugt sonst dreihundert
Speicheranfragen -- auf einem Handy im Zug heisst das: der Ring
haengt, und die Farbe, die am Ende ankommt, ist die aus der Mitte
der Bewegung. Die Vorschau laeuft deshalb sofort und oertlich, die
Anfrage geht einmal, wenn die Bewegung endet. Bei der Tastatur
dasselbe mit einer halben Sekunde Ruhe.
DER RING IST NICHT DIE WAHRHEIT, DIE TABELLE IST ES. Der
Farbverlauf blendet zwischen benachbarten Winkeln weich ueber;
was gewaehlt wird, kommt immer aus kreisToene[winkel]. Sonst
haette man eine Farbe, die auf dem Bildschirm ausgerechnet
zwischen zwei geprueften Werten liegt.
===================================================================== */
let radWinkel = 0;
/* Welche der sieben Helligkeiten gerade gilt. Der Ring bestimmt den
Farbton, die Leiter die Helligkeit -- zwei Achsen derselben
Farbe. */
let radStufe = 's4';
let radUhr = null;
/** Der Schluessel aus beidem. Die Mitte bekommt KEINEN Zusatz:
* „ton-214" ist seit dem 23.09. der gueltige Schluessel, und wer
* ihn traegt, soll nach diesem Umbau nicht ploetzlich einen anderen
* in der Datenbank stehen haben. */
function radSchluessel() {
return radStufe === kreisMitte
? 'ton-' + radWinkel
: 'ton-' + radWinkel + '-' + radStufe;
}
function radBauen() {
const ring = $('kachelrad-ring');
const rad = $('kachelrad');
if (!ring || !rad) return;
/* OHNE KREIS KEIN RING. Kommt die Liste nicht (alter Server,
Netzproblem), bleibt das Rad weg und die Sonderknoepfe
darunter tun weiter ihren Dienst -- statt eines leeren,
schwarzen Kreises, bei dem jeder den Fehler bei sich sucht. */
if (kreisToene.length < 2) { rad.hidden = true; return; }
rad.hidden = false;
ring.style.background = 'conic-gradient(' + kreisToene.join(',') + ',' + kreisToene[0] + ')';
/* Wo der Griff steht, wenn man aufmacht: bei der eigenen Farbe.
Wer Babyblau traegt, hat keinen Winkel -- dann zeigt der Griff
auf den naechstgelegenen, ohne dass etwas umgestellt wird. */
const jetzt = KACHEL_FARBE[meineKachel] || KACHEL_FARBE[autoKachel] || null;
/* Der Schluessel darf seit dem 25.09.2026 eine Stufe tragen
("ton-214-s6"). Ohne Zusatz gilt die Mitte -- so wie der Server
es auch liest. */
const t = /^ton-(\d{1,3})(?:-(s[1-7]))?$/.exec(String(meineKachel || ''));
radWinkel = t ? Number(t[1]) : (jetzt ? naechsterWinkel(jetzt).winkel : 0);
radStufe = (t && t[2]) || kreisMitte;
leiterBauen();
radZeigen(radWinkel, { nurAnzeige: true });
}
/** Griff, Vorschau und Vorlesewert auf diesen Winkel stellen. */
function radZeigen(winkel, { nurAnzeige = false } = {}) {
const ring = $('kachelrad-ring'), arm = $('kachelrad-arm'), mitte = $('kachelrad-mitte');
if (!ring || !arm) return;
const w = ((Math.round(winkel) % 360) + 360) % 360;
radWinkel = w;
arm.style.setProperty('--winkel', w + 'deg');
ring.setAttribute('aria-valuenow', String(w));
/* EINE ZAHL IN GRAD SAGT NIEMANDEM ETWAS. Vorgelesen wird der
Farbname, den ein Mensch benutzt -- "Blaugruen, 190 Grad". */
/* DIE HELLIGKEIT GEHOERT IN DEN VORLESETEXT. Ohne sie saegte eine
Sprachausgabe bei allen sieben Stufen dasselbe -- und man haette
kein Mittel, die Wahl zu hoeren. */
ring.setAttribute('aria-valuetext',
farbwort(w) + ', ' + w + ' Grad, ' + stufenName(radStufe));
if (mitte) {
/* ==== DIE PROBE ZEIGT, WIE ES WIRKLICH AUSSIEHT (25.09.2026)
Hier stand eine gefuellte Flaeche in der gewaehlten Farbe mit
heller Schrift darauf. So sah die Blase bis zum 25.09. aus --
seither ist sie fuer alle dunkles Glas, und der gewaehlte Ton
ist der NAME darauf.
Die Probe zeigte also etwas, das es nicht mehr gibt. Jetzt
zeigt sie genau die Rechnung aus chat.css: der Ton, mit Weiss
aufgehellt (`--namen-anteil`), auf dem Blasengrund. Wer hier
eine Farbe waehlt, sieht die Schrift, die die anderen im Chat
sehen werden.
NEBENWIRKUNG, UND SIE IST DER GRUND, WARUM DIE HELLIGKEITEN
UEBERHAUPT GEHEN: Eine gefuellte Flaeche muesste Schrift
tragen -- und bei sehr hellen Toenen schafft das keine der
beiden Schriftfarben mehr (gemessen: 4,1 statt noetiger 7).
Als NAME auf dunklem Grund ist derselbe helle Ton dagegen
besonders gut lesbar (8,0). Die neue Probe ist damit nicht nur
ehrlicher, sie macht die sieben Stufen erst moeglich. */
const farbe = KACHEL_FARBE[radSchluessel()] || kreisToene[w];
mitte.style.background = '';
mitte.style.setProperty('--probe-ton', farbe || '');
}
if (!nurAnzeige) {
/* Die eigenen Blasen im Verlauf faerben sich mit -- man
entscheidet sich ja nicht fuer einen Fleck, sondern dafuer,
wie die eigenen Nachrichten aussehen. */
meineKachel = radSchluessel();
/* DIE LEITER FAERBT MIT. Ohne diese Zeile zeigten die sieben
Felder die Helligkeiten des Tons von vorhin -- man waehlt
Gruen und sieht sieben Rottoene darunter. */
leiterZeigen();
kachelKnopfZeigen();
eigeneKachelnFaerben();
auswahlMarkieren();
}
}
/** Der Name einer Helligkeitsstufe -- fuer Sprachausgabe und Titel. */
function stufenName(wert) {
return (kreisStufen.find((s) => s.wert === wert) || {}).name || 'normal';
}
/* ==== DIE HELLIGKEITSLEITER (25.09.2026) =========================
Filipe: „ich will dass die leute auch heller und dunkler aussuchen
koennen … so dass die leute viel krassere moeglichkeiten haben."
SIEBEN FELDER, JEDES IN SEINER ECHTEN FARBE -- nicht sieben graue
Kaestchen mit Beschriftung. Man waehlt eine Farbe; also soll man
Farben sehen. Die Felder faerben sich beim Drehen am Ring mit: Wer
den Farbton aendert, sieht sofort, wie die sieben Helligkeiten
DIESES Tons aussehen.
EINE LEISTE UND KEIN ZWEITER KREIS: Farbton laeuft rund (359 Grad
grenzt an 0), Helligkeit nicht -- sie hat ein dunkles und ein
helles Ende. Eine runde Form fuer etwas, das gerade verlaeuft,
waere eine Form, die luegt.
`role="slider"` AM GANZEN KASTEN und nicht sieben Knoepfe: Es ist
EIN Wert mit sieben Stufen. Sieben Knoepfe waeren sieben
Tabulatorschritte fuer eine Entscheidung. */
function leiterBauen() {
const leiter = $('kachelhell-leiter');
if (!leiter) return;
leiter.textContent = '';
kreisStufen.forEach((s, i) => {
const feld = el('span', 'kachelhell__feld');
feld.dataset.stufe = s.wert;
feld.title = s.name;
/* aria-hidden: Der Wert steht am Kasten (`aria-valuenow`), die
Felder sind seine Darstellung. Ohne das laese eine Sprachausgabe
sieben leere Felder vor. */
feld.setAttribute('aria-hidden', 'true');
feld.addEventListener('click', () => stufeWaehlen(i, true));
leiter.append(feld);
});
leiterZeigen();
}
/** Die Felder auf den aktuellen Farbton bringen und die Wahl zeigen. */
function leiterZeigen() {
const leiter = $('kachelhell-leiter');
if (!leiter) return;
const nr = Math.max(0, kreisStufen.findIndex((s) => s.wert === radStufe));
leiter.setAttribute('aria-valuenow', String(nr + 1));
leiter.setAttribute('aria-valuetext', stufenName(radStufe));
for (const feld of leiter.children) {
const s = kreisStufen.find((x) => x.wert === feld.dataset.stufe);
const grund = kreisToene[radWinkel];
if (s && grund) feld.style.background = s.misch ? tonMischen(grund, s.misch) : grund;
feld.dataset.an = feld.dataset.stufe === radStufe ? 'ja' : 'nein';
}
}
/** Eine Stufe waehlen -- ueber den Index, damit Pfeiltasten und
* Klick denselben Weg gehen. */
function stufeWaehlen(nr, sichern) {
const n = Math.max(0, Math.min(kreisStufen.length - 1, nr));
radStufe = kreisStufen[n].wert;
radZeigen(radWinkel);
leiterZeigen();
if (sichern) radSichern(0);
}
function leiterBedienen() {
const leiter = $('kachelhell-leiter');
if (!leiter) return;
leiter.addEventListener('keydown', (e) => {
const nr = kreisStufen.findIndex((s) => s.wert === radStufe);
let ziel = null;
if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') ziel = nr - 1;
else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') ziel = nr + 1;
else if (e.key === 'Home') ziel = 0;
else if (e.key === 'End') ziel = kreisStufen.length - 1;
if (ziel === null) return;
e.preventDefault();
/* Mit einer halben Sekunde Ruhe speichern -- wer mit der Taste
durch die Stufen faehrt, soll nicht sieben Anfragen ausloesen.
Dieselbe Ueberlegung wie am Ring. */
stufeWaehlen(ziel, false);
radSichern(500);
});
}
/** Ein Wort fuer den Bereich, damit eine Sprachausgabe etwas sagen kann. */
function farbwort(w) {
const namen = [
[0, 'Rot'], [20, 'Orangerot'], [45, 'Orange'], [70, 'Goldbraun'],
[95, 'Oliv'], [120, 'Gruen'], [155, 'Tannengruen'], [180, 'Blaugruen'],
[205, 'Tuerkis'], [230, 'Blau'], [265, 'Indigo'], [290, 'Violett'],
[315, 'Magenta'], [340, 'Pink'],
];
let wort = namen[0][1];
for (const [ab, n] of namen) if (w >= ab) wort = n;
return wort;
}
/** Nach der Bewegung einmal speichern. */
function radSichern(verzoegerung = 0) {
clearTimeout(radUhr);
radUhr = setTimeout(() => kachelSetzen(radSchluessel()), verzoegerung);
}
function radBedienen() {
const ring = $('kachelrad-ring');
if (!ring) return;
/* Aus einem Punkt auf dem Bildschirm wird ein Winkel.
atan2(dx, -dy) zaehlt bei ZWOELF UHR los und laeuft im
Uhrzeigersinn -- genau so, wie conic-gradient den Ring malt.
Damit stimmt der Griff mit der Farbe darunter ueberein; mit dem
ueblichen atan2(dy, dx) laege er um 90 Grad daneben. */
const winkelAus = (ev) => {
const r = ring.getBoundingClientRect();
const dx = ev.clientX - (r.left + r.width / 2);
const dy = ev.clientY - (r.top + r.height / 2);
return (Math.atan2(dx, -dy) * 180 / Math.PI + 360) % 360;
};
let zieht = false;
ring.addEventListener('pointerdown', (ev) => {
zieht = true;
ring.setPointerCapture(ev.pointerId);
radZeigen(winkelAus(ev));
ev.preventDefault();
});
ring.addEventListener('pointermove', (ev) => { if (zieht) radZeigen(winkelAus(ev)); });
const los = () => { if (zieht) { zieht = false; radSichern(0); } };
ring.addEventListener('pointerup', los);
ring.addEventListener('pointercancel', los);
/* MIT DER TASTATUR GEHT ES AUCH. Ein Ring, den man nur mit der
Maus bedienen kann, ist fuer jeden ohne Maus eine Sackgasse --
und der Chat ist die Seite, auf der am meisten passiert. */
ring.addEventListener('keydown', (ev) => {
const schritt = { ArrowRight: 1, ArrowUp: 1, ArrowLeft: -1, ArrowDown: -1,
PageUp: 15, PageDown: -15 }[ev.key];
if (schritt === undefined) {
if (ev.key === 'Home') { radZeigen(0); radSichern(500); ev.preventDefault(); }
return;
}
radZeigen(radWinkel + schritt * (ev.shiftKey ? 5 : 1));
radSichern(500);
ev.preventDefault();
});
}
function kachelwahlBedienen() {
radBedienen();
leiterBedienen();
const knopf = $('kachel-oeffnen'), dlg = $('kachel-wahl'), zu = $('kachel-zu');
if (!knopf || !dlg) return;
knopf.addEventListener('click', () => { kachelwahlBauen(); dlg.showModal(); });
zu?.addEventListener('click', () => dlg.close());
}
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 darfAllesLoeschen = false;
/* DARF ICH DAS RUDEL RUFEN? (23.09.2026) Kommt wie die beiden
darueber vom Server (`darf_rudel`) -- hier wird keine Rolle
verglichen. Und das WORT kommt mit, damit es nicht an zwei Stellen
steht: Der Vorschlag beim Tippen setzt genau das ein, was der
Server hinterher erkennt. */
let darfRudel = false;
let rudelWort = 'rudel';
/* WEN "@rudel" IN DIESEM RAUM MEINT -- vom Server, nicht geraten.
'raum' = alle Anwesenden (so im Rudel-Raum), 'team' = Team Dogi
(ueberall sonst). Steht die Unterscheidung auch hier, gibt es
zwei Wahrheiten, und die Liste verspricht irgendwann etwas
anderes, als der Ruf hinterher tut. */
let rudelWen = 'team';
let darfKanal = false;
/* Was der Server ueber die Nachtruhe sagt. `null` heisst: Diese
Person hat mit dem Treff nichts zu tun -- dann gibt es auch
nichts anzuzeigen. */
let treffStand = null;
/* `null` heisst "der Server hat nichts gesagt" -- dann bleibt alles
wie bisher. Erst ein ausdrueckliches `false` nimmt die Knoepfe weg. */
let darfAnrufen = null;
let taktUhr = null;
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. */
/* ---- ANRUFEN (18.09.2026) ------------------------------------------
Die zwei Knöpfe stehen im Kopf des Gesprächs, neben den anderen.
Sie erscheinen NUR, wenn ein Gespräch offen ist -- ein Anrufknopf
ohne Gegenüber wäre ein Angebot ohne Gegenstand, derselbe Gedanke
wie beim Löschen-Knopf daneben. */
function anrufKnoepfeSetzen() {
const ziel = document.querySelector('.chat__kopf-knoepfe');
if (!ziel || !window.anrufStarten) return;
/* DIE COMMUNITY TELEFONIERT NICHT (19.09.2026). Filipe: "die
sollen nicht anrufen koennen. nur schreiben."
Der Riegel sitzt am Anruf-Router; hier faellt nur der Knopf weg.
BEIDES ZUSAMMEN: Ohne den Riegel waere es kosmetisch, ohne den
fehlenden Knopf staende ein Angebot da, das mit 404 antwortet.
`=== false` und nicht `!`: Fehlt die Angabe (aeltere Antwort des
Servers), bleibt alles wie bisher -- ein fehlendes Feld soll
niemandem das Telefon wegnehmen. */
if (darfAnrufen === false) {
document.getElementById('anruf-ton-start')?.remove();
document.getElementById('anruf-video-start')?.remove();
return;
}
let ton = document.getElementById('anruf-ton-start');
let video = document.getElementById('anruf-video-start');
if (!ton) {
ton = document.createElement('button');
ton.type = 'button';
ton.id = 'anruf-ton-start';
ton.className = 'chat__weg chat__anruf';
ton.title = 'Anrufen';
ton.setAttribute('aria-label', 'Anrufen');
ton.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true">'
+ '<path d="M6.5 3.5h3l1.5 4-2 1.5a12 12 0 0 0 6 6l1.5-2 4 1.5v3'
+ 'a2 2 0 0 1-2.2 2A16.5 16.5 0 0 1 4.5 5.7 2 2 0 0 1 6.5 3.5z"/></svg>';
ton.addEventListener('click', () => {
if (!offen) return;
/* DEN NAMEN MITGEBEN (19.09.2026): Der Anrufkasten zeigte
vorher nur „Es klingelt …" -- man sah nicht, wen man
anruft. Der Name steht hier ohnehin schon. */
window.anrufStarten(offen.id, false, offen.name);
});
video = document.createElement('button');
video.type = 'button';
video.id = 'anruf-video-start';
video.className = 'chat__weg chat__anruf';
video.title = 'Mit Video anrufen';
video.setAttribute('aria-label', 'Mit Video anrufen');
video.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true">'
+ '<rect x="3" y="6" width="12" height="12" rx="2"/>'
+ '<path d="M15 10.5 21 7v10l-6-3.5z"/></svg>';
video.addEventListener('click', () => {
if (!offen) return;
window.anrufStarten(offen.id, true, offen.name);
});
ziel.prepend(video);
ziel.prepend(ton);
/* ---- DER WEG ZUR ANRUF-PROBE (19.09.2026) ------------------
Filipe: „geht es nicht auf der app?"
Nein -- und das war mein Fehler. Ich hatte eine Seite gebaut,
die sagt, WARUM es nicht klingelt, und sie war nur ueber die
Adresszeile erreichbar. In der installierten App gibt es
keine. Eine Hilfe, die man nicht aufrufen kann, ist keine.
Sie steht deshalb hier, neben den Anrufknoepfen -- klein und
ohne Farbe. Wer telefoniert, uebersieht sie; wer vor einem
stillen Telefon sitzt, findet sie genau dort, wo er gerade
hinschaut. */
const probe = document.createElement('a');
probe.id = 'anruf-probe-weg';
probe.className = 'chat__weg chat__probe';
probe.href = 'anruf-probe.html';
probe.title = 'Klingelt nichts? Hier nachsehen';
probe.setAttribute('aria-label', 'Klingelt nichts? Hier nachsehen');
probe.textContent = '?';
ziel.append(probe);
}
const da = !!offen;
ton.hidden = !da;
video.hidden = !da;
const probeWeg = document.getElementById('anruf-probe-weg');
if (probeWeg) probeWeg.hidden = !da;
}
window.anrufMeldung = (text) => melde(text);
const melde = (t, art = 'fehler') => {
const z = $('fehler');
z.textContent = t || '';
z.dataset.art = t ? art : 'fehler';
};
/* ==== DIE NACHTRUHE DES TREFFS (19.09.2026) ==========================
Filipe: "nach mitternacht bis morgens 6 uhr keiner schreiben kann
damit auch die privatsphaere beruecksichtig wird ueber nacht."
DREI DINGE, DIE DIESE FUNKTION TUT, UND EINES, DAS SIE NICHT TUT:
1. Sie sagt es, BEVOR jemand tippt -- ueber dem Feld, nicht
darunter.
2. Sie warnt VORHER. Unter 15 Minuten steht da, wie lange noch.
Ohne das waere die Nachtruhe ein Abbruch mitten im Satz.
3. Sie schaltet das Feld aus, wenn zu ist -- sichtbar, nicht
heimlich.
SIE ENTSCHEIDET NICHTS. Ob geschrieben werden darf, sagt der
Server, und zwar auch dann noch einmal, wenn hier alles offen
aussieht. Ein ausgegrautes Feld ist eine Hoeflichkeit, kein
Schloss.
NUR IM TREFF. In einem Teamraum passiert hier gar nichts -- dort
gibt es keine Nachtruhe, dort arbeiten Leute nachts. */
const NACHTRUHE_VORWARNUNG = 15; // Minuten
/* ==== DIE SCHRIFTLEISTE KLAPPT AUF UND ZU (25.09.2026) ==========
Filipe: „damit es nicht zu viel platz nimmt."
SIE IST ZU, BIS JEMAND SIE AUFMACHT. Die vier Zeichen braucht man
in den seltensten Nachrichten; eine Zeile, die immer dasteht, um
selten etwas zu koennen, ist die teuerste Art von Platz.
DIE WAHL WIRD GEMERKT -- wer viel gestaltet, gestaltet meist
weiter. `try` um den Speicher, weil localStorage in einem privaten
Fenster wirft; ohne den Fang bliebe die Leiste dort fuer immer zu,
und der Knopf sähe kaputt aus.
DIE TASTENKUERZEL BLEIBEN IMMER AKTIV. Strg+B und Strg+I haengen
am Schreibfeld, nicht an der Leiste (textform.js) -- wer sie
kennt, braucht den Knopf gar nicht. Eine zugeklappte Leiste nimmt
also nichts weg, sie zeigt nur weniger. */
let schriftAuf = (() => {
try { return localStorage.getItem('chat-schrift') === 'auf'; } catch { return false; }
})();
function schriftLeisteZeigen() {
const form = $('schreiben');
const knopf = $('schrift-auf');
if (form) form.dataset.schrift = schriftAuf ? 'auf' : 'zu';
if (knopf) knopf.setAttribute('aria-expanded', String(schriftAuf));
}
$('schrift-auf')?.addEventListener('click', () => {
schriftAuf = !schriftAuf;
try { localStorage.setItem('chat-schrift', schriftAuf ? 'auf' : 'zu'); } catch { /* egal */ }
schriftLeisteZeigen();
/* ZURUECK INS FELD. Wer die Leiste aufmacht, will gleich darauf
tippen oder schreiben -- ohne diese Zeile steht der Fokus auf
einem Knopf, und die naechste Taste macht nichts. */
if (schriftAuf) $('text')?.focus();
});
function nachtruheZeigen() {
const band = $('nachtruhe');
const feld = $('text');
const senden = $('senden');
if (!band) return;
const imTreff = !!offen && offen.art === 'kanal' && offen.kategorie === 'treff';
if (!imTreff || !treffStand) {
band.hidden = true;
delete band.dataset.lage;
if (feld) { feld.disabled = false; feld.placeholder = 'Nachricht'; }
if (senden) senden.disabled = false;
const k = $('anhang-auf');
if (k) { k.disabled = false; k.title = 'Bild oder PDF anhängen'; }
return;
}
const zu = !!treffStand.zu;
const gleich = !zu && treffStand.minuten <= NACHTRUHE_VORWARNUNG;
band.hidden = !(zu || gleich);
band.dataset.lage = zu ? 'zu' : 'gleich';
if (zu) {
$('nachtruhe-text').textContent = treffStand.satz
+ ` (noch ${stundenSatz(treffStand.minuten)})`;
} else if (gleich) {
$('nachtruhe-text').textContent =
`In ${stundenSatz(treffStand.minuten)} macht das Rudel zu. `
+ `Ab ${String(treffStand.bis).padStart(2, '0')}:00 Uhr geht es weiter.`;
}
/* DIE BUEROKLAMMER GEHOERT DAZU (19.09.2026, beim Ansehen des
Bildschirmfotos aufgefallen).
Der Server lehnt Anhaenge nachts ebenso ab wie Nachrichten --
der Knopf stand aber offen da. Wer ihn drueckt, waehlt ein Bild
aus, wartet auf den Upload und bekommt dann eine Absage. Das ist
schlimmer als ein ausgegrauter Knopf: Die Arbeit ist schon
gemacht. */
const klammer = $('anhang-auf');
if (klammer) {
klammer.disabled = zu;
klammer.title = zu
? `Nachts keine Anhänge – ab ${String(treffStand.bis).padStart(2, '0')}:00 Uhr wieder`
: 'Bild oder PDF anhängen';
}
if (feld) {
feld.disabled = zu;
/* DER PLATZHALTER SAGT DEN GRUND. Ein graues Feld ohne Text
sieht aus wie ein Fehler -- und man tippt trotzdem hinein. */
feld.placeholder = zu
? `Das Rudel schläft – ab ${String(treffStand.bis).padStart(2, '0')}:00 Uhr wieder`
: 'Nachricht';
}
if (senden) senden.disabled = zu;
}
/* ==== DER TAKT: die Minuten laufen wirklich ab ======================
Ohne ihn stuende die Zahl vom Seitenaufruf da, bis jemand neu
laedt. Ein Chat steht aber stundenlang offen -- "noch 12 Minuten"
waere um drei Uhr nachts immer noch zu lesen.
EINMAL JE MINUTE, und er zaehlt SELBST herunter, statt den Server
zu fragen. Eine Abfrage je Minute je offenem Fenster waere Last
ohne Gegenwert: Die Minuten vergehen hier genauso wie dort.
BEIM WECHSEL WIRD GEFRAGT. Laeuft der Zaehler auf null, holt er den
Stand EINMAL frisch -- das ist der Moment, in dem sich wirklich
etwas aendert, und der einzige, in dem die eigene Rechnung
danebenliegen koennte (Zeitumstellung, schlafender Rechner, eine
geaenderte Einstellung).
NUR SOLANGE ES ETWAS ZU ZAEHLEN GIBT. Wer mit dem Treff nichts zu
tun hat, bekommt keinen Zeitgeber. */
function taktStarten() {
if (taktUhr) { clearInterval(taktUhr); taktUhr = null; }
if (!treffStand) return;
taktUhr = setInterval(async () => {
if (!treffStand) { clearInterval(taktUhr); taktUhr = null; return; }
treffStand.minuten -= 1;
if (treffStand.minuten > 0) { nachtruheZeigen(); return; }
/* Der Wechsel. Einmal fragen, dann weiterzaehlen. */
try {
const a = await hole('/workspace/api/chat/raeume');
if (a.ok) {
const d = await a.json();
treffStand = d.treff || null;
}
} catch { /* kein Netz -- beim naechsten Mal wieder */ }
/* Faellt die Antwort aus, wird selbst umgeschaltet: Lieber eine
Minute ungenau als eine Stunde falsch. */
if (treffStand && treffStand.minuten <= 0) {
treffStand.zu = !treffStand.zu;
treffStand.minuten = (treffStand.zu
? (treffStand.bis - treffStand.ab + 24) % 24
: (treffStand.ab - treffStand.bis + 24) % 24) * 60;
}
nachtruheZeigen();
}, 60_000);
}
/* Ein schlafender Rechner haelt `setInterval` an. Wer sein Handy
nach zwei Stunden wieder aufklappt, haette sonst eine Zahl von
vor zwei Stunden vor sich -- und zwar eine, die nach oben zaehlt.
Beim Zurueckkommen wird deshalb einmal gefragt. */
document.addEventListener('visibilitychange', () => {
if (document.hidden || !treffStand) return;
raeumeLaden().catch(() => {});
});
/** „3 Minuten" / „2 Stunden 10 Minuten" -- nie „130 Minuten".
*
* Wer „noch 347 Minuten" liest, rechnet im Kopf und liegt daneben.
* Die Zahl soll eine Auskunft sein, keine Aufgabe. */
function stundenSatz(min) {
const m = Math.max(0, Math.round(min));
if (m < 60) return `${m} Minute${m === 1 ? '' : 'n'}`;
const st = Math.floor(m / 60), rest = m % 60;
return `${st} Stunde${st === 1 ? '' : 'n'}`
+ (rest ? ` ${rest} Minute${rest === 1 ? '' : 'n'}` : '');
}
/* ---------- 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;
/* DER SERVER SAGT, OB DER TREFF SCHLAEFT -- hier wird nichts
nachgerechnet. Eine zweite Rechnung im Browser waere die, die
bei der naechsten Zeitumstellung anders herauskommt als die im
Server; dann sagt die Seite "offen" und der Server lehnt ab. */
treffStand = daten.treff || null;
if (typeof daten.darf_anrufen === 'boolean') darfAnrufen = daten.darf_anrufen;
nachtruheZeigen();
taktStarten();
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();
}
/* ==== DIE DREI FAECHER (25.09.2026) ==============================
Filipe: „ich will 3 kategorien haben. chats mit einzelnen
personen, gruppen chats und kanäle."
DIE ART STEHT SCHON AN JEDEM GESPRAECH (`r.art`: direkt, gruppe,
kanal) -- es gibt also nichts zu erfinden, nur zu sortieren. Genau
deshalb ist diese Liste hier eine ABLEITUNG und keine zweite
Wahrheit: Kaeme morgen eine vierte Art dazu, faellt sie hier auf
(die Zeilen waeren in keinem Fach), statt still zu verschwinden.
GEMERKT WIRD DIE WAHL, und zwar ueber das Neuladen hinaus: Wer im
Kanal arbeitet, arbeitet meist den ganzen Tag im Kanal. Jedes Mal
neu zu waehlen waere ein Griff, den niemand will.
`try` UM DEN SPEICHER: In einem privaten Fenster wirft
localStorage. Ohne den Fang waere die ganze Liste weg -- wegen
einer Bequemlichkeit. */
const FAECHER = [
{ wert: 'direkt', name: 'Personen', leer: 'Noch kein Gespräch mit einer einzelnen Person.' },
{ wert: 'gruppe', name: 'Gruppen', leer: 'Noch keine Gruppe. Mit „Neu" legst du eine an.' },
{ wert: 'kanal', name: 'Kanäle', leer: 'Noch kein Kanal.' },
];
let fach = (() => {
try { return localStorage.getItem('chat-fach') || 'direkt'; } catch { return 'direkt'; }
})();
if (!FAECHER.some((f) => f.wert === fach)) fach = 'direkt';
/* Einmal je Seitenaufruf wird geprueft, ob das gemerkte Fach
ueberhaupt etwas enthaelt -- siehe raeumeZeichnen(). */
let fachGeprueft = false;
function fachWaehlen(wert) {
fach = wert;
try { localStorage.setItem('chat-fach', wert); } catch { /* egal */ }
raeumeZeichnen();
}
/* Die Leiste wird bei jedem Zeichnen neu gebaut -- die Zahlen daran
aendern sich mit jeder Nachricht. */
function faecherZeichnen() {
const leiste = $('chat-arten');
if (!leiste) return;
leiste.textContent = '';
for (const f of FAECHER) {
const drin = raeume.filter((r) => (r.art || 'direkt') === f.wert);
const neu = drin.reduce((n, r) => n + (Number(r.ungelesen) || 0), 0);
/* WURDE ICH IN DIESEM FACH GERUFEN? (25.09.2026)
Gefunden hat das nicht ein Blick, sondern pruef-erwaehnung:
Die Erwaehnung lag in einer GRUPPE, das offene Fach war
„Personen" -- und das @ an der Zeile war damit nirgends zu
sehen. Ein Ruf, den ein geschlossenes Fach verschluckt, ist
schlimmer als gar kein Ruf: Man glaubt, es wartet nichts.
Dieselbe Antwort wie bei den Ungelesenen eine Zeile weiter
oben, und aus demselben Grund. Der Unterschied: Das @ tritt
NICHT zurueck, wenn daneben eine Zahl steht -- es ist die
wichtigere Auskunft von beiden ("es ging an DICH"), genau wie
an der Zeile selbst. */
const gerufen = drin.some((r) => r.erwaehnt);
const k = el('button', 'chat-art');
k.type = 'button';
k.setAttribute('role', 'tab');
k.dataset.art = f.wert;
k.setAttribute('aria-selected', String(f.wert === fach));
/* NICHT AUSWAEHLBAR HEISST NICHT UNSICHTBAR: Ein leeres Fach
bleibt stehen und sagt "hier ist nichts". Waere es weg,
wuesste niemand, dass es die Art ueberhaupt gibt -- und die
Leiste haette mal zwei, mal drei Faecher. */
k.append(el('span', 'chat-art__name', f.name));
if (gerufen) {
const at = el('span', 'chat-art__at', '@');
at.title = 'In diesem Fach wurdest du erwähnt';
/* Ein nacktes "@" wird je nach Vorleseprogramm als "at",
"Klammeraffe" oder gar nicht gelesen -- derselbe Satz wie
an der Zeile. */
at.setAttribute('aria-label', 'In diesem Fach wurdest du erwähnt');
k.append(at);
}
k.append(el('span', 'chat-art__zahl', String(drin.length)));
if (neu) {
/* DIE UNGELESENEN DES FACHS STEHEN AM FACH. Sonst waere ein
geschlossenes Fach ein Ort, an dem Nachrichten liegen
bleiben, ohne dass man es sieht -- der schlimmste Fehler,
den eine Sortierung machen kann. */
const punkt = el('span', 'chat-art__neu', neu > 99 ? '99+' : String(neu));
punkt.setAttribute('aria-label',
neu === 1 ? '1 ungelesene Nachricht' : neu + ' ungelesene Nachrichten');
k.append(punkt);
}
k.addEventListener('click', () => fachWaehlen(f.wert));
/* Mit den Pfeiltasten zwischen den Faechern -- so erwartet es,
wer eine Registerleiste bedient. */
k.addEventListener('keydown', (e) => {
const i = FAECHER.findIndex((x) => x.wert === f.wert);
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
e.preventDefault();
const n = (i + (e.key === 'ArrowRight' ? 1 : FAECHER.length - 1)) % FAECHER.length;
fachWaehlen(FAECHER[n].wert);
$('chat-arten')?.querySelector(`[data-art="${FAECHER[n].wert}"]`)?.focus();
}
});
leiste.append(k);
}
}
/* ==== EIN ZEICHEN, EINE STELLE (25.09.2026) =====================
Das runde Zeichen – Raute für einen Kanal, Zahl für eine Gruppe,
Gesicht oder Anfangsbuchstaben für einen Menschen – stand bisher
nur in der Liste, als rund fünfzig Zeilen mitten im Zeichnen.
Seit der Kopf des Gesprächs dasselbe Zeichen trägt, gäbe es das
zweimal, und die zweite Abschrift ist immer die, in der beim
nächsten Umbau eine Zeile fehlt (siehe 11.09.2026, die
abgeschriebene Spaltenliste – sie hat Daten gekostet).
SIE FÜLLT EIN VORHANDENES ELEMENT, statt eines zu bauen: Der Kopf
hat sein Zeichen fest im HTML und tauscht nur den Inhalt. Deshalb
wird hier ZUERST aufgeräumt – sonst trüge ein Zweiergespräch noch
die Raute des Kanals von vorhin. */
function zeichenFuellen(ziel, r) {
ziel.textContent = '';
delete ziel.dataset.kanal;
delete ziel.dataset.gruppe;
delete ziel.dataset.rolle;
const art = r.art || 'direkt';
if (art === 'kanal') {
/* Das Rautenzeichen ist die Schreibweise, die jeder aus anderen
Messengern kennt -- es muss nicht erklaert werden. */
ziel.dataset.kanal = 'ja';
ziel.textContent = '#';
} else if (art === 'gruppe') {
/* Eine Gruppe zeigt ihre Groesse statt einer Initiale -- der
Name steht ohnehin daneben. */
ziel.dataset.gruppe = 'ja';
ziel.textContent = String(r.teilnehmer.length);
} else {
ziel.textContent = initialen(r.name);
const gegen = r.teilnehmer.find((t) => t.id !== ich.id);
if (gegen) {
/* DAS GESICHT STATT DER INITIALEN (20.09.2026).
Filipe: "da soll man im chat auch die profilfotos von den
leuten sehen wenn die schon eins drin haben."
DER BUCHSTABE BLEIBT ALS UNTERLAGE LIEGEN und wird nicht
ersetzt: Laedt das Bild nicht, steht dort weiterhin etwas
Sinnvolles statt eines kaputten Bildsymbols.
EINMAL, NICHT ZWEIMAL (behoben 24.09.2026): Dieser Block
stand einmal vor und einmal nach der Rollenzeile. Jedes
Gespraech mit Profilbild lud das Bild also zweimal -- zu
sehen war davon nichts, gekostet hat es trotzdem, bei jedem
Neuzeichnen der Liste. */
ziel.dataset.rolle = gegen.rolle;
if (gegen.bild) {
const b = document.createElement('img');
b.src = gegen.bild; b.alt = '';
b.addEventListener('error', () => b.remove());
ziel.append(b);
}
}
}
return ziel;
}
function raeumeZeichnen() {
const ziel = $('raeume');
/* ==== EIN LEERES FACH MERKT MAN SICH NICHT (25.09.2026) =========
Gefunden hat das pruef-gifs, nicht ein Blick: In der Pruefung
gibt es genau EIN Gespraech, den Treff -- einen Kanal. Das
voreingestellte Fach ist „Personen", und dort stand: „Noch kein
Gespräch mit einer einzelnen Person." Die Seite sah leer aus,
obwohl ein voller Kanal danebenlag, nur eine Lasche weiter.
Genau so geht es jedem Neuen im Haus: Der erste Raum, in dem er
landet, ist der Treff -- ein Kanal. Er haette beim ersten
Oeffnen eine leere Liste gesehen.
DIE WAHL WIRD NICHT GESPEICHERT. Das hier ist geraten, nicht
gewaehlt: Sobald jemand selbst auf ein Fach tippt, gilt seine
Wahl und ueberlebt das Neuladen. Wuerde diese Notlage mit
abgespeichert, waere sie beim naechsten Mal nicht mehr von
einer echten Entscheidung zu unterscheiden.
DIE REIHENFOLGE IST NICHT BELIEBIG: erst das Fach, in dem
jemand GERUFEN wurde, dann das mit Ungelesenem, dann das erste,
in dem ueberhaupt etwas steht. Dieselbe Rangfolge wie an jeder
Zeile -- „es ging an dich" wiegt schwerer als „es ist neu",
und das wiegt schwerer als „es ist da".
EINMAL, NICHT BEI JEDEM ZEICHNEN: Die Liste wird bei jeder
eintreffenden Nachricht neu gezeichnet. Ohne den Merker
spraenge das Fach mitten im Lesen um, sobald irgendwo etwas
hereinkommt -- eine Seite, die unter den Haenden wegwandert. */
if (!fachGeprueft && raeume.length) {
fachGeprueft = true;
const inFach = (w, pruefe) => raeume.some(
(r) => (r.art || 'direkt') === w && (!pruefe || pruefe(r)));
if (!inFach(fach)) {
const wahl = FAECHER.find((f) => inFach(f.wert, (r) => r.erwaehnt))
|| FAECHER.find((f) => inFach(f.wert, (r) => r.ungelesen))
|| FAECHER.find((f) => inFach(f.wert));
if (wahl) fach = wahl.wert;
}
}
const suchtext = ($('suche').value || '').trim().toLowerCase();
faecherZeichnen();
/* BEIM SUCHEN GILT DAS FACH NICHT. Wer einen Namen eintippt, will
ihn finden -- und nicht erst raten, in welchem der drei Faecher
er liegt. Ein Treffer, der wegen einer Voreinstellung nicht
erscheint, ist schlimmer als kein Suchfeld. */
const zeigen = suchtext
? raeume.filter((r) => r.name.toLowerCase().includes(suchtext))
: raeume.filter((r) => (r.art || 'direkt') === fach);
ziel.textContent = '';
/* DER LEER-SATZ GEHOERT ZUM FACH, nicht zur ganzen Liste. Vorher
stand er nur da, wenn es GAR kein Gespraech gab -- ein leeres
Fach neben zwei vollen zeigte einfach nichts an, und das sieht
aus wie ein Fehler beim Laden. */
const leerSatz = $('liste-leer');
if (leerSatz) {
leerSatz.hidden = zeigen.length > 0 || !!suchtext;
if (!zeigen.length && !suchtext) {
leerSatz.textContent = raeume.length
? (FAECHER.find((f) => f.wert === fach)?.leer || 'Hier ist noch nichts.')
: 'Noch keine Gespräche. Mit „Neu" schreibst du der ersten Person.';
}
}
/* WIE VIELE WARTEN AUF MICH (24.09.2026) -- die Summe, nicht die
Zahl der Gespraeche. „3 Gespraeche mit Neuem" und „17 neue
Nachrichten" sind zwei verschiedene Auskuenfte, und die zweite
ist die, nach der man fragt, bevor man anfaengt.
GERECHNET UEBER `raeume` UND NICHT UEBER `zeigen`: Wer im
Suchfeld tippt, filtert die Liste -- die Summe darueber wuerde
dann mitwandern und behaupten, es warte weniger. */
{
const zaehler = $('liste-zaehler');
if (zaehler) {
const summe = raeume.reduce((n, r) => n + (Number(r.ungelesen) || 0), 0);
zaehler.textContent = summe > 99 ? '99+' : String(summe);
zaehler.setAttribute('aria-label',
summe === 1 ? '1 ungelesene Nachricht' : summe + ' ungelesene Nachrichten');
zaehler.hidden = summe === 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);
/* DIE ART GEHOERT AN DIE ZEILE (24.09.2026). Kanal, Gruppe und
Zweiergespraech sehen unterschiedlich aus; bisher stand die
Art nur am runden Zeichen, und das Stilblatt konnte deshalb
nur DAS einfaerben -- nicht die Zeile drumherum. */
li.dataset.art = r.art || 'direkt';
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');
/* GEBAUT WIRD ES AN EINER STELLE (25.09.2026). Hier standen
rund fuenfzig Zeilen, die seit heute auch der Kopf des
Gespraechs braucht -- siehe zeichenFuellen() weiter oben.
Die Begruendungen zum Profilbild sind dorthin mitgewandert
und nicht verlorengegangen. */
zeichenFuellen(zeichen, r);
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. */
/* WER GESCHRIEBEN HAT, steht seit dem 24.09.2026 in einem
eigenen Stueck Text statt mitten im Satz. Damit kann das
Stilblatt den Namen leicht absetzen -- beim Ueberfliegen
sucht man „wer", nicht „was", und ein durchgehend grauer
Satz zwingt zum Lesen. */
const wer = r.letzte.von === ich.name ? 'Du: '
: (r.art !== 'direkt' ? `${r.letzte.von}: ` : '');
/* KEIN „Nachricht zurueckgenommen" MEHR (22.09.2026). Hier
stand genau dieser Satz -- und die Gespraechsliste ist die
auffaelligste Stelle der ganzen Seite. Geloescht heisst
geloescht: Der Server liefert eine geloeschte Nachricht gar
nicht mehr als letzte, weil es die Zeile nicht mehr gibt.
Sollte doch einmal eine alte, zurueckgenommene durchkommen,
steht hier lieber nichts als ein Hinweis auf sie. */
if (r.letzte.zurueckgenommen) {
vorschau.textContent = 'Noch nichts geschrieben';
vorschau.dataset.weg = 'ja';
} else {
if (wer) vorschau.append(el('span', 'chat-raum__wer', wer));
vorschau.append(document.createTextNode(r.letzte.text));
}
} else {
vorschau.textContent = 'Noch nichts geschrieben';
vorschau.dataset.weg = 'ja';
}
mitte.append(vorschau);
knopf.append(mitte);
/* WURDE ICH HIER ANGESPROCHEN? (20.09.2026)
Eine Zahl sagt "hier ist etwas". Sie sagt nicht, ob es an DICH
war. Wer morgens vier Gespraeche mit Zahlen sieht, macht das
lauteste zuerst auf -- und dort steht selten das, was auf ihn
wartet.
Das Zeichen steht VOR der Zahl, weil es die wichtigere Auskunft
ist, und es traegt einen eigenen Satz fuer Vorleseprogramme:
Ein "@" allein wird je nach Programm als "at", "Klammeraffe"
oder gar nicht vorgelesen. */
/* ==== DIE RECHTE SPALTE (24.09.2026) =========================
HIER LAG EIN ECHTER FEHLER, und man sah ihn auf Filipes
Bildschirmfoto: Bei „Das Rudel" stand das „@" rechts oben und
die orange „6" eine ZEILE TIEFER, links unter dem Zeichen.
URSACHE: Der Knopf ist ein Raster mit DREI Spalten
(Zeichen | Mitte | Rest). Mit „@" UND Zahl sind es aber VIER
Kinder -- und das vierte faellt in die naechste Rasterzeile.
Die Zeile wurde dadurch hoeher als alle anderen, und die
wichtigste Auskunft der ganzen Liste („hier warten sechs
ungelesene") stand an der unauffaelligsten Stelle.
Beide sitzen jetzt in EINER Spalte nebeneinander. Damit bleibt
das Raster dreispaltig, egal wie viele Marken dazukommen --
und eine vierte Marke morgen bricht es nicht wieder auf. */
const rail = el('span', 'chat-raum__rail');
if (r.erwaehnt) {
const marke = el('span', 'chat-raum__erwaehnt', '@');
marke.title = 'Du wurdest hier erwähnt';
marke.setAttribute('aria-label', 'Du wurdest hier erwähnt');
rail.append(marke);
}
if (r.ungelesen) {
const zahl = el('span', 'chat-raum__zahl',
r.ungelesen > 99 ? '99+' : String(r.ungelesen));
zahl.setAttribute('aria-label', `${r.ungelesen} ungelesen`);
rail.append(zahl);
}
/* Die Spalte steht IMMER da, auch leer. Ohne sie waere die Mitte
bei Zeilen ohne Marke breiter als bei Zeilen mit -- und die
Namen staenden nicht mehr untereinander. Eine Liste, in der
die Kanten wandern, wirkt unruhig, ohne dass man sagen kann,
warum. */
knopf.append(rail);
knopf.addEventListener('click', () => raumOeffnen(r.id));
li.append(knopf);
/* ==== OBEN ANHEFTEN (25.09.2026) ==============================
Filipe: „ich will dass man auch individuel jeder fuer sich
auch in der liste chats fixieren kann. auch mehrere nicht nur
eins."
EIN EIGENER KNOPF NEBEN DER ZEILE, nicht darin. Die Zeile ist
selbst ein <button>, und ein Knopf in einem Knopf ist
ungueltiges HTML -- Browser reissen die Verschachtelung
auseinander, und dann sitzt die Nadel irgendwo. Deshalb sind
es zwei Geschwister im <li>, und das <li> ist das Raster.
ER STEHT IMMER DA, auch wenn nichts angeheftet ist. Zwei
Gruende, und beide zaehlen mehr als die ruhigere Liste:
Auf einem Handy gibt es kein Ueberfahren -- ein Knopf, der
erst beim Zeigen erscheint, gibt es dort nicht (dieselbe
Entscheidung wie am 23.09. bei den Handgriffen an der
Nachricht). Und eine Spalte, die mal da ist und mal nicht,
laesst die Namen daneben wandern.
`aria-pressed` UND NICHT ZWEI KNOEPFE: Es ist ein Schalter mit
zwei Stellungen, und genau so liest ihn ein Vorleseprogramm
vor.
DER ZUSTAND WIRD GESCHICKT, NICHT ERRECHNET. Der Server
bekommt `an: true/false` und kippt nichts selbst um; bei zwei
schnellen Klicks oder zwei offenen Fenstern landet man sonst
im falschen Zustand, ohne dass jemand sieht, warum. */
const nadel = el('button', 'chat-raum__pin');
nadel.type = 'button';
const fix = !!r.fixiert;
nadel.setAttribute('aria-pressed', String(fix));
nadel.title = fix ? 'Nicht mehr oben halten' : 'Oben halten';
nadel.setAttribute('aria-label',
fix ? `„${r.name}" nicht mehr oben halten` : `„${r.name}" oben halten`);
nadel.addEventListener('click', async (e) => {
/* Sonst oeffnet der Klick zusaetzlich das Gespraech -- die
Zeile daneben ist ein Knopf, und der Klick blubbert im <li>
nicht dorthin, wohl aber ein spaeterer Tastendruck. */
e.stopPropagation();
nadel.disabled = true;
try {
const a = await hole(`/workspace/api/chat/raeume/${r.id}/fixieren`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ an: !fix }),
});
if (!a.ok) { melde('Das ließ sich gerade nicht ändern.'); return; }
/* NEU LADEN UND NICHT NUR UMFAERBEN: Anheften aendert die
REIHENFOLGE, und die rechnet der Server. Wer hier nur das
Zeichen umdreht, hat eine Nadel an einer Zeile, die an der
falschen Stelle steht. */
await raeumeLaden();
} catch { /* hole() hat schon gemeldet */ } finally {
nadel.disabled = false;
}
});
li.append(nadel);
if (fix) li.dataset.fixiert = 'ja';
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;
/* NUR BEIM ERSTEN ZEICHNEN EINES RAUMS springt der Verlauf zur
Stelle, an der man aufgehoert hat. Danach nicht mehr: Bei jedem
Neuzeichnen dorthin zu springen hiesse, dass eine eintreffende
Nachricht einen aus dem Lesen reisst -- genau der Fehler, der
weiter unten schon einmal behoben wurde ("Wer liest, bleibt
stehen"). */
let zurNeuLinie = false;
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;
/* Die Anrufknoepfe erscheinen mit dem Gespraech -- und
verschwinden mit ihm. Ein Anrufknopf ohne Gegenueber waere ein
Angebot ohne Gegenstand. */
anrufKnoepfeSetzen();
nachrichten = daten.nachrichten;
angeheftet = daten.angeheftet || [];
darfAnheftenHier = !!daten.darf_anheften;
darfAllesLoeschen = !!daten.darf_alles_loeschen;
darfRudel = !!daten.darf_rudel;
rudelWort = daten.rudel_wort || 'rudel';
rudelWen = daten.rudel_wen === 'raum' ? 'raum' : 'team';
/* DER MIKROFONKNOPF ERSCHEINT, WENN DER SERVER ES SAGT
(23.09.2026). Filipe: „also nur die modis rechte linke hand
und dogfather." Hier wird keine Rolle verglichen -- dieselbe
Ueberlegung wie bei darf_anheften und darf_alles_loeschen. */
const mikro = $('ton-auf');
if (mikro) mikro.hidden = !daten.darf_ton;
const gifKnopf = $('gif-auf');
if (gifKnopf) gifKnopf.hidden = !daten.darf_gif;
/* 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;
/* Ab hier darf EINMAL gesprungen werden -- siehe oben. */
zurNeuLinie = true;
antwortAuf = null;
} catch { return; }
/* DAS GESICHT ZUM NAMEN (25.09.2026) -- dieselbe Funktion, die
auch jede Zeile der Liste fuellt. */
const kopfZeichen = $('verlauf-zeichen');
if (kopfZeichen) {
zeichenFuellen(kopfZeichen, offen);
kopfZeichen.hidden = false;
}
$('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;
/* DIE LEISTE FUER DIE TEXTGESTALTUNG (21.09.2026).
Hier und nicht beim Laden der Seite: Das Formular ist bis zum
Oeffnen eines Raumes versteckt, und eine Leiste ueber einem
versteckten Feld baut man zweimal, wenn man nicht aufpasst.
`Textform.leiste` merkt sich am Feld selbst, dass sie schon
da ist -- zweimal aufrufen schadet also nichts. */
if (window.Textform) {
const leiste = window.Textform.leiste($('text'));
/* DIE LEISTE BEKOMMT IHRE KENNUNG, damit der Knopf daneben
`aria-controls` auf etwas zeigen kann, das es wirklich gibt.
Sie entsteht erst beim Oeffnen eines Raumes -- deshalb hier
und nicht im HTML. */
if (leiste) leiste.id = 'schrift-leiste';
schriftLeisteZeigen();
}
/* JEDES MAL BEIM OEFFNEN. Der Stand haengt am RAUM (nur der Treff
hat eine Nachtruhe) -- wer aus einem Teamraum in den Treff
wechselt, muss das Band sehen, ohne die Seite neu zu laden. */
nachtruheZeigen();
$('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);
/* ==== JEDER LOEST FUER SICH -- DIE LEITUNG HAT DIE WAHL =======
Filipe: „jeder soll das fixierte individuel fuer sich loesen
koennen aber niemals so dass es sich fuer alle loest. ausser
dogfather macht es oder die rechte hand, dann ist es bei
jedem weg. dogfather und die rechte hand sollen die option
haben fuer sich selbst oder fuer alle zu loesen."
BIS HEUTE stand hier EIN Knopf, und den sah nur die Leitung.
Wer ihn nicht sah, musste die Ansage vom Montag bis Freitag
ueber jedem Gespraech stehen lassen; wer ihn sah, nahm sie
damit gleich allen weg.
ZWEI KNOEPFE STATT EINES DIALOGS: „lösen" nimmt den Aushang
nur bei mir weg -- ohne Rueckfrage, denn es ist umkehrbar
(das Menue an der Nachricht holt ihn zurueck). „bei allen"
fragt nach, weil es jeden im Raum betrifft und sich nicht
zurueckholen laesst.
Ein einziger Knopf mit Auswahlfenster waere kuerzer und
schlechter: Der haeufige Fall („weg damit, kenne ich") braucht
dann zwei Klicks, und der seltene, folgenreiche ist genauso
weit entfernt wie der harmlose. */
const los = el('button', 'chat-pin__los', 'lösen');
los.type = 'button';
los.title = 'Nur bei dir ausblenden';
los.setAttribute('aria-label', `„${(a.text || '').slice(0, 40)}“ nur bei dir ausblenden`);
los.addEventListener('click', () => pinFuerMich(a.id, true));
kasten.append(los);
if (a.darf_fuer_alle) {
const alle = el('button', 'chat-pin__los chat-pin__los--alle', 'bei allen');
alle.type = 'button';
alle.title = 'Bei allen im Raum lösen';
alle.setAttribute('aria-label',
`„${(a.text || '').slice(0, 40)}“ bei allen lösen`);
alle.addEventListener('click', async () => {
const ja = await window.frageNach?.({
titel: 'Bei allen lösen?',
was: 'Der Aushang verschwindet für jeden in diesem Raum.',
bleibt: 'Die Nachricht selbst bleibt im Verlauf stehen – '
+ 'nur oben hängt sie dann nicht mehr.',
ja: 'Bei allen lösen',
nein: 'Doch nicht',
});
if (!ja) return;
await anheften(a.id, false);
});
kasten.append(alle);
}
ziel.append(kasten);
}
}
/** Einen Aushang nur bei MIR wegnehmen -- oder zurueckholen.
*
* Kein `frageNach` davor: Der Handgriff ist umkehrbar, und eine
* Rueckfrage vor etwas Umkehrbarem lernt man wegzuklicken. Danach
* klickt man auch die weg, die zaehlt.
*/
async function pinFuerMich(id, aus) {
try {
const a = await hole(`/workspace/api/chat/nachrichten/${id}/pin-fuer-mich`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ aus }),
});
if (!a.ok) {
melde(sagWas((await a.json().catch(() => ({}))).fehler, 'Ging nicht.'));
return;
}
/* Neu holen statt hier nachzurechnen -- dieselbe Ueberlegung wie
beim Anheften: Der Server kennt die Grenze von drei und laesst
den naechsten Aushang nachruecken. Zwei Rechnungen darueber
liefen irgendwann auseinander. */
if (offen) await raumOeffnen(offen.id, { sanft: true });
} catch { /* hole() hat schon gemeldet */ }
}
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(sagWas((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 = sagWas(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) {
/* WOHER WEISS JEMAND, DASS ES "@" GIBT? (20.09.2026)
Nirgends. Ein Zeichen, das man kennen muss, um es zu finden,
kennt beim ersten Mal niemand -- und im Chat gibt es keinen
Hilfeknopf, in den man es schreiben koennte.
Der Satz steht deshalb GENAU DORT, wo eine Gruppe anfaengt:
beim ersten Oeffnen, bevor etwas drinsteht. Er verschwindet mit
der ersten Nachricht und steht damit nie im Weg.
NUR BEI DREI ODER MEHR. In einem Zweiergespraech ist ohnehin
klar, wer gemeint ist -- dort waere der Hinweis Ballast. */
const inGruppe = (offen?.teilnehmer?.length || 0) > 2;
ziel.append(el('p', 'chat__leer', inGruppe
? 'Noch nichts geschrieben. Fang an — mit @ sprichst du jemanden direkt an.'
: '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';
/* UNTERWEGS UND GESCHEITERT SIND ZWEI ZUSTÄNDE -- UND BEIDE
WAREN UNSICHTBAR (20.09.2026).
------------------------------------------------------------
`markiereAlsGescheitert` setzte `n.gescheitert = true` und
zeichnete neu. Gelesen hat das Merkmal NIEMAND: weder das
Skript noch das CSS. Eine gescheiterte Nachricht sah exakt
aus wie eine zugestellte.
Daneben stand der Kommentar „NICHT STILL VERSCHWINDEN LASSEN.
Wer etwas schreibt und es sieht, glaubt, es sei angekommen."
Genau das ist trotzdem passiert -- der Satz in der Meldezeile
verschwindet nach kurzer Zeit, die Nachricht bleibt stehen
und sieht gesendet aus.
Ein Kommentar, der eine Absicht beschreibt, erfüllt sie
nicht. */
if (n.unterwegs) zeile.dataset.unterwegs = 'ja';
if (n.gescheitert) zeile.dataset.gescheitert = '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');
/* DIE EIGENE KACHELFARBE (20.09.2026).
Filipe: „jeder account soll seine kachel personalisieren
koennen."
Der Wert kommt vom Server als SCHLUESSEL, nicht als Farbe --
hier wird er in der Tabelle nachgeschlagen, die ebenfalls vom
Server kam. Waere die Farbe selbst in der Nachricht, stuende
ein CSS-Wert aus der Datenbank in der Seite; ein vertippter
oder boeswilliger Eintrag koennte dann faerben, was er will.
Kein Wert heisst Standard, und dafuer steht gar nichts da --
der Rueckfall liegt im Stilblatt (`var(--kachel, …)`). Eine
Zeile, die die Standardfarbe noch einmal hinschreibt, waere
eine zweite Stelle, an der sie stimmen muss. */
blaseFaerben(blase, n.von_kachel);
if (!gleicheFolge && !n.selbst && offen.art !== 'direkt') {
/* DER NAME TRAEGT SEINE ROLLE MIT (22.09.2026).
Filipe: „die namen so dass man sie gut erkennt."
Die Farbe der Rolle sitzt im Punkt VOR dem Namen, nicht in
der Schrift selbst: Bei zwoelf waehlbaren Flaechen liesse
sich fuer eine Schriftfarbe kein Kontrast mehr garantieren,
fuer einen 6-px-Punkt daneben ist das egal. */
const vonZeile = el('p', 'chat-nachricht__von', n.von);
vonZeile.dataset.rolle = n.rolle || '';
blase.append(vonZeile);
}
/* 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. */
/* EIN ZITAT AUF ETWAS, DAS ES NICHT MEHR GIBT, WIRD WEGGELASSEN
(22.09.2026). Vorher stand darin „Nachricht zurueckgenommen"
-- und damit die Spur an der einen Stelle, an der sie am
meisten auffaellt: mitten in einer fremden Nachricht. Der
Server liefert `antwort` gar nicht mehr, wenn die zitierte
Zeile geloescht wurde; diese Bedingung faengt den Rest. */
if (n.antwort && !n.antwort.zurueckgenommen) {
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',
window.Textform ? window.Textform.nurText(n.antwort.text) : 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);
}
/* HIER STAND „Nachricht zurückgenommen" (bis 22.09.2026).
Filipe: „Gelöschte Nachrichten verschwinden vollständig –
keine Spur, kein ‚wurde gelöscht'-Hinweis, bei niemandem,
auch nicht bei DogFather."
Eine gelöschte Nachricht kommt gar nicht mehr hier an -- die
Zeile gibt es nicht mehr. Der Zweig bleibt trotzdem stehen,
aber ohne Text: Sollte aus einer alten Fassung noch eine
zurückgenommene Zeile durchkommen, zeigt die Blase nichts
statt eines Hinweises darauf, dass hier etwas stand. */
if (n.zurueckgenommen) {
zeile.hidden = true;
} 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(textAbsatz(n.text, !!n.rudel));
}
/* ==== DAS WORT KOMMT INS ELEMENT, NICHT AN DEN KNOPF ==========
(25.09.2026)
Filipe: „diese optionen im chat. ich will dass du das viel
geiler machst und es soll viel kuerzer gestaltet sein. so dass
es nicht zu viel aufmerksamkeit auf sich zieht aber jeder
sieht dass es da ist."
AUS FUENF WOERTERN WERDEN FUENF ZEICHEN. Das Wort verschwindet
aber NICHT -- es wandert in ein eigenes Stueck Text, das nur
fuer das Auge weg ist:
* Ein Vorleseprogramm liest weiterhin „antworten", nicht
„Schaltflaeche".
* `textContent` liefert weiterhin das Wort -- daran haengt
pruef-chat-ausbau, das den Kopierknopf beim Wort nimmt
(„kopiert" und zurueck auf „kopieren").
* Wer mit der Maus darauf zeigt, bekommt es als `title`.
EIN KNOPF OHNE JEDE BESCHRIFTUNG waere schneller gebaut und
schlechter: Er waere fuer ein Vorleseprogramm ein leeres
Feld, und die Pruefung haette ab da nichts mehr zu greifen.
`wortKnopf` STATT FUENFMAL DASSELBE: Fuenf Abschriften waeren
fuenf Gelegenheiten, dass eine das Wort vergisst -- und
gerade die faellt keinem auf, weil man es ja nicht sieht. */
const wortKnopf = (klasse, wort) => {
const b = el('button', klasse);
b.type = 'button';
b.title = wort;
b.setAttribute('aria-label', wort);
b.append(el('span', 'chat-nachricht__wort', wort));
return b;
};
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 = wortKnopf('chat-nachricht__antwort-knopf', 'antworten');
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 = wortKnopf('chat-nachricht__reag-knopf', 'reagieren');
reag.addEventListener('click', () => auswahlZeigen(reag, n.id));
fuss.append(reag);
}
/* LÖSCHEN HEISST LÖSCHEN (22.09.2026).
Filipe: „Löschen darf weiterhin jeder nur seine eigenen –
außer DogFather und rechte Hand: die dürfen jede Nachricht
löschen, für den Notfall."
Der Knopf heisst deshalb auch „löschen" und nicht mehr
„zurücknehmen". Zurücknehmen klingt nach „ich nehme mein Wort
zurück" -- es passte, solange ein Hinweis stehen blieb. Jetzt
ist es weg, und das Wort soll das sagen.
AN FREMDEN NACHRICHTEN STEHT ES ANDERS DA: „löschen (Notfall)".
Wer fremde Worte entfernt, soll im selben Moment sehen, dass
das kein gewöhnlicher Handgriff ist. Die Schranke steht
ohnehin im Server; hier geht es darum, sie nicht aus Versehen
zu benutzen. */
if (n.id > 0 && (n.selbst || darfAllesLoeschen)) {
const fremd = !n.selbst;
const weg = wortKnopf('chat-nachricht__weg-knopf',
fremd ? 'löschen (Notfall)' : 'löschen');
if (fremd) weg.dataset.notfall = 'ja';
weg.addEventListener('click', () => zuruecknehmen(n.id, fremd));
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 = wortKnopf('chat-nachricht__pin-knopf',
n.angeheftet ? 'lösen' : 'anheften');
if (n.angeheftet) pin.dataset.gesetzt = 'ja';
pin.addEventListener('click', () => anheften(n.id, !n.angeheftet));
fuss.append(pin);
}
/* ==== DER RUECKWEG (25.09.2026) ===============================
Wer einen Aushang oben fuer sich weggenommen hat, holt ihn
hier zurueck. Ohne diesen Knopf waere das Wegnehmen eine
Einbahnstrasse -- und dann braeuchte es davor eine Rueckfrage,
also einen Klick mehr fuer den haeufigsten Handgriff im Raum.
ER STEHT NUR DA, WENN ES ETWAS ZURUECKZUHOLEN GIBT: Die
Nachricht haengt noch (`n.angeheftet`), und ich habe sie fuer
mich ausgeblendet. Ein Knopf, der immer da ist und meistens
nichts tut, ist Rauschen.
UND ER GILT FUER JEDEN, auch fuer die, die nicht anheften
duerfen -- deshalb steht er ausserhalb der Bedingung darueber.
Genau darum ging es Filipe: „ansonsten sollen alle anderen
rollen es individuell fuer sich loesen koennen." */
if (n.angeheftet && n.pin_fuer_mich_aus && !n.zurueckgenommen && n.id > 0) {
const zurueck = wortKnopf('chat-nachricht__pin-knopf', 'wieder oben');
zurueck.title = 'Diesen Aushang bei dir wieder oben anzeigen';
zurueck.addEventListener('click', () => pinFuerMich(n.id, false));
fuss.append(zurueck);
}
/* 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 = wortKnopf('chat-nachricht__kopie-knopf', 'kopieren');
/* NUR DAS WORT WECHSELT, nicht der ganze Inhalt. Hier stand
`kopie.textContent = 'kopiert'` -- das haette seit heute auch
das Zeichen mitgenommen, und der Knopf waere fuer anderthalb
Sekunden leer gewesen. */
const kopieWort = kopie.querySelector('.chat-nachricht__wort');
const kopieSagt = (t) => {
kopieWort.textContent = t;
kopie.title = t;
kopie.setAttribute('aria-label', t);
kopie.dataset.getan = t === 'kopieren' ? '' : 'ja';
};
kopie.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(n.text);
kopieSagt('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); }
kopieSagt('markiert');
}
setTimeout(() => { kopieSagt('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));
}
/* NOCHMAL SENDEN, STATT „kopiere sie und versuch es erneut".
------------------------------------------------------------
Dort stand die Seite bat den Nutzer, ihre Arbeit zu machen:
Text markieren, kopieren, Feld leeren, einfügen, senden. Fünf
Handgriffe für etwas, das die Seite in einem tun kann -- sie
hat den Text ja noch. */
if (n.gescheitert) {
const nochmal = el('button', 'chat-nachricht__nochmal', 'Nochmal senden');
nochmal.type = 'button';
nochmal.addEventListener('click', async () => {
nochmal.disabled = true;
nochmal.textContent = 'wird gesendet …';
/* DENSELBEN WEG GEHEN WIE VON HAND -- nur ohne die fünf
Handgriffe. Text zurück ins Feld, Antwortziel wieder
setzen, `abschicken` aufrufen. Kein zweiter Sendeweg:
Ein eigener wäre morgen anders als dieser, und der
Unterschied fiele erst auf, wenn er zählt. */
const weg = nachrichten.findIndex((x) => x.id === n.id);
if (weg >= 0) nachrichten.splice(weg, 1);
$('text').value = n.text;
if (n.antwort) antwortSetzen(n.antwort);
melde('');
await abschicken({ preventDefault() {} });
});
blase.append(nochmal);
}
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. */
/* ==== ZURUECK AN DIE STELLE, AN DER MAN AUFGEHOERT HAT ==========
Die Linie "Ab hier neu" steht seit Tagen im Verlauf -- und war
nie zu sehen: Der Verlauf sprang beim Oeffnen ans Ende, also
daran vorbei. Wer nach zwei Tagen zurueckkommt, landete unten
und suchte die Stelle von Hand, indem er Uhrzeiten las.
NICHT GANZ NACH OBEN, SONDERN AUF EIN VIERTEL. Stuende die
Linie am oberen Rand, saehe man nur noch das Neue -- und nicht
mehr, worauf es antwortet. Ein Viertel Verlauf darueber ist der
Zusammenhang, den man braucht, um wieder hineinzufinden.
DANACH GILT WIEDER DIE ALTE REGEL. "unten" wird auf false
gesetzt, damit der Knopf "zum Neuesten" erscheint (er sagt
auch, wie viel unten wartet) und damit eine eintreffende
Nachricht einen nicht nach unten reisst. */
if (zurNeuLinie) {
zurNeuLinie = false;
const linie = ziel.querySelector('.chat-neu');
if (linie) {
ziel.scrollTop = Math.max(0, linie.offsetTop - ziel.clientHeight * 0.25);
unten = amEnde(ziel);
neuUnten = 0;
springKnopfZeigen();
return;
}
/* Keine Linie heisst: nichts Neues seit dem letzten Mal. Dann
ist das Ende die richtige Stelle -- weiter wie bisher. */
}
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 <img>, 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`;
}
/* ==== EIN KLICK OEFFNET DIE SCHAU (25.09.2026) ================
Filipe: „wenn man bilder aufmacht, will ich dass die in der
mitte sind."
Bis heute fuehrte der Verweis auf die DATEI, und was man dann
sah, war die eingebaute Bildanzeige des Browsers: schwarzer
Grund, Bild in der Ecke. Das ist kein Fehler des Browsers --
eine Datei hat keine Gestaltung.
DER VERWEIS BLEIBT EIN VERWEIS. `target`, `rel` und `href`
stehen unveraendert da; mittlere Maustaste, „In neuem Tab
oeffnen" und Herunterladen gehen wie seit jeher. Abgefangen
wird nur der gewoehnliche Klick -- und auch der nur, wenn die
Schau ueberhaupt geladen ist. Faellt die Datei aus, ist man
wieder beim alten Verhalten statt bei gar keinem.
`metaKey`, `ctrlKey`, `shiftKey` UND DIE MITTLERE TASTE sind
ausgenommen: Das sind die Griffe, mit denen Leute seit zwanzig
Jahren in einem neuen Tab oeffnen. Wer sie abfaengt, nimmt
etwas weg, das niemand vermisst -- bis er es braucht. */
kasten.addEventListener('click', (e) => {
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
if (!window.Bildschau) return;
e.preventDefault();
window.Bildschau.zeigen(a, kasten);
});
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;
}
/* ---- SPRACHNACHRICHT (23.09.2026) ------------------------------
Filipe: „dan will ich auch dass man im chat auch
sprachnachrichten und gifts reinschicken kann."
DAS EINGEBAUTE ABSPIELGERAET DES BROWSERS, nicht ein
nachgebautes. Ein eigenes haette eine eigene Abspieltaste, einen
eigenen Schieber und eine eigene Tastaturbedienung -- drei
Dinge, die das Geraet seit Jahren richtig macht und die man
einzeln kaputtbauen kann. Es laesst sich weit genug gestalten
(siehe chat.css), und es kennt Vorleseprogramme.
`preload="metadata"`: Geladen wird beim Oeffnen nur der Kopf der
Datei -- daraus kennt das Geraet die Laenge. Die Tondaten kommen
erst beim Abspielen. Zehn Sprachnachrichten im Verlauf kosten so
ein paar Kilobyte statt ein paar Megabyte.
DIE LAENGE STEHT TROTZDEM DANEBEN: Bis der Kopf da ist, zeigt
das Geraet nichts, und die Zeile waere erst leer und spraenge
dann. Die Zahl kommt vom Absender und ist nur die Vorschau --
was das Geraet sagt, gilt. */
if (a.art === 'ton') {
const kasten = el('div', 'chat-ton');
const geraet = document.createElement('audio');
geraet.controls = true;
geraet.preload = 'metadata';
geraet.src = a.weg;
geraet.className = 'chat-ton__geraet';
/* Ein Vorleseprogramm sagt sonst nur "Audio". */
geraet.setAttribute('aria-label',
`Sprachnachricht${a.dauer ? ', ' + dauerWort(a.dauer) : ''}`);
const zeile = el('span', 'chat-ton__zeile');
const marke = el('span', 'chat-ton__marke', 'Sprachnachricht');
zeile.append(marke);
if (a.dauer) zeile.append(el('span', 'chat-ton__dauer', dauerWort(a.dauer)));
/* Ein Ton, der nicht laedt, hinterlaesst sonst ein stummes
Geraet ohne Erklaerung -- dieselbe Sicherung wie beim Bild. */
geraet.addEventListener('error', () => {
kasten.dataset.kaputt = 'ja';
marke.textContent = 'Sprachnachricht lässt sich gerade nicht laden';
});
kasten.append(zeile, geraet);
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;
/* =====================================================================
WER IST MIT "@" GEMEINT? -- die Fassung fuer den Browser
(20.09.2026)
DIESELBEN REGELN WIE IM SERVER. Das Original samt Begruendung je
Regel steht in server/chat-erwaehnung.js; hier steht eine zweite,
wortgleiche Fassung.
WARUM UEBERHAUPT ZWEIMAL: Der Browser muss beim Tippen und beim
Zeichnen sofort wissen, welcher Teil des Satzes ein Name ist --
ohne zu fragen. Eine Nachfrage je Nachricht waere eine Abfrage
mehr fuer etwas, das sich aus dem vorliegenden Text ergibt.
UND WARUM DAS GEFAEHRLICH IST: Zwei Rechnungen fuer dieselbe Frage
laufen irgendwann auseinander -- und dann hebt die Seite jemanden
hervor, den der Server nie benachrichtigt hat, oder umgekehrt.
Deshalb stellt `pruef-erwaehnung.mjs` beide Fassungen an denselben
Proben nebeneinander. Sie LIEST diese Funktion aus der Datei und
fuehrt sie aus, statt sie nachzubauen: Ein Nachbau wuerde pruefen,
ob ich zweimal dasselbe schreiben kann.
SIE STEHT BEWUSST OHNE AUSSENBEZUG DA -- auch das Zeichenmuster
und das Wort "rudel" werden hier drinnen gebildet. Sonst liesse
sie sich nicht einzeln herausloesen und ausfuehren.
"@RUDEL" (23.09.2026): Der dritte Parameter sagt, ob DERJENIGE,
der diesen Satz geschrieben hat, das ganze Rudel rufen durfte.
Er wird nicht aus der Rolle geraten -- die Nachricht bringt ihn
mit (`n.rudel`), der Server hat ihn beim Schreiben entschieden.
WARUM NICHT AUS DER ROLLE DES ABSENDERS: Die steht zwar in der
Nachricht, aber es ist die HEUTIGE. Wechselt jemand die Rolle,
verschwaende die Hervorhebung rueckwirkend aus einem Satz von
vorletzter Woche, in dem sie damals richtig war. Und es waere die
zweite Rechnung ueber dieselbe Frage -- genau die, vor der der
Absatz darueber warnt. */
function erwaehnungenFinden(text, leute, wie) {
const RUDEL = 'rudel';
const darfRudel = wie && wie.rudel === true;
const WORT = /[\p{L}\p{N}_]/u;
const wortBei = (t, i) => (i >= 0 && i < t.length && WORT.test(t[i]));
const satz = String(text || '');
if (!satz.includes('@')) return [];
const nachLaenge = (Array.isArray(leute) ? leute.slice() : [])
.filter((x) => x && x.id != null && String(x.name || '').trim())
.sort((a, b) => String(b.name).length - String(a.name).length);
if (!nachLaenge.length && !darfRudel) return [];
const klein = satz.toLowerCase();
const treffer = [];
for (let i = klein.indexOf('@'); i !== -1; i = klein.indexOf('@', i + 1)) {
if (wortBei(satz, i - 1)) continue;
if (darfRudel && klein.startsWith(RUDEL, i + 1)
&& !wortBei(satz, i + 1 + RUDEL.length)) {
treffer.push({ id: null, rudel: true, name: RUDEL,
von: i, bis: i + 1 + RUDEL.length });
continue;
}
for (const person of nachLaenge) {
const name = String(person.name);
if (!klein.startsWith(name.toLowerCase(), i + 1)) continue;
if (wortBei(satz, i + 1 + name.length)) continue;
treffer.push({ id: person.id, name, von: i, bis: i + 1 + name.length });
break;
}
}
return treffer;
}
/* =====================================================================
DIE AUSWAHL BEIM TIPPEN (20.09.2026)
Filipe: "ich will das man die leute mit @ markieren kann im chat."
WARUM EINE AUSWAHLLISTE UND NICHT NUR "@ TIPPEN GENUEGT":
Der Server erkennt auch ein von Hand getipptes "@VanVan" -- die
Liste ist also keine Pflicht, sondern eine Abkuerzung. Sie loest
aber drei Dinge, die man sonst selbst wissen muesste:
* WIE heisst die Person genau? "@Van" trifft nicht, "@vanvan"
schon. Ohne Liste probiert man.
* WER ist ueberhaupt in diesem Raum? Nur die koennen gemeint
sein -- das sieht man sonst nirgends beim Schreiben.
* Habe ich mich VERTIPPT? Ein "@Vanvna" faellt niemandem auf,
am wenigsten dem, der gemeint war.
WAS SIE BEWUSST NICHT TUT: Sie erzwingt nichts. Wer weitertippt,
ohne etwas zu waehlen, schreibt einfach weiter; die Liste
verschwindet, sobald nichts mehr passt. Eine Auswahl, aus der man
nicht herauskommt, ist eine Falle -- und im Chat schreibt man oft
ein "@" ohne jede Absicht ("um 10@Bahnhof").
DIE TASTATUR ZUERST: Pfeil hoch/runter, Enter oder Tab waehlt,
Escape schliesst. Am Rechner schreibt die Betreuung, und dort ist
der Griff zur Maus mitten im Satz der teuerste Handgriff
ueberhaupt.
===================================================================== */
/** Steht gerade eine Auswahl offen? Dann: ab welcher Stelle im Text
* (Position des "@") und welcher Eintrag ist vorgemerkt. */
let erwaehnAuswahl = null;
/** Die Stelle vor dem Schreibzeiger auf ein angefangenes "@..." hin
* ansehen.
*
* GIBT null ZURUECK, SOBALD ETWAS NICHT PASST -- und das ist der
* wichtigste Teil: Ein Leerzeichen beendet die Suche, ein Buchstabe
* vor dem "@" laesst sie gar nicht erst anfangen (sonst oeffnete
* sich die Liste mitten in jeder E-Mail-Adresse). Dieselbe Grenze
* wie beim Aufloesen, damit die Liste nichts anbietet, was der
* Server hinterher nicht erkennt. */
function angefangenesAt(feld) {
const bis = feld.selectionStart;
if (bis !== feld.selectionEnd) return null; // etwas ist markiert
const text = feld.value.slice(0, bis);
const at = text.lastIndexOf('@');
if (at === -1) return null;
const davor = at > 0 ? text[at - 1] : '';
if (davor && /[\p{L}\p{N}_]/u.test(davor)) return null;
const rumpf = text.slice(at + 1);
/* Ein Leerzeichen (oder ein Umbruch) beendet es. Namen in diesem
Haus sind einwortig; dieselbe Annahme steht im Server. */
if (/\s/.test(rumpf)) return null;
if (rumpf.length > 24) return null; // so heisst niemand
return { at, rumpf };
}
/** Wer passt gerade? -- die Teilnehmer dieses Raums, ohne mich.
*
* DAS RUDEL STEHT OBEN, wenn ich es rufen darf (23.09.2026). Es ist
* keine Person, sondern die Gruppe -- deshalb bekommt es eine
* Kennung, die keine Personennummer sein kann.
*
* ES ERSCHEINT NUR, WENN ICH ES DARF. Ein Eintrag, den man waehlen
* kann und der dann nichts tut, ist schlimmer als keiner: Man
* schreibt einen Satz an das ganze Team und merkt nie, dass ihn
* niemand als Ruf bekommen hat. Ob ich darf, sagt der Server
* (`darf_rudel`) -- hier wird keine Rolle verglichen. */
function erwaehnTreffer(rumpf) {
const klein = rumpf.toLowerCase();
const leute = (offen?.teilnehmer || [])
/* SICH SELBST KANN MAN NICHT ANSPRECHEN -- der Server verwirft es
ohnehin. Ein Eintrag, der nichts bewirkt, gehoert nicht in die
Liste. */
.filter((t) => t.id !== ich?.id)
.filter((t) => String(t.name).toLowerCase().startsWith(klein))
.slice(0, 8);
if (darfRudel && rudelWort.toLowerCase().startsWith(klein)) {
return [{ id: 'rudel', name: rudelWort, rudel: true, rolle: null }, ...leute];
}
return leute;
}
function erwaehnSchliessen() {
erwaehnAuswahl = null;
const l = $('erwaehn-liste');
if (l) { l.hidden = true; l.textContent = ''; }
$('text')?.removeAttribute('aria-activedescendant');
}
/** Die Liste zeichnen -- oder schliessen, wenn nichts passt. */
function erwaehnPruefen() {
const feld = $('text');
const liste = $('erwaehn-liste');
if (!feld || !liste) return;
const stand = angefangenesAt(feld);
if (!stand) { erwaehnSchliessen(); return; }
const treffer = erwaehnTreffer(stand.rumpf);
if (!treffer.length) { erwaehnSchliessen(); return; }
/* Die Vormerkung bleibt auf DERSELBEN Person, solange sie noch
passt. Sonst springt die Auswahl bei jedem Buchstaben auf den
ersten Eintrag zurueck, und wer zweimal nach unten getippt hat,
verliert es beim naechsten Zeichen. */
const vorherId = erwaehnAuswahl?.treffer?.[erwaehnAuswahl.wahl]?.id;
const neueWahl = Math.max(0, treffer.findIndex((t) => t.id === vorherId));
erwaehnAuswahl = { at: stand.at, rumpf: stand.rumpf, treffer, wahl: neueWahl };
liste.textContent = '';
treffer.forEach((t, i) => {
const b = el('button', 'erwaehn-liste__eintrag');
b.type = 'button';
b.id = 'erwaehn-' + t.id;
b.setAttribute('role', 'option');
b.setAttribute('aria-selected', i === neueWahl ? 'true' : 'false');
if (i === neueWahl) b.dataset.wahl = 'ja';
const z = el('span', 'erwaehn-liste__zeichen',
/* Das Rudel bekommt ein Zeichen statt Anfangsbuchstaben --
"R" saehe aus wie ein Mensch namens Rita. */
t.rudel ? '◉' : initialen(t.name));
z.setAttribute('aria-hidden', 'true');
z.dataset.rolle = t.rolle || '';
if (t.rudel) { b.dataset.rudel = 'ja'; z.dataset.rudel = 'ja'; }
b.append(z, el('span', 'erwaehn-liste__name', t.name));
/* WAS ES BEDEUTET, STEHT DANEBEN. "rudel" allein sagt keinem
Neuen, wen er damit weckt -- und wer das erst nach dem
Absenden erfaehrt, hat schon alle geweckt.
Und es sagt das RICHTIGE: Im Rudel-Raum trifft der Ruf jeden
Anwesenden, sonst nur Team Dogi. Stuende hier immer "alle im
Team", waere das im Rudel-Raum schlicht gelogen -- und zwar
nach unten, also in die Richtung, in der man leichtfertig
drueckt. */
if (t.rudel) {
b.append(el('span', 'erwaehn-liste__rolle',
rudelWen === 'raum' ? 'alle in diesem Chat' : 'alle im Team'));
}
else if (ROLLENKURZ[t.rolle]) b.append(el('span', 'erwaehn-liste__rolle', ROLLENKURZ[t.rolle]));
/* mousedown statt click: Ein Klick nimmt dem Textfeld zuerst den
Schreibzeiger, und danach weiss niemand mehr, wohin der Name
gehoert. `preventDefault` haelt ihn, wo er ist. */
b.addEventListener('mousedown', (e) => { e.preventDefault(); erwaehnWaehlen(i); });
liste.append(b);
});
liste.hidden = false;
feld.setAttribute('aria-activedescendant', 'erwaehn-' + treffer[neueWahl].id);
}
/** Den vorgemerkten Namen einsetzen. */
function erwaehnWaehlen(i) {
const a = erwaehnAuswahl;
const feld = $('text');
if (!a || !feld) return;
const person = a.treffer[i ?? a.wahl];
if (!person) return;
/* Fuer das Rudel gilt dasselbe wie fuer einen Namen: "@" davor,
Leerzeichen dahinter. Es ist ein Wort im Satz, kein Sonderfall
-- deshalb steht hier auch keine eigene Behandlung. */
const bis = feld.selectionStart;
/* EIN LEERZEICHEN HINTERHER -- sonst klebt das naechste Wort am
Namen, und aus "@VanVankannst" wird gar keine Ansprache mehr.
Nur, wenn dort nicht schon eines steht. */
const rest = feld.value.slice(bis);
const luecke = rest.startsWith(' ') ? '' : ' ';
feld.value = feld.value.slice(0, a.at) + '@' + person.name + luecke + rest;
const neu = a.at + 1 + person.name.length + luecke.length;
feld.setSelectionRange(neu, neu);
erwaehnSchliessen();
feld.focus();
hoeheAnpassen();
entwurfMerken();
zaehlerZeigen();
}
/** Die Tasten, solange die Liste offen ist.
*
* GIBT true ZURUECK, wenn die Taste verbraucht ist -- der Aufrufer
* hoert dann auf. So bleibt Enter zum Abschicken erhalten, sobald
* keine Liste offen steht, und Escape verwirft weiterhin die
* Antwort. Ohne diese Rueckgabe muesste an zwei Stellen dieselbe
* Bedingung stehen. */
function erwaehnTaste(e) {
if (!erwaehnAuswahl) return false;
const n = erwaehnAuswahl.treffer.length;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const richtung = e.key === 'ArrowDown' ? 1 : -1;
erwaehnAuswahl.wahl = (erwaehnAuswahl.wahl + richtung + n) % n;
const knoepfe = $('erwaehn-liste').querySelectorAll('.erwaehn-liste__eintrag');
knoepfe.forEach((b, i) => {
b.setAttribute('aria-selected', i === erwaehnAuswahl.wahl ? 'true' : 'false');
if (i === erwaehnAuswahl.wahl) b.dataset.wahl = 'ja'; else delete b.dataset.wahl;
});
const gewaehlt = erwaehnAuswahl.treffer[erwaehnAuswahl.wahl];
$('text').setAttribute('aria-activedescendant', 'erwaehn-' + gewaehlt.id);
knoepfe[erwaehnAuswahl.wahl]?.scrollIntoView({ block: 'nearest' });
return true;
}
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); erwaehnWaehlen(); return true; }
if (e.key === 'Escape') { e.preventDefault(); erwaehnSchliessen(); return true; }
return false;
}
/* EIN ABSATZ, ZWEI SORTEN HERVORHEBUNG (erweitert 20.09.2026).
Hiess frueher `mitLinks` und konnte nur Adressen. Seit es das
Markieren mit "@" gibt, muessen ZWEI Sorten Stellen in denselben
Absatz -- und sie koennen sich ueberschneiden: In
"schau auf www.x.de/@Tili" steckt ein Name mitten in einer
Adresse.
DESHALB ERST SAMMELN, DANN SORTIEREN, DANN ZEICHNEN. Zwei
Schleifen nacheinander haetten den Text zweimal zerschnitten und
die zweite haette die Stuecke der ersten nicht gesehen. Bei einer
Ueberschneidung gewinnt, was frueher anfaengt; die Adresse ist
laenger und faengt frueher an, also bleibt sie ganz.
textContent, nie innerHTML -- das gilt unveraendert und ist der
Grund, warum hier Knoten gebaut und nicht Zeichenketten
zusammengeklebt werden. Eine Nachricht darf keine Auszeichnung
einschleusen, auch keinen eigenen Namen in spitzen Klammern. */
/* DIE GESTALTUNG LIEGT AUSSEN, LINKS UND ERWAEHNUNGEN INNEN
(21.09.2026).
Filipe: "ich will dass die leute auch immer die art von text
aussuchen können, fettgedrückt, unterstrichen und so alles ...
das überall sei es im chat jeder für sich."
WARUM NICHT EINFACH EINE FUENFTE SORTE STELLE: Links und
Erwaehnungen sind PUNKTE im Text -- sie haben Anfang und Ende und
liegen nebeneinander. Gestaltung ist etwas anderes: Sie UMSCHLIESST
etwas, und sie kann sich verschachteln ("**fett und _auch
kursiv_**"). Beides in dieselbe Liste zu werfen haette bei jeder
Ueberlappung geraten.
Deshalb zwei Schichten: Erst wird der Text in Stuecke mit Stilen
zerlegt, dann bekommt JEDES Stueck die vorhandene Suche nach
Links und Erwaehnungen -- und zum Schluss legt sich die Gestaltung
aussen herum. Ein Link in einem fetten Satz bleibt damit ein Link.
Ohne textform.js (aeltere Fassung im Zwischenspeicher) faellt es
auf den alten Zustand zurueck: ein Stueck, keine Stile. Dann sieht
man Sternchen statt Fettschrift -- aber nichts ist kaputt. */
function textAbsatz(text, rudel = false) {
const p = el('p', 'chat-nachricht__text');
const teilchen = window.Textform
? window.Textform.teile(text)
: [{ text: String(text), stile: {} }];
for (const t of teilchen) {
const innen = stellenBauen(t.text, rudel);
p.append(window.Textform ? window.Textform.huelle(innen, t.stile) : innen);
}
return p;
}
/** Die vorhandene Suche nach Links und Erwaehnungen -- unveraendert,
* nur arbeitet sie jetzt auf einem STUECK statt auf dem ganzen Text
* und gibt einen Baustein zurueck statt einen Absatz. */
function stellenBauen(roh, rudel = false) {
const stueck = document.createDocumentFragment();
const stellen = [];
for (const treffer of roh.matchAll(LINKMUSTER)) {
/* Satzzeichen am Ende gehoeren zum Satz, nicht zur Adresse. */
const sauber = treffer[0].replace(/[.,;:!?)\]]+$/, '');
stellen.push({ art: 'link', von: treffer.index, bis: treffer.index + sauber.length, wort: sauber });
}
/* Gegen die Teilnehmer DIESES Raums -- wie im Server. Steht kein
Raum offen (etwa beim Suchen), wird nichts hervorgehoben: lieber
grau als falsch. */
for (const t of erwaehnungenFinden(roh, offen?.teilnehmer || [], { rudel })) {
stellen.push({ art: 'erwaehnung', von: t.von, bis: t.bis,
wort: roh.slice(t.von, t.bis), id: t.id, rudel: !!t.rudel });
}
stellen.sort((a, b) => a.von - b.von || b.bis - a.bis);
let letzte = 0;
for (const st of stellen) {
if (st.von < letzte) continue; // liegt in einer frueheren Stelle
if (st.von > letzte) stueck.append(document.createTextNode(roh.slice(letzte, st.von)));
if (st.art === 'link') {
const a = el('a', 'chat-nachricht__link', st.wort);
a.href = st.wort.startsWith('www.') ? 'https://' + st.wort : st.wort;
a.target = '_blank';
a.rel = 'noopener noreferrer';
stueck.append(a);
} else {
/* KEIN KNOPF, SONDERN EINE MARKE. Ein Klick darauf muesste
irgendwohin fuehren -- zum Steckbrief? in ein Zweiergespraech?
Beides waere eine Entscheidung, die niemand bestellt hat, und
ein Knopf, der nichts tut, ist schlimmer als keiner.
Hervorgehoben wird, damit man es SIEHT; mehr verspricht es
nicht. */
const m = el('span', 'chat-erwaehnung', st.wort);
/* BIN ICH GEMEINT? Dann deutlicher. Das ist der eigentliche
Zweck: In einem Raum mit acht Leuten soll der eigene Name
aus dem Absatz herausspringen. */
if (ich && st.id === ich.id) m.dataset.ich = 'ja';
/* DAS RUDEL MEINT ALLE -- also auch mich, wenn ich dazugehoere.
Es bekommt sein eigenes Merkmal statt `ich`: "alle sind
gemeint" ist etwas anderes als "du bist gemeint", und wenn
beides gleich aussieht, verliert das zweite seine Wirkung. */
if (st.rudel) m.dataset.rudel = 'ja';
stueck.append(m);
}
letzte = st.bis;
}
if (letzte < roh.length) stueck.append(document.createTextNode(roh.slice(letzte)));
return stueck;
}
/* ---------- 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;
/* IN DER VORSCHAU STEHEN KEINE ZEICHEN (21.09.2026). Hier ist
keine Gestaltung moeglich -- eine Zeile, in der "**super**"
steht, sieht nach kaputt aus. `nurText` nimmt die Zeichen weg
und laesst den Satz stehen. */
$('antwort-text').textContent = window.Textform
? window.Textform.nurText(n.text) : 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: window.sanft?.() || 'auto' });
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,
/* DAMIT DAS EIGENE "@rudel" SOFORT LEUCHTET. Ohne diese Zeile
saehe der Absender seinen Ruf zuerst grau und erst nach der
Antwort des Servers farbig -- derselbe Satz, zwei
Darstellungen, Unterschied nur im Zeitpunkt. Dass der Server
es genauso sieht, entscheidet er selbst; die echte Nachricht
ersetzt diese hier ohnehin. */
rudel: darfRudel && erwaehnungenFinden(
text, offen?.teilnehmer || [], { rudel: true }).some((t) => t.rudel),
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, sagWas(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();
/* Kein „kopiere sie" mehr -- an der Nachricht steht jetzt ein
Knopf, der es in einem Griff wiederholt. */
melde(`${grund} Die Nachricht ist nicht angekommen – `
+ `mit „Nochmal senden" kannst du es gleich wiederholen.`);
}
async function zuruecknehmen(id, fremd = false) {
if (!await frageNach({
titel: fremd ? 'Diese Nachricht löschen?' : 'Nachricht löschen?',
was: fremd
? 'Sie ist danach bei allen weg – auch bei dem, der sie geschrieben hat.'
: 'Sie ist danach bei allen weg.',
/* KEIN „ein Hinweis bleibt stehen" MEHR -- es bleibt keiner.
Ein Rückfragetext, der etwas anderes verspricht als das, was
passiert, ist schlimmer als keiner: Man entscheidet auf
falscher Grundlage. */
bleibt: fremd
? 'Dass DU sie gelöscht hast, steht im Protokoll – der Text nirgends.'
: 'Es bleibt keine Spur im Verlauf.',
endgueltig: true, ja: 'Löschen',
})) 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.splice(i, 1);
for (const x of nachrichten) if (x.antwort?.id === id) x.antwort = 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';
/* DIE LEISTE SAGT, WIE HOCH SIE IST (20.09.2026).
Der Knopf „Zum neuesten" schwebte auf festen 96 Pixeln -- der
Höhe der Leiste an dem Tag, an dem er gebaut wurde. Wächst das
Textfeld (bis 160), verschwindet er dahinter, und zwar genau
dann, wenn man lange tippt und gleichzeitig oben liest.
Gemessen statt gerechnet: Eine Zahl, die das Element selbst
kennt, kann nicht veralten. */
unterbauMessen();
}
/** Wie hoch ist alles, was UNTEN am Verlauf klebt?
*
* Nicht nur die Schreibleiste: Darüber können das Nachtruhe-Band
* und der Hochlade-Balken stehen. Wer nur die Leiste misst, legt
* den Knopf „Zum neuesten" nachts hinter das Band.
*
* Gemessen wird vom unteren Rand des Kastens bis zur obersten
* Kante des sichtbaren Unterbaus -- egal, aus wie vielen Teilen er
* gerade besteht. Kommt morgen ein viertes dazu, rechnet es von
* selbst mit. */
function unterbauMessen() {
const leiste = $('schreiben');
const kasten = leiste?.parentElement;
if (!kasten) return;
const unten = kasten.getBoundingClientRect().bottom;
let oberste = unten;
for (const id of ['nachtruhe', 'anhang-fortschritt', 'schreiben']) {
const e = $(id);
if (!e || e.hidden) continue;
const r = e.getBoundingClientRect();
if (r.height > 0 && r.top < oberste) oberste = r.top;
}
const hoch = Math.max(0, Math.round(unten - oberste));
/* NUR SETZEN, WENN SICH ETWAS AENDERT. Sonst beisst sich der
Beobachter unten in den eigenen Schwanz: Eine Stil-Aenderung am
Kasten ist selbst wieder eine Aenderung. */
if (hoch === letzteLeiste) return;
letzteLeiste = hoch;
kasten.style.setProperty('--leiste', hoch + 'px');
}
let letzteLeiste = -1;
/** DER UNTERBAU AENDERT SICH AUCH OHNE TIPPEN.
*
* Das Nachtruhe-Band erscheint um Mitternacht von selbst, der
* Hochlade-Balken beim Anhaengen einer Datei. `hoeheAnpassen`
* laeuft dabei nicht -- der Knopf „Zum neuesten" staende dann
* wieder hinter etwas.
*
* Ein Beobachter statt einer Liste von Ausloesern: Was sich am
* unteren Rand tut, wird bemerkt, auch wenn es morgen ein viertes
* Bauteil ist. Gerechnet wird hoechstens einmal je Bild -- sonst
* waere jede getippte Zeile eine Messung. */
function unterbauBeobachten() {
const kasten = $('schreiben')?.parentElement;
if (!kasten || !window.MutationObserver) return;
let geplant = false;
new MutationObserver(() => {
if (geplant) return;
geplant = true;
requestAnimationFrame(() => { geplant = false; unterbauMessen(); });
}).observe(kasten, {
childList: true, subtree: true,
attributes: true, attributeFilter: ['hidden', 'class'],
});
/* Und beim Drehen des Geräts: Dort ändert sich nichts am Aufbau,
aber alles an der Breite -- und damit an der Zeilenzahl. */
window.addEventListener('resize', () => unterbauMessen());
}
/* ---------- 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');
/* ANRUFE KOMMEN ÜBER DENSELBEN KANAL (18.09.2026).
Klingeln, Angebot, Antwort und Verbindungswege sind Ereignisse
wie jede Nachricht -- nur mit `event: anruf`. Ein eigener
WebSocket daneben wäre eine zweite Verbindung, die dieselbe
Frage beantwortet, und die zweite ist die, die beim nächsten
Umbau vergessen wird.
Der Chat versteht den Inhalt nicht; er reicht ihn weiter. Wer
ihn versteht, steht in anruf.js. */
strom.addEventListener('anruf', (e) => {
let d;
try { d = JSON.parse(e.data); } catch { return; }
window.anrufEreignis?.(d);
});
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; }
/* SIE VERSCHWINDET, sie wird nicht durchgestrichen (22.09.2026).
Filipe: „Gelöschte Nachrichten verschwinden vollständig –
keine Spur, kein ‚wurde gelöscht'-Hinweis, bei niemandem,
auch nicht bei DogFather."
Hier stand `zurueckgenommen = true` -- die Zeile blieb und
trug den Hinweis. Ein Hinweis „hier stand etwas" markiert die
Stelle; wer etwas aus Versehen schreibt, will es weg haben
und nicht unterstrichen. */
const i = nachrichten.findIndex((x) => x.id === n.id);
if (i >= 0) {
nachrichten.splice(i, 1);
/* Auch ein Zitat darauf: Die zitierte Nachricht gibt es nicht
mehr, also gibt es auch nichts zu zeigen. Der Server liefert
beim nächsten Laden ohnehin nichts; hier passt sich nur das
Bild sofort an. */
for (const x of nachrichten) if (x.antwort?.id === n.id) x.antwort = 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 {
/* FUER EINEN KANAL FRAGEN WIR ANDERS (24.09.2026): Dort duerfen
auch Leute aus der Community dabei sein -- ein Kanal wird
moderiert, ein Zweier-Gespraech nicht. Der Server entscheidet,
ob er die erweiterte Liste herausgibt. */
const fuer = neuArt === 'kanal' ? '?fuer=kanal' : '';
const a = await hole('/workspace/api/chat/partner' + fuer);
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');
/* EIGENE NAMEN SIND ERLAUBT (24.09.2026).
Filipe: „ich will dass man die option hat einen neuen namen zu
erstellen den es noch nicht da in der liste gibt."
`data-frei="ja"` schaltet in wahl.js den Weg frei: Die Liste
bekommt ein Suchfeld, das auch bei kurzer Liste dasteht, und
der getippte Text erscheint als eigener Eintrag ganz oben.
Gebaut war das laengst -- gesetzt hat das Merkmal bisher
niemand, genau wie `grund_min` beim Meldeweg. */
feld.dataset.frei = 'ja';
feld.textContent = '';
for (const k of kanalFrei) {
const o = document.createElement('option');
o.value = k.wert; o.textContent = k.name;
feld.append(o);
}
/* DER HINWEIS „für jede Zuständigkeit gibt es schon einen Kanal"
steht nur noch da, wenn die Liste leer ist -- und das Feld
bleibt TROTZDEM sichtbar. Seit man eigene Namen vergeben darf,
ist eine belegte Liste kein Grund mehr, die Auswahl
wegzunehmen: Genau dann braucht man den eigenen Namen. */
$('kanal-alle').hidden = kanalFrei.length > 0;
feld.hidden = false;
}
/* 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) {
const vorher = neuArt;
neuArt = art;
/* DIE PERSONENLISTE HAENGT AN DER ART (24.09.2026). Fuer einen
Kanal stehen auch Leute aus der Community darin, fuer ein
Gespraech nicht. Ohne dieses Nachladen zeigte die Liste, was
beim Oeffnen des Dialogs gerade galt -- und wer erst auf
„Kanal" umschaltet, saehe die Community nicht. */
if (vorher && vorher !== art) partnerLaden();
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;
/* Auch ein frei getippter Name macht den Knopf scharf -- er
steht in `data-extern`, nicht im Wert. Ohne diese Zeile
haette man tippen koennen und waere nicht weitergekommen. */
$('neu-los').disabled = !kanalUmbau && !$('kanal-kategorie').value
&& !($('kanal-kategorie').dataset.extern || '').trim();
$('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 = sagWas((await a.json().catch(() => ({}))).fehler, 'Ging nicht.');
return;
}
const raumId = kanalUmbau;
/* Gespeichert -- die Verwurfwache darf hier nicht fragen. */
$('neu-dialog').vergessen?.();
$('neu-dialog').close();
await raeumeLaden();
await raumOeffnen(raumId, { sanft: true });
return;
}
/* EIN FREI GETIPPTER NAME STEHT IN `data-extern`, nicht im
Wert -- so legt wahl.js ihn ab (siehe externSetzen). Beides
geht an den Server; er entscheidet, was daraus wird. */
const feldK = $('kanal-kategorie');
const kategorie = feldK.value;
const eigener = (feldK.dataset.extern || '').trim();
if (!kategorie && !eigener) {
$('neu-fehler').textContent = 'Wähle eine Zuständigkeit oder tipp einen eigenen Namen.';
return;
}
const a = await hole('/workspace/api/chat/kanal', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kategorie, eigener_name: eigener, mit: ids }),
});
if (!a.ok) {
$('neu-fehler').textContent = sagWas((await a.json().catch(() => ({}))).fehler, 'Ging nicht.');
return;
}
const raumId = (await a.json()).raum_id;
/* Gespeichert -- die Verwurfwache darf hier nicht fragen. */
$('neu-dialog').vergessen?.();
$('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 = sagWas((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 = sagWas((await a.json().catch(() => ({}))).fehler, 'Ging nicht.');
return;
}
raumId = (await a.json()).raum_id;
}
/* Gespeichert -- die Verwurfwache darf hier nicht fragen. */
$('neu-dialog').vergessen?.();
$('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. */
/* DIE MESSUNG STEHT SEIT DEM 22.09.2026 IN kopf.js.
Sie stand hier, weil sie hier gebraucht wurde. Als die Regelseite
dieselbe Zahl brauchte, war sie dort nicht da -- und der
Rueckfallwert (64 px) lag auf einem Handy um rund 50 px daneben.
Eine Zahl, die eine Seite misst und eine andere raet, ist
schlimmer als gar keine.
kopf.js liegt auf JEDER Seite und misst dasselbe: Kopfleiste plus
Band mit dem fremden Namen, bei jeder Aenderung neu. Hier bleibt
nur ein Aufruf fuer den Fall, dass die Chatseite vor kopf.js
fertig ist. Zweimal messen schadet nicht; zweimal RECHNEN haette
geschadet. */
window.kopfhoeheMessen?.();
/* ---------- 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) => {
/* ERST DIE AUSWAHLLISTE, DANN DIE ANTWORT. Escape raeumt immer
das Oberste weg -- steht eine Liste offen, ist sie gemeint.
Sonst verloere man mit einem Tastendruck beides auf einmal. */
if (erwaehnTaste(e)) return;
if (e.key === 'Escape' && antwortAuf) { e.preventDefault(); antwortLoeschen(); }
});
$('schreiben').addEventListener('submit', abschicken);
$('text').addEventListener('input', () => {
hoeheAnpassen();
entwurfMerken();
zaehlerZeigen();
erwaehnPruefen();
});
/* AUCH BEIM BLOSSEN BEWEGEN DES SCHREIBZEIGERS. Wer mit der Maus
oder den Pfeiltasten hinter ein bereits getipptes "@Van" springt,
will dort weitermachen -- ohne diesen Weg bliebe die Liste zu, bis
er einen Buchstaben tippt. */
$('text').addEventListener('click', erwaehnPruefen);
$('text').addEventListener('keyup', (e) => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight'
|| e.key === 'Home' || e.key === 'End') erwaehnPruefen();
});
/* Wer das Feld verlaesst, hat es sich anders ueberlegt. `mousedown`
auf einem Eintrag haelt den Schreibzeiger fest, deshalb schliesst
dieser Weg die Liste nicht versehentlich vor dem Klick. */
$('text').addEventListener('blur', erwaehnSchliessen);
/* ---------- 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';
}
/* =====================================================================
DIE GIF-KISTE DES RUDELS (23.09.2026)
=====================================================================
Filipe: „dan will ich auch dass man im chat auch sprachnachrichten
und gifts reinschicken kann. also nur die modis rechte linke hand
und dogfather."
Ich lese „gifts" als GIFs -- die bewegten Bildchen -- und sage das
ausdruecklich, statt es stillschweigend anzunehmen.
WARUM EINE EIGENE KISTE UND KEIN GIPHY: Die Begruendung steht im
Server bei der Tabelle. Kurz: fremder Schluessel, fremde Firma,
jede Suche geht hinaus, und ab einer Menge kostet es. Was hier
liegt, hat das Team hineingelegt -- und zehn gute GIFs, die alle
kennen, sind im Alltag mehr wert als zehn Millionen fremde.
AUGENSCHONEND, UND DAS IST HIER EINE ECHTE FRAGE: Eine Wand aus
zwoelf gleichzeitig laufenden Bildchen ist das Gegenteil von
ruhig. Wer `prefers-reduced-motion` gesetzt hat, bekommt deshalb
STEHENDE Bilder (das erste Einzelbild, im Browser auf eine
Zeichenflaeche gemalt) und sieht ein GIF erst dann laufen, wenn er
es anfasst oder mit der Tastatur darauf steht. Fuer alle anderen
laufen sie -- eine GIF-Auswahl, in der sich nichts bewegt, ist
eine, in der man nicht sieht, was man verschickt.
===================================================================== */
let gifs = [];
let gifsGeholt = false;
const ruhigerModus = () => {
try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
catch { return false; }
};
async function gifsHolen({ frisch = false } = {}) {
if (gifsGeholt && !frisch) return;
try {
const a = await hole('/workspace/api/chat/gifs');
if (!a.ok) return;
const j = await a.json();
gifs = Array.isArray(j.gifs) ? j.gifs : [];
gifsGeholt = true;
} catch { /* ohne Kiste geht der Chat weiter */ }
}
/** Das erste Einzelbild auf eine Zeichenflaeche malen.
*
* So steht ein GIF still, ohne dass irgendwo eine zweite Datei
* liegen muesste. Der Browser malt beim Zeichnen das Bild, das
* gerade dran ist -- unmittelbar nach dem Laden ist das das erste. */
function standbildBauen(g) {
const flaeche = document.createElement('canvas');
flaeche.className = 'gif-kachel__bild';
const bild = new Image();
bild.addEventListener('load', () => {
flaeche.width = bild.naturalWidth || 1;
flaeche.height = bild.naturalHeight || 1;
try { flaeche.getContext('2d').drawImage(bild, 0, 0); } catch { /* egal */ }
});
bild.src = g.weg;
return flaeche;
}
function bewegtBauen(g) {
const bild = document.createElement('img');
bild.className = 'gif-kachel__bild';
bild.src = g.weg;
bild.alt = g.name || 'GIF';
bild.loading = 'lazy';
bild.decoding = 'async';
return bild;
}
function gifTafelZeichnen() {
const gitter = $('gif-gitter');
const leer = $('gif-leer');
if (!gitter) return;
const suche = ($('gif-suche')?.value || '').trim().toLowerCase();
const zeigen = suche
? gifs.filter((g) => String(g.name || '').toLowerCase().includes(suche))
: gifs;
gitter.replaceChildren();
if (leer) {
leer.hidden = zeigen.length > 0;
/* DER SATZ PASST ZUR LAGE. „Die Kiste ist leer" waere falsch,
wenn nur die Suche nichts findet -- und man legte ein GIF
hinein, das schon drin ist. */
leer.textContent = gifs.length === 0
? 'Die Kiste ist noch leer. Leg das erste GIF hinein – alle aus dem Team sehen es dann auch.'
: `Nichts gefunden zu „${suche}". Insgesamt liegen ${gifs.length} GIFs in der Kiste.`;
}
const ruhig = ruhigerModus();
for (const g of zeigen) {
const kachel = el('div', 'gif-kachel');
const knopf = el('button', 'gif-kachel__knopf');
knopf.type = 'button';
knopf.title = `${g.name}${g.von ? ' · von ' + g.von : ''}`;
knopf.setAttribute('aria-label', `GIF ${g.name} schicken`);
knopf.append(ruhig ? standbildBauen(g) : bewegtBauen(g));
if (ruhig) {
/* Beim Anfassen laeuft es doch -- sonst waehlt man blind. */
const bewegen = () => {
if (knopf.dataset.bewegt === 'ja') return;
knopf.dataset.bewegt = 'ja';
knopf.replaceChildren(bewegtBauen(g));
};
knopf.addEventListener('mouseenter', bewegen);
knopf.addEventListener('focus', bewegen);
}
knopf.addEventListener('click', () => void gifSchicken(g));
kachel.append(knopf);
/* HERAUSNEHMEN: Der Knopf steht an jeder Kachel und nicht in
einem Menue dahinter. Die Kiste gehoert allen; ein GIF, das
nicht mehr passt, muss man wegnehmen koennen, ohne zu suchen,
wie das geht. Der Server sagt Nein, wenn man nicht darf --
dann steht es als Satz da, nicht als nichts. */
const weg = el('button', 'gif-kachel__weg', '\u2715');
weg.type = 'button';
weg.title = 'Aus der Kiste nehmen';
weg.setAttribute('aria-label', `GIF ${g.name} aus der Kiste nehmen`);
weg.addEventListener('click', async (e) => {
e.stopPropagation();
/* MIT DER RUECKFRAGE DES HAUSES, nicht mit confirm().
Am 23.09.2026 stand hier ein natives confirm() -- meine
eigene Abkuerzung beim Bau der Kiste am selben Morgen.
pruef-nachfrage bewacht genau das und hat es gemeldet,
allerdings erst, nachdem eine kaputte Stelle in derselben
Datei repariert war (ein verlorener Backslash hatte ihr
Suchmuster stillgelegt).
WARUM DAS HAUS KEINE NATIVEN DIALOGE WILL: Sie sehen auf
jedem Geraet anders aus, lassen sich nicht auf Deutsch
beschriften, haben keinen Platz fuer die Frage "was bleibt
danach?" -- und auf manchen Handys unterdrueckt man sie
einmal und bekommt sie nie wieder, dann loescht der Knopf
kommentarlos. */
if (!await frageNach({
titel: 'Aus der Kiste nehmen?',
was: `„${g.name}" ist danach fuer das ganze Team weg.`,
bleibt: 'Schon verschickte GIFs bleiben in den Gespraechen stehen.',
endgueltig: true, ja: 'Herausnehmen',
})) return;
try {
const a = await hole(`/workspace/api/chat/gifs/${g.id}`, { method: 'DELETE' });
const j = await a.json().catch(() => ({}));
if (!a.ok) { melde(sagWas(j.fehler, 'Ging nicht.')); return; }
gifs = Array.isArray(j.gifs) ? j.gifs : gifs.filter((x) => x.id !== g.id);
gifTafelZeichnen();
melde('Aus der Kiste genommen.', 'hinweis');
} catch { melde('Keine Verbindung.'); }
});
kachel.append(weg);
gitter.append(kachel);
}
}
async function gifSchicken(g) {
if (!offen || schicktGerade) return;
schicktGerade = true;
try {
const a = await hole(`/workspace/api/chat/raeume/${offen.id}/gif`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gif_id: g.id, text: $('text').value.trim() }),
});
const j = await a.json().catch(() => ({}));
if (!a.ok) { melde(sagWas(j.fehler, 'Ging nicht.')); return; }
/* DIE TAFEL GEHT ZU. Wer ein GIF geschickt hat, will sehen, wie
es ankommt -- und die Tafel steht genau davor. */
const tafel = $('gif-tafel');
if (tafel) { tafel.hidden = true; $('gif-auf')?.setAttribute('aria-expanded', 'false'); }
nachrichtUebernehmen(j.nachricht);
} catch {
melde('Keine Verbindung – das GIF wurde nicht geschickt.');
} finally {
schicktGerade = false;
}
}
/** Ein GIF in die Kiste legen. */
async function gifDazu(datei) {
if (!datei) return;
if (datei.type !== 'image/gif') { melde('In die Kiste kommen nur GIFs.'); return; }
let anzeige = null;
try {
let lauf = null;
anzeige = hochladeAnzeige($('anhang-fortschritt'), datei.name || 'GIF',
() => lauf && lauf.abbrechen());
lauf = hochladen('/workspace/api/chat/gifs', datei, {
kopf: {
'Content-Type': 'image/gif',
'X-Dateiname': encodeURIComponent(datei.name || 'GIF'),
},
beiFortschritt: anzeige.fortschritt,
beiVerarbeitung: anzeige.verarbeiten,
});
const ergebnis = await lauf.fertig;
if (ergebnis.abgebrochen) { anzeige.abschluss('Abgebrochen.'); anzeige = null; return; }
if (!ergebnis.ok) {
melde(ergebnis.netz ? 'Keine Verbindung – das GIF wurde nicht hinterlegt.'
: sagWas(ergebnis.daten?.fehler, 'Ging nicht.'));
return;
}
await gifsHolen({ frisch: true });
gifTafelZeichnen();
melde(ergebnis.daten?.schon
/* DASSELBE GIF ZWEIMAL IST KEIN FEHLER -- es ist drin, und
genau das wollte man. Verglichen wird am INHALT, nicht am
Namen: "giphy.gif", "giphy(1).gif" und "download.gif" sind
sonst drei Kacheln fuer ein Bild. */
? 'Das lag schon in der Kiste.'
: 'In die Kiste gelegt – alle aus dem Team sehen es jetzt auch.', 'hinweis');
} catch {
melde('Keine Verbindung – das GIF wurde nicht hinterlegt.');
} finally {
if (anzeige) anzeige.weg();
}
}
$('gif-auf')?.addEventListener('click', async () => {
const tafel = $('gif-tafel');
if (!tafel) return;
const auf = tafel.hidden;
if (auf) {
await gifsHolen();
gifTafelZeichnen();
}
tafel.hidden = !auf;
$('gif-auf').setAttribute('aria-expanded', String(auf));
if (auf) $('gif-suche')?.focus();
});
$('gif-suche')?.addEventListener('input', gifTafelZeichnen);
$('gif-dazu')?.addEventListener('click', () => $('gif-feld')?.click());
$('gif-feld')?.addEventListener('change', () => {
const datei = $('gif-feld').files?.[0];
$('gif-feld').value = '';
if (datei) void gifDazu(datei);
});
/* Ein Klick daneben schliesst die Kiste -- wie bei der Emoji-Tafel. */
document.addEventListener('click', (e) => {
const tafel = $('gif-tafel');
if (!tafel || tafel.hidden) return;
if (tafel.contains(e.target) || e.target.closest('#gif-auf')) return;
tafel.hidden = true;
$('gif-auf')?.setAttribute('aria-expanded', 'false');
});
/* ---------- 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) => {
/* STEHT DIE AUSWAHL OFFEN, GEHOERT ENTER IHR. Ohne diese Zeile
waere jedes Waehlen mit der Tastatur ein versehentlich
abgeschickter halber Satz -- und zwar ausgerechnet fuer die, die
am Rechner schnell schreiben.
Der Ausgang steht VOR der Handy-Abfrage: Am Handy schickt Enter
ohnehin nicht ab, aber waehlen soll es auch dort.
GEFRAGT WIRD NACH `defaultPrevented`, NICHT NACH DER LISTE.
Der erste Anlauf fragte `if (erwaehnAuswahl && ...)` -- und lief
ins Leere: Der Handler weiter oben waehlt den Namen aus und
SCHLIESST dabei die Liste. Wenn dieser hier an die Reihe kommt,
ist `erwaehnAuswahl` schon null, die Bedingung falsch, und der
halbe Satz geht raus. Genau der Fehler, den die Funktion
verhindern soll.
`defaultPrevented` beantwortet die Frage, um die es wirklich
geht: Hat diese Taste ihre Arbeit schon getan? */
if (e.defaultPrevented) return;
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;
/** Eine gerade abgeschickte Nachricht in den Verlauf holen.
*
* ================================================================
* HIER STAND EIN FEHLER, UND ZWAR EIN STILLER (gefunden 23.09.2026)
* ================================================================
*
* In `anhangSchicken` stand `const { nachricht } = e.daten;` -- ein
* `e`, das es in dieser Funktion gar nicht gibt (gemeint war
* `ergebnis`). Das ist kein Schreibfehler ohne Folgen: Die Zeile
* wirft einen ReferenceError, der `catch` daneben faengt ihn, und
* der Absender liest „Keine Verbindung -- der Anhang wurde nicht
* geschickt."
*
* DAS FOTO WAR ABER DA. Es ging durch, der Server speicherte es,
* ueber den Ereignisstrom erschien es Sekunden spaeter im Verlauf
* -- mit einer roten Meldung darueber, die das Gegenteil behauptet.
* Dazu blieb der getippte Begleitsatz im Feld stehen (die Zeile,
* die ihn leert, kam nie dran), also schickt man es noch einmal.
*
* Der Fehler ist nur deshalb so lange unbemerkt geblieben, weil er
* sich wie ein Netzproblem anfuehlt -- und Netzprobleme sucht
* niemand im Programm.
*
* Jetzt EINE Stelle fuer beide Wege (Anhang und Sprachnachricht).
* Zwei Fassungen waeren zwei Gelegenheiten fuer denselben Fehler.
*/
function nachrichtUebernehmen(nachricht) {
if (!nachricht) { melde('Ging nicht.'); return; }
/* 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 = '';
if (offen) entwurfWeg(offen.id);
hoeheAnpassen();
zaehlerZeigen();
unten = true; neuUnten = 0;
verlaufZeichnen();
melde('');
raeumeLaden();
}
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)) {
/* SPRACHNACHRICHTEN STEHEN HIER BEWUSST NICHT. Sie kommen aus
dem Mikrofon daneben, nicht aus dem Dateiwaehler -- bestellt
waren Sprachnachrichten, keine Musikdateien. */
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;
/* Die alte Laufmeldung faellt weg -- der Balken sagt dasselbe und
zusaetzlich, WIE WEIT es ist. Zwei Anzeigen fuer dieselbe Sache
waeren Rauschen. */
melde('');
let anzeige = null;
/* 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 {
/* MIT FORTSCHRITT UND ABBRUCH (19.09.2026). Ein Foto vom Handy
ist schnell vier Megabyte gross; ohne Balken sieht der Chat
in der Zeit aus, als sei er eingefroren. */
let lauf = null;
anzeige = hochladeAnzeige($('anhang-fortschritt'), datei.name || 'Anhang',
() => lauf && lauf.abbrechen());
lauf = hochladen(`/workspace/api/chat/raeume/${offen.id}/anhang`, datei, {
kopf: {
'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),
},
beiFortschritt: anzeige.fortschritt,
beiVerarbeitung: anzeige.verarbeiten,
});
const ergebnis = await lauf.fertig;
if (ergebnis.abgebrochen) {
anzeige.abschluss('Abgebrochen – nichts wurde geschickt.');
anzeige = null;
return;
}
if (!ergebnis.ok) {
melde(ergebnis.netz ? 'Keine Verbindung – der Anhang wurde nicht geschickt.'
: sagWas(ergebnis.daten?.fehler, 'Ging nicht.'));
return;
}
nachrichtUebernehmen(ergebnis.daten?.nachricht);
} catch {
melde('Keine Verbindung – der Anhang wurde nicht geschickt.');
} finally {
/* Nur wegnehmen, wenn nicht gerade der Abbruch-Satz steht --
der bekommt seine vier Sekunden. */
if (anzeige) anzeige.weg();
schicktGerade = false;
$('anhang-auf').disabled = false;
}
}
/* =====================================================================
SPRACHNACHRICHTEN AUFNEHMEN (23.09.2026)
=====================================================================
Filipe: „dan will ich auch dass man im chat auch sprachnachrichten
und gifts reinschicken kann. also nur die modis rechte linke hand
und dogfather."
TIPPEN STATT HALTEN -- die Entscheidung steht im HTML begruendet.
DREI BEHAELTER, WEIL DIE GERAETE DREI LIEFERN: Chrome und Android
koennen WebM/Opus, Safari und das iPhone nur MP4, Firefox Ogg. Die
Liste wird der Reihe nach durchprobiert und der erste genommen,
den das Geraet kann. Einen festen Typ zu verlangen hiesse, dass
die Funktion bei der Haelfte des Teams gar nicht erst angeht --
und zwar ohne Fehlermeldung, weil MediaRecorder dann einfach
wirft.
WAS BEWUSST NICHT PASSIERT: kein eigener Pegelausschlag, kein
Wellenbild. Beides sieht gut aus und kostet eine Bildschleife, die
waehrend der Aufnahme dauernd zeichnet -- auf einem aelteren Handy
ist das genau die Rechenzeit, die dem Aufnehmen fehlt. Ein ruhiger
Punkt und die Zeit reichen, um zu wissen, dass es laeuft.
===================================================================== */
/** Aus Millisekunden „1:07". An einer Stelle, weil drei sie
* brauchen: die laufende Aufnahme, die Blase und das Vorlesewort. */
function dauerWort(ms) {
const sek = Math.max(0, Math.round(Number(ms) / 1000));
return `${Math.floor(sek / 60)}:${String(sek % 60).padStart(2, '0')}`;
}
/* DREI MINUTEN. Eine Sprachnachricht im Team ist „hoer mal kurz",
kein Vortrag -- und wer eine halbe Stunde spricht, schickt etwas,
das niemand abhoert. Die Aufnahme stoppt von selbst und sagt es;
abgeschickt wird sie NICHT von selbst (siehe unten). */
const TON_MAX_MS = 3 * 60 * 1000;
let tonGeraet = null; // MediaRecorder
let tonSpur = null; // MediaStream -- muss am Ende aus sein
let tonStuecke = [];
let tonStart = 0;
let tonUhr = null;
let tonAbsicht = null; // 'senden' | 'weg' | null
function tonTypWaehlen() {
const kandidaten = [
'audio/webm;codecs=opus', 'audio/webm',
'audio/ogg;codecs=opus', 'audio/ogg',
'audio/mp4',
];
for (const t of kandidaten) {
try { if (window.MediaRecorder?.isTypeSupported?.(t)) return t; } catch { /* weiter */ }
}
return ''; // der Browser nimmt seinen eigenen
}
function tonLeisteZeigen(an) {
const leiste = $('tonleiste');
if (leiste) leiste.hidden = !an;
const knopf = $('ton-auf');
if (knopf) knopf.dataset.laeuft = an ? 'ja' : 'nein';
/* Waehrend der Aufnahme kann man nichts anhaengen -- zwei
gleichzeitige Uploads waeren zwei Nachrichten in unklarer
Reihenfolge. */
const klammer = $('anhang-auf');
if (klammer) klammer.disabled = an;
}
function tonUhrZeigen() {
const ms = Date.now() - tonStart;
const feld = $('ton-zeit');
if (feld) feld.textContent = dauerWort(ms);
/* NUR ALLE FUENF SEKUNDEN VORLESEN. `aria-live` an der Zeit haette
im Sekundentakt gesprochen und alles andere uebertoent. */
const wort = $('ton-wort');
if (wort) {
const sek = Math.floor(ms / 1000);
const soll = sek >= 5 && sek % 5 === 0 ? `nimmt auf, ${dauerWort(ms)}` : null;
if (soll && wort.textContent !== soll) wort.textContent = soll;
}
if (ms >= TON_MAX_MS) {
/* NICHT VON SELBST ABSCHICKEN. Wer die Grenze erreicht, hat
gerade mitten im Satz aufgehoert -- das ungefragt zu
verschicken waere die schlechteste Sekunde, um es zu tun.
Die Aufnahme haelt an, das Ergebnis liegt bereit. */
tonAbsicht = null;
tonStoppen();
melde(`Aufnahme gestoppt – ${Math.round(TON_MAX_MS / 60000)} Minuten sind das Höchste. `
+ 'Mit dem Häkchen schickst du sie ab.', 'hinweis');
}
}
function tonAufraeumen() {
if (tonUhr) { clearInterval(tonUhr); tonUhr = null; }
/* DIE SPUR MUSS AUS. Ohne das bleibt das Mikrofon an -- am Handy
sichtbar als rotes Zeichen in der Statusleiste, und es zieht
Strom. Eine Aufnahmefunktion, die das Mikrofon offen laesst, ist
ein Vertrauensbruch, auch wenn niemand zuhoert. */
try { tonSpur?.getTracks().forEach((t) => t.stop()); } catch { /* schon aus */ }
tonSpur = null;
tonGeraet = null;
tonLeisteZeigen(false);
const feld = $('ton-zeit');
if (feld) feld.textContent = '0:00';
const wort = $('ton-wort');
if (wort) wort.textContent = 'nimmt auf …';
}
function tonStoppen() {
try { tonGeraet?.stop(); } catch { /* war schon aus */ }
}
async function tonStarten() {
if (!offen || tonGeraet) return;
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) {
melde('Dieser Browser kann keine Sprachnachrichten aufnehmen.');
return;
}
let spur;
try {
spur = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (f) {
/* DER HAEUFIGSTE FALL IST KEIN FEHLER, SONDERN EIN NEIN. Ein
„Ging nicht" liesse jeden raten, woran es lag -- und die
Antwort steht in einem Kasten, den man wegklicken kann und
danach nicht wiederfindet. */
melde(f?.name === 'NotAllowedError'
? 'Das Mikrofon ist gesperrt. Im Browser neben der Adresszeile auf das Schloss tippen und Mikrofon erlauben.'
: f?.name === 'NotFoundError'
? 'Kein Mikrofon gefunden.'
: 'Das Mikrofon lässt sich gerade nicht öffnen.');
return;
}
tonSpur = spur;
tonStuecke = [];
tonAbsicht = null;
const typ = tonTypWaehlen();
try {
tonGeraet = new MediaRecorder(spur, typ ? { mimeType: typ } : undefined);
} catch {
/* Nimmt der Browser den gewaehlten Typ doch nicht, bekommt er
seinen eigenen. Lieber eine Aufnahme in einem anderen Format
als gar keine -- der Server erkennt alle drei. */
try { tonGeraet = new MediaRecorder(spur); } catch {
melde('Dieser Browser kann keine Sprachnachrichten aufnehmen.');
tonAufraeumen();
return;
}
}
tonGeraet.addEventListener('dataavailable', (x) => {
if (x.data && x.data.size) tonStuecke.push(x.data);
});
tonGeraet.addEventListener('stop', () => {
const dauer = Date.now() - tonStart;
const art = tonGeraet?.mimeType || typ || 'audio/webm';
const absicht = tonAbsicht;
const stuecke = tonStuecke;
tonStuecke = [];
tonAufraeumen();
if (absicht !== 'senden') {
if (absicht === 'weg') melde('Aufnahme verworfen.', 'hinweis');
return;
}
/* UNTER EINER SEKUNDE IST EIN VERSEHEN. Ein Tippen auf den
Knopf und gleich wieder auf das Haekchen ergibt eine Datei
mit nichts darin -- die zu verschicken hiesse, dem anderen
eine leere Nachricht zu schicken, die er dreimal abspielt. */
if (dauer < 900 || !stuecke.length) {
melde('Zu kurz – nichts aufgenommen.');
return;
}
const brocken = new Blob(stuecke, { type: art });
void tonSchicken(brocken, dauer, art);
});
tonStart = Date.now();
/* 250 ms: kleine Stuecke, damit beim Stoppen nichts verlorengeht.
Ohne Zeitscheibe liefert mancher Browser alles erst am Ende --
und wenn die Seite dazwischen weggeht, ist alles weg. */
tonGeraet.start(250);
tonLeisteZeigen(true);
tonUhrZeigen();
tonUhr = setInterval(tonUhrZeigen, 250);
melde('');
}
/** Die fertige Aufnahme hochladen.
*
* UEBER DENSELBEN WEG WIE JEDER ANHANG -- dieselbe Route, dieselbe
* Fortschrittsanzeige, dasselbe Abbrechen. Ein eigener Weg waere
* eine zweite Gelegenheit, eine Pruefung zu vergessen. */
async function tonSchicken(brocken, dauer, typ) {
if (!offen) return;
if (schicktGerade) { melde('Einen Moment – der letzte Anhang ist noch unterwegs.'); return; }
schicktGerade = true;
$('anhang-auf').disabled = true;
if ($('ton-auf')) $('ton-auf').disabled = true;
let anzeige = null;
const begleittext = $('text').value.trim();
try {
let lauf = null;
anzeige = hochladeAnzeige($('anhang-fortschritt'),
`Sprachnachricht (${dauerWort(dauer)})`, () => lauf && lauf.abbrechen());
/* DIE ENDUNG IM NAMEN FOLGT DEM TYP, nicht umgekehrt. Der Server
bestimmt sie ohnehin aus den ersten Bytes neu -- hier geht es
nur darum, dass in der Datenbank kein "aufnahme.webm" steht,
wenn es eine m4a ist. */
const endung = /mp4/.test(typ) ? 'm4a' : /ogg/.test(typ) ? 'ogg' : 'webm';
lauf = hochladen(`/workspace/api/chat/raeume/${offen.id}/anhang`, brocken, {
kopf: {
'Content-Type': typ,
'X-Dateiname': encodeURIComponent(`Sprachnachricht.${endung}`),
'X-Text': encodeURIComponent(begleittext),
'X-Dauer': String(Math.round(dauer)),
},
beiFortschritt: anzeige.fortschritt,
beiVerarbeitung: anzeige.verarbeiten,
});
const ergebnis = await lauf.fertig;
if (ergebnis.abgebrochen) {
anzeige.abschluss('Abgebrochen – nichts wurde geschickt.');
anzeige = null;
return;
}
if (!ergebnis.ok) {
melde(ergebnis.netz ? 'Keine Verbindung – die Sprachnachricht wurde nicht geschickt.'
: sagWas(ergebnis.daten?.fehler, 'Ging nicht.'));
return;
}
nachrichtUebernehmen(ergebnis.daten?.nachricht);
} catch {
melde('Keine Verbindung – die Sprachnachricht wurde nicht geschickt.');
} finally {
if (anzeige) anzeige.weg();
schicktGerade = false;
$('anhang-auf').disabled = false;
if ($('ton-auf')) $('ton-auf').disabled = false;
}
}
$('ton-auf')?.addEventListener('click', () => {
if (tonGeraet) { tonAbsicht = 'senden'; tonStoppen(); return; }
void tonStarten();
});
$('ton-senden')?.addEventListener('click', () => { tonAbsicht = 'senden'; tonStoppen(); });
$('ton-weg')?.addEventListener('click', () => { tonAbsicht = 'weg'; tonStoppen(); });
/* WER DIE SEITE VERLAESST, LAESST DAS MIKROFON NICHT AN. */
window.addEventListener('pagehide', () => { tonAbsicht = 'weg'; tonStoppen(); tonAufraeumen(); });
$('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;
/* UND ES GEHT AUCH WIEDER WEG. Ein Gesicht neben „Wähle ein
Gespräch" gehörte zu einem Gespräch, das gerade geschlossen
wurde – es zeigte auf niemanden. */
const kopfZeichenWeg = $('verlauf-zeichen');
if (kopfZeichenWeg) { kopfZeichenWeg.hidden = true; kopfZeichenWeg.textContent = ''; }
$('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 (!await frageNach({
titel: `Gespräch mit ${wer} wegräumen?`,
was: 'Es verschwindet nur bei dir aus der Liste.',
bleibt: `Der Verlauf bei ${wer} bleibt vollständig. Schreibt ihr euch `
+ 'wieder, kommt das Gespräch mit dem Neuen zurück – das Alte bleibt weg.',
ja: 'Wegräumen',
})) 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 (!await frageNach({
titel: `${wasIstEs} „${offen.name}" für alle auflösen?`,
was: `Der ganze Verlauf wird gelöscht – auch bei ${andere === 1
? 'der anderen Person' : `den ${andere} anderen`}, samt aller Anhänge.`,
bleibt: 'Wenn du sie nur bei dir loswerden willst, nimm den Knopf '
+ 'daneben – dann behalten die anderen alles.',
endgueltig: true, ja: 'Für alle auflösen',
})) 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 {
/* NICHT MEHR STUMM (20.09.2026). Hier stand `catch { return; }`
-- und damit blieb die Seite bei jedem Netzaussetzer fuer
immer auf "wird geladen …" stehen. Gemessen auf 13 von 21
Seiten. `ladefehler()` sagt, was los ist, und bietet einen
Knopf an, der es noch einmal versucht. */
window.ladefehler?.();
return;
}
window.werZeigen?.(ich);
await raeumeLaden();
/* DIE KACHELN VOR DEM ERSTEN VERLAUF. Kaemen sie danach, stuenden
die ersten Nachrichten kurz in Standardfarbe und faerbten sich
dann um -- eine Seite, die sich unter den Fingern aendert. */
await kachelnLaden();
kachelwahlBedienen();
stromOeffnen();
unterbauBeobachten();
unterbauMessen();
/* Kommt man aus einer Benachrichtigung, steht das Gespräch in der
Adresse -- dann direkt hinein. */
const suche = new URLSearchParams(location.search);
/* ?raum=treff -- der Weg von der Treff-Kachel (19.09.2026).
ANLASS: Ein Modi sah ZWEI Kacheln namens "Chat", beide auf
dieselbe Seite -- eine in "Taeglich" (Team), eine in "Community"
(Treff). Sie taten auch dasselbe: Man landete in der
Gespraechsliste und musste den richtigen Raum suchen. Zwei Wege
zum selben Ziel verwirren mehr, als ein fehlender fehlt.
Die Nummer des Treff-Raums steht nicht fest (er wird beim ersten
Start angelegt), deshalb das Wort statt einer Zahl. Gesucht wird
er in der Liste, die ohnehin schon geladen ist -- an denselben
zwei Merkmalen, an denen ihn der Rest der Datei erkennt. */
if (suche.get('raum') === 'treff') {
const t = raeume.find((r) => r.art === 'kanal' && r.kategorie === 'treff');
if (t) { await raumOeffnen(t.id); return; }
/* DRITTER AUSGANG: Gibt es ihn (noch) nicht, bleibt die Liste
stehen -- das ist die richtige Antwort und kein Fehler. Ein
leerer Verlauf waere schlimmer als eine Liste. */
}
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 */ }
})();
})();