Filipe: "Es darf vor allem keine Stellen geben, an denen ein Benutzer
etwas falsch machen kann, nur weil die Seite es nicht verstaendlich
genug erklaert."
Gemessen: 30 Stellen in 16 Dateien benutzten confirm() oder prompt().
Das Haus hatte die richtige Bauweise laengst -- einen <dialog>, in
aufgaben.html sogar ausfuehrlich begruendet -- aber sie stand IN EINER
SEITE. Wer anderswo etwas loeschen liess, hatte sie nicht.
confirm('Wirklich loeschen?') stellt die falsche Frage: Es fragt, ob
man sicher ist, und nennt nicht, WAS passiert, was BLEIBT und ob es
ZURUECK geht. Jetzt beantwortet jeder der 41 Dialoge alle drei.
Neu: workspace/assets/js/nachfrage.js -- window.frageNach() mit
Pflichtgrund, Zahlenfeld, einzeiliger Eingabe und Abtippsicherung.
Drei Ausgaenge: <dialog> / confirm()-Notnagel fuer Safari vor 15.4 /
Abbruch (Esc, Klick daneben, "Doch nicht" -- immer false).
DREIMAL DERSELBE FALLSTRICK, dreimal nachgemessen statt vermutet:
.dialog stand in aufgaben.css und leistung.css -> auf dateien.html
waere der Dialog ein weisser Systemkasten gewesen. 14 Regeln
klammergenau nach module.css verschoben (Klammern gezaehlt, nicht
per Muster geschnitten -- heute frueh hat ein nicht-gieriges
Muster schon einmal CSS zerrissen).
Das Formular trug .neu neu--blank -- und .neu gibt seine Abstaende
nur in aufgaben.css. Gemessen: padding 0px, und die Felder
verloren ihre height:44px. Jetzt steht alles unter
.nachfrage__form in module.css; der Dialog borgt nichts mehr.
Die erste Fassung der Pruefung zaehlte nachfrage.js SELBST als
Nutzer -- damit war jede Seite trivialerweise "Nutzer" und die
Pruefung gruen ohne Inhalt. Jetzt ausdruecklich ausgenommen.
ZWEI FUNDE NEBENBEI:
hilfeAufraeumen() wird im Betrieb NIE aufgerufen. Der Kommentar
behauptete "wird beim Start aufgerufen (siehe index.js)" -- das
war nie wahr; einziger Aufrufer ist die eigene Pruefung. Folge:
geschlossene vertrauliche Faelle bleiben unbegrenzt stehen. NICHT
eingeschaltet (das loescht echte Daten und ist Filipes
Entscheidung), sondern der Kommentar richtiggestellt.
Einen Hilfe-Fall zu schliessen ist endgueltig -- es gibt keine
Route, die ihn wieder oeffnet. Vorher stand darueber nur die
Frage nach einem Schlusswort. Jetzt sagt der Dialog es.
pruef-struktur hat meine eigene Pruefung von heute Nachmittag
erwischt: Sie bildete ihr Datum aus UTC. Beim Beheben erst
heuteLokal(datum) genommen -- die Funktion nimmt gar kein Argument
und haette still "heute" statt "+3 Tage" geliefert. Jetzt tagLokal(3),
nachgerechnet: Abstand 3 Tage.
Am Bildschirm angesehen (Rechner 1280, Handy 390): passt rein, Esc
ergibt false, Fokus liegt auf dem harmlosen Knopf, Knoepfe 44px auf
Touch. Der Platzhalter im Abtippfeld zeigte den erwarteten Namen --
das sah aus wie ein schon ausgefuelltes Feld, entfernt.
Neu: server/pruef-nachfrage.mjs -- 17/0, mit sechs Gegenproben und
beiden Richtungen (wer fragt, laedt die Datei; wer nie fragt, laedt
sie nicht -- sonst truege die Anmeldewand 4,8 KB fuer nichts).
pruef-meldungen 8/0, pruef-css-klassen gruen, pruef-struktur gruen,
pruef-leistung gruen.
2364 lines
97 KiB
JavaScript
2364 lines
97 KiB
JavaScript
/* =====================================================================
|
||
chat.js — Gespräche im Workspace.
|
||
|
||
Wunsch Filipe, 06.09.2026: *"jeder kann dann eine nachricht
|
||
reinschreiben, die andere person bekommt eine benachrichtigung und
|
||
kann dann antworten."*
|
||
|
||
---------------------------------------------------------------------
|
||
DREI ENTSCHEIDUNGEN, die den Rest erklären
|
||
|
||
1. NEUE NACHRICHTEN KOMMEN VON SELBST (EventSource / SSE). Kein
|
||
Nachfragen im Takt: Das wäre fünf Sekunden langsam und stellte bei
|
||
zehn Angemeldeten 7200 Anfragen in der Stunde für Nachrichten, die
|
||
es meistens nicht gibt. Reisst die Verbindung ab, verbindet der
|
||
Browser sich von selbst neu -- und wir holen die Lücke über den
|
||
normalen Verlauf nach.
|
||
|
||
2. DIE EIGENE NACHRICHT STEHT SOFORT DA, bevor der Server antwortet.
|
||
Ein Chat, in dem man nach dem Abschicken eine halbe Sekunde ins
|
||
Leere sieht, fühlt sich kaputt an. Kommt der Server nicht mit,
|
||
wird sie sichtbar als "nicht angekommen" markiert -- nicht still
|
||
entfernt.
|
||
|
||
3. GELESEN WIRD GEMELDET, wenn man wirklich hinsieht. Nicht schon
|
||
beim Laden der Seite: Wer die Seite im Hintergrund offen hat, hat
|
||
nichts gelesen, und eine Zahl, die von selbst verschwindet, ist
|
||
schlimmer als keine.
|
||
===================================================================== */
|
||
(() => {
|
||
'use strict';
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
const el = (tag, klasse, text) => {
|
||
const k = document.createElement(tag);
|
||
if (klasse) k.className = klasse;
|
||
if (text !== undefined) k.textContent = text;
|
||
return k;
|
||
};
|
||
|
||
/* Aus der einen Quelle. Stand hier als eigenes Objekt und hatte
|
||
'spicy' nicht -- siehe bereiche.js. */
|
||
const ROLLENKURZ = window.Bereiche.ROLLEN_NAME;
|
||
|
||
let ich = null;
|
||
let raeume = [];
|
||
let offen = null; // der gerade angesehene Raum
|
||
let nachrichten = [];
|
||
let strom = null; // die EventSource
|
||
let gewaehlt = new Set(); // im Neu-Dialog
|
||
|
||
/* 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 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
|
||
|
||
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 der Treff 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
|
||
? `Der Treff 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();
|
||
}
|
||
|
||
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 === 'kanal') {
|
||
/* Das Rautenzeichen ist die Schreibweise, die jeder aus
|
||
anderen Messengern kennt -- es muss nicht erklaert werden. */
|
||
zeichen.dataset.kanal = 'ja';
|
||
zeichen.textContent = '#';
|
||
} else 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) {
|
||
/* 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. */
|
||
const wer = r.letzte.von === ich.name ? 'Du: '
|
||
: (r.art !== 'direkt' ? `${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;
|
||
/* 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;
|
||
/* 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;
|
||
/* 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;
|
||
/* 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);
|
||
|
||
if (darfAnheftenHier) {
|
||
const los = el('button', 'chat-pin__los', 'lösen');
|
||
los.type = 'button';
|
||
los.setAttribute('aria-label', `„${(a.text || '').slice(0, 40)}“ lösen`);
|
||
los.addEventListener('click', () => anheften(a.id, false));
|
||
kasten.append(los);
|
||
}
|
||
ziel.append(kasten);
|
||
}
|
||
}
|
||
|
||
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) {
|
||
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);
|
||
}
|
||
|
||
if (n.angeheftet) zeile.dataset.angeheftet = 'ja';
|
||
|
||
const blase = el('div', 'chat-nachricht__blase');
|
||
if (!gleicheFolge && !n.selbst && offen.art !== 'direkt') {
|
||
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);
|
||
|
||
/* 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 = el('button', 'chat-nachricht__reag-knopf', 'reagieren');
|
||
reag.type = 'button';
|
||
reag.addEventListener('click', () => auswahlZeigen(reag, n.id));
|
||
fuss.append(reag);
|
||
}
|
||
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);
|
||
}
|
||
/* 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 = el('button', 'chat-nachricht__pin-knopf',
|
||
n.angeheftet ? 'lösen' : 'anheften');
|
||
pin.type = 'button';
|
||
pin.addEventListener('click', () => anheften(n.id, !n.angeheftet));
|
||
fuss.append(pin);
|
||
}
|
||
/* 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);
|
||
|
||
/* 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));
|
||
}
|
||
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, 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();
|
||
melde(`${grund} Die Nachricht steht noch da – kopiere sie und versuch es erneut.`);
|
||
}
|
||
|
||
async function zuruecknehmen(id) {
|
||
if (!await frageNach({
|
||
titel: 'Nachricht zurücknehmen?',
|
||
was: 'Der Text ist danach weg – auch bei allen anderen.',
|
||
bleibt: 'Im Verlauf bleibt ein Hinweis stehen, dass hier etwas stand.',
|
||
endgueltig: true, ja: 'Zurücknehmen',
|
||
})) 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');
|
||
|
||
/* 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; }
|
||
const i = nachrichten.findIndex((x) => x.id === n.id);
|
||
if (i >= 0) {
|
||
nachrichten[i].zurueckgenommen = true;
|
||
nachrichten[i].text = 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 {
|
||
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. */
|
||
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');
|
||
feld.textContent = '';
|
||
for (const k of kanalFrei) {
|
||
const o = document.createElement('option');
|
||
o.value = k.wert; o.textContent = k.name;
|
||
feld.append(o);
|
||
}
|
||
$('kanal-alle').hidden = kanalFrei.length > 0;
|
||
feld.hidden = kanalFrei.length === 0;
|
||
}
|
||
|
||
/* 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) {
|
||
neuArt = art;
|
||
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;
|
||
$('neu-los').disabled = !kanalUmbau && !$('kanal-kategorie').value;
|
||
$('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;
|
||
$('neu-dialog').close();
|
||
await raeumeLaden();
|
||
await raumOeffnen(raumId, { sanft: true });
|
||
return;
|
||
}
|
||
const kategorie = $('kanal-kategorie').value;
|
||
if (!kategorie) { $('neu-fehler').textContent = 'Wähle eine Kategorie.'; return; }
|
||
const a = await hole('/workspace/api/chat/kanal', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ kategorie, mit: ids }),
|
||
});
|
||
if (!a.ok) {
|
||
$('neu-fehler').textContent = sagWas((await a.json().catch(() => ({}))).fehler, 'Ging nicht.');
|
||
return;
|
||
}
|
||
const raumId = (await a.json()).raum_id;
|
||
$('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;
|
||
}
|
||
$('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. */
|
||
const UEBER_DER_FLAECHE = ['.kopfleiste', '.fremd-band'];
|
||
function kopfMessen() {
|
||
const teile = UEBER_DER_FLAECHE.map((w) => document.querySelector(w)).filter(Boolean);
|
||
if (!teile.length) return;
|
||
const h = Math.ceil(teile.reduce((s, e) => s + e.getBoundingClientRect().height, 0));
|
||
document.documentElement.style.setProperty('--kopf-hoehe', `${h}px`);
|
||
}
|
||
kopfMessen();
|
||
window.addEventListener('resize', kopfMessen);
|
||
/* Das Band entsteht erst, wenn kopf.js weiss, wer angemeldet ist --
|
||
also nach dieser Messung. Ohne diesen Beobachter bliebe der erste
|
||
Wert stehen, und die Flaeche waere genau um das Band zu lang. */
|
||
if (window.MutationObserver) {
|
||
new MutationObserver(kopfMessen).observe(document.body, { childList: true });
|
||
}
|
||
/* 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 () => {
|
||
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;
|
||
|
||
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. */
|
||
/* 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;
|
||
$('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 { 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);
|
||
|
||
/* ?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 */ }
|
||
})();
|
||
})();
|