Filipe: "dan will ich auch dass nur die modis, rechte hand, linke hand
und dogfather, alle auch auf einmal markieren koennen im chat mit
einem, @rudel ,dann sollen alle eine benarichtigung bekommen."
DAS WAR EINE OFFENE FRAGE, UND ER HAT SIE BEANTWORTET
-----------------------------------------------------
In chat-erwaehnung.js stand seit dem 20.09. woertlich: "KEIN @alle. Es
waere in fuenf Minuten gebaut und ist der zuverlaessigste Weg, dass alle
die Benachrichtigungen abschalten -- und dann kommt auch die an, die
wirklich fuer einen bestimmten Menschen gedacht war. Wenn Filipe es
ausdruecklich will, gehoert dazu eine Entscheidung, WER es benutzen
darf; das ist eine Frage an ihn, keine, die ich hier beantworte."
Seine Antwort ist genau diese Entscheidung, und sie ist die Sicherung:
Team Dogi und DogFather duerfen rufen, sonst niemand.
WEN DER RUF ERREICHT: DAS TEAM IM RAUM, NICHT DEN RAUM
------------------------------------------------------
"Rudel" heisst das Team -- dieselben vier Gruppen, die auch rufen
duerfen. In einem Team-Kanal ist das jeder Anwesende, also "alle auf
einmal". Sichtbar wird der Unterschied im TREFF, und dort haette die
andere Lesart wehgetan: Dort sitzt die Community. Ein Ruf, der jeden
Zuschauer weckt, waere etwas anderes als der bestellte -- und beim
zweiten Mal haetten sie die Benachrichtigungen abgeschaltet.
WO WAS ENTSCHIEDEN WIRD
-----------------------
chat-erwaehnung.js bleibt ohne Abhaengigkeiten (die Pruefung soll sie
lesen koennen, ohne einen Server hochzufahren). Sie sagt nur, DASS
gerufen wurde; der Aufrufer sagt ihr, ob der Schreibende darf. Wer zum
Rudel gehoert, entscheidet workspace-chat.js ueber istTeamDogi().
Das Schluesselwort gewinnt gegen einen Menschen, der "Rudel" heisst.
Heute heisst niemand so -- aber das ist eine Tatsache von heute, kein
Gesetz. So herum verliert niemand eine Meldung (wer so heisst, ist im
Rudel dabei); andersherum haette ein einziger Zugang den Ruf ans Team
stillschweigend abgeschaltet.
istTeamDogi() NEU IN workspace.js
---------------------------------
Derselbe Ausdruck ("admin oder TEAM_DOGI_ROLLEN") stand dort dreimal
wortgleich: siehtModis, kanaeleFuer, kategorienFuer. Drei gleiche
Ausdruecke sind drei Gelegenheiten, dass einer beim naechsten
Rollenzuschnitt nicht mitgeht. Jetzt eine Stelle, die drei benutzen.
DIE NACHRICHT MERKT SICH DEN RUF (Spalte chat_nachrichten.rudel)
----------------------------------------------------------------
Beim Lesen muesste der Browser sonst wissen, ob der Absender es DAMALS
durfte. Er kennt nur dessen heutige Rolle -- wechselt jemand die Rolle,
verschwaende die Hervorhebung rueckwirkend aus einem Satz von vorletzter
Woche. Und es waere die zweite Rechnung ueber dieselbe Frage.
Vorgabe 0; alle alten Nachrichten haben kein Rudel gerufen, und das ist
keine Annahme, sondern eine Tatsache: Das Wort gab es noch nicht.
DIE MELDUNG SAGT, WAS LOS IST
-----------------------------
"X hat das Rudel gerufen", nicht "X hat dich erwaehnt" -- letzteres
stimmt beim Rudel nicht, und wer dreimal liest, dass er gemeint sei,
und jedes Mal merkt, dass es alle betraf, glaubt beim vierten Mal auch
dem echten nicht mehr. Auf DEMSELBEN Schalter wie die Erwaehnung: Ein
vierter Schalter waere der, den jemand abschaltet und der dann genau im
wichtigen Moment fehlt. Jeder Ruf steht im Protokoll (chat_rudel) --
"@rudel wird zu oft benutzt" soll eine Zahl sein koennen, kein Gefuehl.
DIE FARBE WIRD ABGELEITET, NICHT GESETZT
----------------------------------------
Eine Nachricht liegt in der Blase, deren Farbe ihr Absender AUSGESUCHT
hat -- siebzehn Moeglichkeiten. Eine feste Farbe darauf ist eine Wette,
und genau die habe ich am 22.09. beim Loeschknopf verloren (1,91:1 auf
Babyblau, gemessen, nachdem es live war). Die Marke nimmt deshalb
`--blase-text` -- die Schrift, die schriftFuer() fuer DIESE Blase mit
mindestens 7:1 ausgerechnet hat. Unterschieden wird ueber Form statt
Farbton: Toenung, Kante, Gewicht 700, ein Zeichen davor. In der
Auswahlliste darf es einen eigenen Ton haben -- sie liegt auf der
Flaeche des Hauses, deren Farbe feststeht.
DER VORSCHLAG ERSCHEINT NUR, WO ER ETWAS BEWIRKT
------------------------------------------------
In einem Zweier-Gespraech mit einem Creator ist ausser mir niemand aus
dem Team. `darf_rudel` fragt deshalb beides: darf ich, und sitzt hier
noch jemand aus dem Team. Dieselbe Ueberlegung wie beim eigenen Namen,
den die Liste auch nicht anbietet. Die Schranke beim Schreiben haengt
nicht daran -- wer es von Hand tippt, ruft eben niemanden.
GEPRUEFT
--------
pruef-erwaehnung: 119 Pruefungen, 0 Fehler (vorher 66).
Neu darin, und die zweite ist die wichtigere:
* der Modi ruft im Treff genau das Team -- die Liste wird aus der
Besetzung ABGELEITET, nicht abgeschrieben
* der Gast im selben Raum ruft NICHTS: kein Eintrag, kein Merkmal,
kein Vorschlag. Ohne diese Pruefung stuende Filipes "nur die
modis, rechte hand, linke hand und dogfather" bloss im Kommentar
* beide Fassungen der Regel (Server und Browser) an 14 zusaetzlichen
Proben nebeneinander, mit beiden Rechten -- samt Gegenprobe, dass
der Vergleich einen Unterschied ueberhaupt sehen kann
* am Bildschirm: der Ruf steht oben in der Liste, sagt daneben, was
er bedeutet, Enter setzt ihn ein, und im Satz ist er an Gewicht
und Rahmen erkennbar -- nicht nur an der Farbe
Beim Bauen gemessen statt vermutet: Ein Scout und ein Modi kommen gar
nicht in denselben Raum (403, Haeusertrennung) -- deshalb prueft der
Verhaltenstest im Treff. Und ein Gast meldet sich nur mit
Altersbestaetigung an (400 ohne).
Die Nachtruhe des Treffs wird in dieser Pruefung abgeschaltet (gleiche
Stunden = keine Nachtruhe). Sonst waere sie zwischen Mitternacht und
sechs rot und danach gruen -- ein Test, der die Wanduhr misst.
pruef-chat, pruef-treffchat (110), pruef-chat-kanaele (81),
pruef-chat-ausbau (64): alle unveraendert gruen.
Co-Authored-By: Claude Opus 5 <[email protected]>
3236 lines
137 KiB
JavaScript
3236 lines
137 KiB
JavaScript
/* =====================================================================
|
||
chat.js — Gespräche im Workspace.
|
||
|
||
Wunsch Filipe, 06.09.2026: *"jeder kann dann eine nachricht
|
||
reinschreiben, die andere person bekommt eine benachrichtigung und
|
||
kann dann antworten."*
|
||
|
||
---------------------------------------------------------------------
|
||
DREI ENTSCHEIDUNGEN, die den Rest erklären
|
||
|
||
1. NEUE NACHRICHTEN KOMMEN VON SELBST (EventSource / SSE). Kein
|
||
Nachfragen im Takt: Das wäre fünf Sekunden langsam und stellte bei
|
||
zehn Angemeldeten 7200 Anfragen in der Stunde für Nachrichten, die
|
||
es meistens nicht gibt. Reisst die Verbindung ab, verbindet der
|
||
Browser sich von selbst neu -- und wir holen die Lücke über den
|
||
normalen Verlauf nach.
|
||
|
||
2. DIE EIGENE NACHRICHT STEHT SOFORT DA, bevor der Server antwortet.
|
||
Ein Chat, in dem man nach dem Abschicken eine halbe Sekunde ins
|
||
Leere sieht, fühlt sich kaputt an. Kommt der Server nicht mit,
|
||
wird sie sichtbar als "nicht angekommen" markiert -- nicht still
|
||
entfernt.
|
||
|
||
3. GELESEN WIRD GEMELDET, wenn man wirklich hinsieht. Nicht schon
|
||
beim Laden der Seite: Wer die Seite im Hintergrund offen hat, hat
|
||
nichts gelesen, und eine Zahl, die von selbst verschwindet, ist
|
||
schlimmer als keine.
|
||
===================================================================== */
|
||
(() => {
|
||
'use strict';
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
/* =====================================================================
|
||
MEINE KACHEL (20.09.2026)
|
||
|
||
Filipe: „jeder account soll seine kachel personalisieren koennen."
|
||
|
||
DIE TABELLE IST LEER UND WIRD GEFUELLT. Sie steht nicht als feste
|
||
Liste hier: Die zwoelf Toene gehoeren dem Server (CHAT_KACHELN in
|
||
workspace-chat.js), und eine zweite Fassung im Browser waere beim
|
||
dreizehnten die vergessene -- dieselbe Krankheit wie bei KANAL_WORT
|
||
auf dem Highlight-Brett, die heute behoben wurde.
|
||
|
||
WOZU SIE HIER GEBRAUCHT WIRD: Die Nachricht bringt einen
|
||
SCHLUESSEL mit („moos"), keine Farbe. Waere die Farbe selbst in der
|
||
Nachricht, stuende ein CSS-Wert aus der Datenbank in der Seite --
|
||
und ein vertippter oder boeswilliger Eintrag koennte faerben, was
|
||
er will. So kommt nur durch, was in der Tabelle steht. */
|
||
const KACHEL_FARBE = Object.create(null);
|
||
/* WELCHE SCHRIFT AUF WELCHER KACHEL -- vom Server gerechnet
|
||
(schriftFuer in workspace-chat.js). Hier wird sie nur
|
||
nachgeschlagen: Eine zweite Rechnung im Browser waere die
|
||
Fassung, die beim naechsten Ton aelter ist als die andere. */
|
||
const KACHEL_SCHRIFT = Object.create(null);
|
||
let kachelListe = [];
|
||
let meineKachel = null;
|
||
let autoKachel = null;
|
||
|
||
async function kachelnLaden() {
|
||
try {
|
||
const a2 = await fetch('/workspace/api/chat/kacheln', { credentials: 'same-origin' });
|
||
if (!a2.ok) return;
|
||
const d = await a2.json();
|
||
kachelListe = Array.isArray(d.kacheln) ? d.kacheln : [];
|
||
meineKachel = d.meine || null;
|
||
/* Was getragen wird, wenn man nichts waehlt. Der Server rechnet
|
||
das aus der Person aus; die Oberflaeche rechnet es NICHT nach.
|
||
Zwei Rechnungen fuer dieselbe Farbe waeren zwei Stellen, an
|
||
denen sie stimmen muss. */
|
||
autoKachel = d.automatisch || null;
|
||
for (const k of kachelListe) {
|
||
KACHEL_FARBE[k.wert] = k.farbe;
|
||
if (k.schrift) KACHEL_SCHRIFT[k.wert] = k.schrift;
|
||
}
|
||
kachelKnopfZeigen();
|
||
} catch { /* ohne die Liste bleibt alles beim Standard -- kein Grund
|
||
zu scheitern, der Chat funktioniert weiter */ }
|
||
}
|
||
|
||
function kachelKnopfZeigen() {
|
||
const knopf = $('kachel-oeffnen');
|
||
if (!knopf) return;
|
||
/* OHNE LISTE KEIN KNOPF. Ein Knopf, der eine leere Auswahl oeffnet,
|
||
ist schlimmer als keiner -- man drueckt ihn und sucht den Fehler
|
||
bei sich. */
|
||
knopf.hidden = kachelListe.length === 0;
|
||
const probe = knopf.querySelector('.chat__kachelknopf-probe');
|
||
if (probe) {
|
||
/* Der Knopf zeigt, was WIRKLICH getragen wird -- also die eigene
|
||
Wahl, sonst die automatische. Zeigte er den neutralen Ton,
|
||
waere er eine Auskunft ueber etwas, das man auf dem Bildschirm
|
||
daneben anders sieht. */
|
||
probe.style.background = KACHEL_FARBE[meineKachel]
|
||
|| KACHEL_FARBE[autoKachel] || KACHEL_FARBE.grund || '#4f545c';
|
||
}
|
||
}
|
||
|
||
function kachelwahlBauen() {
|
||
const gitter = $('kachel-gitter');
|
||
if (!gitter) return;
|
||
gitter.textContent = '';
|
||
for (const k of kachelListe) {
|
||
const b = el('button', 'chat-kachelwahl__ton');
|
||
b.type = 'button';
|
||
b.dataset.wert = k.wert;
|
||
b.style.background = k.farbe;
|
||
b.setAttribute('role', 'radio');
|
||
/* DER NAME GEHOERT DAZU, nicht nur die Farbe. Wer Farben schlecht
|
||
unterscheidet, waehlt sonst zwoelf Mal dasselbe Kaestchen --
|
||
und ein Vorleseprogramm saehe zwoelf namenlose Knoepfe. */
|
||
b.setAttribute('aria-label', k.name);
|
||
b.title = k.name;
|
||
b.setAttribute('aria-checked', String((meineKachel || 'grund') === k.wert));
|
||
/* EINE VORSCHAU MIT ECHTEM TEXT, nicht nur ein Farbfleck: Gewaehlt
|
||
wird ja nicht die Farbe, sondern wie die eigene Nachricht
|
||
aussieht. */
|
||
/* DAS „Aa" TRAEGT DIE SCHRIFT DIESER KACHEL (22.09.2026).
|
||
Sonst stuende in der Auswahl ein helles Aa auf Babyblau --
|
||
also genau das Bild, das es zu vermeiden gilt, und zwar an der
|
||
Stelle, an der man sich entscheidet. */
|
||
const wort = el('span', 'chat-kachelwahl__wort', 'Aa');
|
||
if (k.schrift) wort.style.color = k.schrift.text;
|
||
b.append(wort);
|
||
b.addEventListener('click', () => kachelSetzen(k.wert, b));
|
||
gitter.append(b);
|
||
}
|
||
}
|
||
|
||
async function kachelSetzen(wert, knopf) {
|
||
const vorher = meineKachel;
|
||
/* SOFORT UMSTELLEN, dann speichern. Wer eine Farbe antippt und eine
|
||
halbe Sekunde nichts sieht, tippt noch einmal. Geht es schief,
|
||
wird es zurueckgenommen -- und zwar sichtbar. */
|
||
meineKachel = (wert === 'grund') ? null : wert;
|
||
for (const b of knopf.parentElement.querySelectorAll('.chat-kachelwahl__ton')) {
|
||
b.setAttribute('aria-checked', String(b === knopf));
|
||
}
|
||
kachelKnopfZeigen();
|
||
eigeneKachelnFaerben();
|
||
try {
|
||
const a2 = await hole('/workspace/api/chat/kachel', {
|
||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ kachel: wert }),
|
||
});
|
||
if (!a2.ok) throw new Error('abgelehnt');
|
||
} catch {
|
||
meineKachel = vorher;
|
||
kachelKnopfZeigen();
|
||
eigeneKachelnFaerben();
|
||
kachelwahlBauen();
|
||
melde('Die Farbe konnte nicht gespeichert werden.');
|
||
}
|
||
}
|
||
|
||
/** Die eigenen Nachrichten im offenen Verlauf sofort umfaerben.
|
||
*
|
||
* OHNE NEU ZU LADEN: Der Verlauf neu zu holen hiesse, beim Faerben
|
||
* nach unten zu springen und die Stelle zu verlieren, an der man
|
||
* gerade liest. Die Farbe steht ohnehin nur an der Kachel. */
|
||
/** Flaeche UND Schrift in einem Griff.
|
||
*
|
||
* ZUSAMMEN UND NICHT NACHEINANDER (22.09.2026): Wer die Farbe an
|
||
* einer Stelle setzt und die Schrift an einer anderen, hat
|
||
* irgendwann eine helle Kachel mit heller Schrift -- und merkt es
|
||
* erst, wenn jemand nichts mehr lesen kann. Es gibt deshalb genau
|
||
* diesen einen Weg, eine Blase einzufaerben. */
|
||
function blaseFaerben(blase, wert) {
|
||
const farbe = KACHEL_FARBE[wert] || null;
|
||
const schrift = KACHEL_SCHRIFT[wert] || null;
|
||
if (farbe) blase.style.setProperty('--kachel', farbe);
|
||
else blase.style.removeProperty('--kachel');
|
||
if (schrift) {
|
||
blase.style.setProperty('--blase-text', schrift.text);
|
||
blase.style.setProperty('--blase-leise', schrift.leise);
|
||
} else {
|
||
blase.style.removeProperty('--blase-text');
|
||
blase.style.removeProperty('--blase-leise');
|
||
}
|
||
}
|
||
|
||
function eigeneKachelnFaerben() {
|
||
for (const z of document.querySelectorAll('.chat-nachricht[data-selbst="ja"] .chat-nachricht__blase')) {
|
||
blaseFaerben(z, meineKachel);
|
||
}
|
||
}
|
||
|
||
function kachelwahlBedienen() {
|
||
const knopf = $('kachel-oeffnen'), dlg = $('kachel-wahl'), zu = $('kachel-zu');
|
||
if (!knopf || !dlg) return;
|
||
knopf.addEventListener('click', () => { kachelwahlBauen(); dlg.showModal(); });
|
||
zu?.addEventListener('click', () => dlg.close());
|
||
}
|
||
|
||
const el = (tag, klasse, text) => {
|
||
const k = document.createElement(tag);
|
||
if (klasse) k.className = klasse;
|
||
if (text !== undefined) k.textContent = text;
|
||
return k;
|
||
};
|
||
|
||
/* Aus der einen Quelle. Stand hier als eigenes Objekt und hatte
|
||
'spicy' nicht -- siehe bereiche.js. */
|
||
const ROLLENKURZ = window.Bereiche.ROLLEN_NAME;
|
||
|
||
let ich = null;
|
||
let raeume = [];
|
||
let offen = null; // der gerade angesehene Raum
|
||
let nachrichten = [];
|
||
let strom = null; // die EventSource
|
||
let gewaehlt = new Set(); // im Neu-Dialog
|
||
|
||
/* KANAELE UND ANGEHEFTETES (10.09.2026, Kapitel 7.2).
|
||
|
||
`darfKanal` und `darfAnheftenHier` kommen vom SERVER, nicht aus
|
||
einem Rollenvergleich hier. Diese Datei bekommt jeder, der die
|
||
Seite oeffnet -- ein `rolle === 'admin'` darin waere eine Auskunft
|
||
ueber den Aufbau des Hauses an alle. Ein Ja/Nein ist keine. */
|
||
let angeheftet = [];
|
||
let darfAnheftenHier = false;
|
||
let darfAllesLoeschen = false;
|
||
/* DARF ICH DAS RUDEL RUFEN? (23.09.2026) Kommt wie die beiden
|
||
darueber vom Server (`darf_rudel`) -- hier wird keine Rolle
|
||
verglichen. Und das WORT kommt mit, damit es nicht an zwei Stellen
|
||
steht: Der Vorschlag beim Tippen setzt genau das ein, was der
|
||
Server hinterher erkennt. */
|
||
let darfRudel = false;
|
||
let rudelWort = 'rudel';
|
||
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 das Rudel zu. `
|
||
+ `Ab ${String(treffStand.bis).padStart(2, '0')}:00 Uhr geht es weiter.`;
|
||
}
|
||
|
||
/* DIE BUEROKLAMMER GEHOERT DAZU (19.09.2026, beim Ansehen des
|
||
Bildschirmfotos aufgefallen).
|
||
|
||
Der Server lehnt Anhaenge nachts ebenso ab wie Nachrichten --
|
||
der Knopf stand aber offen da. Wer ihn drueckt, waehlt ein Bild
|
||
aus, wartet auf den Upload und bekommt dann eine Absage. Das ist
|
||
schlimmer als ein ausgegrauter Knopf: Die Arbeit ist schon
|
||
gemacht. */
|
||
const klammer = $('anhang-auf');
|
||
if (klammer) {
|
||
klammer.disabled = zu;
|
||
klammer.title = zu
|
||
? `Nachts keine Anhänge – ab ${String(treffStand.bis).padStart(2, '0')}:00 Uhr wieder`
|
||
: 'Bild oder PDF anhängen';
|
||
}
|
||
|
||
if (feld) {
|
||
feld.disabled = zu;
|
||
/* DER PLATZHALTER SAGT DEN GRUND. Ein graues Feld ohne Text
|
||
sieht aus wie ein Fehler -- und man tippt trotzdem hinein. */
|
||
feld.placeholder = zu
|
||
? `Das Rudel schläft – ab ${String(treffStand.bis).padStart(2, '0')}:00 Uhr wieder`
|
||
: 'Nachricht';
|
||
}
|
||
if (senden) senden.disabled = zu;
|
||
}
|
||
|
||
/* ==== DER TAKT: die Minuten laufen wirklich ab ======================
|
||
|
||
Ohne ihn stuende die Zahl vom Seitenaufruf da, bis jemand neu
|
||
laedt. Ein Chat steht aber stundenlang offen -- "noch 12 Minuten"
|
||
waere um drei Uhr nachts immer noch zu lesen.
|
||
|
||
EINMAL JE MINUTE, und er zaehlt SELBST herunter, statt den Server
|
||
zu fragen. Eine Abfrage je Minute je offenem Fenster waere Last
|
||
ohne Gegenwert: Die Minuten vergehen hier genauso wie dort.
|
||
|
||
BEIM WECHSEL WIRD GEFRAGT. Laeuft der Zaehler auf null, holt er den
|
||
Stand EINMAL frisch -- das ist der Moment, in dem sich wirklich
|
||
etwas aendert, und der einzige, in dem die eigene Rechnung
|
||
danebenliegen koennte (Zeitumstellung, schlafender Rechner, eine
|
||
geaenderte Einstellung).
|
||
|
||
NUR SOLANGE ES ETWAS ZU ZAEHLEN GIBT. Wer mit dem Treff nichts zu
|
||
tun hat, bekommt keinen Zeitgeber. */
|
||
function taktStarten() {
|
||
if (taktUhr) { clearInterval(taktUhr); taktUhr = null; }
|
||
if (!treffStand) return;
|
||
taktUhr = setInterval(async () => {
|
||
if (!treffStand) { clearInterval(taktUhr); taktUhr = null; return; }
|
||
treffStand.minuten -= 1;
|
||
if (treffStand.minuten > 0) { nachtruheZeigen(); return; }
|
||
/* Der Wechsel. Einmal fragen, dann weiterzaehlen. */
|
||
try {
|
||
const a = await hole('/workspace/api/chat/raeume');
|
||
if (a.ok) {
|
||
const d = await a.json();
|
||
treffStand = d.treff || null;
|
||
}
|
||
} catch { /* kein Netz -- beim naechsten Mal wieder */ }
|
||
/* Faellt die Antwort aus, wird selbst umgeschaltet: Lieber eine
|
||
Minute ungenau als eine Stunde falsch. */
|
||
if (treffStand && treffStand.minuten <= 0) {
|
||
treffStand.zu = !treffStand.zu;
|
||
treffStand.minuten = (treffStand.zu
|
||
? (treffStand.bis - treffStand.ab + 24) % 24
|
||
: (treffStand.ab - treffStand.bis + 24) % 24) * 60;
|
||
}
|
||
nachtruheZeigen();
|
||
}, 60_000);
|
||
}
|
||
|
||
/* Ein schlafender Rechner haelt `setInterval` an. Wer sein Handy
|
||
nach zwei Stunden wieder aufklappt, haette sonst eine Zahl von
|
||
vor zwei Stunden vor sich -- und zwar eine, die nach oben zaehlt.
|
||
Beim Zurueckkommen wird deshalb einmal gefragt. */
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (document.hidden || !treffStand) return;
|
||
raeumeLaden().catch(() => {});
|
||
});
|
||
|
||
/** „3 Minuten" / „2 Stunden 10 Minuten" -- nie „130 Minuten".
|
||
*
|
||
* Wer „noch 347 Minuten" liest, rechnet im Kopf und liegt daneben.
|
||
* Die Zahl soll eine Auskunft sein, keine Aufgabe. */
|
||
function stundenSatz(min) {
|
||
const m = Math.max(0, Math.round(min));
|
||
if (m < 60) return `${m} Minute${m === 1 ? '' : 'n'}`;
|
||
const st = Math.floor(m / 60), rest = m % 60;
|
||
return `${st} Stunde${st === 1 ? '' : 'n'}`
|
||
+ (rest ? ` ${rest} Minute${rest === 1 ? '' : 'n'}` : '');
|
||
}
|
||
|
||
/* ---------- Die Gesprächsliste ------------------------------------------- */
|
||
|
||
async function raeumeLaden() {
|
||
try {
|
||
const a = await hole('/workspace/api/chat/raeume');
|
||
if (!a.ok) return;
|
||
const daten = await a.json();
|
||
raeume = daten.raeume || [];
|
||
darfKanal = !!daten.darf_kanal;
|
||
/* DER SERVER SAGT, OB DER TREFF SCHLAEFT -- hier wird nichts
|
||
nachgerechnet. Eine zweite Rechnung im Browser waere die, die
|
||
bei der naechsten Zeitumstellung anders herauskommt als die im
|
||
Server; dann sagt die Seite "offen" und der Server lehnt ab. */
|
||
treffStand = daten.treff || null;
|
||
if (typeof daten.darf_anrufen === 'boolean') darfAnrufen = daten.darf_anrufen;
|
||
nachtruheZeigen();
|
||
taktStarten();
|
||
kanalFrei = daten.kanal_frei || [];
|
||
/* Der Knopf erscheint nur, wenn der Server etwas zu bieten hat.
|
||
Ein Knopf, der beim Druecken "darfst du nicht" sagt, ist eine
|
||
Enttaeuschung mit Ansage. */
|
||
$('neu-art').hidden = !darfKanal;
|
||
/* Abgeleitet, nicht zweimal entschieden: Die Erklaerung gehoert
|
||
genau dann hin, wenn es auch etwas zu entscheiden gibt. */
|
||
$('neu-erklaerung').hidden = $('neu-art').hidden;
|
||
if (!darfKanal) neuArt = 'gespraech';
|
||
} catch { return; }
|
||
raeumeZeichnen();
|
||
ungelesenMelden();
|
||
}
|
||
|
||
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) {
|
||
/* DAS GESICHT STATT DER INITIALEN (20.09.2026).
|
||
|
||
Filipe: "da soll man im chat auch die profilfotos von den
|
||
leuten sehen wenn die schon eins drin haben."
|
||
|
||
Im offenen Gespraech stand es laengst -- in der Liste
|
||
daneben ein Buchstabe, vom selben Menschen. Der Grund war
|
||
eine Zeile im Server, die das Bild wegwarf.
|
||
|
||
DER BUCHSTABE BLEIBT ALS UNTERLAGE LIEGEN und wird nicht
|
||
ersetzt: Laedt das Bild nicht, steht dort weiterhin etwas
|
||
Sinnvolles statt eines kaputten Bildsymbols. Dieselbe
|
||
Bauweise wie im Verlauf, in der Kopfleiste und in der
|
||
Personenauswahl -- vier Stellen, ein Verhalten. */
|
||
if (gegen.bild) {
|
||
const b = document.createElement('img');
|
||
b.src = gegen.bild; b.alt = '';
|
||
b.addEventListener('error', () => b.remove());
|
||
zeichen.append(b);
|
||
}
|
||
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}: ` : '');
|
||
/* KEIN „Nachricht zurueckgenommen" MEHR (22.09.2026). Hier
|
||
stand genau dieser Satz -- und die Gespraechsliste ist die
|
||
auffaelligste Stelle der ganzen Seite. Geloescht heisst
|
||
geloescht: Der Server liefert eine geloeschte Nachricht gar
|
||
nicht mehr als letzte, weil es die Zeile nicht mehr gibt.
|
||
Sollte doch einmal eine alte, zurueckgenommene durchkommen,
|
||
steht hier lieber nichts als ein Hinweis auf sie. */
|
||
vorschau.textContent = r.letzte.zurueckgenommen
|
||
? 'Noch nichts geschrieben'
|
||
: 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);
|
||
|
||
/* WURDE ICH HIER ANGESPROCHEN? (20.09.2026)
|
||
|
||
Eine Zahl sagt "hier ist etwas". Sie sagt nicht, ob es an DICH
|
||
war. Wer morgens vier Gespraeche mit Zahlen sieht, macht das
|
||
lauteste zuerst auf -- und dort steht selten das, was auf ihn
|
||
wartet.
|
||
|
||
Das Zeichen steht VOR der Zahl, weil es die wichtigere Auskunft
|
||
ist, und es traegt einen eigenen Satz fuer Vorleseprogramme:
|
||
Ein "@" allein wird je nach Programm als "at", "Klammeraffe"
|
||
oder gar nicht vorgelesen. */
|
||
if (r.erwaehnt) {
|
||
const marke = el('span', 'chat-raum__erwaehnt', '@');
|
||
marke.title = 'Du wurdest hier erwähnt';
|
||
marke.setAttribute('aria-label', 'Du wurdest hier erwähnt');
|
||
knopf.append(marke);
|
||
}
|
||
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;
|
||
darfAllesLoeschen = !!daten.darf_alles_loeschen;
|
||
darfRudel = !!daten.darf_rudel;
|
||
rudelWort = daten.rudel_wort || 'rudel';
|
||
/* 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;
|
||
/* DIE LEISTE FUER DIE TEXTGESTALTUNG (21.09.2026).
|
||
|
||
Hier und nicht beim Laden der Seite: Das Formular ist bis zum
|
||
Oeffnen eines Raumes versteckt, und eine Leiste ueber einem
|
||
versteckten Feld baut man zweimal, wenn man nicht aufpasst.
|
||
`Textform.leiste` merkt sich am Feld selbst, dass sie schon
|
||
da ist -- zweimal aufrufen schadet also nichts. */
|
||
if (window.Textform) window.Textform.leiste($('text'));
|
||
/* 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) {
|
||
/* WOHER WEISS JEMAND, DASS ES "@" GIBT? (20.09.2026)
|
||
|
||
Nirgends. Ein Zeichen, das man kennen muss, um es zu finden,
|
||
kennt beim ersten Mal niemand -- und im Chat gibt es keinen
|
||
Hilfeknopf, in den man es schreiben koennte.
|
||
|
||
Der Satz steht deshalb GENAU DORT, wo eine Gruppe anfaengt:
|
||
beim ersten Oeffnen, bevor etwas drinsteht. Er verschwindet mit
|
||
der ersten Nachricht und steht damit nie im Weg.
|
||
|
||
NUR BEI DREI ODER MEHR. In einem Zweiergespraech ist ohnehin
|
||
klar, wer gemeint ist -- dort waere der Hinweis Ballast. */
|
||
const inGruppe = (offen?.teilnehmer?.length || 0) > 2;
|
||
ziel.append(el('p', 'chat__leer', inGruppe
|
||
? 'Noch nichts geschrieben. Fang an — mit @ sprichst du jemanden direkt an.'
|
||
: 'Noch nichts geschrieben. Fang an.'));
|
||
return;
|
||
}
|
||
|
||
let letzterTag = '';
|
||
let letzterVon = null;
|
||
let linieGesetzt = false;
|
||
for (const n of nachrichten) {
|
||
/* DIE LINIE "AB HIER IST ES NEU" (09.09.2026, Punkt 15).
|
||
|
||
Sie steht vor der ersten Nachricht, die beim Oeffnen noch
|
||
nicht gelesen war -- und nur EINMAL. Wer nach zwei Tagen
|
||
zurueckkommt, sucht sonst die Stelle, an der er aufgehoert
|
||
hat, indem er Uhrzeiten liest.
|
||
|
||
Nicht bei eigenen Nachrichten: Was man selbst geschrieben hat,
|
||
ist nie "neu fuer einen". */
|
||
if (!linieGesetzt && gelesenBeimOeffnen && n.id > gelesenBeimOeffnen && !n.selbst) {
|
||
linieGesetzt = true;
|
||
const linie = el('div', 'chat-neu');
|
||
linie.append(el('span', 'chat-neu__text', 'Ab hier neu'));
|
||
ziel.append(linie);
|
||
}
|
||
/* Ein Datumstrenner, wenn ein neuer Tag beginnt. Ohne ihn steht
|
||
die Antwort von vorgestern direkt unter der von heute. */
|
||
const tag = new Date(n.erstellt).toDateString();
|
||
if (tag !== letzterTag) {
|
||
letzterTag = tag;
|
||
letzterVon = null;
|
||
const trenner = el('div', 'chat-tag');
|
||
trenner.append(el('span', 'chat-tag__text', tagName(n.erstellt)));
|
||
ziel.append(trenner);
|
||
}
|
||
|
||
const zeile = el('article', 'chat-nachricht');
|
||
zeile.dataset.id = String(n.id);
|
||
if (n.selbst) zeile.dataset.selbst = 'ja';
|
||
if (n.zurueckgenommen) zeile.dataset.weg = 'ja';
|
||
/* UNTERWEGS UND GESCHEITERT SIND ZWEI ZUSTÄNDE -- UND BEIDE
|
||
WAREN UNSICHTBAR (20.09.2026).
|
||
------------------------------------------------------------
|
||
`markiereAlsGescheitert` setzte `n.gescheitert = true` und
|
||
zeichnete neu. Gelesen hat das Merkmal NIEMAND: weder das
|
||
Skript noch das CSS. Eine gescheiterte Nachricht sah exakt
|
||
aus wie eine zugestellte.
|
||
|
||
Daneben stand der Kommentar „NICHT STILL VERSCHWINDEN LASSEN.
|
||
Wer etwas schreibt und es sieht, glaubt, es sei angekommen."
|
||
Genau das ist trotzdem passiert -- der Satz in der Meldezeile
|
||
verschwindet nach kurzer Zeit, die Nachricht bleibt stehen
|
||
und sieht gesendet aus.
|
||
|
||
Ein Kommentar, der eine Absicht beschreibt, erfüllt sie
|
||
nicht. */
|
||
if (n.unterwegs) zeile.dataset.unterwegs = 'ja';
|
||
if (n.gescheitert) zeile.dataset.gescheitert = 'ja';
|
||
/* Mehrere Nachrichten derselben Person hintereinander rücken
|
||
zusammen und wiederholen den Namen nicht -- sonst liest man
|
||
fünfmal "Patrick" und dreimal dasselbe Gesicht. */
|
||
const gleicheFolge = letzterVon === n.von_id;
|
||
if (gleicheFolge) zeile.dataset.folge = 'ja';
|
||
letzterVon = n.von_id;
|
||
|
||
if (!gleicheFolge && !n.selbst) {
|
||
/* DAS GESICHT STATT DER INITIALEN (07.09.2026).
|
||
|
||
Der Anfangsbuchstabe bleibt als Unterlage LIEGEN und wird
|
||
nicht ersetzt: Laedt das Bild nicht, steht dort weiterhin
|
||
etwas Sinnvolles statt eines kaputten Bildsymbols. Dieselbe
|
||
Bauweise wie in der Kopfleiste und in der Personenauswahl --
|
||
drei Stellen, ein Verhalten. */
|
||
const z = el('span', 'chat-nachricht__zeichen', initialen(n.von));
|
||
z.setAttribute('aria-hidden', 'true');
|
||
z.dataset.rolle = n.rolle || '';
|
||
if (n.von_bild) {
|
||
const b = document.createElement('img');
|
||
b.src = n.von_bild; b.alt = '';
|
||
b.addEventListener('error', () => b.remove());
|
||
z.append(b);
|
||
}
|
||
zeile.append(z);
|
||
}
|
||
|
||
if (n.angeheftet) zeile.dataset.angeheftet = 'ja';
|
||
|
||
const blase = el('div', 'chat-nachricht__blase');
|
||
/* DIE EIGENE KACHELFARBE (20.09.2026).
|
||
|
||
Filipe: „jeder account soll seine kachel personalisieren
|
||
koennen."
|
||
|
||
Der Wert kommt vom Server als SCHLUESSEL, nicht als Farbe --
|
||
hier wird er in der Tabelle nachgeschlagen, die ebenfalls vom
|
||
Server kam. Waere die Farbe selbst in der Nachricht, stuende
|
||
ein CSS-Wert aus der Datenbank in der Seite; ein vertippter
|
||
oder boeswilliger Eintrag koennte dann faerben, was er will.
|
||
|
||
Kein Wert heisst Standard, und dafuer steht gar nichts da --
|
||
der Rueckfall liegt im Stilblatt (`var(--kachel, …)`). Eine
|
||
Zeile, die die Standardfarbe noch einmal hinschreibt, waere
|
||
eine zweite Stelle, an der sie stimmen muss. */
|
||
blaseFaerben(blase, n.von_kachel);
|
||
if (!gleicheFolge && !n.selbst && offen.art !== 'direkt') {
|
||
/* DER NAME TRAEGT SEINE ROLLE MIT (22.09.2026).
|
||
Filipe: „die namen so dass man sie gut erkennt."
|
||
Die Farbe der Rolle sitzt im Punkt VOR dem Namen, nicht in
|
||
der Schrift selbst: Bei zwoelf waehlbaren Flaechen liesse
|
||
sich fuer eine Schriftfarbe kein Kontrast mehr garantieren,
|
||
fuer einen 6-px-Punkt daneben ist das egal. */
|
||
const vonZeile = el('p', 'chat-nachricht__von', n.von);
|
||
vonZeile.dataset.rolle = n.rolle || '';
|
||
blase.append(vonZeile);
|
||
}
|
||
|
||
/* DAS ZITAT STEHT IN DER BLASE, NICHT DARUEBER. So wandert es beim
|
||
Rollen mit und gehoert sichtbar zur Antwort -- eine Zeile
|
||
darueber liest sich als eigene Nachricht. */
|
||
/* EIN ZITAT AUF ETWAS, DAS ES NICHT MEHR GIBT, WIRD WEGGELASSEN
|
||
(22.09.2026). Vorher stand darin „Nachricht zurueckgenommen"
|
||
-- und damit die Spur an der einen Stelle, an der sie am
|
||
meisten auffaellt: mitten in einer fremden Nachricht. Der
|
||
Server liefert `antwort` gar nicht mehr, wenn die zitierte
|
||
Zeile geloescht wurde; diese Bedingung faengt den Rest. */
|
||
if (n.antwort && !n.antwort.zurueckgenommen) {
|
||
const z = el('button', 'chat-zitat');
|
||
z.type = 'button';
|
||
z.append(el('span', 'chat-zitat__von', n.antwort.von));
|
||
z.append(el('span', 'chat-zitat__text',
|
||
window.Textform ? window.Textform.nurText(n.antwort.text) : n.antwort.text));
|
||
z.title = 'Zur zitierten Nachricht springen';
|
||
/* Ein Zitat, das nicht zur Stelle fuehrt, ist ein Bild von
|
||
einer Nachricht. Der Klick springt hin und hebt sie kurz
|
||
hervor. */
|
||
z.addEventListener('click', () => zurStelle(n.antwort.id));
|
||
blase.append(z);
|
||
}
|
||
|
||
/* HIER STAND „Nachricht zurückgenommen" (bis 22.09.2026).
|
||
|
||
Filipe: „Gelöschte Nachrichten verschwinden vollständig –
|
||
keine Spur, kein ‚wurde gelöscht'-Hinweis, bei niemandem,
|
||
auch nicht bei DogFather."
|
||
|
||
Eine gelöschte Nachricht kommt gar nicht mehr hier an -- die
|
||
Zeile gibt es nicht mehr. Der Zweig bleibt trotzdem stehen,
|
||
aber ohne Text: Sollte aus einer alten Fassung noch eine
|
||
zurückgenommene Zeile durchkommen, zeigt die Blase nichts
|
||
statt eines Hinweises darauf, dass hier etwas stand. */
|
||
if (n.zurueckgenommen) {
|
||
zeile.hidden = true;
|
||
} else {
|
||
/* DER ANHANG STEHT ÜBER DEM TEXT. Ein Begleitsatz gehört unter
|
||
das Bild, nicht darüber -- man sieht zuerst, worum es geht. */
|
||
if (n.anhang) blase.append(anhangBauen(n.anhang));
|
||
/* textContent, nie innerHTML: Eine Nachricht darf keine
|
||
Auszeichnung einschleusen. Zeilenumbrüche macht das CSS
|
||
(white-space: pre-wrap).
|
||
|
||
NUR WENN WIRKLICH TEXT DA IST: Eine Nachricht, die nur aus
|
||
einem Foto besteht, hätte sonst einen leeren Absatz darunter
|
||
-- und der nimmt Platz, den niemand erklären kann. */
|
||
if (n.text) blase.append(textAbsatz(n.text, !!n.rudel));
|
||
}
|
||
|
||
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);
|
||
}
|
||
/* LÖSCHEN HEISST LÖSCHEN (22.09.2026).
|
||
|
||
Filipe: „Löschen darf weiterhin jeder nur seine eigenen –
|
||
außer DogFather und rechte Hand: die dürfen jede Nachricht
|
||
löschen, für den Notfall."
|
||
|
||
Der Knopf heisst deshalb auch „löschen" und nicht mehr
|
||
„zurücknehmen". Zurücknehmen klingt nach „ich nehme mein Wort
|
||
zurück" -- es passte, solange ein Hinweis stehen blieb. Jetzt
|
||
ist es weg, und das Wort soll das sagen.
|
||
|
||
AN FREMDEN NACHRICHTEN STEHT ES ANDERS DA: „löschen (Notfall)".
|
||
Wer fremde Worte entfernt, soll im selben Moment sehen, dass
|
||
das kein gewöhnlicher Handgriff ist. Die Schranke steht
|
||
ohnehin im Server; hier geht es darum, sie nicht aus Versehen
|
||
zu benutzen. */
|
||
if (n.id > 0 && (n.selbst || darfAllesLoeschen)) {
|
||
const fremd = !n.selbst;
|
||
const weg = el('button', 'chat-nachricht__weg-knopf',
|
||
fremd ? 'löschen (Notfall)' : 'löschen');
|
||
weg.type = 'button';
|
||
if (fremd) weg.dataset.notfall = 'ja';
|
||
weg.addEventListener('click', () => zuruecknehmen(n.id, fremd));
|
||
fuss.append(weg);
|
||
}
|
||
/* ANHEFTEN steht an derselben Stelle wie "antworten" -- dieselbe
|
||
Sorte Handgriff, dieselbe Zeile. Nur fuer die, die es duerfen,
|
||
und nie an einer zurueckgenommenen oder noch unterwegs
|
||
befindlichen Nachricht (`n.id > 0`): Die haette noch keine
|
||
Nummer, unter der man sie anheften koennte. */
|
||
if (darfAnheftenHier && !n.zurueckgenommen && n.id > 0) {
|
||
const pin = 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));
|
||
}
|
||
/* NOCHMAL SENDEN, STATT „kopiere sie und versuch es erneut".
|
||
------------------------------------------------------------
|
||
Dort stand die Seite bat den Nutzer, ihre Arbeit zu machen:
|
||
Text markieren, kopieren, Feld leeren, einfügen, senden. Fünf
|
||
Handgriffe für etwas, das die Seite in einem tun kann -- sie
|
||
hat den Text ja noch. */
|
||
if (n.gescheitert) {
|
||
const nochmal = el('button', 'chat-nachricht__nochmal', 'Nochmal senden');
|
||
nochmal.type = 'button';
|
||
nochmal.addEventListener('click', async () => {
|
||
nochmal.disabled = true;
|
||
nochmal.textContent = 'wird gesendet …';
|
||
/* DENSELBEN WEG GEHEN WIE VON HAND -- nur ohne die fünf
|
||
Handgriffe. Text zurück ins Feld, Antwortziel wieder
|
||
setzen, `abschicken` aufrufen. Kein zweiter Sendeweg:
|
||
Ein eigener wäre morgen anders als dieser, und der
|
||
Unterschied fiele erst auf, wenn er zählt. */
|
||
const weg = nachrichten.findIndex((x) => x.id === n.id);
|
||
if (weg >= 0) nachrichten.splice(weg, 1);
|
||
$('text').value = n.text;
|
||
if (n.antwort) antwortSetzen(n.antwort);
|
||
melde('');
|
||
await abschicken({ preventDefault() {} });
|
||
});
|
||
blase.append(nochmal);
|
||
}
|
||
|
||
zeile.append(blase);
|
||
ziel.append(zeile);
|
||
}
|
||
|
||
/* NUR NACH UNTEN SPRINGEN, WENN MAN SCHON UNTEN WAR.
|
||
|
||
Vorher sprang der Verlauf bei JEDEM Neuzeichnen ans Ende. Wer
|
||
weiter oben etwas nachlas und dabei eine neue Nachricht bekam,
|
||
wurde mitten im Satz weggerissen -- und das passiert im Live-
|
||
Strom staendig. Jetzt gilt: Wer unten steht, bleibt unten
|
||
(das ist der Normalfall). Wer liest, bleibt stehen und bekommt
|
||
einen Knopf, der sagt, wie viel unten wartet. */
|
||
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;
|
||
/* =====================================================================
|
||
WER IST MIT "@" GEMEINT? -- die Fassung fuer den Browser
|
||
(20.09.2026)
|
||
|
||
DIESELBEN REGELN WIE IM SERVER. Das Original samt Begruendung je
|
||
Regel steht in server/chat-erwaehnung.js; hier steht eine zweite,
|
||
wortgleiche Fassung.
|
||
|
||
WARUM UEBERHAUPT ZWEIMAL: Der Browser muss beim Tippen und beim
|
||
Zeichnen sofort wissen, welcher Teil des Satzes ein Name ist --
|
||
ohne zu fragen. Eine Nachfrage je Nachricht waere eine Abfrage
|
||
mehr fuer etwas, das sich aus dem vorliegenden Text ergibt.
|
||
|
||
UND WARUM DAS GEFAEHRLICH IST: Zwei Rechnungen fuer dieselbe Frage
|
||
laufen irgendwann auseinander -- und dann hebt die Seite jemanden
|
||
hervor, den der Server nie benachrichtigt hat, oder umgekehrt.
|
||
Deshalb stellt `pruef-erwaehnung.mjs` beide Fassungen an denselben
|
||
Proben nebeneinander. Sie LIEST diese Funktion aus der Datei und
|
||
fuehrt sie aus, statt sie nachzubauen: Ein Nachbau wuerde pruefen,
|
||
ob ich zweimal dasselbe schreiben kann.
|
||
|
||
SIE STEHT BEWUSST OHNE AUSSENBEZUG DA -- auch das Zeichenmuster
|
||
und das Wort "rudel" werden hier drinnen gebildet. Sonst liesse
|
||
sie sich nicht einzeln herausloesen und ausfuehren.
|
||
|
||
"@RUDEL" (23.09.2026): Der dritte Parameter sagt, ob DERJENIGE,
|
||
der diesen Satz geschrieben hat, das ganze Rudel rufen durfte.
|
||
Er wird nicht aus der Rolle geraten -- die Nachricht bringt ihn
|
||
mit (`n.rudel`), der Server hat ihn beim Schreiben entschieden.
|
||
|
||
WARUM NICHT AUS DER ROLLE DES ABSENDERS: Die steht zwar in der
|
||
Nachricht, aber es ist die HEUTIGE. Wechselt jemand die Rolle,
|
||
verschwaende die Hervorhebung rueckwirkend aus einem Satz von
|
||
vorletzter Woche, in dem sie damals richtig war. Und es waere die
|
||
zweite Rechnung ueber dieselbe Frage -- genau die, vor der der
|
||
Absatz darueber warnt. */
|
||
function erwaehnungenFinden(text, leute, wie) {
|
||
const RUDEL = 'rudel';
|
||
const darfRudel = wie && wie.rudel === true;
|
||
const WORT = /[\p{L}\p{N}_]/u;
|
||
const wortBei = (t, i) => (i >= 0 && i < t.length && WORT.test(t[i]));
|
||
const satz = String(text || '');
|
||
if (!satz.includes('@')) return [];
|
||
const nachLaenge = (Array.isArray(leute) ? leute.slice() : [])
|
||
.filter((x) => x && x.id != null && String(x.name || '').trim())
|
||
.sort((a, b) => String(b.name).length - String(a.name).length);
|
||
if (!nachLaenge.length && !darfRudel) return [];
|
||
const klein = satz.toLowerCase();
|
||
const treffer = [];
|
||
for (let i = klein.indexOf('@'); i !== -1; i = klein.indexOf('@', i + 1)) {
|
||
if (wortBei(satz, i - 1)) continue;
|
||
if (darfRudel && klein.startsWith(RUDEL, i + 1)
|
||
&& !wortBei(satz, i + 1 + RUDEL.length)) {
|
||
treffer.push({ id: null, rudel: true, name: RUDEL,
|
||
von: i, bis: i + 1 + RUDEL.length });
|
||
continue;
|
||
}
|
||
for (const person of nachLaenge) {
|
||
const name = String(person.name);
|
||
if (!klein.startsWith(name.toLowerCase(), i + 1)) continue;
|
||
if (wortBei(satz, i + 1 + name.length)) continue;
|
||
treffer.push({ id: person.id, name, von: i, bis: i + 1 + name.length });
|
||
break;
|
||
}
|
||
}
|
||
return treffer;
|
||
}
|
||
|
||
/* =====================================================================
|
||
DIE AUSWAHL BEIM TIPPEN (20.09.2026)
|
||
|
||
Filipe: "ich will das man die leute mit @ markieren kann im chat."
|
||
|
||
WARUM EINE AUSWAHLLISTE UND NICHT NUR "@ TIPPEN GENUEGT":
|
||
Der Server erkennt auch ein von Hand getipptes "@VanVan" -- die
|
||
Liste ist also keine Pflicht, sondern eine Abkuerzung. Sie loest
|
||
aber drei Dinge, die man sonst selbst wissen muesste:
|
||
|
||
* WIE heisst die Person genau? "@Van" trifft nicht, "@vanvan"
|
||
schon. Ohne Liste probiert man.
|
||
* WER ist ueberhaupt in diesem Raum? Nur die koennen gemeint
|
||
sein -- das sieht man sonst nirgends beim Schreiben.
|
||
* Habe ich mich VERTIPPT? Ein "@Vanvna" faellt niemandem auf,
|
||
am wenigsten dem, der gemeint war.
|
||
|
||
WAS SIE BEWUSST NICHT TUT: Sie erzwingt nichts. Wer weitertippt,
|
||
ohne etwas zu waehlen, schreibt einfach weiter; die Liste
|
||
verschwindet, sobald nichts mehr passt. Eine Auswahl, aus der man
|
||
nicht herauskommt, ist eine Falle -- und im Chat schreibt man oft
|
||
ein "@" ohne jede Absicht ("um 10@Bahnhof").
|
||
|
||
DIE TASTATUR ZUERST: Pfeil hoch/runter, Enter oder Tab waehlt,
|
||
Escape schliesst. Am Rechner schreibt die Betreuung, und dort ist
|
||
der Griff zur Maus mitten im Satz der teuerste Handgriff
|
||
ueberhaupt.
|
||
===================================================================== */
|
||
|
||
/** Steht gerade eine Auswahl offen? Dann: ab welcher Stelle im Text
|
||
* (Position des "@") und welcher Eintrag ist vorgemerkt. */
|
||
let erwaehnAuswahl = null;
|
||
|
||
/** Die Stelle vor dem Schreibzeiger auf ein angefangenes "@..." hin
|
||
* ansehen.
|
||
*
|
||
* GIBT null ZURUECK, SOBALD ETWAS NICHT PASST -- und das ist der
|
||
* wichtigste Teil: Ein Leerzeichen beendet die Suche, ein Buchstabe
|
||
* vor dem "@" laesst sie gar nicht erst anfangen (sonst oeffnete
|
||
* sich die Liste mitten in jeder E-Mail-Adresse). Dieselbe Grenze
|
||
* wie beim Aufloesen, damit die Liste nichts anbietet, was der
|
||
* Server hinterher nicht erkennt. */
|
||
function angefangenesAt(feld) {
|
||
const bis = feld.selectionStart;
|
||
if (bis !== feld.selectionEnd) return null; // etwas ist markiert
|
||
const text = feld.value.slice(0, bis);
|
||
const at = text.lastIndexOf('@');
|
||
if (at === -1) return null;
|
||
const davor = at > 0 ? text[at - 1] : '';
|
||
if (davor && /[\p{L}\p{N}_]/u.test(davor)) return null;
|
||
const rumpf = text.slice(at + 1);
|
||
/* Ein Leerzeichen (oder ein Umbruch) beendet es. Namen in diesem
|
||
Haus sind einwortig; dieselbe Annahme steht im Server. */
|
||
if (/\s/.test(rumpf)) return null;
|
||
if (rumpf.length > 24) return null; // so heisst niemand
|
||
return { at, rumpf };
|
||
}
|
||
|
||
/** Wer passt gerade? -- die Teilnehmer dieses Raums, ohne mich.
|
||
*
|
||
* DAS RUDEL STEHT OBEN, wenn ich es rufen darf (23.09.2026). Es ist
|
||
* keine Person, sondern die Gruppe -- deshalb bekommt es eine
|
||
* Kennung, die keine Personennummer sein kann.
|
||
*
|
||
* ES ERSCHEINT NUR, WENN ICH ES DARF. Ein Eintrag, den man waehlen
|
||
* kann und der dann nichts tut, ist schlimmer als keiner: Man
|
||
* schreibt einen Satz an das ganze Team und merkt nie, dass ihn
|
||
* niemand als Ruf bekommen hat. Ob ich darf, sagt der Server
|
||
* (`darf_rudel`) -- hier wird keine Rolle verglichen. */
|
||
function erwaehnTreffer(rumpf) {
|
||
const klein = rumpf.toLowerCase();
|
||
const leute = (offen?.teilnehmer || [])
|
||
/* SICH SELBST KANN MAN NICHT ANSPRECHEN -- der Server verwirft es
|
||
ohnehin. Ein Eintrag, der nichts bewirkt, gehoert nicht in die
|
||
Liste. */
|
||
.filter((t) => t.id !== ich?.id)
|
||
.filter((t) => String(t.name).toLowerCase().startsWith(klein))
|
||
.slice(0, 8);
|
||
if (darfRudel && rudelWort.toLowerCase().startsWith(klein)) {
|
||
return [{ id: 'rudel', name: rudelWort, rudel: true, rolle: null }, ...leute];
|
||
}
|
||
return leute;
|
||
}
|
||
|
||
function erwaehnSchliessen() {
|
||
erwaehnAuswahl = null;
|
||
const l = $('erwaehn-liste');
|
||
if (l) { l.hidden = true; l.textContent = ''; }
|
||
$('text')?.removeAttribute('aria-activedescendant');
|
||
}
|
||
|
||
/** Die Liste zeichnen -- oder schliessen, wenn nichts passt. */
|
||
function erwaehnPruefen() {
|
||
const feld = $('text');
|
||
const liste = $('erwaehn-liste');
|
||
if (!feld || !liste) return;
|
||
const stand = angefangenesAt(feld);
|
||
if (!stand) { erwaehnSchliessen(); return; }
|
||
const treffer = erwaehnTreffer(stand.rumpf);
|
||
if (!treffer.length) { erwaehnSchliessen(); return; }
|
||
|
||
/* Die Vormerkung bleibt auf DERSELBEN Person, solange sie noch
|
||
passt. Sonst springt die Auswahl bei jedem Buchstaben auf den
|
||
ersten Eintrag zurueck, und wer zweimal nach unten getippt hat,
|
||
verliert es beim naechsten Zeichen. */
|
||
const vorherId = erwaehnAuswahl?.treffer?.[erwaehnAuswahl.wahl]?.id;
|
||
const neueWahl = Math.max(0, treffer.findIndex((t) => t.id === vorherId));
|
||
erwaehnAuswahl = { at: stand.at, rumpf: stand.rumpf, treffer, wahl: neueWahl };
|
||
|
||
liste.textContent = '';
|
||
treffer.forEach((t, i) => {
|
||
const b = el('button', 'erwaehn-liste__eintrag');
|
||
b.type = 'button';
|
||
b.id = 'erwaehn-' + t.id;
|
||
b.setAttribute('role', 'option');
|
||
b.setAttribute('aria-selected', i === neueWahl ? 'true' : 'false');
|
||
if (i === neueWahl) b.dataset.wahl = 'ja';
|
||
const z = el('span', 'erwaehn-liste__zeichen',
|
||
/* Das Rudel bekommt ein Zeichen statt Anfangsbuchstaben --
|
||
"R" saehe aus wie ein Mensch namens Rita. */
|
||
t.rudel ? '◉' : initialen(t.name));
|
||
z.setAttribute('aria-hidden', 'true');
|
||
z.dataset.rolle = t.rolle || '';
|
||
if (t.rudel) { b.dataset.rudel = 'ja'; z.dataset.rudel = 'ja'; }
|
||
b.append(z, el('span', 'erwaehn-liste__name', t.name));
|
||
/* WAS ES BEDEUTET, STEHT DANEBEN. "rudel" allein sagt keinem
|
||
Neuen, dass damit das ganze Team gemeint ist -- und wer das
|
||
erst nach dem Absenden erfaehrt, hat schon alle geweckt. */
|
||
if (t.rudel) b.append(el('span', 'erwaehn-liste__rolle', 'alle im Team'));
|
||
else if (ROLLENKURZ[t.rolle]) b.append(el('span', 'erwaehn-liste__rolle', ROLLENKURZ[t.rolle]));
|
||
/* mousedown statt click: Ein Klick nimmt dem Textfeld zuerst den
|
||
Schreibzeiger, und danach weiss niemand mehr, wohin der Name
|
||
gehoert. `preventDefault` haelt ihn, wo er ist. */
|
||
b.addEventListener('mousedown', (e) => { e.preventDefault(); erwaehnWaehlen(i); });
|
||
liste.append(b);
|
||
});
|
||
liste.hidden = false;
|
||
feld.setAttribute('aria-activedescendant', 'erwaehn-' + treffer[neueWahl].id);
|
||
}
|
||
|
||
/** Den vorgemerkten Namen einsetzen. */
|
||
function erwaehnWaehlen(i) {
|
||
const a = erwaehnAuswahl;
|
||
const feld = $('text');
|
||
if (!a || !feld) return;
|
||
const person = a.treffer[i ?? a.wahl];
|
||
if (!person) return;
|
||
/* Fuer das Rudel gilt dasselbe wie fuer einen Namen: "@" davor,
|
||
Leerzeichen dahinter. Es ist ein Wort im Satz, kein Sonderfall
|
||
-- deshalb steht hier auch keine eigene Behandlung. */
|
||
const bis = feld.selectionStart;
|
||
/* EIN LEERZEICHEN HINTERHER -- sonst klebt das naechste Wort am
|
||
Namen, und aus "@VanVankannst" wird gar keine Ansprache mehr.
|
||
Nur, wenn dort nicht schon eines steht. */
|
||
const rest = feld.value.slice(bis);
|
||
const luecke = rest.startsWith(' ') ? '' : ' ';
|
||
feld.value = feld.value.slice(0, a.at) + '@' + person.name + luecke + rest;
|
||
const neu = a.at + 1 + person.name.length + luecke.length;
|
||
feld.setSelectionRange(neu, neu);
|
||
erwaehnSchliessen();
|
||
feld.focus();
|
||
hoeheAnpassen();
|
||
entwurfMerken();
|
||
zaehlerZeigen();
|
||
}
|
||
|
||
/** Die Tasten, solange die Liste offen ist.
|
||
*
|
||
* GIBT true ZURUECK, wenn die Taste verbraucht ist -- der Aufrufer
|
||
* hoert dann auf. So bleibt Enter zum Abschicken erhalten, sobald
|
||
* keine Liste offen steht, und Escape verwirft weiterhin die
|
||
* Antwort. Ohne diese Rueckgabe muesste an zwei Stellen dieselbe
|
||
* Bedingung stehen. */
|
||
function erwaehnTaste(e) {
|
||
if (!erwaehnAuswahl) return false;
|
||
const n = erwaehnAuswahl.treffer.length;
|
||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
const richtung = e.key === 'ArrowDown' ? 1 : -1;
|
||
erwaehnAuswahl.wahl = (erwaehnAuswahl.wahl + richtung + n) % n;
|
||
const knoepfe = $('erwaehn-liste').querySelectorAll('.erwaehn-liste__eintrag');
|
||
knoepfe.forEach((b, i) => {
|
||
b.setAttribute('aria-selected', i === erwaehnAuswahl.wahl ? 'true' : 'false');
|
||
if (i === erwaehnAuswahl.wahl) b.dataset.wahl = 'ja'; else delete b.dataset.wahl;
|
||
});
|
||
const gewaehlt = erwaehnAuswahl.treffer[erwaehnAuswahl.wahl];
|
||
$('text').setAttribute('aria-activedescendant', 'erwaehn-' + gewaehlt.id);
|
||
knoepfe[erwaehnAuswahl.wahl]?.scrollIntoView({ block: 'nearest' });
|
||
return true;
|
||
}
|
||
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); erwaehnWaehlen(); return true; }
|
||
if (e.key === 'Escape') { e.preventDefault(); erwaehnSchliessen(); return true; }
|
||
return false;
|
||
}
|
||
|
||
/* EIN ABSATZ, ZWEI SORTEN HERVORHEBUNG (erweitert 20.09.2026).
|
||
|
||
Hiess frueher `mitLinks` und konnte nur Adressen. Seit es das
|
||
Markieren mit "@" gibt, muessen ZWEI Sorten Stellen in denselben
|
||
Absatz -- und sie koennen sich ueberschneiden: In
|
||
"schau auf www.x.de/@Tili" steckt ein Name mitten in einer
|
||
Adresse.
|
||
|
||
DESHALB ERST SAMMELN, DANN SORTIEREN, DANN ZEICHNEN. Zwei
|
||
Schleifen nacheinander haetten den Text zweimal zerschnitten und
|
||
die zweite haette die Stuecke der ersten nicht gesehen. Bei einer
|
||
Ueberschneidung gewinnt, was frueher anfaengt; die Adresse ist
|
||
laenger und faengt frueher an, also bleibt sie ganz.
|
||
|
||
textContent, nie innerHTML -- das gilt unveraendert und ist der
|
||
Grund, warum hier Knoten gebaut und nicht Zeichenketten
|
||
zusammengeklebt werden. Eine Nachricht darf keine Auszeichnung
|
||
einschleusen, auch keinen eigenen Namen in spitzen Klammern. */
|
||
/* DIE GESTALTUNG LIEGT AUSSEN, LINKS UND ERWAEHNUNGEN INNEN
|
||
(21.09.2026).
|
||
|
||
Filipe: "ich will dass die leute auch immer die art von text
|
||
aussuchen können, fettgedrückt, unterstrichen und so alles ...
|
||
das überall sei es im chat jeder für sich."
|
||
|
||
WARUM NICHT EINFACH EINE FUENFTE SORTE STELLE: Links und
|
||
Erwaehnungen sind PUNKTE im Text -- sie haben Anfang und Ende und
|
||
liegen nebeneinander. Gestaltung ist etwas anderes: Sie UMSCHLIESST
|
||
etwas, und sie kann sich verschachteln ("**fett und _auch
|
||
kursiv_**"). Beides in dieselbe Liste zu werfen haette bei jeder
|
||
Ueberlappung geraten.
|
||
|
||
Deshalb zwei Schichten: Erst wird der Text in Stuecke mit Stilen
|
||
zerlegt, dann bekommt JEDES Stueck die vorhandene Suche nach
|
||
Links und Erwaehnungen -- und zum Schluss legt sich die Gestaltung
|
||
aussen herum. Ein Link in einem fetten Satz bleibt damit ein Link.
|
||
|
||
Ohne textform.js (aeltere Fassung im Zwischenspeicher) faellt es
|
||
auf den alten Zustand zurueck: ein Stueck, keine Stile. Dann sieht
|
||
man Sternchen statt Fettschrift -- aber nichts ist kaputt. */
|
||
function textAbsatz(text, rudel = false) {
|
||
const p = el('p', 'chat-nachricht__text');
|
||
const teilchen = window.Textform
|
||
? window.Textform.teile(text)
|
||
: [{ text: String(text), stile: {} }];
|
||
for (const t of teilchen) {
|
||
const innen = stellenBauen(t.text, rudel);
|
||
p.append(window.Textform ? window.Textform.huelle(innen, t.stile) : innen);
|
||
}
|
||
return p;
|
||
}
|
||
|
||
/** Die vorhandene Suche nach Links und Erwaehnungen -- unveraendert,
|
||
* nur arbeitet sie jetzt auf einem STUECK statt auf dem ganzen Text
|
||
* und gibt einen Baustein zurueck statt einen Absatz. */
|
||
function stellenBauen(roh, rudel = false) {
|
||
const stueck = document.createDocumentFragment();
|
||
const stellen = [];
|
||
|
||
for (const treffer of roh.matchAll(LINKMUSTER)) {
|
||
/* Satzzeichen am Ende gehoeren zum Satz, nicht zur Adresse. */
|
||
const sauber = treffer[0].replace(/[.,;:!?)\]]+$/, '');
|
||
stellen.push({ art: 'link', von: treffer.index, bis: treffer.index + sauber.length, wort: sauber });
|
||
}
|
||
/* Gegen die Teilnehmer DIESES Raums -- wie im Server. Steht kein
|
||
Raum offen (etwa beim Suchen), wird nichts hervorgehoben: lieber
|
||
grau als falsch. */
|
||
for (const t of erwaehnungenFinden(roh, offen?.teilnehmer || [], { rudel })) {
|
||
stellen.push({ art: 'erwaehnung', von: t.von, bis: t.bis,
|
||
wort: roh.slice(t.von, t.bis), id: t.id, rudel: !!t.rudel });
|
||
}
|
||
|
||
stellen.sort((a, b) => a.von - b.von || b.bis - a.bis);
|
||
|
||
let letzte = 0;
|
||
for (const st of stellen) {
|
||
if (st.von < letzte) continue; // liegt in einer frueheren Stelle
|
||
if (st.von > letzte) stueck.append(document.createTextNode(roh.slice(letzte, st.von)));
|
||
if (st.art === 'link') {
|
||
const a = el('a', 'chat-nachricht__link', st.wort);
|
||
a.href = st.wort.startsWith('www.') ? 'https://' + st.wort : st.wort;
|
||
a.target = '_blank';
|
||
a.rel = 'noopener noreferrer';
|
||
stueck.append(a);
|
||
} else {
|
||
/* KEIN KNOPF, SONDERN EINE MARKE. Ein Klick darauf muesste
|
||
irgendwohin fuehren -- zum Steckbrief? in ein Zweiergespraech?
|
||
Beides waere eine Entscheidung, die niemand bestellt hat, und
|
||
ein Knopf, der nichts tut, ist schlimmer als keiner.
|
||
Hervorgehoben wird, damit man es SIEHT; mehr verspricht es
|
||
nicht. */
|
||
const m = el('span', 'chat-erwaehnung', st.wort);
|
||
/* BIN ICH GEMEINT? Dann deutlicher. Das ist der eigentliche
|
||
Zweck: In einem Raum mit acht Leuten soll der eigene Name
|
||
aus dem Absatz herausspringen. */
|
||
if (ich && st.id === ich.id) m.dataset.ich = 'ja';
|
||
/* DAS RUDEL MEINT ALLE -- also auch mich, wenn ich dazugehoere.
|
||
Es bekommt sein eigenes Merkmal statt `ich`: "alle sind
|
||
gemeint" ist etwas anderes als "du bist gemeint", und wenn
|
||
beides gleich aussieht, verliert das zweite seine Wirkung. */
|
||
if (st.rudel) m.dataset.rudel = 'ja';
|
||
stueck.append(m);
|
||
}
|
||
letzte = st.bis;
|
||
}
|
||
if (letzte < roh.length) stueck.append(document.createTextNode(roh.slice(letzte)));
|
||
return stueck;
|
||
}
|
||
|
||
/* ---------- Antworten und Springen (09.09.2026, Punkt 15) ---------------- */
|
||
|
||
function antwortSetzen(n) {
|
||
antwortAuf = { id: n.id, von: n.von, text: n.text };
|
||
const leiste = $('antwort-leiste');
|
||
if (!leiste) return;
|
||
leiste.hidden = false;
|
||
$('antwort-von').textContent = n.von;
|
||
/* IN DER VORSCHAU STEHEN KEINE ZEICHEN (21.09.2026). Hier ist
|
||
keine Gestaltung moeglich -- eine Zeile, in der "**super**"
|
||
steht, sieht nach kaputt aus. `nurText` nimmt die Zeichen weg
|
||
und laesst den Satz stehen. */
|
||
$('antwort-text').textContent = window.Textform
|
||
? window.Textform.nurText(n.text) : n.text;
|
||
$('text').focus();
|
||
}
|
||
|
||
function antwortLoeschen() {
|
||
antwortAuf = null;
|
||
const leiste = $('antwort-leiste');
|
||
if (leiste) leiste.hidden = true;
|
||
}
|
||
|
||
/** Zur zitierten Nachricht springen und sie kurz hervorheben.
|
||
*
|
||
* Steht sie nicht (mehr) im geladenen Ausschnitt, wird das GESAGT --
|
||
* ein Klick, der nichts tut, laesst einen glauben, der Knopf sei
|
||
* kaputt. */
|
||
function zurStelle(id) {
|
||
const el2 = document.querySelector(`.chat-nachricht[data-id="${id}"]`);
|
||
if (!el2) { melde('Diese Nachricht liegt weiter oben – bitte nach oben blättern.'); return; }
|
||
el2.scrollIntoView({ block: 'center', behavior: window.sanft?.() || 'auto' });
|
||
el2.dataset.hervor = 'ja';
|
||
setTimeout(() => { delete el2.dataset.hervor; }, 2200);
|
||
}
|
||
|
||
function tagName(iso) {
|
||
const d = new Date(iso);
|
||
const heute = new Date();
|
||
const gleich = (a, b) => a.toDateString() === b.toDateString();
|
||
if (gleich(d, heute)) return 'Heute';
|
||
if (gleich(d, new Date(heute.getTime() - 86400000))) return 'Gestern';
|
||
return d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' });
|
||
}
|
||
|
||
/* ---------- Schreiben ---------------------------------------------------- */
|
||
|
||
async function abschicken(e) {
|
||
e.preventDefault();
|
||
const feld = $('text');
|
||
const text = feld.value.trim();
|
||
if (!text || !offen) return;
|
||
|
||
/* SOFORT ANZEIGEN. Die vorläufige Nachricht bekommt eine negative
|
||
Nummer -- damit lässt sie sich später zweifelsfrei durch die
|
||
echte ersetzen, ohne mit einer Server-Nummer zu kollidieren. */
|
||
/* Das Antwortziel wird HIER festgehalten, nicht erst im
|
||
`await`. Wer waehrend des Sendens auf eine andere Nachricht
|
||
antwortet, wuerde sonst das falsche Zitat mitschicken -- die
|
||
Anfrage laeuft, die Variable hat sich schon geaendert. */
|
||
const zielAntwort = antwortAuf;
|
||
|
||
const vorlaeufig = {
|
||
id: -Date.now(), von_id: ich.id, von: ich.name, rolle: ich.rolle,
|
||
text, erstellt: new Date().toISOString(), selbst: true, unterwegs: true,
|
||
/* DAMIT DAS EIGENE "@rudel" SOFORT LEUCHTET. Ohne diese Zeile
|
||
saehe der Absender seinen Ruf zuerst grau und erst nach der
|
||
Antwort des Servers farbig -- derselbe Satz, zwei
|
||
Darstellungen, Unterschied nur im Zeitpunkt. Dass der Server
|
||
es genauso sieht, entscheidet er selbst; die echte Nachricht
|
||
ersetzt diese hier ohnehin. */
|
||
rudel: darfRudel && erwaehnungenFinden(
|
||
text, offen?.teilnehmer || [], { rudel: true }).some((t) => t.rudel),
|
||
antwort: zielAntwort
|
||
? { id: zielAntwort.id, von: zielAntwort.von, text: zielAntwort.text }
|
||
: null,
|
||
};
|
||
nachrichten.push(vorlaeufig);
|
||
/* Wer selbst schreibt, will das Ergebnis sehen -- auch wenn er
|
||
vorher weiter oben gelesen hat. */
|
||
unten = true; neuUnten = 0;
|
||
verlaufZeichnen();
|
||
feld.value = '';
|
||
entwurfWeg(offen.id);
|
||
zaehlerZeigen();
|
||
antwortLoeschen();
|
||
hoeheAnpassen();
|
||
melde('');
|
||
|
||
try {
|
||
const a = await hole(`/workspace/api/chat/raeume/${offen.id}/nachrichten`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ text, antwort_auf: zielAntwort?.id ?? null }),
|
||
});
|
||
if (!a.ok) {
|
||
const d = await a.json().catch(() => ({}));
|
||
markiereAlsGescheitert(vorlaeufig.id, sagWas(d.fehler, 'Nicht angekommen.'));
|
||
return;
|
||
}
|
||
const { nachricht } = await a.json();
|
||
const i = nachrichten.findIndex((n) => n.id === vorlaeufig.id);
|
||
if (i >= 0) nachrichten[i] = { ...nachricht, selbst: true };
|
||
verlaufZeichnen();
|
||
raeumeLaden();
|
||
} catch {
|
||
markiereAlsGescheitert(vorlaeufig.id, 'Keine Verbindung.');
|
||
}
|
||
}
|
||
|
||
/** NICHT STILL VERSCHWINDEN LASSEN. Wer etwas schreibt und es sieht,
|
||
* glaubt, es sei angekommen. Verschwindet es wortlos, merkt er es
|
||
* im schlimmsten Fall Tage später. */
|
||
function markiereAlsGescheitert(id, grund) {
|
||
const i = nachrichten.findIndex((n) => n.id === id);
|
||
if (i >= 0) { nachrichten[i].gescheitert = true; delete nachrichten[i].unterwegs; }
|
||
verlaufZeichnen();
|
||
/* Kein „kopiere sie" mehr -- an der Nachricht steht jetzt ein
|
||
Knopf, der es in einem Griff wiederholt. */
|
||
melde(`${grund} Die Nachricht ist nicht angekommen – `
|
||
+ `mit „Nochmal senden" kannst du es gleich wiederholen.`);
|
||
}
|
||
|
||
async function zuruecknehmen(id, fremd = false) {
|
||
if (!await frageNach({
|
||
titel: fremd ? 'Diese Nachricht löschen?' : 'Nachricht löschen?',
|
||
was: fremd
|
||
? 'Sie ist danach bei allen weg – auch bei dem, der sie geschrieben hat.'
|
||
: 'Sie ist danach bei allen weg.',
|
||
/* KEIN „ein Hinweis bleibt stehen" MEHR -- es bleibt keiner.
|
||
Ein Rückfragetext, der etwas anderes verspricht als das, was
|
||
passiert, ist schlimmer als keiner: Man entscheidet auf
|
||
falscher Grundlage. */
|
||
bleibt: fremd
|
||
? 'Dass DU sie gelöscht hast, steht im Protokoll – der Text nirgends.'
|
||
: 'Es bleibt keine Spur im Verlauf.',
|
||
endgueltig: true, ja: 'Löschen',
|
||
})) return;
|
||
try {
|
||
const a = await hole(`/workspace/api/chat/nachrichten/${id}`, { method: 'DELETE' });
|
||
if (!a.ok) { melde('Ging nicht.'); return; }
|
||
const i = nachrichten.findIndex((n) => n.id === id);
|
||
if (i >= 0) nachrichten.splice(i, 1);
|
||
for (const x of nachrichten) if (x.antwort?.id === id) x.antwort = null;
|
||
verlaufZeichnen();
|
||
raeumeLaden();
|
||
} catch { /* umgeleitet */ }
|
||
}
|
||
|
||
/** Das Feld wächst mit dem Text -- bis zu einer Grenze, sonst
|
||
* verdrängt eine lange Nachricht den ganzen Verlauf. */
|
||
function hoeheAnpassen() {
|
||
const f = $('text');
|
||
f.style.height = 'auto';
|
||
f.style.height = Math.min(f.scrollHeight, 160) + 'px';
|
||
/* DIE LEISTE SAGT, WIE HOCH SIE IST (20.09.2026).
|
||
Der Knopf „Zum neuesten" schwebte auf festen 96 Pixeln -- der
|
||
Höhe der Leiste an dem Tag, an dem er gebaut wurde. Wächst das
|
||
Textfeld (bis 160), verschwindet er dahinter, und zwar genau
|
||
dann, wenn man lange tippt und gleichzeitig oben liest.
|
||
Gemessen statt gerechnet: Eine Zahl, die das Element selbst
|
||
kennt, kann nicht veralten. */
|
||
unterbauMessen();
|
||
}
|
||
|
||
/** Wie hoch ist alles, was UNTEN am Verlauf klebt?
|
||
*
|
||
* Nicht nur die Schreibleiste: Darüber können das Nachtruhe-Band
|
||
* und der Hochlade-Balken stehen. Wer nur die Leiste misst, legt
|
||
* den Knopf „Zum neuesten" nachts hinter das Band.
|
||
*
|
||
* Gemessen wird vom unteren Rand des Kastens bis zur obersten
|
||
* Kante des sichtbaren Unterbaus -- egal, aus wie vielen Teilen er
|
||
* gerade besteht. Kommt morgen ein viertes dazu, rechnet es von
|
||
* selbst mit. */
|
||
function unterbauMessen() {
|
||
const leiste = $('schreiben');
|
||
const kasten = leiste?.parentElement;
|
||
if (!kasten) return;
|
||
const unten = kasten.getBoundingClientRect().bottom;
|
||
let oberste = unten;
|
||
for (const id of ['nachtruhe', 'anhang-fortschritt', 'schreiben']) {
|
||
const e = $(id);
|
||
if (!e || e.hidden) continue;
|
||
const r = e.getBoundingClientRect();
|
||
if (r.height > 0 && r.top < oberste) oberste = r.top;
|
||
}
|
||
const hoch = Math.max(0, Math.round(unten - oberste));
|
||
/* NUR SETZEN, WENN SICH ETWAS AENDERT. Sonst beisst sich der
|
||
Beobachter unten in den eigenen Schwanz: Eine Stil-Aenderung am
|
||
Kasten ist selbst wieder eine Aenderung. */
|
||
if (hoch === letzteLeiste) return;
|
||
letzteLeiste = hoch;
|
||
kasten.style.setProperty('--leiste', hoch + 'px');
|
||
}
|
||
let letzteLeiste = -1;
|
||
|
||
/** DER UNTERBAU AENDERT SICH AUCH OHNE TIPPEN.
|
||
*
|
||
* Das Nachtruhe-Band erscheint um Mitternacht von selbst, der
|
||
* Hochlade-Balken beim Anhaengen einer Datei. `hoeheAnpassen`
|
||
* laeuft dabei nicht -- der Knopf „Zum neuesten" staende dann
|
||
* wieder hinter etwas.
|
||
*
|
||
* Ein Beobachter statt einer Liste von Ausloesern: Was sich am
|
||
* unteren Rand tut, wird bemerkt, auch wenn es morgen ein viertes
|
||
* Bauteil ist. Gerechnet wird hoechstens einmal je Bild -- sonst
|
||
* waere jede getippte Zeile eine Messung. */
|
||
function unterbauBeobachten() {
|
||
const kasten = $('schreiben')?.parentElement;
|
||
if (!kasten || !window.MutationObserver) return;
|
||
let geplant = false;
|
||
new MutationObserver(() => {
|
||
if (geplant) return;
|
||
geplant = true;
|
||
requestAnimationFrame(() => { geplant = false; unterbauMessen(); });
|
||
}).observe(kasten, {
|
||
childList: true, subtree: true,
|
||
attributes: true, attributeFilter: ['hidden', 'class'],
|
||
});
|
||
/* Und beim Drehen des Geräts: Dort ändert sich nichts am Aufbau,
|
||
aber alles an der Breite -- und damit an der Zeilenzahl. */
|
||
window.addEventListener('resize', () => unterbauMessen());
|
||
}
|
||
|
||
/* ---------- Gelesen ------------------------------------------------------ */
|
||
|
||
function gelesenMelden() {
|
||
if (!offen || !nachrichten.length) return;
|
||
/* NUR WENN MAN WIRKLICH HINSIEHT. Eine Seite im Hintergrund hat
|
||
niemand gelesen. */
|
||
if (document.hidden) return;
|
||
const echte = nachrichten.filter((n) => n.id > 0);
|
||
if (!echte.length) return;
|
||
const bis = echte[echte.length - 1].id;
|
||
|
||
fetch(`/workspace/api/chat/raeume/${offen.id}/gelesen`, {
|
||
method: 'PUT', credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ bis }),
|
||
}).then(() => {
|
||
const r = raeume.find((x) => x.id === offen.id);
|
||
if (r) { r.ungelesen = 0; raeumeZeichnen(); }
|
||
ungelesenMelden();
|
||
}).catch(() => {});
|
||
}
|
||
|
||
/** Sagt der Kopfleiste, wie viele insgesamt warten. */
|
||
async function ungelesenMelden() {
|
||
try {
|
||
const a = await fetch('/workspace/api/chat/ungelesen', { credentials: 'same-origin' });
|
||
if (!a.ok) return;
|
||
const { ungelesen } = await a.json();
|
||
window.chatZahlZeigen?.(ungelesen);
|
||
} catch { /* die Zahl ist Zugabe */ }
|
||
}
|
||
|
||
/* ---------- Neue Nachrichten kommen von selbst --------------------------- */
|
||
|
||
function stromOeffnen() {
|
||
if (strom) strom.close();
|
||
strom = new EventSource('/workspace/api/chat/strom');
|
||
|
||
/* ANRUFE KOMMEN ÜBER DENSELBEN KANAL (18.09.2026).
|
||
|
||
Klingeln, Angebot, Antwort und Verbindungswege sind Ereignisse
|
||
wie jede Nachricht -- nur mit `event: anruf`. Ein eigener
|
||
WebSocket daneben wäre eine zweite Verbindung, die dieselbe
|
||
Frage beantwortet, und die zweite ist die, die beim nächsten
|
||
Umbau vergessen wird.
|
||
|
||
Der Chat versteht den Inhalt nicht; er reicht ihn weiter. Wer
|
||
ihn versteht, steht in anruf.js. */
|
||
strom.addEventListener('anruf', (e) => {
|
||
let d;
|
||
try { d = JSON.parse(e.data); } catch { return; }
|
||
window.anrufEreignis?.(d);
|
||
});
|
||
|
||
strom.addEventListener('neu', (e) => {
|
||
let n;
|
||
try { n = JSON.parse(e.data); } catch { return; }
|
||
|
||
if (offen && n.raum_id === offen.id) {
|
||
/* SCHON DA? Zwei Wege führen hierher, und beide sind richtig:
|
||
die Antwort auf das eigene POST und der Strom. */
|
||
if (nachrichten.some((x) => x.id === n.id)) return;
|
||
|
||
/* DER WETTLAUF (gefunden 06.09.2026, im Zwei-Browser-Test):
|
||
Beim Abschicken steht die eigene Nachricht sofort da -- mit
|
||
einer vorläufigen negativen Nummer. Ist der Strom SCHNELLER
|
||
als die Antwort auf das POST, greift die Prüfung darüber
|
||
nicht (die Nummern sind verschieden), und die Nachricht
|
||
stand zweimal im Verlauf.
|
||
|
||
Deshalb wird eine eigene Nachricht, die noch unterwegs ist
|
||
und denselben Text trägt, hier ERSETZT statt ergänzt. Damit
|
||
gewinnt, wer zuerst kommt, und das Ergebnis ist dasselbe. */
|
||
if (n.von_id === ich.id) {
|
||
const i = nachrichten.findIndex(
|
||
(x) => x.unterwegs && x.text === n.text);
|
||
if (i >= 0) {
|
||
nachrichten[i] = { ...n, selbst: true };
|
||
verlaufZeichnen();
|
||
return;
|
||
}
|
||
}
|
||
|
||
nachrichten.push({ ...n, selbst: n.von_id === ich.id });
|
||
/* Wer gerade weiter oben liest, soll sehen, WIE VIEL unten
|
||
dazugekommen ist -- nicht nur, DASS etwas da ist. */
|
||
if (!unten && n.von_id !== ich.id) neuUnten++;
|
||
verlaufZeichnen();
|
||
gelesenMelden();
|
||
}
|
||
raeumeLaden();
|
||
});
|
||
|
||
strom.addEventListener('weg', (e) => {
|
||
let n;
|
||
try { n = JSON.parse(e.data); } catch { return; }
|
||
/* SIE VERSCHWINDET, sie wird nicht durchgestrichen (22.09.2026).
|
||
|
||
Filipe: „Gelöschte Nachrichten verschwinden vollständig –
|
||
keine Spur, kein ‚wurde gelöscht'-Hinweis, bei niemandem,
|
||
auch nicht bei DogFather."
|
||
|
||
Hier stand `zurueckgenommen = true` -- die Zeile blieb und
|
||
trug den Hinweis. Ein Hinweis „hier stand etwas" markiert die
|
||
Stelle; wer etwas aus Versehen schreibt, will es weg haben
|
||
und nicht unterstrichen. */
|
||
const i = nachrichten.findIndex((x) => x.id === n.id);
|
||
if (i >= 0) {
|
||
nachrichten.splice(i, 1);
|
||
/* Auch ein Zitat darauf: Die zitierte Nachricht gibt es nicht
|
||
mehr, also gibt es auch nichts zu zeigen. Der Server liefert
|
||
beim nächsten Laden ohnehin nichts; hier passt sich nur das
|
||
Bild sofort an. */
|
||
for (const x of nachrichten) if (x.antwort?.id === n.id) x.antwort = null;
|
||
verlaufZeichnen();
|
||
}
|
||
raeumeLaden();
|
||
});
|
||
|
||
/* DER RAUM IST AUFGELOEST (11.09.2026).
|
||
|
||
Kommt beim Gruender genauso an wie bei allen anderen -- er
|
||
braucht keinen Sonderweg, und dadurch gibt es keinen zweiten
|
||
Ablauf, der irgendwann anders aussieht.
|
||
|
||
Wer ihn gerade offen hat, bekommt einen Satz dazu. Ohne den
|
||
spraenge die Seite wortlos auf die Liste zurueck, und man
|
||
haelt es fuer einen Fehler. Wer ihn NICHT offen hat, merkt nur,
|
||
dass ein Eintrag aus der Liste verschwindet -- das reicht. */
|
||
strom.addEventListener('raum-weg', (e) => {
|
||
let n;
|
||
try { n = JSON.parse(e.data); } catch { return; }
|
||
if (offen && n.raum_id === offen.id) {
|
||
verlaufSchliessen();
|
||
melde('Dieses Gespräch wurde von der Person aufgelöst, die es '
|
||
+ 'angelegt hat.');
|
||
setTimeout(() => melde(''), 8000);
|
||
}
|
||
raeumeLaden();
|
||
});
|
||
|
||
/* EINE REAKTION -- alle im Raum sehen sie sofort (14.09.2026).
|
||
|
||
Betroffen ist nur, wer denselben Raum offen hat. Wer ihn nicht
|
||
offen hat, bekommt NICHTS: kein Ungelesen-Zeichen, keine Meldung
|
||
aufs Handy. Ein Daumen ist keine Nachricht; wer dafuer geweckt
|
||
wird, schaltet Meldungen ab. */
|
||
strom.addEventListener('reaktion', (e) => {
|
||
let n;
|
||
try { n = JSON.parse(e.data); } catch { return; }
|
||
if (!offen || n.raum_id !== offen.id) return;
|
||
standSetzen(n.id, n.reaktionen || []);
|
||
});
|
||
|
||
/* ANGEHEFTET ODER GELOEST -- alle im Raum sehen es sofort.
|
||
Betroffen ist nur, wer denselben Raum offen hat; sonst genuegt
|
||
die Liste. */
|
||
strom.addEventListener('pin', (e) => {
|
||
let n;
|
||
try { n = JSON.parse(e.data); } catch { return; }
|
||
if (offen && n.raum_id === offen.id) raumOeffnen(offen.id, { sanft: true });
|
||
});
|
||
|
||
/* Bei einem Abriss verbindet EventSource von selbst neu. Wir holen
|
||
danach den Verlauf nach -- sonst fehlte, was während der Pause
|
||
geschrieben wurde. */
|
||
strom.addEventListener('open', () => {
|
||
if (offen) raumOeffnen(offen.id, { sanft: true });
|
||
else raeumeLaden();
|
||
});
|
||
}
|
||
|
||
/* ---------- Neues Gespräch ----------------------------------------------- */
|
||
|
||
async function partnerLaden(vorbelegt = []) {
|
||
const ziel = $('partner');
|
||
ziel.textContent = '';
|
||
gewaehlt = new Set(vorbelegt);
|
||
$('gruppenname-zeile').hidden = true;
|
||
$('neu-los').disabled = true;
|
||
$('neu-fehler').textContent = '';
|
||
|
||
let personen = [];
|
||
try {
|
||
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;
|
||
/* Gespeichert -- die Verwurfwache darf hier nicht fragen. */
|
||
$('neu-dialog').vergessen?.();
|
||
$('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;
|
||
/* Gespeichert -- die Verwurfwache darf hier nicht fragen. */
|
||
$('neu-dialog').vergessen?.();
|
||
$('neu-dialog').close();
|
||
await raeumeLaden();
|
||
await raumOeffnen(raumId);
|
||
} catch { /* umgeleitet */ } finally { knopf.disabled = false; }
|
||
return;
|
||
}
|
||
|
||
if (!ids.length) return;
|
||
knopf.disabled = true;
|
||
|
||
try {
|
||
let raumId;
|
||
if (ids.length === 1) {
|
||
const a = await hole('/workspace/api/chat/direkt', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ mit: ids[0] }),
|
||
});
|
||
if (!a.ok) {
|
||
$('neu-fehler').textContent = sagWas((await a.json().catch(() => ({}))).fehler, 'Ging nicht.');
|
||
return;
|
||
}
|
||
raumId = (await a.json()).raum_id;
|
||
} else {
|
||
const name = $('gruppenname').value.trim();
|
||
if (name.length < 2) {
|
||
$('neu-fehler').textContent = 'Die Gruppe braucht einen Namen.';
|
||
$('gruppenname').focus();
|
||
return;
|
||
}
|
||
const a = await hole('/workspace/api/chat/gruppe', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name, mit: ids }),
|
||
});
|
||
if (!a.ok) {
|
||
$('neu-fehler').textContent = sagWas((await a.json().catch(() => ({}))).fehler, 'Ging nicht.');
|
||
return;
|
||
}
|
||
raumId = (await a.json()).raum_id;
|
||
}
|
||
/* Gespeichert -- die Verwurfwache darf hier nicht fragen. */
|
||
$('neu-dialog').vergessen?.();
|
||
$('neu-dialog').close();
|
||
await raeumeLaden();
|
||
await raumOeffnen(raumId);
|
||
} catch { /* umgeleitet */ } finally { knopf.disabled = false; }
|
||
}
|
||
|
||
/* ---------- Die Höhe der Kopfleiste MESSEN, nicht raten ------------------
|
||
|
||
Die Chatfläche ist so hoch wie das Fenster minus die Kopfleiste.
|
||
Deren Höhe stand zuerst als fester Wert im CSS (62 px) -- am Handy
|
||
ist sie zweizeilig und gut doppelt so hoch. Folge: Die Seite war zu
|
||
lang, und das Eingabefeld stand halb unter dem Bildschirmrand.
|
||
|
||
Ein fester Wert ist hier immer falsch: Die Leiste wächst mit der
|
||
Schriftgröße, mit der Glocke, mit einem längeren Namen. Also wird
|
||
sie gemessen -- einmal beim Start und wieder, wenn sich etwas an
|
||
der Fenstergröße ändert. */
|
||
/* SEIT DEM 09.09.2026 GEHOERT DAS BAND DAZU.
|
||
|
||
Ueber der Chatflaeche steht in einer fremden Sicht ein zweites
|
||
Element: das Band mit dem Namen (.fremd-band, kopf.js). Wer nur die
|
||
Kopfleiste misst, rechnet dessen Hoehe nicht ab -- und das
|
||
Eingabefeld steht genau so weit unter dem Bildschirmrand, wie das
|
||
Band hoch ist. Derselbe Fehler wie mit den festen 62 px, nur mit
|
||
einem anderen Element.
|
||
|
||
Gezaehlt wird deshalb ALLES, was ueber der Flaeche liegt. */
|
||
/* DIE MESSUNG STEHT SEIT DEM 22.09.2026 IN kopf.js.
|
||
|
||
Sie stand hier, weil sie hier gebraucht wurde. Als die Regelseite
|
||
dieselbe Zahl brauchte, war sie dort nicht da -- und der
|
||
Rueckfallwert (64 px) lag auf einem Handy um rund 50 px daneben.
|
||
Eine Zahl, die eine Seite misst und eine andere raet, ist
|
||
schlimmer als gar keine.
|
||
|
||
kopf.js liegt auf JEDER Seite und misst dasselbe: Kopfleiste plus
|
||
Band mit dem fremden Namen, bei jeder Aenderung neu. Hier bleibt
|
||
nur ein Aufruf fuer den Fall, dass die Chatseite vor kopf.js
|
||
fertig ist. Zweimal messen schadet nicht; zweimal RECHNEN haette
|
||
geschadet. */
|
||
window.kopfhoeheMessen?.();
|
||
|
||
/* ---------- Start -------------------------------------------------------- */
|
||
/* Der Verwerfen-Knopf der Antwortleiste. Zusaetzlich beendet Escape
|
||
die Antwort -- wer im Eingabefeld steht, greift eher zur Taste als
|
||
zur Maus. */
|
||
$('antwort-weg')?.addEventListener('click', antwortLoeschen);
|
||
$('text')?.addEventListener('keydown', (e) => {
|
||
/* ERST DIE AUSWAHLLISTE, DANN DIE ANTWORT. Escape raeumt immer
|
||
das Oberste weg -- steht eine Liste offen, ist sie gemeint.
|
||
Sonst verloere man mit einem Tastendruck beides auf einmal. */
|
||
if (erwaehnTaste(e)) return;
|
||
if (e.key === 'Escape' && antwortAuf) { e.preventDefault(); antwortLoeschen(); }
|
||
});
|
||
|
||
|
||
$('schreiben').addEventListener('submit', abschicken);
|
||
$('text').addEventListener('input', () => {
|
||
hoeheAnpassen();
|
||
entwurfMerken();
|
||
zaehlerZeigen();
|
||
erwaehnPruefen();
|
||
});
|
||
/* AUCH BEIM BLOSSEN BEWEGEN DES SCHREIBZEIGERS. Wer mit der Maus
|
||
oder den Pfeiltasten hinter ein bereits getipptes "@Van" springt,
|
||
will dort weitermachen -- ohne diesen Weg bliebe die Liste zu, bis
|
||
er einen Buchstaben tippt. */
|
||
$('text').addEventListener('click', erwaehnPruefen);
|
||
$('text').addEventListener('keyup', (e) => {
|
||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight'
|
||
|| e.key === 'Home' || e.key === 'End') erwaehnPruefen();
|
||
});
|
||
/* Wer das Feld verlaesst, hat es sich anders ueberlegt. `mousedown`
|
||
auf einem Eintrag haelt den Schreibzeiger fest, deshalb schliesst
|
||
dieser Weg die Liste nicht versehentlich vor dem Klick. */
|
||
$('text').addEventListener('blur', erwaehnSchliessen);
|
||
|
||
/* ---------- Entwurf je Gespraech (09.09.2026) ----------------------------
|
||
Wer mitten im Satz das Gespraech wechselt, findet den Satz beim
|
||
Zurueckkommen wieder. Ohne das ist jeder Wechsel eine Entscheidung:
|
||
"wegwerfen oder erst fertig schreiben?"
|
||
|
||
Im localStorage und nicht auf dem Server: Ein Entwurf ist nichts,
|
||
was jemand anders sehen soll, und er gehoert an dieses Geraet.
|
||
Abgeschickte oder geleerte Entwuerfe werden geloescht -- eine
|
||
Ablage, die nur waechst, ist ein Leck. */
|
||
const ENTWURF = (id) => `dfw-chat-entwurf-${id}`;
|
||
function entwurfMerken() {
|
||
if (!offen) return;
|
||
const t = $('text').value;
|
||
try {
|
||
if (t.trim()) localStorage.setItem(ENTWURF(offen.id), t);
|
||
else localStorage.removeItem(ENTWURF(offen.id));
|
||
} catch { /* privater Modus: dann eben nicht */ }
|
||
}
|
||
function entwurfHolen(raumId) {
|
||
try { return localStorage.getItem(ENTWURF(raumId)) || ''; } catch { return ''; }
|
||
}
|
||
function entwurfWeg(raumId) {
|
||
try { localStorage.removeItem(ENTWURF(raumId)); } catch { /* egal */ }
|
||
}
|
||
|
||
/* ---------- Zeichenzaehler ------------------------------------------------
|
||
Er erscheint erst, wenn es eng wird. Ein Zaehler, der von Anfang an
|
||
mitlaeuft, zaehlt die meiste Zeit etwas, das niemanden interessiert --
|
||
und wird dann auch dann uebersehen, wenn er zaehlt. */
|
||
/* Die Zahl steht NICHT hier. Sie steht im maxlength des Feldes, und
|
||
dort steht sie, weil der Server sie so haben will (TEXT_MAX = 4000
|
||
in workspace-chat.js). Zwei Stellen fuer dieselbe Zahl laufen
|
||
auseinander -- dann zaehlt der Zaehler bis 4000 herunter, waehrend
|
||
das Feld schon bei 2000 dichtmacht, und niemand versteht warum. */
|
||
const GRENZE = Number($('text').getAttribute('maxlength')) || 4000;
|
||
function zaehlerZeigen() {
|
||
const z = $('zeichen');
|
||
if (!z) return;
|
||
const n = $('text').value.length;
|
||
const rest = GRENZE - n;
|
||
z.hidden = rest > 400;
|
||
z.textContent = `noch ${rest}`;
|
||
z.dataset.eng = rest <= 100 ? 'ja' : 'nein';
|
||
}
|
||
|
||
/* ---------- Emoji-Auswahl -------------------------------------------------
|
||
Eine kleine, feste Auswahl statt einer vollstaendigen Tafel: Diese
|
||
dreissig decken den Alltag ab, und sie laden nichts nach. Eine
|
||
Emoji-Bibliothek waere ein Megabyte fuer etwas, das die
|
||
Bildschirmtastatur ohnehin kann -- am Rechner aber nicht.
|
||
|
||
Eingefuegt wird an der CURSORSTELLE, nicht am Ende: Wer mitten im
|
||
Satz eines setzen will, soll nicht erst umsortieren. */
|
||
const EMOJIS = ['😀','😂','🥹','😍','😎','🤔','😅','🙃','😴','🤝',
|
||
'👍','👏','🙌','💪','🙏','🔥','✨','⭐','💯','🎉',
|
||
'❤️','🩵','💚','🖤','👀','🎮','🎧','📈','⏰','✅'];
|
||
function emojiTafelBauen() {
|
||
const tafel = $('emoji-tafel');
|
||
if (!tafel || tafel.childElementCount) return;
|
||
for (const e of EMOJIS) {
|
||
const b = el('button', 'emoji-tafel__knopf', e);
|
||
b.type = 'button';
|
||
b.setAttribute('aria-label', `Emoji ${e} einfügen`);
|
||
b.addEventListener('click', () => {
|
||
const f = $('text');
|
||
const a = f.selectionStart ?? f.value.length;
|
||
const b2 = f.selectionEnd ?? a;
|
||
f.value = f.value.slice(0, a) + e + f.value.slice(b2);
|
||
f.focus();
|
||
f.selectionStart = f.selectionEnd = a + e.length;
|
||
hoeheAnpassen(); entwurfMerken(); zaehlerZeigen();
|
||
});
|
||
tafel.append(b);
|
||
}
|
||
}
|
||
$('emoji-auf')?.addEventListener('click', () => {
|
||
const tafel = $('emoji-tafel');
|
||
emojiTafelBauen();
|
||
const auf = tafel.hidden;
|
||
tafel.hidden = !auf;
|
||
$('emoji-auf').setAttribute('aria-expanded', String(auf));
|
||
if (auf) tafel.querySelector('button')?.focus();
|
||
});
|
||
/* Ein Klick daneben schliesst die Tafel -- sonst steht sie im Weg,
|
||
und man sucht den Knopf, mit dem man sie aufgemacht hat. */
|
||
document.addEventListener('click', (e) => {
|
||
const tafel = $('emoji-tafel');
|
||
if (!tafel || tafel.hidden) return;
|
||
if (tafel.contains(e.target) || e.target.closest('#emoji-auf')) return;
|
||
tafel.hidden = true;
|
||
$('emoji-auf')?.setAttribute('aria-expanded', 'false');
|
||
});
|
||
/* Escape schliesst sie -- und zwar von ueberall her, nicht nur wenn
|
||
die Schreibmarke zufaellig noch in der Tafel steht.
|
||
|
||
Erster Anlauf hing der Zuhoerer an der Tafel selbst. Das geht gut,
|
||
solange man sie gerade aufgemacht hat (dann liegt der Fokus auf dem
|
||
ersten Zeichen). Wer danach ins Schreibfeld zurueckklickt, drueckt
|
||
Escape aber ins Leere -- und die Tafel steht weiter im Weg. Am
|
||
Dokument gilt es immer; ist die Tafel zu, tut die Zeile nichts. */
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key !== 'Escape') return;
|
||
const tafel = $('emoji-tafel');
|
||
if (!tafel || tafel.hidden) return;
|
||
tafel.hidden = true;
|
||
$('emoji-auf')?.setAttribute('aria-expanded', 'false');
|
||
$('text').focus();
|
||
});
|
||
|
||
/* ---------- Zum neuesten springen ---------------------------------------- */
|
||
$('verlauf')?.addEventListener('scroll', () => {
|
||
const v = $('verlauf');
|
||
const jetztUnten = amEnde(v);
|
||
if (jetztUnten !== unten) {
|
||
unten = jetztUnten;
|
||
if (unten) neuUnten = 0;
|
||
springKnopfZeigen();
|
||
}
|
||
});
|
||
$('zum-neuesten')?.addEventListener('click', () => {
|
||
const v = $('verlauf');
|
||
v.scrollTop = v.scrollHeight;
|
||
unten = true; neuUnten = 0;
|
||
springKnopfZeigen();
|
||
});
|
||
|
||
/* Enter schickt, Umschalt+Enter macht eine neue Zeile. Am Handy
|
||
NICHT -- dort ist Enter die Zeilenschaltung der Bildschirmtastatur,
|
||
und wer eine zweite Zeile will, käme sonst nie dazu. */
|
||
$('text').addEventListener('keydown', (e) => {
|
||
/* STEHT DIE AUSWAHL OFFEN, GEHOERT ENTER IHR. Ohne diese Zeile
|
||
waere jedes Waehlen mit der Tastatur ein versehentlich
|
||
abgeschickter halber Satz -- und zwar ausgerechnet fuer die, die
|
||
am Rechner schnell schreiben.
|
||
|
||
Der Ausgang steht VOR der Handy-Abfrage: Am Handy schickt Enter
|
||
ohnehin nicht ab, aber waehlen soll es auch dort.
|
||
|
||
GEFRAGT WIRD NACH `defaultPrevented`, NICHT NACH DER LISTE.
|
||
Der erste Anlauf fragte `if (erwaehnAuswahl && ...)` -- und lief
|
||
ins Leere: Der Handler weiter oben waehlt den Namen aus und
|
||
SCHLIESST dabei die Liste. Wenn dieser hier an die Reihe kommt,
|
||
ist `erwaehnAuswahl` schon null, die Bedingung falsch, und der
|
||
halbe Satz geht raus. Genau der Fehler, den die Funktion
|
||
verhindern soll.
|
||
|
||
`defaultPrevented` beantwortet die Frage, um die es wirklich
|
||
geht: Hat diese Taste ihre Arbeit schon getan? */
|
||
if (e.defaultPrevented) return;
|
||
const handy = window.matchMedia('(hover: none)').matches;
|
||
if (e.key === 'Enter' && !e.shiftKey && !handy) {
|
||
e.preventDefault();
|
||
$('schreiben').requestSubmit();
|
||
}
|
||
});
|
||
|
||
$('suche').addEventListener('input', () => { raeumeZeichnen(); nachrichtenSuchen(); });
|
||
$('neu-oeffnen').addEventListener('click', async () => {
|
||
kanalUmbau = null;
|
||
kanalWahlFuellen();
|
||
neuArtSetzen('gespraech');
|
||
await partnerLaden();
|
||
$('neu-dialog').showModal();
|
||
});
|
||
|
||
for (const b of document.querySelectorAll('#neu-art [data-art]')) {
|
||
b.addEventListener('click', () => neuArtSetzen(b.dataset.art));
|
||
}
|
||
$('kanal-kategorie').addEventListener('change', wahlGeaendert);
|
||
|
||
/* WER IST IM KANAL. Derselbe Dialog, vorbelegt mit denen, die schon
|
||
drin sind -- so sieht man beim Oeffnen den Stand und muss ihn nicht
|
||
erst aus der Kopfzeile abschreiben. */
|
||
$('kanal-leute').addEventListener('click', async () => {
|
||
if (!offen || offen.art !== 'kanal') return;
|
||
kanalUmbau = offen.id;
|
||
kanalWahlFuellen();
|
||
neuArtSetzen('kanal');
|
||
await partnerLaden(offen.teilnehmer.map((t) => t.id));
|
||
$('neu-dialog').showModal();
|
||
});
|
||
$('neu-zu').addEventListener('click', () => $('neu-dialog').close());
|
||
$('neu-form').addEventListener('submit', neuAbschicken);
|
||
$('zur-liste').addEventListener('click', () => {
|
||
$('chat').dataset.ansicht = 'liste';
|
||
history.replaceState(null, '', 'chat.html');
|
||
});
|
||
|
||
/* =======================================================================
|
||
ANHÄNGE SCHICKEN (09.09.2026)
|
||
|
||
Filipe: "am besten wäre es auch wenn man da auch im chat pdfs
|
||
schicken könnte. pdfs und fotos."
|
||
|
||
DREI WEGE ZU DERSELBEN SACHE, weil Leute unterschiedlich arbeiten:
|
||
die Büroklammer (immer da, auch am Handy), Hineinziehen (am
|
||
Rechner der schnellste Weg) und Einfügen mit Strg+V (der Weg für
|
||
einen Screenshot -- der liegt in der Zwischenablage und nirgendwo
|
||
als Datei).
|
||
|
||
Alle drei landen in derselben Funktion. Drei Fassungen desselben
|
||
Ablaufs wären drei Gelegenheiten, dass eine davon die Prüfung
|
||
vergisst.
|
||
|
||
GEPRÜFT WIRD TROTZDEM AUCH HIER, obwohl der Server es noch einmal
|
||
tut: Eine Absage nach zwölf Megabyte Wartezeit ist keine Absage,
|
||
das ist eine Strafe. Die Prüfung im Browser ist die Höflichkeit,
|
||
die auf dem Server ist die Sicherheit -- keine ersetzt die andere. */
|
||
const ANHANG_MAX = 12 * 1024 * 1024;
|
||
const ANHANG_TYPEN = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'application/pdf']);
|
||
let schicktGerade = false;
|
||
|
||
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;
|
||
/* Die alte Laufmeldung faellt weg -- der Balken sagt dasselbe und
|
||
zusaetzlich, WIE WEIT es ist. Zwei Anzeigen fuer dieselbe Sache
|
||
waeren Rauschen. */
|
||
melde('');
|
||
let anzeige = null;
|
||
/* Ein angefangener Begleitsatz geht mit -- wer erst tippt und dann
|
||
das Bild anhängt, will beides in einer Nachricht. */
|
||
const begleittext = $('text').value.trim();
|
||
try {
|
||
/* MIT FORTSCHRITT UND ABBRUCH (19.09.2026). Ein Foto vom Handy
|
||
ist schnell vier Megabyte gross; ohne Balken sieht der Chat
|
||
in der Zeit aus, als sei er eingefroren. */
|
||
let lauf = null;
|
||
anzeige = hochladeAnzeige($('anhang-fortschritt'), datei.name || 'Anhang',
|
||
() => lauf && lauf.abbrechen());
|
||
lauf = hochladen(`/workspace/api/chat/raeume/${offen.id}/anhang`, datei, {
|
||
kopf: {
|
||
'Content-Type': datei.type,
|
||
/* Kopfzeilen sind streng genommen nur ASCII -- ohne die
|
||
Kodierung käme aus "Größe.pdf" unterwegs Unsinn. */
|
||
'X-Dateiname': encodeURIComponent(datei.name || 'anhang'),
|
||
'X-Text': encodeURIComponent(begleittext),
|
||
},
|
||
beiFortschritt: anzeige.fortschritt,
|
||
beiVerarbeitung: anzeige.verarbeiten,
|
||
});
|
||
const ergebnis = await lauf.fertig;
|
||
if (ergebnis.abgebrochen) {
|
||
anzeige.abschluss('Abgebrochen – nichts wurde geschickt.');
|
||
anzeige = null;
|
||
return;
|
||
}
|
||
if (!ergebnis.ok) {
|
||
melde(ergebnis.netz ? 'Keine Verbindung – der Anhang wurde nicht geschickt.'
|
||
: sagWas(ergebnis.daten?.fehler, 'Ging nicht.'));
|
||
return;
|
||
}
|
||
const { nachricht } = e.daten;
|
||
/* 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 {
|
||
/* Nur wegnehmen, wenn nicht gerade der Abbruch-Satz steht --
|
||
der bekommt seine vier Sekunden. */
|
||
if (anzeige) anzeige.weg();
|
||
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 {
|
||
/* NICHT MEHR STUMM (20.09.2026). Hier stand `catch { return; }`
|
||
-- und damit blieb die Seite bei jedem Netzaussetzer fuer
|
||
immer auf "wird geladen …" stehen. Gemessen auf 13 von 21
|
||
Seiten. `ladefehler()` sagt, was los ist, und bietet einen
|
||
Knopf an, der es noch einmal versucht. */
|
||
window.ladefehler?.();
|
||
return;
|
||
}
|
||
window.werZeigen?.(ich);
|
||
|
||
await raeumeLaden();
|
||
/* DIE KACHELN VOR DEM ERSTEN VERLAUF. Kaemen sie danach, stuenden
|
||
die ersten Nachrichten kurz in Standardfarbe und faerbten sich
|
||
dann um -- eine Seite, die sich unter den Fingern aendert. */
|
||
await kachelnLaden();
|
||
kachelwahlBedienen();
|
||
stromOeffnen();
|
||
unterbauBeobachten();
|
||
unterbauMessen();
|
||
|
||
/* Kommt man aus einer Benachrichtigung, steht das Gespräch in der
|
||
Adresse -- dann direkt hinein. */
|
||
const suche = new URLSearchParams(location.search);
|
||
|
||
/* ?raum=treff -- der Weg von der Treff-Kachel (19.09.2026).
|
||
|
||
ANLASS: Ein Modi sah ZWEI Kacheln namens "Chat", beide auf
|
||
dieselbe Seite -- eine in "Taeglich" (Team), eine in "Community"
|
||
(Treff). Sie taten auch dasselbe: Man landete in der
|
||
Gespraechsliste und musste den richtigen Raum suchen. Zwei Wege
|
||
zum selben Ziel verwirren mehr, als ein fehlender fehlt.
|
||
|
||
Die Nummer des Treff-Raums steht nicht fest (er wird beim ersten
|
||
Start angelegt), deshalb das Wort statt einer Zahl. Gesucht wird
|
||
er in der Liste, die ohnehin schon geladen ist -- an denselben
|
||
zwei Merkmalen, an denen ihn der Rest der Datei erkennt. */
|
||
if (suche.get('raum') === 'treff') {
|
||
const t = raeume.find((r) => r.art === 'kanal' && r.kategorie === 'treff');
|
||
if (t) { await raumOeffnen(t.id); return; }
|
||
/* DRITTER AUSGANG: Gibt es ihn (noch) nicht, bleibt die Liste
|
||
stehen -- das ist die richtige Antwort und kein Fehler. Ein
|
||
leerer Verlauf waere schlimmer als eine Liste. */
|
||
}
|
||
|
||
const wunsch = Number(suche.get('raum'));
|
||
if (Number.isInteger(wunsch) && wunsch > 0) { await raumOeffnen(wunsch); return; }
|
||
|
||
/* ?mit=<Person> -- der Weg von woanders her (06.09.2026).
|
||
Die Frühwarnung im Dashboard schlägt vor nachzufassen; ohne
|
||
diesen Einstieg müsste man danach die Gesprächsliste durchsuchen
|
||
oder ein Gespräch von Hand anlegen, das vielleicht schon
|
||
existiert. `/direkt` legt beim ersten Mal an und findet beim
|
||
zweiten dasselbe wieder -- ein Aufruf für beide Fälle.
|
||
|
||
Die Berechtigung prüft der Server (darfSchreibenMit). Eine von
|
||
Hand getippte Personennummer bringt hier nichts. */
|
||
const mit = Number(suche.get('mit'));
|
||
if (!Number.isInteger(mit) || mit < 1) return;
|
||
try {
|
||
const a = await hole('/workspace/api/chat/direkt', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ mit }),
|
||
});
|
||
if (!a.ok) return; /* die Liste steht ja */
|
||
const { raum_id: raumId } = await a.json();
|
||
await raeumeLaden();
|
||
await raumOeffnen(raumId);
|
||
} catch { /* die Liste steht ja */ }
|
||
})();
|
||
})();
|