Deutsch oeffnet mit „ und schliesst mit “. An 199 Stellen, die ein
Mensch liest, stand als Schlusszeichen ein GERADES " -- „Nächstes"
statt „Nächstes“. Auf sechzehn oeffentlichen Seiten, in vierzig
Dateien des Workspace und in zwoelf Servermodulen, die Texte
verschicken.
Auf dem Bildschirm sieht man den Unterschied sofort. Beim Schreiben
nicht: Das gerade " liegt auf der Tastatur, die anderen nicht.
WARUM EIN ERSTER ANLAUF ZURUECKGENOMMEN WURDE
Ein gerades " ist an vielen Stellen SYNTAX und kein Schriftzeichen --
Grenze einer Zeichenkette, Grenze eines HTML-Attributs, Zeichen in
einem regulaeren Ausdruck. Wer stumpf ersetzt, macht aus
„<a href="https://… ein kaputtes Attribut
/^["'„»\s]+|["'“«.\s]+$/ einen kaputten Ausdruck
"… nichts „mal " + "eben …" eine kaputte Zeichenkette
DIE UNTERSCHEIDUNG LAEUFT AN MERKMALEN, NICHT AN EINER LISTE
< > = dazwischen -> HTML-Marke oder Attribut
endet auf Leerzeichen -> die Zeichenkette hoert hier auf, der
Satz geht in der naechsten Zeile weiter.
Ein deutsches Schlusszeichen steht NIE
hinter einem Leerzeichen.
Rueckstrich mittendrin -> regulaerer Ausdruck
${ ohne } -> mitten in einem Ausdruck
Eine Liste erlaubter Ausnahmen waere die naechste, die niemand
pflegt. Zwoelf Stellen bleiben dadurch stehen, alle zwoelf einzeln
angesehen und alle zwoelf zu Recht -- dort steht das richtige
Schlusszeichen ohnehin weiter unten im Satz.
Fuenf davon waren allerdings ECHTE Fehler HINTER dem Link
(`…>HasiDog</a>".`) -- die erste Regel hatte nur das Attribut
gesehen, nicht den Satz danach. Gezielt nachgezogen.
Ein maskiertes `\"` in workspace-vorlagen.js (28 Hooks) wird zu “ --
ohne Rueckstrich, denn “ begrenzt nichts.
KOMMENTARE BLEIBEN, WIE SIE SIND. Dort liest es niemand ausser mir;
eine Wache, die auch Kosmetik anmahnt, wird weggeklickt. Beim ersten
Messen fielen ausserdem acht Stellen aus buehne.html faelschlich an,
weil `/* */` in HTML (in <style> und <script>) nicht ausgeblendet
war -- jetzt schon.
DIE WACHE DAZU
pruef-struktur prueft es ab sofort mit derselben Regel: 322 Dateien
mit sichtbarem Text, 0 Funde, und die zwoelf bewussten Ausnahmen
werden GEZAEHLT und genannt (erlaubt: 12). Eine Ausnahme, die niemand
sieht, waechst -- und irgendwann steht der echte Fall darin.
GEPRUEFT
pruef-struktur 59 -> 68 Pruefungen, 0 Fehler
node --check auf allen geaenderten JS-Dateien
nachgemessen: 199 geaendert, 12 mit Grund stehen geblieben
gruen geblieben: bewerbung-aufgaben 163, nachwuchs 262,
reaktion 421, support 63, content 45, terminregel 35, treff 85
Und nachgesehen, ob eine Pruefung noch die alte Schreibweise
ERWARTET: 13 Fundstellen, alle dreizehn nur Text in ihrer eigenen
Ausgabe, keine einzige ein Vergleich mit dem Seitentext.
GEGENPROBE: In reaktion.html ein Schlusszeichen zurueckgedreht ->
„workspace/reaktion.html:546 „Nächstes"", mit Datei und Zeile. Und
die Erkennung einzeln gegen HTML-Attribut, fortgesetzte
Zeichenkette, regulaeren Ausdruck und eingesetzten Wert geprueft.
Stempel gesetzt: workspace 670 Verweise in 45 Dateien, oeffentlich
554 in 48 Seiten.
Co-Authored-By: Claude Opus 5 <[email protected]>
5028 lines
218 KiB
JavaScript
5028 lines
218 KiB
JavaScript
/* =====================================================================
|
||
chat.js — Gespräche im Workspace.
|
||
|
||
Wunsch Filipe, 06.09.2026: *"jeder kann dann eine nachricht
|
||
reinschreiben, die andere person bekommt eine benachrichtigung und
|
||
kann dann antworten."*
|
||
|
||
---------------------------------------------------------------------
|
||
DREI ENTSCHEIDUNGEN, die den Rest erklären
|
||
|
||
1. NEUE NACHRICHTEN KOMMEN VON SELBST (EventSource / SSE). Kein
|
||
Nachfragen im Takt: Das wäre fünf Sekunden langsam und stellte bei
|
||
zehn Angemeldeten 7200 Anfragen in der Stunde für Nachrichten, die
|
||
es meistens nicht gibt. Reisst die Verbindung ab, verbindet der
|
||
Browser sich von selbst neu -- und wir holen die Lücke über den
|
||
normalen Verlauf nach.
|
||
|
||
2. DIE EIGENE NACHRICHT STEHT SOFORT DA, bevor der Server antwortet.
|
||
Ein Chat, in dem man nach dem Abschicken eine halbe Sekunde ins
|
||
Leere sieht, fühlt sich kaputt an. Kommt der Server nicht mit,
|
||
wird sie sichtbar als "nicht angekommen" markiert -- nicht still
|
||
entfernt.
|
||
|
||
3. GELESEN WIRD GEMELDET, wenn man wirklich hinsieht. Nicht schon
|
||
beim Laden der Seite: Wer die Seite im Hintergrund offen hat, hat
|
||
nichts gelesen, und eine Zahl, die von selbst verschwindet, ist
|
||
schlimmer als keine.
|
||
===================================================================== */
|
||
(() => {
|
||
'use strict';
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
/* =====================================================================
|
||
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;
|
||
/* ==== WIRD NOCH NACHGEZOGEN? (25.09.2026) ======================
|
||
|
||
Steht hier oben und nicht im Block darunter, obwohl es nur dort
|
||
benutzt wird -- und zwar mit Grund: Der Zuhoerer, der das
|
||
Nachziehen beendet, wird EINMAL an den Verlauf gebunden und
|
||
ueberlebt jeden Raumwechsel. Haelt er eine lokale Variable fest,
|
||
stoppt er beim zweiten Gespraech die falsche (die vom ersten),
|
||
und im neuen wird noch zwei Sekunden lang weitergezogen, waehrend
|
||
der Mensch schon scrollt. Eine Variable an dieser Stelle kann das
|
||
nicht passieren. */
|
||
let nochNachziehen = 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. */
|
||
/* ==== NUR BEIM ECHTEN OEFFNEN, NICHT BEI JEDEM NACHLADEN ====
|
||
|
||
Filipe: „wenn ich in den chat rein gehe und es neue kommentare
|
||
gibt, will ich dass mein chat sich da öffnet wo die neuen
|
||
nachrichten anfangen ... und nicht immer ganz unten."
|
||
|
||
HIER STAND DIE ZUWEISUNG OHNE BEDINGUNG -- und damit lief sie
|
||
auch bei jedem SANFTEN Nachladen. Sanft laedt der Verlauf
|
||
staendig nach: wenn der Ereignisstrom sich verbindet
|
||
(`strom.addEventListener('open', ...)`), wenn jemand etwas
|
||
anheftet, nach einer eigenen Nachricht.
|
||
|
||
DAS ERSTE DAVON IST DER FEHLER, DEN FILIPE GESEHEN HAT. Die
|
||
Seite laedt, zeichnet, meldet „gelesen bis hier" -- und dann
|
||
verbindet sich der Strom und laedt sanft nach. Jetzt steht in
|
||
`gelesen_bis` schon die LETZTE Nachricht, die Linie entsteht
|
||
nicht mehr, und der Verlauf springt ans Ende.
|
||
|
||
WARUM ES FLATTERTE: Kommt die Strom-Verbindung vor dem ersten
|
||
Zeichnen, passiert nichts; kommt sie danach, ist die Linie
|
||
weg. Gemessen ueber sechs Rollen waren mal drei rot, mal
|
||
zwei, mal andere -- bei unveraendertem Code. Ein Fehler, der
|
||
wuerfelt, sieht wie „geht manchmal" aus und wird nie gemeldet.
|
||
|
||
`sanft` HEISST: dasselbe Gespraech, nur frisch geholt. Dann
|
||
gehoert die Linie genau dorthin, wo sie schon stand. */
|
||
if (!sanft) {
|
||
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) {
|
||
const linie = ziel.querySelector('.chat-neu');
|
||
if (linie) {
|
||
/* ==== EINMAL SPRINGEN REICHT NICHT (25.09.2026) ============
|
||
|
||
Filipe: „wenn ich in den chat rein gehe und es neue
|
||
kommentare gibt, will ich dass mein chat sich da öffnet wo
|
||
die neuen nachrichten anfangen ... sonst muss man immer
|
||
hoch scrollen um die neuen zu lesen und das ist scheisse."
|
||
|
||
DER SPRUNG STAND SEIT DEM 09.09. HIER -- und er sass
|
||
trotzdem falsch. Gemessen auf 390 px: Die Linie landete
|
||
23 px OBERHALB des sichtbaren Bereichs, man musste also
|
||
genau das tun, was Filipe nicht mehr tun wollte. Am Rechner
|
||
stimmte es (74 px unter dem Rand); deshalb ist es nie
|
||
aufgefallen.
|
||
|
||
DER GRUND IST DAS NACHWACHSEN. Zwischen dem Sprung und dem
|
||
fertigen Bild aendert sich die Hoehe noch: Schriften kommen
|
||
an und setzen den Text neu um, Bilder melden ihre Groesse,
|
||
Sprachnachrichten bekommen ihre Leiste. Gemessen sind das
|
||
auf dem Handy 45 px in einem Verlauf von 5000 -- und das
|
||
reicht, weil dort jede Zeile dreimal umbricht. Am Rechner
|
||
faellt derselbe Zuwachs nicht auf.
|
||
|
||
ALSO WIRD NACHGEZOGEN, statt die Hoehe zu raten: nach dem
|
||
naechsten Bild, nach den Schriften, nach zwei
|
||
Bildwiederholungen. Jedes Mal an dieselbe Stelle -- die
|
||
Rechnung ist billig, und sie ist nach jedem Zuwachs wieder
|
||
richtig.
|
||
|
||
UND NUR, SOLANGE NIEMAND SELBST SCROLLT. Wer nach dem
|
||
Oeffnen sofort weiterliest, darf nicht zurueckgerissen
|
||
werden; das waere derselbe Fehler wie der, den die Regel
|
||
„nur nach unten springen, wenn man unten war" verhindert. */
|
||
/* ==== DIE STELLE HAELT, BIS DER MENSCH ETWAS TUT ===========
|
||
|
||
Zuerst wurde `zurNeuLinie` beim ersten Zeichnen auf false
|
||
gesetzt -- einmal springen, fertig. Das reicht nicht: Der
|
||
Verlauf wird staendig SANFT nachgeladen (der Ereignisstrom
|
||
verbindet sich, jemand heftet etwas an, eine Nachricht
|
||
kommt). Jedes Nachladen baut die Zeilen neu auf, und die
|
||
Bildlaufposition ist danach nicht mehr die von vorhin.
|
||
Gemessen landete der Verlauf dann doch am Ende -- 2016 px
|
||
unter der Linie.
|
||
|
||
JETZT BLEIBT DER MERKER STEHEN, bis einer von zwei Dingen
|
||
passiert: Der Mensch scrollt selbst (dann hat er die Stelle
|
||
gesehen und will woanders hin), oder der Raum wechselt.
|
||
Bis dahin findet jedes Neuzeichnen dieselbe Stelle wieder.
|
||
|
||
DAS IST AUCH INHALTLICH RICHTIG. „Ich habe dich an die neue
|
||
Stelle gesetzt" ist eine Zusage; sie beim naechsten
|
||
Nachladen zu brechen, waere schlimmer, als sie nie gegeben
|
||
zu haben. */
|
||
nochNachziehen = true;
|
||
const ansZiel = () => {
|
||
if (!nochNachziehen) return;
|
||
const l = ziel.querySelector('.chat-neu');
|
||
if (!l) return;
|
||
/* ==== `offsetTop` MISST GEGEN DEN FALSCHEN PUNKT ==========
|
||
|
||
Hier stand `l.offsetTop`. Das ist der Abstand zum
|
||
`offsetParent` -- und der ist nur dann der Verlauf selbst,
|
||
wenn dieser `position: relative` traegt. Tut er nicht;
|
||
gemessen landete die Linie damit 23 px OBERHALB des
|
||
sichtbaren Bereichs, also genau dort, wo man sie nicht
|
||
sieht. Die Rechnung war um 140 px daneben, und am Rechner
|
||
fiel das nicht auf, weil dort weniger dazwischenliegt.
|
||
|
||
DER ABSTAND ZWEIER KAESTEN IST IMMER RICHTIG: die Oberkante
|
||
der Linie minus die Oberkante des Verlaufs, plus dessen
|
||
aktuelle Bildlaufposition. Das gilt unabhaengig davon, wer
|
||
gerade wessen `offsetParent` ist -- und es ist dieselbe
|
||
Rechnung, mit der die Messung den Fehler gefunden hat. */
|
||
const bezug = ziel.getBoundingClientRect().top;
|
||
const wo = l.getBoundingClientRect().top - bezug + ziel.scrollTop;
|
||
ziel.scrollTop = Math.max(0, wo - ziel.clientHeight * 0.25);
|
||
unten = amEnde(ziel);
|
||
springKnopfZeigen();
|
||
};
|
||
/* WER SELBST SCROLLT, HAT ENTSCHIEDEN. Ab da wird nicht
|
||
mehr nachgezogen und auch beim naechsten Nachladen nicht
|
||
mehr gesprungen -- sonst risse es einen aus dem Text, den
|
||
man gerade liest. Die Zuhoerer haengen am Verlauf und
|
||
werden nur einmal gesetzt (`einmalGebunden`), sonst kaemen
|
||
bei jedem Neuzeichnen neue dazu. */
|
||
/* DIE WACHE STEHT NICHT MEHR HIER (30.09.2026).
|
||
|
||
Sie hing bis dahin in diesem Block — also wurde sie erst
|
||
angehängt, wenn einmal zur Neu-Linie gesprungen wurde. In
|
||
einem ruhigen Gespräch passiert das nie: Wer alles gelesen
|
||
hat, bekommt keine Linie, der Block läuft nicht, die
|
||
Zuhörer gibt es nicht. Scrollt er dann hoch und es kommt
|
||
eine Nachricht, entsteht die Linie ZUM ERSTEN MAL — der
|
||
Block läuft, hängt die Zuhörer an und springt im selben
|
||
Atemzug. Sein Hochscrollen hat nie jemand bemerkt.
|
||
|
||
Gemessen: „die Stelle bleibt stehen (0 -> 3574)". Jetzt
|
||
steht die Wache beim Aufbau, bei den übrigen Zuhörern des
|
||
Verlaufs. */
|
||
/* Nach spaetestens zwei Sekunden wird nicht mehr NACHGEZOGEN
|
||
-- der Merker bleibt aber stehen. Nachziehen faengt das
|
||
Nachwachsen von Schriften und Bildern ab; das ist nach zwei
|
||
Sekunden vorbei. Ein Zuhoerer, der fuer immer zieht, holte
|
||
den Verlauf beim naechsten Bild in zehn Minuten wieder an
|
||
eine Stelle, die niemand mehr sucht. */
|
||
setTimeout(() => { nochNachziehen = false; }, 2000);
|
||
|
||
ansZiel();
|
||
requestAnimationFrame(() => requestAnimationFrame(ansZiel));
|
||
document.fonts?.ready?.then(ansZiel).catch(() => {});
|
||
for (const bild of ziel.querySelectorAll('img')) {
|
||
if (!bild.complete) bild.addEventListener('load', ansZiel, { once: true });
|
||
}
|
||
|
||
neuUnten = 0;
|
||
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();
|
||
});
|
||
|
||
/* ---------- WER SELBST SCROLLT, HAT ENTSCHIEDEN -------------------------
|
||
|
||
Ab da wird nicht mehr zur Neu-Linie gesprungen und nicht mehr
|
||
nachgezogen — sonst risse es einen aus dem Text, den man gerade
|
||
liest.
|
||
|
||
EINMAL BEIM AUFBAU, nicht im Sprung-Block (30.09.2026). Dort
|
||
stand sie bis dahin und wurde deshalb erst scharf, NACHDEM
|
||
einmal gesprungen worden war. Im häufigsten Fall überhaupt — ein
|
||
stiller Chat, man liest etwas weiter oben nach, jemand schreibt
|
||
— gab es sie damit nie, und man wurde ans Ende gerissen.
|
||
|
||
RAD, FINGER, TASTE, ZEIGER — und ausdrücklich NICHT `scroll`.
|
||
Der Sprung scrollt selbst; ein `scroll`-Ereignis unterscheidet
|
||
nicht, wer gescrollt hat. Diese vier tut nur ein Mensch. */
|
||
{
|
||
const wache = () => { nochNachziehen = false; zurNeuLinie = false; };
|
||
const v = $('verlauf');
|
||
if (v) {
|
||
for (const art of ['wheel', 'touchstart', 'keydown', 'pointerdown']) {
|
||
v.addEventListener(art, wache, { passive: true });
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ---------- 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 */ }
|
||
})();
|
||
})();
|