Files
dogfather-universe/workspace/assets/js/chat.js
T
DogFatherGitandClaude Opus 5 14e9bbf7a5 Chat: Fotos und PDFs schicken, Gespraeche wegraeumen
Filipe, mit Bildschirmfoto: "man muss die chats auch geloescht
bekommen!!! am besten waere es auch wenn man da auch im chat pdfs
schicken koennte. pdfs und fotos."

Zwei Entscheidungen vorher abgestimmt, weil sie den Bau bestimmen:
Geloescht wird NUR BEI MIR. Schicken duerfen ALLE im Gespraech, auch
Creator -- anders als in der Dateiablage, wo etwas in einem Bereich
landet, den mehrere sehen. Hier bekommt es genau der, mit dem man
ohnehin gerade spricht.

--- ANHAENGE ---

Drei Wege zur selben Sache, weil Leute unterschiedlich arbeiten: die
Bueroklammer, Hineinziehen und Einfuegen mit Strg+V (der Weg fuer einen
Screenshot -- der liegt in der Zwischenablage und nirgends als Datei).
Alle drei laufen durch dieselbe Funktion; drei Fassungen waeren drei
Gelegenheiten, dass eine die Pruefung vergisst.

Ein Foto wird GEZEIGT, ein PDF wird als Karte ANGEBOTEN. Das ist kein
Schoenheitsunterschied: Ein Bild erkennt man in einer Zehntelsekunde,
ein PDF muss man ohnehin oeffnen -- eine Vorschau davon waere ein
grauer Kasten, der so tut, als koennte man etwas lesen.

DIE SICHERHEIT STEHT IN dateiErkennen(). Dateiname und Content-Type
kommen vom Absender und sind frei erfunden; der Typ wird deshalb aus
den ersten Bytes bestimmt (PNG/GIF/WebP/JPEG/PDF, mit Breite und Hoehe
im selben Durchgang). SVG steht absichtlich NICHT in der Liste -- das
ist XML, das Skripte enthalten darf, ein als Bild getarntes Programm.
Ausgeliefert wird spaeter genau der ERKANNTE Typ, nie der eingeschickte,
dazu nosniff und "default-src 'none'; sandbox".

Auf der Platte bekommt jede Datei einen erzeugten Zufallsnamen, in
einem Ordner ausserhalb des Repos. Zuruecknehmen loescht die Datei
wirklich -- sonst waere sie im Verlauf weg und ueber die Adresse noch
da, also nur der Anschein einer Ruecknahme.

--- WEGRAEUMEN ---

Zwei Spalten in chat_teilnehmer statt einer, wegen eines Randfalls: Bei
einem Gespraech ohne jede Nachricht waere die Grenze 0 -- und 0 heisst
sonst "nie geloescht". geloescht_am unterscheidet die beiden.

Die Grenze wirkt in der Liste, im Verlauf, in der Vorschau, in der
SUCHE, bei den Anhaengen und in der Ungelesen-Zahl. Die Suche ist die
Stelle, an der so etwas typischerweise durchrutscht: aus der Liste weg,
ueber die Suche noch da.

--- DER FUND: 323 PIXEL ---

Ein Bild ist spaeter fertig als der Rest der Seite. Beim Oeffnen springt
der Verlauf ans Ende, dann kommt das Foto an und schiebt alles darunter
weg -- man steht ploetzlich mittendrin, ohne etwas angeklickt zu haben.

Das width/height-Attribut am <img>, wie man es ueberall liest, hilft
hier NICHT: Es gibt nur das Seitenverhaeltnis. Damit daraus eine Hoehe
wird, muss die Breite feststehen -- bei "width: auto" und einem nicht
geladenen Bild ist sie 0. Der Platz wird jetzt am KASTEN reserviert
(aspect-ratio + max-width aus den gemessenen Massen).

Und die Pruefung dazu hatte denselben Fehler wie das Auge: Sie mass in
einem Fenster, in dem das Bild laengst im Zwischenspeicher lag, und
meldete tadellose 0 px. Sie misst jetzt in einem FRISCHEN Browser --
der Fall, um den es geht, ist der erste.

--- SICHERUNG ---

chat-anhaenge/ ist im selben Zug in tools/sicherung-holen.sh
eingetragen, nicht spaeter, wenn es auffaellt: Genau so ist die Luecke
bei den Profilbildern entstanden. tools/wiederherstellung-proben.mjs
bestaetigt: "der Server benutzt 4 Datenordner", keiner fehlt.

--- Pruefung ---

server/pruef-chat-anhaenge.mjs, neu, 60 Pruefungen, alle gruen.
Abschnitt 3 versucht ausdruecklich, hereinzukommen: HTML als bild.png,
SVG als foto.png, SVG als svg, eine Textdatei, ein PNG-Kopf ohne Inhalt
-- alle fuenf mit 415 abgewiesen, ein echtes JPEG danach mit 201
angenommen (sonst bewiese der Block nur, dass gar nichts durchkommt).
13 MB geben 413 mit lesbarem Text, nicht 500.

Konsolenfehler werden nach ZEITPUNKT getrennt, nicht nach Statusnummer:
Was waehrend eines absichtlichen Versuchs entsteht, ist gewollt. Nach
Nummer zu filtern waere bequem und wuerde ab morgen echte 404 mit
verschlucken.

Ausserdem gruen: pruef-chat, pruef-chat-optik, pruef-chat-ausbau,
pruef-css-klassen. Angesehen bei 1440 px und bei 390 px.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-09 12:51:54 +02:00

