Reaction: Rollen sichtbar, Moderation im Live-Chat

Filipes Wunsch: „die modis, linke hand und rechte hand soll im chat
extra aussehen und auch sachen wie stummschaltungen, sperrungen,
meldunge und alles mögliche im chat machen können falls sich jemand
nicht benimmt."

WER SPRICHT
Jeder Beitrag aus dem Team trägt jetzt ein Abzeichen: „Dogi", „Team"
(beide Hände) und „Modi". Ein WORT und nicht nur eine Farbe — rund
acht Prozent der Männer unterscheiden Rot und Grün schlecht, und hier
hängt an der Unterscheidung etwas. Gedeckte Töne auf schwacher Fläche,
11,52 px (die Hausgrenze ist 11,5), weil daneben ein Video läuft.
Die Wörter sagen den RANG nicht: Filipe steht nie über seinem Team.
Eine Prüfung schlägt an, wenn dort „Chef", „Boss" oder „Leitung"
stünde.

WAS DIE MODERATION KANN
Am Namen öffnet sich ein Menü mit vier Wegen: Beitrag wegnehmen,
10 Minuten stumm, aus der Sendung — und der Verweis in den Treff, wo
längere Maßnahmen hingehören (mit Begründungspflicht und Frist in
Tagen). Die Reaction baut kein zweites Sperrsystem daneben: Wer im
Treff eine Pause hat, schreibt hier auch nicht. Neu ist nur, was der
Treff nicht hat — MINUTEN. Höchstens eine Stunde; wer länger etwas
braucht, nimmt den Treff.
Nicht gegen das Team: Wer einen Modi stummschalten könnte, hätte einen
Weg, die Moderation selbst auszuschalten, mitten in der Sendung.

MELDEN DARF JEDER
Die Moderation sieht nicht alles, wer mitliest schon. Zweimal melden
zählt einmal (sonst füllt einer allein die Liste). Bei der Moderation
erscheint im Kopf der Schiene „1 Meldung" — nur wenn etwas offen ist;
eine Zahl, die immer dasteht und meistens null ist, wird nach drei
Tagen nicht mehr gelesen.

VIER FEHLER, DIE DABEI AUFGEFALLEN SIND

1. `oeffentlich()` nahm `meldungen` und `massnahmen` NICHT aus dem
   Rundruf. Der Rundruf wird aus dem Stand dessen gebaut, der gerade
   etwas getan hat — bei einer Moderationshandlung wären der gemeldete
   Text, der Name des Gemeldeten und der Name des MELDERS an jeden im
   Saal gegangen. Wer meldet, muss sich darauf verlassen können, dass
   das niemand sieht.

2. `meldung.js` hatte zwei Schlüssel doppelt: `geschlossen` und
   `nur_leitung`. Der spätere gewinnt stillschweigend — in der Hilfe
   stand dadurch „Der Saal ist zu". Ein Wort, ein Satz; eine Prüfung
   hält das jetzt fest.

3. Wer stummgeschaltet war, bekam bei OFFENEM Chat „Der Chat ist
   gerade zu" — der Aufrufer reimte sich den Grund aus der Chat-Stufe
   zusammen. Jetzt gibt `schreibGrund()` den echten Grund zurück.

4. Wer rausgenommen wurde, merkte nichts: Das Video lief weiter, nur
   die Anwesenheitsmeldung schlug still fehl. Jetzt hält alles an und
   es steht ein Satz da, der sagt, dass es nur für heute gilt.

UND EINER, DEN NUR DAS BILDSCHIRMFOTO GEFUNDEN HAT
Das Menü lag messbar komplett im Fenster (x=1099..1315 von 1440,
y=652..840 von 900) und war trotzdem abgeschnitten: Die Chatliste
rollt, und ein rollender Vorfahre beschneidet sein Kind. „Im Fenster"
und „sichtbar" sind zwei verschiedene Fragen, und ich hatte die
falsche gemessen. Das Menü hängt jetzt fest am Fenster und klappt nach
oben oder links, wo kein Platz ist. Die Messung fasst seitdem mit
`elementFromPoint` an jeden Knopf an, statt Rechtecke zu vergleichen.

