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
+248 -15
View File
@@ -48,8 +48,13 @@ import { einstellung } from "./workspace.js";
import { mitZugangsdaten, wegeAusfaechern } from "./workspace-turn.js";
import {
SICHT_PLAETZE, GAESTE_MAX, TEXT_MAX, SCHREIB_BREMSE, STAENDE, LAYOUTS,
VERLAUF_TAGE, LISTE_MAX, CHAT_MODI,
VERLAUF_TAGE, LISTE_MAX, CHAT_MODI, STUMM_MAX_MINUTEN, MASSNAHMEN,
} from "./reaktion-tabellen.js";
/* DIE SPERREN DES TREFFS GELTEN AUCH HIER. Ein zweites System
daneben hiesse, dass jemand im Treff gesperrt ist und in der
Sendung weiterschreibt -- genau die Luecke, die man erst bemerkt,
wenn sie benutzt wird. */
import { treffSperreLesen } from "./treff-tabellen.js";
export const reaktionRouter = express.Router();
@@ -266,6 +271,22 @@ function lage(person) {
})),
chat_modus: s.chat_modus || "offen",
chat_modi: CHAT_MODI,
/* WAS DIE MODERATION SEHEN MUSS -- und niemand sonst. Eine Liste
von Massnahmen gegen andere ist fuer einen Zuschauer eine
Liste darueber, wer sich danebenbenommen hat. */
meldungen: MODERIERT.has(person.rolle)
? db().prepare(`SELECT m.id, m.beitrag_id, m.grund, m.erstellt,
p.name AS melder, c.text, c.name AS wer, c.person_id AS wer_id, c.weg_am
FROM reaktion_meldungen m
JOIN reaktion_chat c ON c.id = m.beitrag_id
LEFT JOIN personen p ON p.id = m.melder_id
WHERE m.erledigt_am IS NULL ORDER BY m.id`).all()
: null,
massnahmen: MODERIERT.has(person.rolle)
? db().prepare(`SELECT m.person_id, m.art, m.bis, m.grund, p.name
FROM reaktion_massnahmen m JOIN personen p ON p.id = m.person_id
ORDER BY m.erstellt DESC`).all()
: null,
dabei: dabei.length,
/* ==== WER DIE LISTE BEKOMMT -- UND WARUM GENAU DIESE ==========
@@ -301,6 +322,15 @@ function lage(person) {
kamera_aus: !!(dabei.find((d) => d.id === person.id)?.kamera_aus),
mikro_aus: !!(dabei.find((d) => d.id === person.id)?.mikro_aus),
darf_schreiben: darfSchreiben(person, s),
/* Der Grund steht daneben -- die Seite muss ihn nicht raten. */
schreib_grund: schreibGrund(person, s),
/* WER STUMM IST, MUSS ES ERFAHREN. Ein Feld, das sich nicht
beschreiben laesst und schweigt, sieht kaputt aus -- und dann
sucht jemand den Fehler bei sich statt bei seinem Verhalten. */
massnahme: (() => {
const m = massnahmeFuer(person.id);
return m ? { art: m.art, bis: m.bis, grund: m.grund } : null;
})(),
},
/* DIE WARTESCHLANGE SEHEN NUR DIE ZWEI. Sie ist Teil der
Vorbereitung, und Filipe hat dazu gesagt: „nur die zwei sollen
@@ -329,17 +359,55 @@ function lage(person) {
* erst anzubieten. Zwei Rechnungen waeren zwei Gelegenheiten, dass
* ein Feld da ist und mit 403 antwortet -- und das ist eine Falle,
* keine Bedienung. */
function darfSchreiben(person, stand) {
if (stand.stand === "zu") return false;
/** Die kurze Massnahme dieser Sendung -- oder nichts.
*
* ABGELAUFENES ZAEHLT NICHT. Eine Stummschaltung ueber zehn Minuten
* endet nach zehn Minuten, ohne dass jemand etwas tun muss. Eine,
* die von Hand aufgehoben werden muss, bleibt sonst die ganze
* Sendung stehen, weil im Live niemand daran denkt. */
function massnahmeFuer(personId) {
const m = db().prepare("SELECT * FROM reaktion_massnahmen WHERE person_id = ?")
.get(personId);
if (!m) return null;
if (!m.bis) return m;
const ende = Date.parse(m.bis);
if (Number.isFinite(ende) && ende > Date.now()) return m;
db().prepare("DELETE FROM reaktion_massnahmen WHERE person_id = ?").run(personId);
return null;
}
/** WARUM jemand nicht schreiben darf -- oder `null`, wenn er darf.
*
* Der Grund und nicht nur das Ja/Nein, und zwar aus einem Fehler
* heraus: Vorher gab es nur „darf/darf nicht", und der Aufrufer hat
* sich den Grund aus der Chat-Stufe zusammengereimt. Fuer jemanden,
* der stummgeschaltet war, waehrend der Chat offen stand, kam damit
* „Der Chat ist gerade zu" heraus -- eine Auskunft, die nachweisbar
* falsch war und ihn beim naechsten Aufmachen wieder vergeblich
* tippen liess. Wer den Grund kennt, gibt ihn weiter. */
function schreibGrund(person, stand) {
if (stand.stand === "zu") return "saal_zu";
/* ERST DIE SPERREN DES TREFFS. Wer dort eine Pause hat oder
ausgeschlossen ist, schreibt hier auch nicht -- es sind dieselben
Menschen und dieselbe Gemeinschaft. */
try {
if (treffSperreLesen(db(), person.id)) return "treff_massnahme";
} catch { /* gibt es die Tabelle nicht, gilt sie auch nicht */ }
const m = massnahmeFuer(person.id);
if (m) return m.art === "raus" ? "aus_der_sendung" : "stumm_geschaltet";
const modus = stand.chat_modus || "offen";
if (modus === "offen") return true;
if (modus === "offen") return null;
/* „team": wer moderiert, darf. Das ist dieselbe Menge, die auch
Beitraege wegnehmen darf -- wer aufraeumen soll, muss dabei
reden koennen. */
if (modus === "team") return MODERIERT.has(person.rolle);
if (modus === "team") return MODERIERT.has(person.rolle) ? null : "chat_nur_team";
/* „zu": nur die zwei, die die Sendung fuehren. Sonst saesse der
Saal vor einem Fenster, in dem gar nichts mehr passiert. */
return HOST_ROLLEN.has(person.rolle);
return HOST_ROLLEN.has(person.rolle) ? null : "chat_zu";
}
function darfSchreiben(person, stand) {
return schreibGrund(person, stand) === null;
}
function melden(art, daten) {
@@ -481,7 +549,20 @@ reaktionRouter.post("/workspace/api/reaktion/dabei", express.json({ limit: "2kb"
(req, res) => {
try {
const s = sendung();
if (s.stand === "zu") return res.status(409).json({ fehler: "geschlossen" });
if (s.stand === "zu") return res.status(409).json({ fehler: "saal_zu" });
/* WER DRAUSSEN IST, KOMMT NICHT WIEDER HEREIN. Sonst laedt die
Seite neu und ist wieder dabei -- und die Massnahme waere
eine Empfehlung. */
const raus = massnahmeFuer(req.person.id);
if (raus && raus.art === "raus") {
return res.status(403).json({ fehler: "aus_der_sendung" });
}
try {
if (treffSperreLesen(db(), req.person.id)) {
return res.status(403).json({ fehler: "treff_massnahme" });
}
} catch { /* gibt es die Tabelle nicht, gilt sie auch nicht */ }
const schon = db().prepare("SELECT * FROM reaktion_dabei WHERE person_id = ?")
.get(req.person.id);
@@ -814,11 +895,154 @@ reaktionRouter.post("/workspace/api/reaktion/weiter", nurHost, express.json({ li
JEDER SENDER DARF DAS FUER SICH -- Host wie Gast. Ein Gast, der
sein eigenes Bild nicht abschalten kann, muesste den Host darum
bitten. */
/* ---------- Moderation im Live-Chat -------------------------------------
Filipe: „sachen wie stummschaltungen, sperrungen, meldunge und
alles moegliche im chat machen koennen falls sich jemand nicht
benimmt."
WAS HIER PASSIERT, GILT NUR FUER DIESE SENDUNG. Was laenger gelten
soll, gehoert in die Massnahmen des Treffs -- die haben eine
Begruendungspflicht, eine Frist in Tagen und stehen in der
Geschichte. Ein Fuenf-Minuten-Aerger gehoert dort nicht hinein. */
reaktionRouter.post("/workspace/api/reaktion/massnahme", express.json({ limit: "4kb" }),
(req, res) => {
try {
if (!MODERIERT.has(req.person.rolle)) {
return res.status(403).json({ fehler: "nur_moderation" });
}
const art = String(req.body?.art ?? "");
if (!MASSNAHMEN.includes(art)) return res.status(400).json({ fehler: "massnahme_unbekannt" });
const zielId = Number(req.body?.person_id) || 0;
const ziel = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?").get(zielId);
if (!ziel) return res.status(404).json({ fehler: "nicht_gefunden" });
/* NICHT GEGEN DAS TEAM. Dieselbe Regel wie im Treff: Wer einen
anderen Modi stummschalten koennte, haette einen Weg, die
Moderation selbst auszuschalten -- mitten in der Sendung und
an „Personen & Zugaenge" vorbei, wo diese Entscheidung
hingehoert. */
if (MODERIERT.has(ziel.rolle) || HOST_ROLLEN.has(ziel.rolle)) {
return res.status(403).json({ fehler: "nicht_gegen_team" });
}
let bis = null;
if (art === "stumm") {
const min = Number(req.body?.minuten);
if (!Number.isFinite(min) || min <= 0 || min > STUMM_MAX_MINUTEN) {
return res.status(400).json({ fehler: "dauer_unklar" });
}
bis = new Date(Date.now() + min * 60_000).toISOString();
}
const grund = String(req.body?.grund ?? "").trim().slice(0, 200);
db().prepare(`INSERT INTO reaktion_massnahmen (person_id, art, bis, grund, von, erstellt)
VALUES (?,?,?,?,?,?)
ON CONFLICT(person_id) DO UPDATE SET
art = excluded.art, bis = excluded.bis, grund = excluded.grund,
von = excluded.von, erstellt = excluded.erstellt`)
.run(ziel.id, art, bis, grund, req.person.id, jetzt());
/* „raus" heisst raus: aus dem Saal, aus der Gaesteliste. Sonst
bliebe die Kamera im Bild, waehrend die Person nicht mehr
dabei sein soll. */
if (art === "raus") {
db().prepare("DELETE FROM reaktion_dabei WHERE person_id = ?").run(ziel.id);
db().prepare("DELETE FROM reaktion_gaeste WHERE person_id = ?").run(ziel.id);
}
protokolliere(req.person.id, "reaktion_massnahme",
`${art} gegen ${ziel.name}${bis ? ` bis ${bis.slice(11, 16)}` : ""}`, echteIp(req));
/* DIE PERSON ERFAEHRT ES SELBST -- und zwar sofort. Wer nicht
mehr schreiben kann und nicht weiss warum, sucht den Fehler
bei der Technik. */
stromAn([ziel.id], "reaktion", { was: "massnahme", lage: null });
melden("reaktion", { was: "moderation", lage: oeffentlich(lage(req.person)) });
res.json(lage(req.person));
} catch (fehler) {
console.error("[reaktion] Massnahme:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
reaktionRouter.delete("/workspace/api/reaktion/massnahme/:id(\\d+)", (req, res) => {
try {
if (!MODERIERT.has(req.person.rolle)) {
return res.status(403).json({ fehler: "nur_moderation" });
}
const n = db().prepare("DELETE FROM reaktion_massnahmen WHERE person_id = ?")
.run(Number(req.params.id));
if (!n.changes) return res.status(404).json({ fehler: "nicht_gefunden" });
stromAn([Number(req.params.id)], "reaktion", { was: "massnahme", lage: null });
melden("reaktion", { was: "moderation", lage: oeffentlich(lage(req.person)) });
res.json(lage(req.person));
} catch (fehler) {
console.error("[reaktion] Massnahme weg:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Einen Beitrag melden. Darf jeder -- auch die Community.
*
* DAS IST DER PUNKT DARAN. Die Moderation sieht nicht alles; wer
* mitliest, sieht mehr. Ohne einen Weg fuer die Zusehenden bliebe
* Moderation auf das beschraenkt, was zufaellig jemand vom Team
* gerade gelesen hat. */
reaktionRouter.post("/workspace/api/reaktion/chat/:id(\\d+)/melden",
express.json({ limit: "4kb" }), (req, res) => {
try {
const id = Number(req.params.id);
const b = db().prepare("SELECT * FROM reaktion_chat WHERE id = ?").get(id);
if (!b) return res.status(404).json({ fehler: "nicht_gefunden" });
if (b.person_id === req.person.id) {
return res.status(400).json({ fehler: "eigener_beitrag" });
}
const grund = String(req.body?.grund ?? "").trim().slice(0, 200);
try {
db().prepare(`INSERT INTO reaktion_meldungen (beitrag_id, melder_id, grund, erstellt)
VALUES (?,?,?,?)`).run(id, req.person.id, grund, jetzt());
} catch {
/* Schon gemeldet. Kein Fehler -- wer zweimal tippt, meint
dasselbe, und eine Fehlermeldung dafuer waere eine
Bestrafung fuers Mithelfen. */
return res.json({ ok: true, schon: true });
}
/* Die Moderation erfaehrt es sofort. */
stromAn(
db().prepare(`SELECT id FROM personen WHERE rolle IN ('admin','hand','linke','modi')
AND aktiv = 1`).all().map((p) => p.id),
"reaktion", { was: "meldung" },
);
res.status(201).json({ ok: true });
} catch (fehler) {
console.error("[reaktion] Melden:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
reaktionRouter.post("/workspace/api/reaktion/meldung/:id(\\d+)/erledigt", (req, res) => {
try {
if (!MODERIERT.has(req.person.rolle)) {
return res.status(403).json({ fehler: "nur_moderation" });
}
const n = db().prepare(`UPDATE reaktion_meldungen SET erledigt_am = ?, erledigt_von = ?
WHERE id = ? AND erledigt_am IS NULL`)
.run(jetzt(), req.person.id, Number(req.params.id));
if (!n.changes) return res.status(409).json({ fehler: "nicht_offen" });
res.json(lage(req.person));
} catch (fehler) {
console.error("[reaktion] Meldung erledigt:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
reaktionRouter.post("/workspace/api/reaktion/senden", express.json({ limit: "2kb" }),
(req, res) => {
try {
const s = sendung();
if (s.stand === "zu") return res.status(409).json({ fehler: "geschlossen" });
if (s.stand === "zu") return res.status(409).json({ fehler: "saal_zu" });
const istHost = HOST_ROLLEN.has(req.person.rolle);
const binGast = !!db().prepare("SELECT 1 FROM reaktion_gaeste WHERE person_id = ?")
@@ -910,15 +1134,12 @@ reaktionRouter.post("/workspace/api/reaktion/chat", express.json({ limit: "8kb"
(req, res) => {
try {
const s = sendung();
if (s.stand === "zu") return res.status(409).json({ fehler: "geschlossen" });
if (s.stand === "zu") return res.status(409).json({ fehler: "saal_zu" });
/* DIE STUFE GILT HIER, NICHT NUR IN DER OBERFLAECHE. Ein Feld,
das ausgegraut ist, haelt niemanden auf, der die Schnittstelle
kennt. */
if (!darfSchreiben(req.person, s)) {
return res.status(403).json({
fehler: s.chat_modus === "team" ? "chat_nur_team" : "chat_zu",
});
}
const grund = schreibGrund(req.person, s);
if (grund) return res.status(403).json({ fehler: grund });
const text = String(req.body?.text ?? "").trim().slice(0, TEXT_MAX);
if (!text) return res.status(400).json({ fehler: "leer" });
if (zuSchnell(req.person.id)) return res.status(429).json({ fehler: "zu_schnell" });
@@ -1032,7 +1253,19 @@ export function videoKennung(roh) {
* ohne dass es jemandem auffaellt, weil die Oberflaeche sie ja
* nicht anzeigt. */
function oeffentlich(l) {
const { zuschauer, ich, liste, ...rest } = l;
/* WAS IM RUNDRUF NICHTS ZU SUCHEN HAT.
`zuschauer`, `ich` und `liste` gehoeren nur dem Empfaenger.
`meldungen` und `massnahmen` KAMEN AM 28.09. DAZU -- sie sind
die heikelsten Felder des ganzen Standes: In `meldungen` stehen
der gemeldete Text, der Name des Gemeldeten UND der Name dessen,
der gemeldet hat; in `massnahmen` steht, wer stumm ist und
warum. Der Rundruf wird aus dem Stand DESSEN gebaut, der gerade
etwas getan hat -- und wenn das die Moderation war, gingen beide
Listen an jeden im Saal. Wer meldet, muss sich darauf verlassen
koennen, dass das niemand sieht; sonst meldet beim naechsten Mal
keiner mehr. */
const { zuschauer, ich, liste, meldungen, massnahmen, ...rest } = l;
return rest;
}