1415 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
chat.js — Gespräche im Workspace.
Wunsch Filipe, 06.09.2026: *"jeder kann dann eine nachricht
reinschreiben, die andere person bekommt eine benachrichtigung und
kann dann antworten."*
---------------------------------------------------------------------
DREI ENTSCHEIDUNGEN, die den Rest erklären
1. NEUE NACHRICHTEN KOMMEN VON SELBST (EventSource / SSE). Kein
Nachfragen im Takt: Das wäre fünf Sekunden langsam und stellte bei
zehn Angemeldeten 7200 Anfragen in der Stunde für Nachrichten, die
es meistens nicht gibt. Reisst die Verbindung ab, verbindet der
Browser sich von selbst neu -- und wir holen die Lücke über den
normalen Verlauf nach.
2. DIE EIGENE NACHRICHT STEHT SOFORT DA, bevor der Server antwortet.
Ein Chat, in dem man nach dem Abschicken eine halbe Sekunde ins
Leere sieht, fühlt sich kaputt an. Kommt der Server nicht mit,
wird sie sichtbar als "nicht angekommen" markiert -- nicht still
entfernt.
3. GELESEN WIRD GEMELDET, wenn man wirklich hinsieht. Nicht schon
beim Laden der Seite: Wer die Seite im Hintergrund offen hat, hat
nichts gelesen, und eine Zahl, die von selbst verschwindet, ist
schlimmer als keine.
===================================================================== */
(() => {
'use strict';
const $ = (id) => document.getElementById(id);
const el = (tag, klasse, text) => {
const k = document.createElement(tag);
if (klasse) k.className = klasse;
if (text !== undefined) k.textContent = text;
return k;
};
/* Aus der einen Quelle. Stand hier als eigenes Objekt und hatte
'spicy' nicht -- siehe bereiche.js. */
const ROLLENKURZ = window.Bereiche.ROLLEN_NAME;
let ich = null;
let raeume = [];
let offen = null; // der gerade angesehene Raum
let nachrichten = [];
let strom = null; // die EventSource
let gewaehlt = new Set(); // im Neu-Dialog
/* ---------- Kleinkram ---------------------------------------------------- */
async function hole(weg, einst) {
const a = await fetch(weg, { credentials: 'same-origin', ...einst });
if (a.status === 401) { location.assign('/workspace/'); throw new Error('weg'); }
return a;
}
/** Ein bis zwei Buchstaben für den Kreis. Dieselbe Regel wie bei der
* Teilnehmerwahl im Kalender -- Anfang UND Ende, weil "Patrick von
* Hohenstein" sonst zu "PV" würde. */
function initialen(name) {
const teile = String(name || '').trim().split(/\s+/).filter(Boolean);
if (!teile.length) return '?';
const erst = [...teile[0]][0] || '';
const letzt = teile.length > 1 ? ([...teile[teile.length - 1]][0] || '') : '';
return (erst + letzt).toLocaleUpperCase('de-DE');
}
/** "14:32" heute, "Gestern", sonst "3. Sep".
*
* ORTSZEIT, nicht UTC: Die Zeitstempel kommen als ISO-Text vom
* Server. `new Date(text)` liest sie richtig, `slice()` darauf wäre
* UTC und läge nachts einen Tag daneben. */
function wann(iso, lang = false) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
const uhr = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
if (lang) return `${d.toLocaleDateString('de-DE', { day: 'numeric', month: 'long' })}, ${uhr}`;
const heute = new Date();
const gleich = (a, b) => a.getFullYear() === b.getFullYear()
&& a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
if (gleich(d, heute)) return uhr;
const gestern = new Date(heute.getTime() - 86400000);
if (gleich(d, gestern)) return 'Gestern';
return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' });
}
/* Dieselbe Zeile trägt zwei Sorten Nachricht: eine Störung (rot) und
einen Zwischenstand wie "wird geschickt …" (ruhig). Beides rot zu
zeigen hiesse, dass man Rot irgendwann nicht mehr ernst nimmt --
und Rot ist hier das Einzige, was Aufmerksamkeit verlangen darf.
Die Farbe entscheidet das CSS über data-art, nicht diese Zeile. */
const melde = (t, art = 'fehler') => {
const z = $('fehler');
z.textContent = t || '';
z.dataset.art = t ? art : 'fehler';
};
/* ---------- Die Gesprächsliste ------------------------------------------- */
async function raeumeLaden() {
try {
const a = await hole('/workspace/api/chat/raeume');
if (!a.ok) return;
raeume = (await a.json()).raeume || [];
} catch { return; }
raeumeZeichnen();
ungelesenMelden();
}
function raeumeZeichnen() {
const ziel = $('raeume');
const suchtext = ($('suche').value || '').trim().toLowerCase();
const zeigen = suchtext
? raeume.filter((r) => r.name.toLowerCase().includes(suchtext))
: raeume;
ziel.textContent = '';
$('liste-leer').hidden = raeume.length > 0;
/* Das Suchfeld erst, wenn die Liste lang genug ist, um darin zu
suchen. Vorher ist es ein Feld ohne Zweck. */
/* IMMER SICHTBAR SEIT DEM 09.09.2026.
Hier stand `raeume.length < 8` mit der Begruendung, ein Suchfeld
ueber drei Eintraegen sei Zierde. Das stimmte, solange es nur
NAMEN durchsuchte. Seit es auch die Nachrichten durchsucht, ist
es das Gegenteil: Zwei Gespraeche koennen tausend Nachrichten
haben, und genau dann braucht man es am dringendsten. */
$('suche-zeile').hidden = false;
for (const r of zeigen) {
const li = el('li', 'chat-raum');
li.dataset.id = String(r.id);
if (offen && r.id === offen.id) li.dataset.offen = 'ja';
if (r.ungelesen) li.dataset.neu = 'ja';
const knopf = el('button', 'chat-raum__knopf');
knopf.type = 'button';
const zeichen = el('span', 'chat-raum__zeichen');
zeichen.setAttribute('aria-hidden', 'true');
if (r.art === 'gruppe') {
zeichen.dataset.gruppe = 'ja';
zeichen.textContent = String(r.teilnehmer.length);
} else {
zeichen.textContent = initialen(r.name);
const gegen = r.teilnehmer.find((t) => t.id !== ich.id);
if (gegen) {
zeichen.dataset.rolle = gegen.rolle;
if (gegen.bild) {
const b = document.createElement('img');
b.src = gegen.bild; b.alt = '';
b.addEventListener('error', () => b.remove());
zeichen.append(b);
}
}
}
knopf.append(zeichen);
const mitte = el('span', 'chat-raum__mitte');
const oben = el('span', 'chat-raum__oben');
oben.append(el('span', 'chat-raum__name', r.name));
if (r.letzte) oben.append(el('span', 'chat-raum__zeit', wann(r.letzte.erstellt)));
mitte.append(oben);
const vorschau = el('span', 'chat-raum__vorschau');
if (r.letzte) {
const wer = r.letzte.von === ich.name ? 'Du: ' : (r.art === 'gruppe' ? `${r.letzte.von}: ` : '');
vorschau.textContent = r.letzte.zurueckgenommen
? `${wer}Nachricht zurückgenommen`
: wer + r.letzte.text;
if (r.letzte.zurueckgenommen) vorschau.dataset.weg = 'ja';
} else {
vorschau.textContent = 'Noch nichts geschrieben';
vorschau.dataset.weg = 'ja';
}
mitte.append(vorschau);
knopf.append(mitte);
if (r.ungelesen) {
const zahl = el('span', 'chat-raum__zahl', String(r.ungelesen));
zahl.setAttribute('aria-label', `${r.ungelesen} ungelesen`);
knopf.append(zahl);
}
knopf.addEventListener('click', () => raumOeffnen(r.id));
li.append(knopf);
ziel.append(li);
}
if (suchtext && !zeigen.length) {
const li = el('li', 'chat__leer', 'Kein Gespräch passt dazu.');
ziel.append(li);
}
}
/* Bis wohin war beim Oeffnen gelesen -- daraus entsteht die Linie
"ab hier ist es neu". Und worauf gerade geantwortet wird. */
let gelesenBeimOeffnen = 0;
let antwortAuf = null;
/* ---------- In Nachrichten suchen (09.09.2026, Punkt 15) ----------------- */
let suchLauf = 0;
async function nachrichtenSuchen() {
const frage = ($('suche').value || '').trim();
const kasten = $('funde');
const liste = $('funde-liste');
if (!kasten) return;
if (frage.length < 2) { kasten.hidden = true; liste.textContent = ''; return; }
/* JEDE ANFRAGE BEKOMMT EINE NUMMER, und nur die neueste darf
zeichnen. Beim Tippen laufen mehrere gleichzeitig; ohne das
koennte die Antwort auf "Pa" nach der auf "Patrick" ankommen und
die richtige Liste ueberschreiben. Das sieht dann aus, als
fuende die Suche das Falsche. */
const meine = ++suchLauf;
let daten;
try {
const a = await hole(`/workspace/api/chat/suche?q=${encodeURIComponent(frage)}`);
if (!a.ok) return;
daten = await a.json();
} catch { return; }
if (meine !== suchLauf) return;
liste.textContent = '';
kasten.hidden = false;
$('funde-schild').textContent = daten.treffer.length
? `In Nachrichten · ${daten.treffer.length}`
: 'In Nachrichten';
if (!daten.treffer.length) {
/* Ein leeres Ergebnis wird GESAGT. Ein Kasten, der einfach leer
bleibt, sieht aus, als laufe die Suche noch. */
liste.append(el('li', 'chat-funde__leer', 'Nichts gefunden.'));
return;
}
for (const t of daten.treffer) {
const li = el('li');
const b = el('button', 'chat-fund');
b.type = 'button';
const kopf = el('span', 'chat-fund__kopf');
kopf.append(el('span', 'chat-fund__raum', t.raum));
kopf.append(el('span', 'chat-fund__wann', wann(t.erstellt)));
b.append(kopf);
b.append(el('span', 'chat-fund__von', t.von));
b.append(el('span', 'chat-fund__text', t.text));
b.addEventListener('click', async () => {
await raumOeffnen(t.raum_id);
/* Erst oeffnen, dann springen -- vorher gibt es die Zeile
nicht. Steht sie nicht im geladenen Ausschnitt, sagt
`zurStelle` das. */
zurStelle(t.id);
});
li.append(b);
liste.append(li);
}
}
/* ---------- Ein Gespräch öffnen ------------------------------------------ */
async function raumOeffnen(id, { sanft = false } = {}) {
try {
const a = await hole(`/workspace/api/chat/raeume/${id}/nachrichten`);
if (!a.ok) { melde('Dieses Gespräch gibt es nicht mehr.'); return; }
const daten = await a.json();
offen = daten.raum;
nachrichten = daten.nachrichten;
/* DER STAND VON VORHIN, nicht der von jetzt. `gelesenMelden()`
weiter unten setzt ihn gleich auf die letzte Nachricht -- die
Linie muss also aus dem Wert gezogen werden, der beim OEFFNEN
galt. Danach wuerde sie nie irgendwo stehen. */
gelesenBeimOeffnen = daten.gelesen_bis ?? 0;
antwortAuf = null;
} catch { return; }
$('verlauf-titel').textContent = offen.name;
$('verlauf-unter').textContent = offen.art === 'gruppe'
? offen.teilnehmer.map((t) => t.name).join(' · ')
: (ROLLENKURZ[offen.teilnehmer.find((t) => t.id !== ich.id)?.rolle] || '');
$('schreiben').hidden = false;
$('verlauf-leer').hidden = true;
$('chat').dataset.ansicht = 'verlauf';
/* Erst jetzt gibt es etwas zum Wegräumen. */
$('gespraech-weg').hidden = false;
/* Ein frisch geoeffnetes Gespraech steht immer am Ende -- man will
das Letzte sehen, nicht das Erste. */
unten = true; neuUnten = 0;
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();
}
function verlaufZeichnen() {
const ziel = $('verlauf');
/* NUR DIE NACHRICHTEN WEG — NICHT DER GANZE KASTEN.
------------------------------------------------------------------
Hier stand `ziel.textContent = ''`. Das loeschte auch
`#verlauf-leer`, den Absatz "Links ein Gespraech auswaehlen", denn
der liegt IN diesem Kasten. Nach dem ersten Zeichnen gab es ihn
also nicht mehr — und `raumOeffnen` faellt sechs Zeilen weiter
genau darueber:
$('verlauf-leer').hidden = true; -> null
Folge, und die ist die eigentliche: `raumOeffnen` bricht an dieser
Stelle ab. Damit laufen `history.replaceState`, `gelesenMelden()`
und `raeumeZeichnen()` beim ZWEITEN Aufruf nie. Der zweite Aufruf
ist aber der Normalfall — der Ereignisstrom ruft nach jedem
Verbindungsaufbau genau das (`{ sanft: true }`), um nachzuholen,
was waehrend der Pause geschrieben wurde. Dieses Nachholen hat
nie funktioniert; der Absturz stand still in der Konsole.
Gefunden hat das nicht das Auge, sondern der Lauscher auf
`pageerror` in pruef-chat-ausbau.mjs. Ohne ihn waere es geblieben:
Die Seite sieht danach voellig normal aus. */
for (const alt of [...ziel.children]) if (alt.id !== 'verlauf-leer') alt.remove();
if (!nachrichten.length) {
ziel.append(el('p', 'chat__leer', 'Noch nichts geschrieben. Fang an.'));
return;
}
let letzterTag = '';
let letzterVon = null;
let linieGesetzt = false;
for (const n of nachrichten) {
/* DIE LINIE "AB HIER IST ES NEU" (09.09.2026, Punkt 15).
Sie steht vor der ersten Nachricht, die beim Oeffnen noch
nicht gelesen war -- und nur EINMAL. Wer nach zwei Tagen
zurueckkommt, sucht sonst die Stelle, an der er aufgehoert
hat, indem er Uhrzeiten liest.
Nicht bei eigenen Nachrichten: Was man selbst geschrieben hat,
ist nie "neu fuer einen". */
if (!linieGesetzt && gelesenBeimOeffnen && n.id > gelesenBeimOeffnen && !n.selbst) {
linieGesetzt = true;
const linie = el('div', 'chat-neu');
linie.append(el('span', 'chat-neu__text', 'Ab hier neu'));
ziel.append(linie);
}
/* Ein Datumstrenner, wenn ein neuer Tag beginnt. Ohne ihn steht
die Antwort von vorgestern direkt unter der von heute. */
const tag = new Date(n.erstellt).toDateString();
if (tag !== letzterTag) {
letzterTag = tag;
letzterVon = null;
const trenner = el('div', 'chat-tag');
trenner.append(el('span', 'chat-tag__text', tagName(n.erstellt)));
ziel.append(trenner);
}
const zeile = el('article', 'chat-nachricht');
zeile.dataset.id = String(n.id);
if (n.selbst) zeile.dataset.selbst = 'ja';
if (n.zurueckgenommen) zeile.dataset.weg = 'ja';
/* Mehrere Nachrichten derselben Person hintereinander rücken
zusammen und wiederholen den Namen nicht -- sonst liest man
fünfmal "Patrick" und dreimal dasselbe Gesicht. */
const gleicheFolge = letzterVon === n.von_id;
if (gleicheFolge) zeile.dataset.folge = 'ja';
letzterVon = n.von_id;
if (!gleicheFolge && !n.selbst) {
/* DAS GESICHT STATT DER INITIALEN (07.09.2026).
Der Anfangsbuchstabe bleibt als Unterlage LIEGEN und wird
nicht ersetzt: Laedt das Bild nicht, steht dort weiterhin
etwas Sinnvolles statt eines kaputten Bildsymbols. Dieselbe
Bauweise wie in der Kopfleiste und in der Personenauswahl --
drei Stellen, ein Verhalten. */
const z = el('span', 'chat-nachricht__zeichen', initialen(n.von));
z.setAttribute('aria-hidden', 'true');
z.dataset.rolle = n.rolle || '';
if (n.von_bild) {
const b = document.createElement('img');
b.src = n.von_bild; b.alt = '';
b.addEventListener('error', () => b.remove());
z.append(b);
}
zeile.append(z);
}
const blase = el('div', 'chat-nachricht__blase');
if (!gleicheFolge && !n.selbst && offen.art === 'gruppe') {
blase.append(el('p', 'chat-nachricht__von', n.von));
}
/* DAS ZITAT STEHT IN DER BLASE, NICHT DARUEBER. So wandert es beim
Rollen mit und gehoert sichtbar zur Antwort -- eine Zeile
darueber liest sich als eigene Nachricht. */
if (n.antwort) {
const z = el('button', 'chat-zitat');
z.type = 'button';
z.append(el('span', 'chat-zitat__von', n.antwort.von));
z.append(el('span', 'chat-zitat__text', n.antwort.zurueckgenommen
? 'Nachricht zurückgenommen' : n.antwort.text));
z.title = 'Zur zitierten Nachricht springen';
/* Ein Zitat, das nicht zur Stelle fuehrt, ist ein Bild von
einer Nachricht. Der Klick springt hin und hebt sie kurz
hervor. */
z.addEventListener('click', () => zurStelle(n.antwort.id));
blase.append(z);
}
if (n.zurueckgenommen) {
blase.append(el('p', 'chat-nachricht__text chat-nachricht__weg',
'Nachricht zurückgenommen'));
} else {
/* DER ANHANG STEHT ÜBER DEM TEXT. Ein Begleitsatz gehört unter
das Bild, nicht darüber -- man sieht zuerst, worum es geht. */
if (n.anhang) blase.append(anhangBauen(n.anhang));
/* textContent, nie innerHTML: Eine Nachricht darf keine
Auszeichnung einschleusen. Zeilenumbrüche macht das CSS
(white-space: pre-wrap).
NUR WENN WIRKLICH TEXT DA IST: Eine Nachricht, die nur aus
einem Foto besteht, hätte sonst einen leeren Absatz darunter
-- und der nimmt Platz, den niemand erklären kann. */
if (n.text) blase.append(mitLinks(n.text));
}
const fuss = el('div', 'chat-nachricht__fuss');
const zeit = el('time', 'chat-nachricht__zeit', wann(n.erstellt));
zeit.dateTime = n.erstellt;
zeit.title = wann(n.erstellt, true);
fuss.append(zeit);
/* Antworten geht auf JEDE Nachricht, auch die eigene -- in einer
Gruppe bezieht man sich durchaus auf sich selbst. Nur auf eine
zurueckgenommene nicht: Das Zitat waere leer. */
if (!n.zurueckgenommen && n.id > 0) {
const ant = el('button', 'chat-nachricht__antwort-knopf', 'antworten');
ant.type = 'button';
ant.addEventListener('click', () => antwortSetzen(n));
fuss.append(ant);
}
if (n.selbst && !n.zurueckgenommen) {
const weg = el('button', 'chat-nachricht__weg-knopf', 'zurücknehmen');
weg.type = 'button';
weg.addEventListener('click', () => zuruecknehmen(n.id));
fuss.append(weg);
}
/* KOPIEREN. Eine Adresse, ein Code, eine Uhrzeit -- man will sie
weitergeben, nicht abtippen. Der Knopf bestaetigt sich selbst
und faellt danach zurueck; eine Meldung anderswo waere ein
zweiter Ort, an dem man nachsehen muesste. */
if (!n.zurueckgenommen && n.text) {
const kopie = el('button', 'chat-nachricht__kopie-knopf', 'kopieren');
kopie.type = 'button';
kopie.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(n.text);
kopie.textContent = 'kopiert';
} catch {
/* Ohne Zwischenablage-Recht (aeltere Browser, kein HTTPS)
den Text wenigstens auswaehlen -- dann geht Strg+C. */
const p = kopie.closest('.chat-nachricht')?.querySelector('.chat-nachricht__text');
if (p) { const r = document.createRange(); r.selectNodeContents(p);
const sel = getSelection(); sel.removeAllRanges(); sel.addRange(r); }
kopie.textContent = 'markiert';
}
setTimeout(() => { kopie.textContent = 'kopieren'; }, 1600);
});
fuss.append(kopie);
}
blase.append(fuss);
zeile.append(blase);
ziel.append(zeile);
}
/* NUR NACH UNTEN SPRINGEN, WENN MAN SCHON UNTEN WAR.
Vorher sprang der Verlauf bei JEDEM Neuzeichnen ans Ende. Wer
weiter oben etwas nachlas und dabei eine neue Nachricht bekam,
wurde mitten im Satz weggerissen -- und das passiert im Live-
Strom staendig. Jetzt gilt: Wer unten steht, bleibt unten
(das ist der Normalfall). Wer liest, bleibt stehen und bekommt
einen Knopf, der sagt, wie viel unten wartet. */
if (unten) {
ziel.scrollTop = ziel.scrollHeight;
neuUnten = 0;
}
springKnopfZeigen();
}
/* Steht der Verlauf am Ende? 40 Pixel Nachsicht -- ein Bildlauf trifft
die letzte Zeile selten auf den Punkt, und ein Knopf, der bei einem
Pixel Abstand erscheint, ist ein Flackern. */
let unten = true;
let neuUnten = 0;
function amEnde(e) { return e.scrollHeight - e.scrollTop - e.clientHeight < 40; }
function springKnopfZeigen() {
const k = $('zum-neuesten');
if (!k) return;
k.hidden = unten || !nachrichten.length;
k.textContent = neuUnten
? (neuUnten === 1 ? '1 neue Nachricht' : `${neuUnten} neue Nachrichten`)
: 'Zum neuesten';
}
/* ---------- Anhänge: Fotos und PDF (09.09.2026) -------------------------
Filipe: "pdfs und fotos".
Ein Foto wird GEZEIGT, ein PDF wird ANGEBOTEN. Das ist kein
Schönheitsunterschied: Ein Bild erkennt man in einer Zehntelsekunde,
ein PDF muss man ohnehin öffnen -- eine Vorschau davon wäre ein
grauer Kasten, der so tut, als könnte man etwas lesen.
`width`/`height` stehen am Bild, obwohl das CSS die Größe macht.
Sie sind das Seitenverhältnis: Damit hält der Browser den Platz
frei, BEVOR das Bild da ist. Ohne sie springt der ganze Verlauf,
sobald ein Bild fertig geladen ist -- und zwar genau dann, wenn
man gerade anfängt zu lesen. */
function grosse(bytes) {
const b = Number(bytes) || 0;
if (b < 1024) return `${b} B`;
if (b < 1024 * 1024) return `${Math.round(b / 1024)} kB`;
return `${(b / 1048576).toFixed(1)} MB`;
}
function anhangBauen(a) {
if (a.art === 'bild') {
const kasten = el('a', 'chat-bild');
kasten.href = a.weg;
kasten.target = '_blank';
kasten.rel = 'noopener noreferrer';
kasten.title = `${a.name} · ${grosse(a.groesse)} – in neuem Tab öffnen`;
/* DER PLATZ WIRD AM KASTEN RESERVIERT, NICHT AM BILD.
------------------------------------------------------------------
Erster Anlauf: width- und height-Attribut ans <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`;
}
const bild = document.createElement('img');
bild.src = a.weg;
bild.alt = a.name || 'Foto';
bild.decoding = 'async';
if (a.breite > 0 && a.hoehe > 0) { bild.width = a.breite; bild.height = a.hoehe; }
/* KEIN loading="lazy". In einem Verlauf, der beim Öffnen ganz nach
unten springt, sind die sichtbaren Bilder die zuletzt
geschriebenen -- lazy verzögert genau die, die man sehen will,
und spart nichts, weil ein Chat selten hundert Fotos hat.
WENN EIN MASS FEHLT (seltene WebP-Formen), ist oben nichts
reserviert. Dann ist dieser Zuhörer die Sicherung: War man
unten, bleibt man unten. Er wird beim Bauen festgehalten, nicht
beim Laden gelesen -- bis dahin hat ein Bildlauf-Ereignis den
Zustand längst umgeworfen. */
const warUnten = unten;
bild.addEventListener('load', () => {
if (!warUnten) return;
const v = $('verlauf');
v.scrollTop = v.scrollHeight;
unten = true; neuUnten = 0;
springKnopfZeigen();
});
/* Ein Bild, das nicht lädt, hinterlässt sonst ein kaputtes
Symbol ohne Erklärung. */
bild.addEventListener('error', () => {
kasten.dataset.kaputt = 'ja';
kasten.textContent = 'Foto lässt sich gerade nicht laden';
});
kasten.append(bild);
return kasten;
}
const karte = el('a', 'chat-datei');
karte.href = a.weg;
karte.target = '_blank';
karte.rel = 'noopener noreferrer';
const zeichen = el('span', 'chat-datei__zeichen', 'PDF');
zeichen.setAttribute('aria-hidden', 'true');
const mitte = el('span', 'chat-datei__mitte');
mitte.append(el('span', 'chat-datei__name', a.name || 'Dokument'));
mitte.append(el('span', 'chat-datei__mass', `${grosse(a.groesse)} · öffnen`));
karte.append(zeichen, mitte);
karte.title = `${a.name} · ${grosse(a.groesse)}`;
return karte;
}
/* ---------- Links in Nachrichten (09.09.2026) ---------------------------
Eine Adresse im Text soll anklickbar sein. Gebaut wird sie als
ECHTER Knoten, nie ueber innerHTML: Sonst koennte ein Absender
Auszeichnung einschleusen, und in einem Chat schreibt jeder.
Erkannt wird bewusst wenig -- http, https und www. Wer mehr
erkennt (Mailadressen, nackte Domains), erwischt irgendwann
"3.stelle" oder "z.b." und macht daraus einen Link ins Nichts.
`rel="noopener noreferrer"` ist Pflicht: Ohne `noopener` kann die
geoeffnete Seite auf das Fenster zurueckgreifen, aus dem sie kam. */
const LINKMUSTER = /(https?:\/\/[^\s<>"']+|www\.[^\s<>"']+)/g;
function mitLinks(text) {
const p = el('p', 'chat-nachricht__text');
let letzte = 0;
for (const treffer of String(text).matchAll(LINKMUSTER)) {
const roh = treffer[0];
/* Satzzeichen am Ende gehoeren zum Satz, nicht zur Adresse. */
const sauber = roh.replace(/[.,;:!?)\]]+$/, '');
if (treffer.index > letzte) p.append(document.createTextNode(text.slice(letzte, treffer.index)));
const a = el('a', 'chat-nachricht__link', sauber);
a.href = sauber.startsWith('www.') ? 'https://' + sauber : sauber;
a.target = '_blank';
a.rel = 'noopener noreferrer';
p.append(a);
if (sauber.length < roh.length) p.append(document.createTextNode(roh.slice(sauber.length)));
letzte = treffer.index + roh.length;
}
if (letzte < text.length) p.append(document.createTextNode(text.slice(letzte)));
return p;
}
/* ---------- Antworten und Springen (09.09.2026, Punkt 15) ---------------- */
function antwortSetzen(n) {
antwortAuf = { id: n.id, von: n.von, text: n.text };
const leiste = $('antwort-leiste');
if (!leiste) return;
leiste.hidden = false;
$('antwort-von').textContent = n.von;
$('antwort-text').textContent = n.text;
$('text').focus();
}
function antwortLoeschen() {
antwortAuf = null;
const leiste = $('antwort-leiste');
if (leiste) leiste.hidden = true;
}
/** Zur zitierten Nachricht springen und sie kurz hervorheben.
*
* Steht sie nicht (mehr) im geladenen Ausschnitt, wird das GESAGT --
* ein Klick, der nichts tut, laesst einen glauben, der Knopf sei
* kaputt. */
function zurStelle(id) {
const el2 = document.querySelector(`.chat-nachricht[data-id="${id}"]`);
if (!el2) { melde('Diese Nachricht liegt weiter oben – bitte nach oben blättern.'); return; }
el2.scrollIntoView({ block: 'center', behavior: 'smooth' });
el2.dataset.hervor = 'ja';
setTimeout(() => { delete el2.dataset.hervor; }, 2200);
}
function tagName(iso) {
const d = new Date(iso);
const heute = new Date();
const gleich = (a, b) => a.toDateString() === b.toDateString();
if (gleich(d, heute)) return 'Heute';
if (gleich(d, new Date(heute.getTime() - 86400000))) return 'Gestern';
return d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' });
}
/* ---------- Schreiben ---------------------------------------------------- */
async function abschicken(e) {
e.preventDefault();
const feld = $('text');
const text = feld.value.trim();
if (!text || !offen) return;
/* SOFORT ANZEIGEN. Die vorläufige Nachricht bekommt eine negative
Nummer -- damit lässt sie sich später zweifelsfrei durch die
echte ersetzen, ohne mit einer Server-Nummer zu kollidieren. */
/* Das Antwortziel wird HIER festgehalten, nicht erst im
`await`. Wer waehrend des Sendens auf eine andere Nachricht
antwortet, wuerde sonst das falsche Zitat mitschicken -- die
Anfrage laeuft, die Variable hat sich schon geaendert. */
const zielAntwort = antwortAuf;
const vorlaeufig = {
id: -Date.now(), von_id: ich.id, von: ich.name, rolle: ich.rolle,
text, erstellt: new Date().toISOString(), selbst: true, unterwegs: true,
antwort: zielAntwort
? { id: zielAntwort.id, von: zielAntwort.von, text: zielAntwort.text }
: null,
};
nachrichten.push(vorlaeufig);
/* Wer selbst schreibt, will das Ergebnis sehen -- auch wenn er
vorher weiter oben gelesen hat. */
unten = true; neuUnten = 0;
verlaufZeichnen();
feld.value = '';
entwurfWeg(offen.id);
zaehlerZeigen();
antwortLoeschen();
hoeheAnpassen();
melde('');
try {
const a = await hole(`/workspace/api/chat/raeume/${offen.id}/nachrichten`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, antwort_auf: zielAntwort?.id ?? null }),
});
if (!a.ok) {
const d = await a.json().catch(() => ({}));
markiereAlsGescheitert(vorlaeufig.id, d.fehler || 'Nicht angekommen.');
return;
}
const { nachricht } = await a.json();
const i = nachrichten.findIndex((n) => n.id === vorlaeufig.id);
if (i >= 0) nachrichten[i] = { ...nachricht, selbst: true };
verlaufZeichnen();
raeumeLaden();
} catch {
markiereAlsGescheitert(vorlaeufig.id, 'Keine Verbindung.');
}
}
/** NICHT STILL VERSCHWINDEN LASSEN. Wer etwas schreibt und es sieht,
* glaubt, es sei angekommen. Verschwindet es wortlos, merkt er es
* im schlimmsten Fall Tage später. */
function markiereAlsGescheitert(id, grund) {
const i = nachrichten.findIndex((n) => n.id === id);
if (i >= 0) { nachrichten[i].gescheitert = true; delete nachrichten[i].unterwegs; }
verlaufZeichnen();
melde(`${grund} Die Nachricht steht noch da – kopiere sie und versuch es erneut.`);
}
async function zuruecknehmen(id) {
if (!window.confirm('Diese Nachricht zurücknehmen? Der Text ist dann weg, '
+ 'im Verlauf bleibt ein Hinweis stehen.')) return;
try {
const a = await hole(`/workspace/api/chat/nachrichten/${id}`, { method: 'DELETE' });
if (!a.ok) { melde('Ging nicht.'); return; }
const i = nachrichten.findIndex((n) => n.id === id);
if (i >= 0) { nachrichten[i].zurueckgenommen = true; nachrichten[i].text = null; }
verlaufZeichnen();
raeumeLaden();
} catch { /* umgeleitet */ }
}
/** Das Feld wächst mit dem Text -- bis zu einer Grenze, sonst
* verdrängt eine lange Nachricht den ganzen Verlauf. */
function hoeheAnpassen() {
const f = $('text');
f.style.height = 'auto';
f.style.height = Math.min(f.scrollHeight, 160) + 'px';
}
/* ---------- Gelesen ------------------------------------------------------ */
function gelesenMelden() {
if (!offen || !nachrichten.length) return;
/* NUR WENN MAN WIRKLICH HINSIEHT. Eine Seite im Hintergrund hat
niemand gelesen. */
if (document.hidden) return;
const echte = nachrichten.filter((n) => n.id > 0);
if (!echte.length) return;
const bis = echte[echte.length - 1].id;
fetch(`/workspace/api/chat/raeume/${offen.id}/gelesen`, {
method: 'PUT', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bis }),
}).then(() => {
const r = raeume.find((x) => x.id === offen.id);
if (r) { r.ungelesen = 0; raeumeZeichnen(); }
ungelesenMelden();
}).catch(() => {});
}
/** Sagt der Kopfleiste, wie viele insgesamt warten. */
async function ungelesenMelden() {
try {
const a = await fetch('/workspace/api/chat/ungelesen', { credentials: 'same-origin' });
if (!a.ok) return;
const { ungelesen } = await a.json();
window.chatZahlZeigen?.(ungelesen);
} catch { /* die Zahl ist Zugabe */ }
}
/* ---------- Neue Nachrichten kommen von selbst --------------------------- */
function stromOeffnen() {
if (strom) strom.close();
strom = new EventSource('/workspace/api/chat/strom');
strom.addEventListener('neu', (e) => {
let n;
try { n = JSON.parse(e.data); } catch { return; }
if (offen && n.raum_id === offen.id) {
/* SCHON DA? Zwei Wege führen hierher, und beide sind richtig:
die Antwort auf das eigene POST und der Strom. */
if (nachrichten.some((x) => x.id === n.id)) return;
/* DER WETTLAUF (gefunden 06.09.2026, im Zwei-Browser-Test):
Beim Abschicken steht die eigene Nachricht sofort da -- mit
einer vorläufigen negativen Nummer. Ist der Strom SCHNELLER
als die Antwort auf das POST, greift die Prüfung darüber
nicht (die Nummern sind verschieden), und die Nachricht
stand zweimal im Verlauf.
Deshalb wird eine eigene Nachricht, die noch unterwegs ist
und denselben Text trägt, hier ERSETZT statt ergänzt. Damit
gewinnt, wer zuerst kommt, und das Ergebnis ist dasselbe. */
if (n.von_id === ich.id) {
const i = nachrichten.findIndex(
(x) => x.unterwegs && x.text === n.text);
if (i >= 0) {
nachrichten[i] = { ...n, selbst: true };
verlaufZeichnen();
return;
}
}
nachrichten.push({ ...n, selbst: n.von_id === ich.id });
/* Wer gerade weiter oben liest, soll sehen, WIE VIEL unten
dazugekommen ist -- nicht nur, DASS etwas da ist. */
if (!unten && n.von_id !== ich.id) neuUnten++;
verlaufZeichnen();
gelesenMelden();
}
raeumeLaden();
});
strom.addEventListener('weg', (e) => {
let n;
try { n = JSON.parse(e.data); } catch { return; }
const i = nachrichten.findIndex((x) => x.id === n.id);
if (i >= 0) {
nachrichten[i].zurueckgenommen = true;
nachrichten[i].text = null;
verlaufZeichnen();
}
raeumeLaden();
});
/* Bei einem Abriss verbindet EventSource von selbst neu. Wir holen
danach den Verlauf nach -- sonst fehlte, was während der Pause
geschrieben wurde. */
strom.addEventListener('open', () => {
if (offen) raumOeffnen(offen.id, { sanft: true });
else raeumeLaden();
});
}
/* ---------- Neues Gespräch ----------------------------------------------- */
async function partnerLaden() {
const ziel = $('partner');
ziel.textContent = '';
gewaehlt = new Set();
$('gruppenname-zeile').hidden = true;
$('neu-los').disabled = true;
$('neu-fehler').textContent = '';
let personen = [];
try {
const a = await hole('/workspace/api/chat/partner');
if (a.ok) personen = (await a.json()).personen || [];
} catch { return; }
if (!personen.length) {
ziel.append(el('p', 'chat__leer',
'Dir ist noch niemand zugeordnet – melde dich bei DogFather.'));
return;
}
/* Nach Rollen gruppiert, wie bei der Teilnehmerwahl im Kalender.
Die Rolle steht einmal über der Gruppe statt an jedem Namen. */
/* Die Rollenfolge kommt aus bereiche.js. Stand sie hier selbst,
fehlte 'spicy' -- und wer nicht in der Schleife vorkommt, taucht
in der Gesprächsliste gar nicht auf. Kein Fehler, nur eine
Person, die es fuer die Anderen nicht gibt. */
for (const rolle of window.Bereiche.ROLLENFOLGE) {
const leute = personen.filter((p) => p.rolle === rolle);
if (!leute.length) continue;
const gruppe = el('div', 'chat-wahl__gruppe');
gruppe.dataset.rolle = rolle;
const titel = el('p', 'chat-wahl__titel');
const punkt = el('span', 'chat-wahl__punkt');
punkt.setAttribute('aria-hidden', 'true');
titel.append(punkt, document.createTextNode(
window.Bereiche.ROLLEN_GRUPPE[rolle]));
gruppe.append(titel);
const liste = el('div', 'chat-wahl__liste');
liste.setAttribute('role', 'group');
for (const p of leute) {
const b = el('button', 'chat-wahl__knopf');
b.type = 'button';
b.dataset.id = String(p.id);
b.dataset.rolle = p.rolle;
b.setAttribute('aria-pressed', 'false');
const z = el('span', 'chat-wahl__zeichen', initialen(p.name));
z.setAttribute('aria-hidden', 'true');
b.append(z, el('span', 'chat-wahl__name', p.name));
b.addEventListener('click', () => {
if (gewaehlt.has(p.id)) { gewaehlt.delete(p.id); delete b.dataset.an; }
else { gewaehlt.add(p.id); b.dataset.an = 'ja'; }
b.setAttribute('aria-pressed', gewaehlt.has(p.id) ? 'true' : 'false');
wahlGeaendert();
});
liste.append(b);
}
gruppe.append(liste);
ziel.append(gruppe);
}
}
function wahlGeaendert() {
const n = gewaehlt.size;
/* Ab zwei ist es eine Gruppe -- und die braucht einen Namen. */
$('gruppenname-zeile').hidden = n < 2;
$('neu-los').disabled = n === 0;
$('neu-los').textContent = n >= 2 ? 'Gruppe anlegen' : 'Schreiben';
}
async function neuAbschicken(e) {
e.preventDefault();
const ids = [...gewaehlt];
if (!ids.length) return;
const knopf = $('neu-los');
knopf.disabled = true;
try {
let raumId;
if (ids.length === 1) {
const a = await hole('/workspace/api/chat/direkt', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mit: ids[0] }),
});
if (!a.ok) {
$('neu-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.';
return;
}
raumId = (await a.json()).raum_id;
} else {
const name = $('gruppenname').value.trim();
if (name.length < 2) {
$('neu-fehler').textContent = 'Die Gruppe braucht einen Namen.';
$('gruppenname').focus();
return;
}
const a = await hole('/workspace/api/chat/gruppe', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, mit: ids }),
});
if (!a.ok) {
$('neu-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.';
return;
}
raumId = (await a.json()).raum_id;
}
$('neu-dialog').close();
await raeumeLaden();
await raumOeffnen(raumId);
} catch { /* umgeleitet */ } finally { knopf.disabled = false; }
}
/* ---------- Die Höhe der Kopfleiste MESSEN, nicht raten ------------------
Die Chatfläche ist so hoch wie das Fenster minus die Kopfleiste.
Deren Höhe stand zuerst als fester Wert im CSS (62 px) -- am Handy
ist sie zweizeilig und gut doppelt so hoch. Folge: Die Seite war zu
lang, und das Eingabefeld stand halb unter dem Bildschirmrand.
Ein fester Wert ist hier immer falsch: Die Leiste wächst mit der
Schriftgröße, mit der Glocke, mit einem längeren Namen. Also wird
sie gemessen -- einmal beim Start und wieder, wenn sich etwas an
der Fenstergröße ändert. */
function kopfMessen() {
const kopf = document.querySelector('.kopfleiste');
if (!kopf) return;
const h = Math.ceil(kopf.getBoundingClientRect().height);
document.documentElement.style.setProperty('--kopf-hoehe', `${h}px`);
}
kopfMessen();
window.addEventListener('resize', kopfMessen);
/* Auch wenn die Leiste selbst sich ändert (Glocke erscheint, Name
wird eingesetzt) -- ohne das bliebe der erste Messwert stehen. */
if (window.ResizeObserver) {
const kopf = document.querySelector('.kopfleiste');
if (kopf) new ResizeObserver(kopfMessen).observe(kopf);
}
/* ---------- Start -------------------------------------------------------- */
/* Der Verwerfen-Knopf der Antwortleiste. Zusaetzlich beendet Escape
die Antwort -- wer im Eingabefeld steht, greift eher zur Taste als
zur Maus. */
$('antwort-weg')?.addEventListener('click', antwortLoeschen);
$('text')?.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && antwortAuf) { e.preventDefault(); antwortLoeschen(); }
});
$('schreiben').addEventListener('submit', abschicken);
$('text').addEventListener('input', () => {
hoeheAnpassen();
entwurfMerken();
zaehlerZeigen();
});
/* ---------- Entwurf je Gespraech (09.09.2026) ----------------------------
Wer mitten im Satz das Gespraech wechselt, findet den Satz beim
Zurueckkommen wieder. Ohne das ist jeder Wechsel eine Entscheidung:
"wegwerfen oder erst fertig schreiben?"
Im localStorage und nicht auf dem Server: Ein Entwurf ist nichts,
was jemand anders sehen soll, und er gehoert an dieses Geraet.
Abgeschickte oder geleerte Entwuerfe werden geloescht -- eine
Ablage, die nur waechst, ist ein Leck. */
const ENTWURF = (id) => `dfw-chat-entwurf-${id}`;
function entwurfMerken() {
if (!offen) return;
const t = $('text').value;
try {
if (t.trim()) localStorage.setItem(ENTWURF(offen.id), t);
else localStorage.removeItem(ENTWURF(offen.id));
} catch { /* privater Modus: dann eben nicht */ }
}
function entwurfHolen(raumId) {
try { return localStorage.getItem(ENTWURF(raumId)) || ''; } catch { return ''; }
}
function entwurfWeg(raumId) {
try { localStorage.removeItem(ENTWURF(raumId)); } catch { /* egal */ }
}
/* ---------- Zeichenzaehler ------------------------------------------------
Er erscheint erst, wenn es eng wird. Ein Zaehler, der von Anfang an
mitlaeuft, zaehlt die meiste Zeit etwas, das niemanden interessiert --
und wird dann auch dann uebersehen, wenn er zaehlt. */
/* Die Zahl steht NICHT hier. Sie steht im maxlength des Feldes, und
dort steht sie, weil der Server sie so haben will (TEXT_MAX = 4000
in workspace-chat.js). Zwei Stellen fuer dieselbe Zahl laufen
auseinander -- dann zaehlt der Zaehler bis 4000 herunter, waehrend
das Feld schon bei 2000 dichtmacht, und niemand versteht warum. */
const GRENZE = Number($('text').getAttribute('maxlength')) || 4000;
function zaehlerZeigen() {
const z = $('zeichen');
if (!z) return;
const n = $('text').value.length;
const rest = GRENZE - n;
z.hidden = rest > 400;
z.textContent = `noch ${rest}`;
z.dataset.eng = rest <= 100 ? 'ja' : 'nein';
}
/* ---------- Emoji-Auswahl -------------------------------------------------
Eine kleine, feste Auswahl statt einer vollstaendigen Tafel: Diese
dreissig decken den Alltag ab, und sie laden nichts nach. Eine
Emoji-Bibliothek waere ein Megabyte fuer etwas, das die
Bildschirmtastatur ohnehin kann -- am Rechner aber nicht.
Eingefuegt wird an der CURSORSTELLE, nicht am Ende: Wer mitten im
Satz eines setzen will, soll nicht erst umsortieren. */
const EMOJIS = ['😀','😂','🥹','😍','😎','🤔','😅','🙃','😴','🤝',
'👍','👏','🙌','💪','🙏','🔥','✨','⭐','💯','🎉',
'❤️','💙','💚','🖤','👀','🎮','🎧','📈','⏰','✅'];
function emojiTafelBauen() {
const tafel = $('emoji-tafel');
if (!tafel || tafel.childElementCount) return;
for (const e of EMOJIS) {
const b = el('button', 'emoji-tafel__knopf', e);
b.type = 'button';
b.setAttribute('aria-label', `Emoji ${e} einfügen`);
b.addEventListener('click', () => {
const f = $('text');
const a = f.selectionStart ?? f.value.length;
const b2 = f.selectionEnd ?? a;
f.value = f.value.slice(0, a) + e + f.value.slice(b2);
f.focus();
f.selectionStart = f.selectionEnd = a + e.length;
hoeheAnpassen(); entwurfMerken(); zaehlerZeigen();
});
tafel.append(b);
}
}
$('emoji-auf')?.addEventListener('click', () => {
const tafel = $('emoji-tafel');
emojiTafelBauen();
const auf = tafel.hidden;
tafel.hidden = !auf;
$('emoji-auf').setAttribute('aria-expanded', String(auf));
if (auf) tafel.querySelector('button')?.focus();
});
/* Ein Klick daneben schliesst die Tafel -- sonst steht sie im Weg,
und man sucht den Knopf, mit dem man sie aufgemacht hat. */
document.addEventListener('click', (e) => {
const tafel = $('emoji-tafel');
if (!tafel || tafel.hidden) return;
if (tafel.contains(e.target) || e.target.closest('#emoji-auf')) return;
tafel.hidden = true;
$('emoji-auf')?.setAttribute('aria-expanded', 'false');
});
/* Escape schliesst sie -- und zwar von ueberall her, nicht nur wenn
die Schreibmarke zufaellig noch in der Tafel steht.
Erster Anlauf hing der Zuhoerer an der Tafel selbst. Das geht gut,
solange man sie gerade aufgemacht hat (dann liegt der Fokus auf dem
ersten Zeichen). Wer danach ins Schreibfeld zurueckklickt, drueckt
Escape aber ins Leere -- und die Tafel steht weiter im Weg. Am
Dokument gilt es immer; ist die Tafel zu, tut die Zeile nichts. */
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
const tafel = $('emoji-tafel');
if (!tafel || tafel.hidden) return;
tafel.hidden = true;
$('emoji-auf')?.setAttribute('aria-expanded', 'false');
$('text').focus();
});
/* ---------- Zum neuesten springen ---------------------------------------- */
$('verlauf')?.addEventListener('scroll', () => {
const v = $('verlauf');
const jetztUnten = amEnde(v);
if (jetztUnten !== unten) {
unten = jetztUnten;
if (unten) neuUnten = 0;
springKnopfZeigen();
}
});
$('zum-neuesten')?.addEventListener('click', () => {
const v = $('verlauf');
v.scrollTop = v.scrollHeight;
unten = true; neuUnten = 0;
springKnopfZeigen();
});
/* Enter schickt, Umschalt+Enter macht eine neue Zeile. Am Handy
NICHT -- dort ist Enter die Zeilenschaltung der Bildschirmtastatur,
und wer eine zweite Zeile will, käme sonst nie dazu. */
$('text').addEventListener('keydown', (e) => {
const handy = window.matchMedia('(hover: none)').matches;
if (e.key === 'Enter' && !e.shiftKey && !handy) {
e.preventDefault();
$('schreiben').requestSubmit();
}
});
$('suche').addEventListener('input', () => { raeumeZeichnen(); nachrichtenSuchen(); });
$('neu-oeffnen').addEventListener('click', async () => {
await partnerLaden();
$('neu-dialog').showModal();
});
$('neu-zu').addEventListener('click', () => $('neu-dialog').close());
$('neu-form').addEventListener('submit', neuAbschicken);
$('zur-liste').addEventListener('click', () => {
$('chat').dataset.ansicht = 'liste';
history.replaceState(null, '', 'chat.html');
});
/* =======================================================================
ANHÄNGE SCHICKEN (09.09.2026)
Filipe: "am besten wäre es auch wenn man da auch im chat pdfs
schicken könnte. pdfs und fotos."
DREI WEGE ZU DERSELBEN SACHE, weil Leute unterschiedlich arbeiten:
die Büroklammer (immer da, auch am Handy), Hineinziehen (am
Rechner der schnellste Weg) und Einfügen mit Strg+V (der Weg für
einen Screenshot -- der liegt in der Zwischenablage und nirgendwo
als Datei).
Alle drei landen in derselben Funktion. Drei Fassungen desselben
Ablaufs wären drei Gelegenheiten, dass eine davon die Prüfung
vergisst.
GEPRÜFT WIRD TROTZDEM AUCH HIER, obwohl der Server es noch einmal
tut: Eine Absage nach zwölf Megabyte Wartezeit ist keine Absage,
das ist eine Strafe. Die Prüfung im Browser ist die Höflichkeit,
die auf dem Server ist die Sicherheit -- keine ersetzt die andere. */
const ANHANG_MAX = 12 * 1024 * 1024;
const ANHANG_TYPEN = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'application/pdf']);
let schicktGerade = false;
async function anhangSchicken(datei) {
if (!offen || !datei) return;
if (schicktGerade) { melde('Einen Moment – der letzte Anhang ist noch unterwegs.'); return; }
if (!ANHANG_TYPEN.has(datei.type)) {
melde('Das geht nicht – erlaubt sind Fotos (PNG, JPEG, WebP, GIF) und PDF.');
return;
}
if (datei.size > ANHANG_MAX) {
melde(`Zu groß – erlaubt sind ${Math.round(ANHANG_MAX / 1048576)} MB je Datei.`);
return;
}
schicktGerade = true;
$('anhang-auf').disabled = true;
melde(`„${datei.name}“ wird geschickt …`, 'lauf');
/* Ein angefangener Begleitsatz geht mit -- wer erst tippt und dann
das Bild anhängt, will beides in einer Nachricht. */
const begleittext = $('text').value.trim();
try {
const a = await hole(`/workspace/api/chat/raeume/${offen.id}/anhang`, {
method: 'POST',
headers: {
'Content-Type': datei.type,
/* Kopfzeilen sind streng genommen nur ASCII -- ohne die
Kodierung käme aus "Größe.pdf" unterwegs Unsinn. */
'X-Dateiname': encodeURIComponent(datei.name || 'anhang'),
'X-Text': encodeURIComponent(begleittext),
},
body: datei,
});
if (!a.ok) {
let grund = 'Ging nicht.';
try { grund = (await a.json()).fehler || grund; } catch { /* nichts Lesbares */ }
melde(grund);
return;
}
const { nachricht } = await a.json();
/* Nur eintragen, wenn sie nicht schon über den Strom kam --
sonst stünde dasselbe Foto zweimal da. */
if (!nachrichten.some((x) => x.id === nachricht.id)) {
nachrichten.push({ ...nachricht, selbst: true });
}
$('text').value = '';
entwurfWeg(offen.id);
hoeheAnpassen();
zaehlerZeigen();
unten = true; neuUnten = 0;
verlaufZeichnen();
melde('');
raeumeLaden();
} catch {
melde('Keine Verbindung – der Anhang wurde nicht geschickt.');
} finally {
schicktGerade = false;
$('anhang-auf').disabled = false;
}
}
$('anhang-auf').addEventListener('click', () => $('anhang-feld').click());
$('anhang-feld').addEventListener('change', () => {
const datei = $('anhang-feld').files?.[0];
/* Zurücksetzen, BEVOR geschickt wird: Sonst löst dieselbe Datei
beim zweiten Mal kein `change` aus, und man wundert sich, warum
nichts passiert. */
$('anhang-feld').value = '';
if (datei) void anhangSchicken(datei);
});
/* ---- Hineinziehen ------------------------------------------------------
`dragover` MUSS abgefangen werden, sonst öffnet der Browser die
Datei einfach als Seite -- der Verlauf wäre weg und das Bild
bildschirmfüllend da. */
{
const flaeche = document.querySelector('.chat__spalte--verlauf');
let tiefe = 0; /* dragenter/-leave feuert je Kind */
const zeigen = (ja) => {
const k = $('ablage');
if (k) k.hidden = !ja || !offen;
};
flaeche.addEventListener('dragenter', (e) => {
if (!e.dataTransfer?.types?.includes('Files')) return;
tiefe++; zeigen(true);
});
flaeche.addEventListener('dragover', (e) => {
if (!e.dataTransfer?.types?.includes('Files')) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
});
flaeche.addEventListener('dragleave', () => { if (--tiefe <= 0) { tiefe = 0; zeigen(false); } });
flaeche.addEventListener('drop', (e) => {
if (!e.dataTransfer?.files?.length) return;
e.preventDefault();
tiefe = 0; zeigen(false);
if (!offen) { melde('Zuerst ein Gespräch öffnen.'); return; }
void anhangSchicken(e.dataTransfer.files[0]);
});
/* Fällt etwas neben dem Fenster, darf der Browser es trotzdem nicht
öffnen. */
window.addEventListener('dragover', (e) => e.preventDefault());
window.addEventListener('drop', (e) => e.preventDefault());
}
/* ---- Einfügen (Strg+V) --------------------------------------------------
Der Weg für Screenshots. Ein kopiertes Bild hat keinen Namen --
deshalb bekommt es hier einen mit Datum, sonst hiesse jede
Bildschirmaufnahme "image.png". */
$('text').addEventListener('paste', (e) => {
const stuecke = [...(e.clipboardData?.items || [])];
const bild = stuecke.find((s) => s.kind === 'file' && ANHANG_TYPEN.has(s.type));
if (!bild) return; /* normaler Text: nichts tun */
const datei = bild.getAsFile();
if (!datei) return;
e.preventDefault();
const endung = (datei.type.split('/')[1] || 'png').replace('jpeg', 'jpg');
const stempel = new Date().toISOString().slice(0, 16).replace(/[-:]/g, '').replace('T', '-');
void anhangSchicken(new File([datei], datei.name && datei.name !== 'image.png'
? datei.name : `bildschirmfoto-${stempel}.${endung}`, { type: datei.type }));
});
/* =======================================================================
GESPRÄCH WEGRÄUMEN — NUR BEI MIR
Die Rückfrage sagt ausdrücklich, was NICHT passiert. "Wirklich
löschen?" liesse offen, ob der andere seinen Verlauf verliert --
und das ist genau die Frage, die man in dem Moment hat. */
$('gespraech-weg').addEventListener('click', async () => {
if (!offen) return;
const wer = offen.name;
if (!window.confirm(
`Gespräch mit ${wer} wegräumen?\n\n`
+ 'Es verschwindet nur bei dir. Der Verlauf beim Gegenüber bleibt '
+ 'vollständig.\n\nSchreibt euch jemand wieder, kommt das Gespräch '
+ 'mit dem Neuen zurück – das Alte bleibt weg.')) return;
try {
const a = await hole(`/workspace/api/chat/raeume/${offen.id}`, { method: 'DELETE' });
if (!a.ok) { melde('Ging nicht.'); return; }
entwurfWeg(offen.id);
offen = null;
nachrichten = [];
$('schreiben').hidden = true;
$('gespraech-weg').hidden = true;
$('verlauf-titel').textContent = 'Wähle ein Gespräch';
$('verlauf-unter').textContent = '';
$('verlauf-leer').hidden = false;
$('chat').dataset.ansicht = 'liste';
verlaufZeichnen();
history.replaceState(null, '', 'chat.html');
await raeumeLaden();
melde('');
} catch { melde('Keine Verbindung.'); }
});
/* Zurück an den Bildschirm: nachsehen, was inzwischen kam, und melden,
dass jetzt wirklich jemand hinsieht. */
document.addEventListener('visibilitychange', () => {
if (document.hidden) return;
gelesenMelden();
raeumeLaden();
});
(async () => {
try {
const a = await hole('/workspace/api/ich');
if (!a.ok) { location.assign('/workspace/'); return; }
ich = await a.json();
} catch { return; }
window.werZeigen?.(ich);
await raeumeLaden();
stromOeffnen();
/* Kommt man aus einer Benachrichtigung, steht das Gespräch in der
Adresse -- dann direkt hinein. */
const suche = new URLSearchParams(location.search);
const wunsch = Number(suche.get('raum'));
if (Number.isInteger(wunsch) && wunsch > 0) { await raumOeffnen(wunsch); return; }
/* ?mit=<Person> -- der Weg von woanders her (06.09.2026).
Die Frühwarnung im Dashboard schlägt vor nachzufassen; ohne
diesen Einstieg müsste man danach die Gesprächsliste durchsuchen
oder ein Gespräch von Hand anlegen, das vielleicht schon
existiert. `/direkt` legt beim ersten Mal an und findet beim
zweiten dasselbe wieder -- ein Aufruf für beide Fälle.
Die Berechtigung prüft der Server (darfSchreibenMit). Eine von
Hand getippte Personennummer bringt hier nichts. */
const mit = Number(suche.get('mit'));
if (!Number.isInteger(mit) || mit < 1) return;
try {
const a = await hole('/workspace/api/chat/direkt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mit }),
});
if (!a.ok) return; /* die Liste steht ja */
const { raum_id: raumId } = await a.json();
await raeumeLaden();
await raumOeffnen(raumId);
} catch { /* die Liste steht ja */ }
})();
})();