GEPRUEFT
pruef-reaktion: 216 Prüfungen (vorher 156), 0 Fehler — zwei neue
Abschnitte. mess-reaktion misst die Moderation jetzt im echten
Browser: Abzeichen, Überlauf der Schiene, Menü, Stummschaltung beim
Betroffenen, Meldung bis zur Moderation, Rauswurf.
Dazu grün: pruef-meldungen, pruef-struktur, pruef-css-klassen,
pruef-tippziele, pruef-deutsche-texte, pruef-hilfe.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-28 09:32:10 +02:00
co-authored by Claude Opus 5
parent cb8e59bb08
commit a02cf5c026
52 changed files with 2071 additions and 691 deletions
+334 -4
View File
@@ -129,6 +129,16 @@
saal.dataset.stand = lage.stand;
document.body.dataset.stand = lage.stand;
meineId = lage.ich.id;
/* WER RAUS IST, SIEHT AB HIER NICHTS MEHR. Die Prüfung steht ganz
oben und nicht am Ende: Alles darunter baut den Saal auf, und
ein Saal, der sich noch aufbaut, während darüber steht „du bist
raus", ist eine widersprüchliche Auskunft. */
if (lage.ich.massnahme && lage.ich.massnahme.art === 'raus') {
rausgenommen('aus_der_sendung');
return;
}
const warSender = binSender;
binSender = lage.ich.host || lage.ich.gast;
/* WER GERADE ZUM SENDER GEWORDEN IST, BAUT SEINE LEITUNGEN NEU AUF.
@@ -933,15 +943,29 @@
}
}
meldungenZeichnen();
const feld = $('chat-feld');
const senden = document.querySelector('.schiene__senden');
const darf = lage.ich.darf_schreiben !== false;
feld.disabled = !darf;
if (senden) senden.disabled = !darf;
feld.placeholder = darf ? 'Sag was …'
: (lage.chat_modus === 'team'
? 'Gerade schreibt nur das Team'
: 'Der Chat ist gerade zu');
/* DER GRUND KOMMT VOM SERVER, er wird hier nicht erraten.
Vorher stand hier eine Ableitung aus der Chat-Stufe -- und die
war für jemanden, der stummgeschaltet war, während der Chat
offen stand, schlicht falsch („Der Chat ist gerade zu"). Wer
die Antwort hat, muss sie nicht rekonstruieren. */
const GRUENDE = {
stumm_geschaltet: 'Du bist gerade stumm geschaltet',
aus_der_sendung: 'Du bist aus dieser Sendung',
treff_massnahme: 'Im Treff läuft eine Maßnahme gegen dich',
chat_nur_team: 'Gerade schreibt nur das Team',
chat_zu: 'Der Chat ist gerade zu',
saal_zu: 'Der Saal ist zu',
};
feld.placeholder = darf
? 'Sag was …'
: (GRUENDE[lage.ich.schreib_grund] || 'Gerade geht das nicht');
$('schiene').dataset.chat = lage.chat_modus || 'offen';
}
@@ -958,6 +982,27 @@
} catch { /* der Chat kommt gleich per Strom nach */ }
}
/* ==== WER SPRICHT ================================================
Filipe: „die modis, linke hand und rechte hand soll im chat
extra aussehen."
EIN ABZEICHEN, KEINE FARBE ALLEIN. Farbe allein ist für rund
acht Prozent der Männer keine Auskunft -- und im Live-Chat ist
genau das die Frage, auf die es ankommt: Ist das jemand vom
Team oder irgendwer?
Die Wörter sind kurz, weil die Schiene schmal ist, und sie
sagen den RANG nicht. „Team" für die Hände, „Modi" für die
Moderation, „Dogi" für den, der sendet. Wer was darf, steht in
der Rechtetafel; hier geht es nur darum, wen man vor sich hat. */
const ABZEICHEN = {
admin: ['Dogi', 'dogi'],
hand: ['Team', 'team'],
linke: ['Team', 'team'],
modi: ['Modi', 'modi'],
};
function beitragBauen(b) {
const li = el('li', 'beitrag');
li.dataset.id = String(b.id);
@@ -966,6 +1011,14 @@
const kopf = el('div', 'beitrag__wer');
kopf.append(el('span', null, b.name));
const abz = ABZEICHEN[b.rolle];
if (abz) {
const marke = el('span', 'beitrag__rolle', abz[0]);
marke.dataset.rolle = abz[1];
kopf.append(marke);
}
/* Wegnehmen darf, wem der Beitrag gehört -- und wer moderiert.
Der Knopf steht nur dort, wo er auch wirkt; ein Knopf, der mit
403 antwortet, ist eine Falle. */
@@ -976,11 +1029,227 @@
weg.addEventListener('click', () => beitragWeg(b.id));
kopf.append(weg);
}
/* MELDEN DARF JEDER -- ausser bei sich selbst.
Das ist der Punkt daran: Die Moderation sieht nicht alles, wer
mitliest schon. Ohne einen Weg für die Zusehenden bliebe
Moderation auf das beschränkt, was zufällig jemand vom Team
gerade gelesen hat. */
if (lage && b.person_id !== lage.ich.id && !lage.ich.moderiert) {
const m = el('button', 'beitrag__melden', 'melden');
m.type = 'button';
m.title = 'Der Moderation zeigen';
m.addEventListener('click', () => beitragMelden(b.id, m));
kopf.append(m);
}
/* DIE MODERATION GREIFT AM NAMEN AN. So machen es alle großen
Chats, und aus gutem Grund: Man sieht den Beitrag, will etwas
gegen die PERSON tun, und der Name ist das, worauf man
ohnehin zeigt. */
if (lage && lage.ich.moderiert && b.person_id && b.person_id !== lage.ich.id) {
kopf.firstChild.replaceWith(namensknopf(b));
}
li.append(kopf);
li.append(el('div', null, b.text));
return li;
}
function namensknopf(b) {
const k = el('button', 'beitrag__name', b.name);
k.type = 'button';
k.title = 'Was tun?';
k.addEventListener('click', () => moderationsMenue(k, b));
return k;
}
/** Das kleine Menü am Namen. Drei Dinge, mehr nicht.
*
* WARUM NICHT MEHR: Im Live entscheidet man in Sekunden. Eine
* Liste mit sieben Möglichkeiten liest niemand zu Ende -- und was
* länger gelten soll als diese Sendung, gehört ohnehin in die
* Maßnahmen des Treffs, wo es eine Begründungspflicht gibt. Der
* Weg dorthin steht als letzter Punkt. */
function moderationsMenue(knopf, b) {
document.querySelector('.modmenue')?.remove();
const menue = el('div', 'modmenue');
menue.setAttribute('role', 'menu');
const tun = (wort, was) => {
const k = el('button', 'modmenue__knopf', wort);
k.type = 'button';
k.addEventListener('click', async () => { menue.remove(); await was(); });
menue.append(k);
};
tun('Beitrag wegnehmen', () => beitragWeg(b.id));
tun('10 Minuten stumm', () => massnahme(b.person_id, 'stumm', 10));
tun('Aus der Sendung', async () => {
const ja = window.frageNach
? await window.frageNach({
titel: `${b.name} aus der Sendung nehmen?`,
text: 'Die Person kann bis zum Ende der Sendung nicht mehr zusehen. '
+ 'Für länger nimm die Maßnahmen im Treff.',
knopf: 'Rausnehmen',
})
: confirm(`${b.name} aus der Sendung nehmen?`);
if (ja) massnahme(b.person_id, 'raus');
});
const treff = el('a', 'modmenue__knopf modmenue__knopf--weg', 'Im Treff sperren …');
treff.href = '/workspace/treff-moderation.html';
treff.target = '_blank';
treff.rel = 'noopener';
menue.append(treff);
menueHinstellen(menue, knopf);
}
/* ==== WARUM DAS MENUE AM KOERPER HAENGT UND NICHT AM KNOPF =========
GEMESSEN, NICHT ÜBERLEGT. Zuerst stand es als Kind der
Beitragszeile da, mit `position: absolute`. Die Rechnung ging
auf: 216 px breit, x=1099..1315 im 1440er Fenster, y=652..840 bei
900 px Höhe — komplett im Bild. Das Bildschirmfoto zeigte
trotzdem einen abgeschnittenen Streifen hinter dem Eingabefeld.
Der Grund: Die Chatliste rollt (`overflow-y: auto`). Ein
absolut gesetztes Kind wird von einem rollenden Vorfahren
BESCHNITTEN, ganz gleich, wo es im Fenster liegt. „Im Fenster"
und „sichtbar" sind zwei verschiedene Fragen, und ich hatte die
falsche gemessen.
Also: fest am Fenster (`position: fixed`), Lage aus dem
tatsächlichen Rechteck des Knopfes gerechnet, und wo unten oder
rechts kein Platz ist, klappt es nach oben bzw. nach links. Und
es geht zu, sobald jemand rollt — sonst stünde es gleich neben
einem ganz anderen Namen. */
function menueHinstellen(menue, anker) {
menue.style.position = 'fixed';
menue.style.visibility = 'hidden';
document.body.append(menue);
const a = anker.getBoundingClientRect();
const m = menue.getBoundingClientRect();
const luft = 8;
let links = a.left;
if (links + m.width > window.innerWidth - luft) links = window.innerWidth - m.width - luft;
if (links < luft) links = luft;
let oben = a.bottom + 6;
/* Passt es unten nicht, klappt es über den Knopf. Passt es auch
dort nicht (kleines Fenster, langes Menü), wird es oben
angesetzt und rollt in sich selbst. */
if (oben + m.height > window.innerHeight - luft) {
const darueber = a.top - m.height - 6;
oben = darueber >= luft ? darueber : luft;
}
menue.style.left = `${Math.round(links)}px`;
menue.style.top = `${Math.round(oben)}px`;
menue.style.maxHeight = `${Math.round(window.innerHeight - oben - luft)}px`;
menue.style.visibility = '';
const zumachen = () => {
menue.remove();
document.removeEventListener('click', beiKlick, true);
window.removeEventListener('resize', zumachen);
document.removeEventListener('scroll', zumachen, true);
};
function beiKlick(e) { if (!menue.contains(e.target) && e.target !== anker) zumachen(); }
setTimeout(() => {
document.addEventListener('click', beiKlick, true);
window.addEventListener('resize', zumachen);
/* `true` -- Rollen steigt nicht auf, es muss beim Einfangen
abgeholt werden, sonst merkt man das Rollen der Chatliste
nicht. */
document.addEventListener('scroll', zumachen, true);
}, 0);
}
async function massnahme(personId, art, minuten) {
const a = await hole('/workspace/api/reaktion/massnahme', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ person_id: personId, art, minuten }),
});
if (!a.ok) { melde(await a.json().catch(() => null), 'Das ging nicht.'); return; }
lage = { ...(await a.json()) };
zeichnen();
sagFehler(art === 'stumm' ? 'Für 10 Minuten stumm.' : 'Aus der Sendung genommen.');
}
async function beitragMelden(id, knopf) {
const a = await hole(`/workspace/api/reaktion/chat/${id}/melden`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
if (!a.ok) { melde(await a.json().catch(() => null), 'Das ging nicht.'); return; }
knopf.textContent = 'gemeldet';
knopf.disabled = true;
}
/* ---- Die Meldungen im Kopf der Schiene ----------------------------
Nur für die Moderation, und nur, wenn etwas offen ist. Eine Zahl,
die immer dasteht und meistens null ist, wird nach drei Tagen
nicht mehr gelesen. */
function meldungenZeichnen() {
const knopf = $('chat-meldungen');
if (!knopf) return;
const offen = lage.meldungen || [];
knopf.hidden = !lage.ich.moderiert || offen.length === 0;
knopf.textContent = offen.length === 1 ? '1 Meldung' : `${offen.length} Meldungen`;
}
function meldungenZeigen() {
const offen = lage.meldungen || [];
if (!offen.length) return;
document.querySelector('.modmenue')?.remove();
const menue = el('div', 'modmenue modmenue--liste');
for (const m of offen) {
const zeile = el('div', 'modmeldung');
zeile.append(el('span', 'modmeldung__wer', `${m.wer} — gemeldet von ${m.melder || 'jemandem'}`));
zeile.append(el('span', 'modmeldung__text', m.text));
if (m.weg_am) zeile.append(el('span', 'modmeldung__schon', 'schon weggenommen'));
const reihe = el('div', 'modmeldung__knoepfe');
if (!m.weg_am) {
const weg = el('button', 'modmenue__knopf', 'Wegnehmen');
weg.type = 'button';
weg.addEventListener('click', async () => {
await beitragWeg(m.beitrag_id);
await erledigt(m.id);
});
reihe.append(weg);
}
const stumm = el('button', 'modmenue__knopf', '10 Min stumm');
stumm.type = 'button';
stumm.addEventListener('click', async () => {
await massnahme(m.wer_id, 'stumm', 10);
await erledigt(m.id);
});
reihe.append(stumm);
const ok = el('button', 'modmenue__knopf modmenue__knopf--weg', 'Passt schon');
ok.type = 'button';
ok.addEventListener('click', () => erledigt(m.id));
reihe.append(ok);
zeile.append(reihe);
menue.append(zeile);
}
menueHinstellen(menue, $('chat-meldungen'));
}
async function erledigt(id) {
const a = await hole(`/workspace/api/reaktion/meldung/${id}/erledigt`, { method: 'POST' });
if (!a.ok) { melde(await a.json().catch(() => null), 'Das ging nicht.'); return; }
lage = { ...(await a.json()) };
document.querySelector('.modmenue')?.remove();
zeichnen();
}
async function beitragWeg(id) {
try {
const a = await hole('/workspace/api/reaktion/chat/' + id, { method: 'DELETE' });
@@ -1943,6 +2212,26 @@
const d = JSON.parse(e.data);
if (d.was === 'video') { folgen(d); return; }
if (d.was === 'layout') { $('teil-live').dataset.layout = d.layout; return; }
/* ==== MODERATION ==========================================
Drei Meldungen ohne Stand im Gepäck, und alle drei aus
demselben Grund: Was sie betrifft, steht NICHT im Rundruf.
`meldungen` und `massnahmen` sind aus `oeffentlich()`
ausgenommen, weil dort der gemeldete Text und der Name des
Melders stehen. Wer sie sehen darf, holt sie sich selbst. */
if (d.was === 'massnahme') { standHolen(); return; }
if (d.was === 'meldung') {
if (lage && lage.ich.moderiert) standHolen();
return;
}
if (d.was === 'moderation') {
if (lage && lage.ich.moderiert) standHolen();
/* Für alle anderen ist trotzdem etwas passiert: Wer gerade
stumm geschaltet wurde, erfährt es über den eigenen
Stand, und wer rausgenommen wurde, muss es sofort sehen. */
return;
}
if (d.lage) {
/* Der Rundruf enthält keine Namensliste und kein „ich" --
beides gehört nur dem Empfänger. Was hier ankommt, wird
@@ -2045,6 +2334,17 @@
lage = neu;
zeichnen();
draehteNachziehen();
return;
}
/* HINAUSGENOMMEN ODER GESPERRT. Ohne diesen Zweig läuft das
Video weiter, der Saal sieht normal aus, und nur die
Anwesenheit schlägt still fehl -- die Person wäre
„rausgenommen" und würde davon nichts merken. Das ist
dieselbe Sorte Fehler wie ein stummes Eingabefeld: Es sieht
aus wie ein Defekt, nicht wie eine Entscheidung. */
if (a.status === 403) {
const wort = (await a.json().catch(() => null))?.fehler;
if (wort === 'aus_der_sendung' || wort === 'treff_massnahme') rausgenommen(wort);
}
} catch { /* der nächste Takt versucht es wieder */ }
};
@@ -2170,6 +2470,34 @@
/* =================================================================
LOS
================================================================= */
/** Aus der Sendung -- und das wird auch gesagt.
*
* Drei Dinge müssen zusammen passieren, sonst bleibt ein Rest:
* Der Takt hört auf (sonst klopft er alle 30 Sekunden gegen eine
* geschlossene Tür), die Leitungen gehen weg (sonst stünde die
* eigene Kamera weiter im Bild der anderen), und das Video hält an
* (sonst läuft die Sendung hinter der Meldung weiter). */
function rausgenommen(wort) {
if (document.body.dataset.raus === 'ja') return;
document.body.dataset.raus = 'ja';
clearInterval(dabeiUhr);
try { draehteWeg(); } catch { /* es gab vielleicht keine */ }
try { if (spieler && spielerBereit) spieler.pauseVideo(); } catch { /* egal */ }
try { if (strom) strom.close(); } catch { /* war schon zu */ }
const kasten = el('div', 'raus');
kasten.setAttribute('role', 'alert');
kasten.append(el('h2', 'raus__titel',
wort === 'treff_massnahme' ? 'Du bist gerade gesperrt' : 'Du bist aus der Sendung'));
kasten.append(el('p', 'raus__text',
wort === 'treff_massnahme'
? 'Im Treff läuft eine Maßnahme gegen dich. Solange sie läuft, bist du '
+ 'bei der Reaction nicht dabei. Was los ist, steht im Treff unter „Meine Maßnahmen".'
: 'Die Moderation hat dich aus dieser Sendung genommen. Das gilt nur für heute — '
+ 'bei der nächsten Reaction bist du wieder dabei.'));
document.body.append(kasten);
}
async function standHolen() {
const a = await hole('/workspace/api/reaktion');
if (!a.ok) { sagFehler('Die Reaction ist gerade nicht erreichbar.'); return; }
@@ -2196,6 +2524,8 @@
() => sendenSchalten('mikro', !lage.ich.mikro_aus));
/* Die Chat-Stufe -- nur fuer die zwei */
$('chat-meldungen')?.addEventListener('click', meldungenZeigen);
for (const k of $('chat-stufen').children) {
k.addEventListener('click', async () => {
const a = await hole('/workspace/api/reaktion/chatmodus', {