diff --git a/server/chat-erwaehnung.js b/server/chat-erwaehnung.js new file mode 100644 index 00000000..c3929a1a --- /dev/null +++ b/server/chat-erwaehnung.js @@ -0,0 +1,152 @@ +/* ===================================================================== + WER IST MIT "@" GEMEINT? (20.09.2026) + + Filipe: "ich will das man die leute mit @ markieren kann im chat." + + --------------------------------------------------------------------- + WARUM DAS EINE EIGENE DATEI IST + + Die Auflösung "Text -> Menschen" wird an DREI Stellen gebraucht und + muss überall dasselbe ergeben: + + 1. beim Schreiben -- wer bekommt eine Benachrichtigung, + 2. im Browser -- welcher Teil des Satzes wird hervorgehoben, + 3. in der Prüfung -- ohne dafür einen Server hochzufahren. + + Stünde sie in workspace-chat.js, müsste die Prüfung die halbe + Anwendung laden, um einen Satz zu zerlegen. Und der Browser hätte + ohnehin eine zweite, eigene Fassung -- also genau die Stelle, an der + zwei Rechnungen irgendwann auseinanderlaufen und die Seite jemanden + farbig hervorhebt, den der Server nie benachrichtigt hat. + + DIE DATEI HAT DESHALB KEINE ABHÄNGIGKEITEN. Kein Datenbankzugriff, + kein Import. Herein kommt ein Text und eine Liste von Menschen, + heraus kommt, wer gemeint ist. Der Browser bekommt dieselbe Logik in + `chat.js` -- bewusst als kurze, gleichlautende Fassung, die + pruef-erwaehnung.mjs Zeile für Zeile gegen diese hier prüft. + + --------------------------------------------------------------------- + DIE REGELN, UND WARUM JEDE EINZELNE NÖTIG IST + + (1) NUR WER IM RAUM IST. + "@Marina" in einem Gespräch, in dem Marina nicht sitzt, ist kein + Ansprechen, sondern ein Wort über sie. Sie bekäme sonst eine + Meldung zu einem Gespräch, das sie nicht öffnen darf -- eine + Benachrichtigung, die auf eine verschlossene Tür führt. + + (2) DAS ZEICHEN DAVOR DARF KEIN BUCHSTABE UND KEINE ZIFFER SEIN. + Ohne diese Regel wird aus "filipe@dogfather-universe.com" ein + Ansprechen von Dogfather -- jede E-Mail-Adresse im Chat würde + jemanden anpiepsen. Gemessen an einer echten Adresse, siehe + Prüfung. + + (3) DAS ZEICHEN DANACH DARF KEIN BUCHSTABE UND KEINE ZIFFER SEIN. + Sonst wäre "@Tili" in "@Tilikum" enthalten. Satzzeichen dagegen + schon: "@Tili, kannst du?" und "@Tili!" sind normale Sätze. + + (4) DER LÄNGSTE PASSENDE NAME GEWINNT. + Gäbe es "Van" und "VanVan", träfe die kurze Fassung zuerst und + ließe "Van" stehen. Heute hat niemand einen Namen, der im + Namen eines anderen steckt -- aber das ist eine Tatsache von + heute, kein Gesetz, und sie ändert sich mit dem nächsten Zugang. + + (5) GROSS UND KLEIN EGAL. + Wer "@vanvan" tippt, meint VanVan. Auf einer Handytastatur + entscheidet die Autokorrektur mit, nicht der Mensch. + + (6) JEDE PERSON HÖCHSTENS EINMAL. + Dreimal "@Anna" in einem Satz ist trotzdem eine Ansprache. + + --------------------------------------------------------------------- + WAS ES BEWUSST NICHT GIBT + + KEIN "@alle". Es wäre in fünf Minuten gebaut und ist der zuverlässigste + Weg, dass alle die Benachrichtigungen abschalten -- und dann kommt + auch die an, die wirklich für einen bestimmten Menschen gedacht war. + Wenn Filipe es ausdrücklich will, gehört dazu eine Entscheidung, WER + es benutzen darf; das ist eine Frage an ihn, keine, die ich hier + beantworte. + + AUFRUF (auch aus einer Prüfung heraus): + import { erwaehnungenFinden } from "./chat-erwaehnung.js"; + ===================================================================== */ + +/** Ein Zeichen, das einen Namen fortsetzen könnte. + * + * BUCHSTABE ODER ZIFFER, und zwar auch mit Umlaut oder Akzent: + * `\p{L}` statt `[a-z]`. Mit `[a-zA-Z0-9]` wäre "@Jose" in "@José" + * ein Treffer gewesen -- das "é" hätte als Grenze gezählt. */ +const WORTZEICHEN = /[\p{L}\p{N}_]/u; + +/** Steht an dieser Stelle ein Wortzeichen? + * Außerhalb des Textes (Anfang/Ende) ist die Antwort NEIN -- ein + * Satzanfang ist eine Grenze wie jede andere. */ +function wortzeichenBei(text, i) { + if (i < 0 || i >= text.length) return false; + return WORTZEICHEN.test(text[i]); +} + +/** + * Wer ist in diesem Text mit "@" angesprochen? + * + * @param {string} text Die geschriebene Nachricht. + * @param {Array<{id:number,name:string}>} leute + * Die Teilnehmer DIESES Raums. Wer nicht darin steht, kann + * nicht gemeint sein -- siehe Regel (1) oben. + * @returns {Array<{id:number, name:string, von:number, bis:number}>} + * Je Treffer die Person und die Stelle im Text (`von` bis + * `bis`, ohne das "@"-Zeichen gerechnet wie `slice`). Die + * Stellen braucht der Browser zum Hervorheben; der Server + * benutzt nur `id`. Sortiert nach Vorkommen im Text. + */ +export function erwaehnungenFinden(text, leute) { + const satz = String(text || ""); + if (!satz.includes("@")) return []; + + /* REGEL (4): der längste Name zuerst. Einmal sortiert statt bei + jedem Treffer neu -- und auf einer Kopie, damit die Liste des + Aufrufers unberührt bleibt. */ + const nachLaenge = (Array.isArray(leute) ? leute.slice() : []) + .filter((p) => p && p.id != null && String(p.name || "").trim()) + .sort((a, b) => String(b.name).length - String(a.name).length); + if (!nachLaenge.length) return []; + + const klein = satz.toLowerCase(); + const treffer = []; + const schonDa = new Set(); // Regel (6) + + for (let i = klein.indexOf("@"); i !== -1; i = klein.indexOf("@", i + 1)) { + /* REGEL (2): Was steht vor dem "@"? */ + if (wortzeichenBei(satz, i - 1)) continue; + + for (const person of nachLaenge) { + const name = String(person.name); + const nk = name.toLowerCase(); + if (!klein.startsWith(nk, i + 1)) continue; + /* REGEL (3): Was steht hinter dem Namen? */ + if (wortzeichenBei(satz, i + 1 + name.length)) continue; + + /* Die Stelle wird IMMER gemeldet, auch wenn dieselbe Person + schon einmal vorkam: Der Browser soll jedes "@Anna" im Satz + hervorheben, nicht nur das erste. Nur die ID-Liste für die + Benachrichtigung wird entdoppelt -- das macht `ideeVon()` + weiter unten. */ + treffer.push({ id: person.id, name, von: i, bis: i + 1 + name.length }); + schonDa.add(person.id); + break; // Regel (4): der Längste hat gewonnen + } + } + return treffer; +} + +/** + * Nur die Personennummern, jede einmal -- das, was der Server zum + * Benachrichtigen braucht. + * + * ALS EIGENE FUNKTION, damit an der Aufrufstelle nicht steht + * `[...new Set(x.map(t => t.id))]`. Diese Zeile wäre an drei Orten + * gleich zu schreiben, und an einem davon irgendwann anders. + */ +export function erwaehnteIds(text, leute) { + return [...new Set(erwaehnungenFinden(text, leute).map((t) => t.id))]; +} diff --git a/server/pruef-erwaehnung.mjs b/server/pruef-erwaehnung.mjs new file mode 100644 index 00000000..e71109d0 --- /dev/null +++ b/server/pruef-erwaehnung.mjs @@ -0,0 +1,612 @@ +/* ===================================================================== + pruef-erwaehnung.mjs — "@Name" im Chat + + Filipe, 20.09.2026: "ich will das man die leute mit @ markieren + kann im chat." + + --------------------------------------------------------------------- + WAS HIER GEPRÜFT WIRD, UND WARUM GERADE DAS + + Markieren ist keine Farbe, sondern eine Ansprache. Es funktioniert + nur, wenn DREI Dinge zusammen stimmen: + + 1. Der Richtige ist gemeint. -> Abschnitt 1 und 3 + 2. Er merkt es. -> Abschnitt 2 und 4 + 3. Man sieht es im Satz. -> Abschnitt 5 (echter Browser) + + Stimmt eines davon nicht, ist die ganze Funktion eine Attrappe -- + und zwar eine, die gut aussieht. Deshalb hört diese Prüfung nicht + beim "wird gespeichert" auf. + + --------------------------------------------------------------------- + DIE GEFÄHRLICHSTE STELLE + + Es gibt ZWEI Auflösungen desselben Problems: eine im Server + (chat-erwaehnung.js) und eine im Browser (chat.js). Die zweite ist + nötig, weil der Browser sofort hervorheben muss, ohne zu fragen. + + Zwei Rechnungen für dieselbe Frage sind genau die Stelle, an der + etwas auseinanderläuft -- dann hebt die Seite jemanden hervor, den + der Server nie benachrichtigt hat. Abschnitt 6 stellt beide + nebeneinander und prüft sie an denselben Fällen. + + AUFRUF: node server/pruef-erwaehnung.mjs + ===================================================================== */ + +import { mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { request as httpAnfrage } from "node:http"; +import { portMussFreiSein } from "./helfer-port.mjs"; +import { notbremse } from "./helfer-notbremse.mjs"; +import { erwaehnungenFinden, erwaehnteIds } from "./chat-erwaehnung.js"; + +const HIER = dirname(fileURLToPath(import.meta.url)); +const PORT = await portMussFreiSein(4426, "pruef-erwaehnung"); + +const ordner = mkdtempSync(join(tmpdir(), "ws-erwaehnung-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = String(PORT); +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +notbremse(300_000, "pruef-erwaehnung"); +await new Promise((r) => setTimeout(r, 900)); + +process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); }); +process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); }); + +let fehler = 0, geprueft = 0; +const melde = (t) => console.log(t); +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +/* KEINE HOST-UMSCHREIBUNG, UND DAS HAT EINEN GRUND. + + Der erste Anlauf meldete sich ueber HTTP mit `Host: crew.…` an und + liess den Browser auf 127.0.0.1 laufen. Beides zusammen geht nicht: + Auf 127.0.0.1 liefert der Server die AGENTUR-Anmeldewand, und dort + gibt es die Rolle "hand" gar nicht -- die Pruefung wartete 30 + Sekunden auf eine Kachel, die es nie geben konnte. + + Das Markieren mit "@" ist von Haus und Rolle voellig unabhaengig: + Es fragt nur, wer im RAUM sitzt. Also wird hier durchgehend die + Adresse benutzt, auf der auch der Browser landet, und es werden + Rollen genommen, die dort vorkommen. Wer die Trennung der beiden + Haeuser pruefen will, findet das in pruef-haus-trennung. */ + +console.log('\n=== @Name im Chat ==='); + +/* ===================================================================== + 1. DIE REGELN — ohne Server, ohne Datenbank + ===================================================================== */ +melde("\n=== 1. Wer ist gemeint? ==="); +{ + const leute = [ + { id: 1, name: "Dogfather" }, { id: 2, name: "VanVan" }, { id: 3, name: "Tili" }, + { id: 4, name: "Miss" }, { id: 5, name: "Marina" }, + ]; + /* Jeder Fall steht für eine Regel aus chat-erwaehnung.js. Die + beiden wichtigsten sind die E-Mail-Adresse und das längere Wort: + Ohne sie piepst jede Adresse im Chat jemanden an, und "@Tilikum" + spricht Tili an. */ + const faelle = [ + ["@VanVan kannst du kurz?", [2], "der einfache Fall"], + ["@vanvan schaust du mal", [2], "klein geschrieben trifft trotzdem"], + ["Hey @Tili, und @Miss auch!", [3, 4], "zwei Leute, mit Satzzeichen dahinter"], + ["@Tili @Tili @Tili", [3], "dreimal derselbe zählt einmal"], + ["schreib an filipe@dogfather.com", [], "eine E-Mail-Adresse spricht niemanden an"], + ["@Tilikum ist ein Wal", [], "ein längeres Wort ist kein Name"], + ["@Missgeschick passiert", [], "ein Name im Wortinneren zählt nicht"], + ["@Ghost ist nicht im Raum", [], "wer nicht im Raum sitzt, ist nicht gemeint"], + ["@Marina", [5], "ganz am Ende des Satzes"], + ["(@Marina)", [5], "in Klammern"], + ["@VanVan-Team", [2], "ein Bindestrich beendet den Namen"], + ["x@VanVan", [], "ein Buchstabe davor macht es zur Adresse"], + ["Ohne at-Zeichen hier", [], "kein @ — keine Ansprache"], + ]; + let stimmt = 0; + for (const [text, soll, was] of faelle) { + const ist = erwaehnteIds(text, leute); + const passt = JSON.stringify(ist) === JSON.stringify(soll); + if (passt) stimmt++; + ok(passt, `${was}${passt ? "" : ` (${JSON.stringify(text)} -> ${JSON.stringify(ist)}, erwartet ${JSON.stringify(soll)})`}`); + } + /* DIE ZAHL GEHÖRT IN DIE BEDINGUNG: Liefe die Schleife über null + Fälle, wäre oben nichts rot -- und nichts geprüft. */ + ok(stimmt === faelle.length && faelle.length >= 13, + `alle ${faelle.length} Regelfälle stimmen`); + + /* DER LÄNGSTE NAME GEWINNT. Heute steckt kein Name im anderen -- + das ist eine Tatsache von heute und kein Gesetz. Geprüft mit + erfundenen Namen, damit die Regel unabhängig vom Bestand hält. */ + const ineinander = [{ id: 10, name: "Van" }, { id: 11, name: "VanVan" }]; + ok(JSON.stringify(erwaehnteIds("@VanVan hallo", ineinander)) === "[11]", + "steckt ein Name im anderen, gewinnt der längere"); + ok(JSON.stringify(erwaehnteIds("@Van hallo", ineinander)) === "[10]", + " und der kürzere trifft trotzdem, wenn er allein steht"); + + /* Die Stellen fürs Hervorheben. Jedes Vorkommen wird gemeldet, nicht + nur das erste -- sonst bliebe das zweite "@Anna" im Satz grau. */ + const roh = "Hey @Tili und nochmal @Tili"; + const st = erwaehnungenFinden(roh, leute); + ok(st.length === 2 && st.every((t) => roh.slice(t.von, t.bis) === "@Tili"), + `jedes Vorkommen bekommt eine Stelle (${st.length} Stück)`); + + /* GEGENPROBE: Sie muss auch NEIN sagen können. Eine Auflösung, die + immer jemanden findet, findet nichts. */ + ok(erwaehnteIds("@VanVan", []).length === 0, + "Gegenprobe: ohne Teilnehmerliste ist niemand gemeint"); +} + +/* ===================================================================== + 2. DURCH DIE SCHNITTSTELLE + ===================================================================== */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); + +const jetzt = new Date().toISOString(); +let idDogi, idVan, idFunny; +{ + const d = new DatabaseSync(process.env.WORKSPACE_DB); + const schluessel = randomBytes(32).toString("hex"); + d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", schluessel, jetzt); + const anlegen = (name, rolle, code) => { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)" + + " VALUES (?,?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, createHmac("sha256", schluessel).update(code).digest("hex"), jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; + }; + idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); + idVan = anlegen("VanVan", "scout", "CODE-SCOU-0001"); + idFunny = anlegen("Funny", "scout", "CODE-SCOU-0002"); + d.close(); +} + +function ruf(pfad, keks, methode = "GET", koerper = null) { + return new Promise((fertig, schief) => { + const a = httpAnfrage({ + host: "127.0.0.1", port: PORT, path: pfad, method: methode, + headers: { + ...(keks ? { Cookie: keks } : {}), + ...(koerper ? { "Content-Type": "application/json", + Origin: `http://127.0.0.1:${PORT}` } : {}), + }, + }, (antwort) => { + let text = ""; + antwort.on("data", (s) => { text += s; }); + antwort.on("end", () => fertig({ code: antwort.statusCode, text })); + }); + a.on("error", schief); + if (koerper) a.write(JSON.stringify(koerper)); + a.end(); + }); +} +const json = (a) => { try { return JSON.parse(a.text); } catch { return {}; } }; + +async function anmelden(rolle, code) { + const a = await ruf("/workspace/api/anmelden", null, "POST", { rolle, code }); + if (a.code !== 200) console.log(` (Anmeldung ${rolle}: ${a.code} ${a.text.slice(0, 80)})`); + return ""; +} +/* Die Kekse holen wir direkt aus der Antwort -- `ruf` gibt sie nicht + zurück, also ein eigener Weg fürs Anmelden. */ +function anmeldenMitKeks(rolle, code) { + return new Promise((fertig, schief) => { + const koerper = JSON.stringify({ rolle, code }); + const a = httpAnfrage({ + host: "127.0.0.1", port: PORT, path: "/workspace/api/anmelden", method: "POST", + headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(koerper) }, + }, (antwort) => { + let text = ""; + antwort.on("data", (s) => { text += s; }); + antwort.on("end", () => fertig({ + code: antwort.statusCode, text, + keks: [].concat(antwort.headers["set-cookie"] || []).map((c) => c.split(";")[0]).join("; "), + })); + }); + a.on("error", schief); + a.write(koerper); + a.end(); + }); +} +void anmelden; + +melde('\n=== 2. Ein @ geht durch die Schnittstelle ==='); +const aDogi = await anmeldenMitKeks("admin", "CODE-DOGI-0001"); +const aVan = await anmeldenMitKeks("scout", "CODE-SCOU-0001"); +const aFunny = await anmeldenMitKeks("scout", "CODE-SCOU-0002"); +ok(!!aDogi.keks && !!aVan.keks && !!aFunny.keks, "drei Zugänge sind angemeldet"); + +/* Eine Gruppe mit allen dreien. */ +const gruppe = await ruf("/workspace/api/chat/gruppe", aDogi.keks, "POST", + { name: "Testgruppe", mit: [idVan, idFunny] }); +const raumId = json(gruppe).raum_id; +ok(Number.isInteger(raumId), `eine Gruppe mit drei Leuten steht (Raum ${raumId})`); + +/* Die Teilnehmer kommen MIT der Raumliste -- die Auswahlliste im + Browser braucht deshalb keine eigene Abfrage. Genau das wird hier + festgehalten, damit niemand später eine zweite baut. */ +{ + const l = json(await ruf("/workspace/api/chat/raeume", aDogi.keks)); + const r = (l.raeume || []).find((x) => x.id === raumId); + ok(Array.isArray(r?.teilnehmer) && r.teilnehmer.length === 3, + `die Raumliste bringt die Teilnehmer schon mit (${r?.teilnehmer?.length})`); + ok(r?.teilnehmer?.every((t) => t.id && t.name), + " mit Nummer und Namen — mehr braucht die Auswahl nicht"); +} + +/* Jetzt die eigentliche Nachricht. */ +const gesendet = await ruf(`/workspace/api/chat/raeume/${raumId}/nachrichten`, + aDogi.keks, "POST", { text: "Kannst du das übernehmen, @VanVan? Danke!" }); +ok(gesendet.code === 201, `die Nachricht geht raus (${gesendet.code})`); +const nachrichtId = json(gesendet).nachricht?.id; + +{ + const d = new DatabaseSync(process.env.WORKSPACE_DB); + const zeilen = d.prepare("SELECT person_id FROM chat_erwaehnungen WHERE nachricht_id = ?") + .all(nachrichtId); + d.close(); + ok(zeilen.length === 1 && zeilen[0].person_id === idVan, + `genau VanVan ist vermerkt (${zeilen.length} Eintrag/Einträge)`); +} + +/* SELBST ERWÄHNEN LÖST NICHTS AUS. */ +{ + const s = await ruf(`/workspace/api/chat/raeume/${raumId}/nachrichten`, + aDogi.keks, "POST", { text: "@Filipe hat recht, sagt @Filipe" }); + const d = new DatabaseSync(process.env.WORKSPACE_DB); + const n = d.prepare("SELECT COUNT(*) AS n FROM chat_erwaehnungen WHERE nachricht_id = ?") + .get(json(s).nachricht?.id).n; + d.close(); + ok(n === 0, `wer sich selbst erwähnt, spricht niemanden an (${n} Einträge)`); +} + +/* WER NICHT IM RAUM IST, KANN NICHT ANGESPROCHEN WERDEN -- auch nicht + über die Schnittstelle. Dafür ein vierter Mensch, der draußen bleibt. */ +{ + const d = new DatabaseSync(process.env.WORKSPACE_DB); + const salt = randomBytes(16).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt)" + + " VALUES (?,?,?,?,?,1,?)").run("Draussen", "modi", "x", salt, 32768, jetzt); + const idAussen = d.prepare("SELECT last_insert_rowid() AS id").get().id; + d.close(); + + const s = await ruf(`/workspace/api/chat/raeume/${raumId}/nachrichten`, + aDogi.keks, "POST", { text: "@Draussen bist du da?" }); + const d2 = new DatabaseSync(process.env.WORKSPACE_DB); + const n = d2.prepare("SELECT COUNT(*) AS n FROM chat_erwaehnungen WHERE nachricht_id = ? AND person_id = ?") + .get(json(s).nachricht?.id, idAussen).n; + d2.close(); + ok(n === 0, "wer nicht im Raum sitzt, wird auch über die Schnittstelle nicht angesprochen"); +} + +/* ===================================================================== + 3. MERKT ES DER RICHTIGE? — das Zeichen in der Gesprächsliste + ===================================================================== */ +melde("\n=== 3. Man merkt es auch ohne offenen Raum ==="); +{ + const beiVan = json(await ruf("/workspace/api/chat/raeume", aVan.keks)) + .raeume?.find((r) => r.id === raumId); + const beiFunny = json(await ruf("/workspace/api/chat/raeume", aFunny.keks)) + .raeume?.find((r) => r.id === raumId); + ok(beiVan?.erwaehnt === true, "VanVan sieht das Zeichen in ihrer Gesprächsliste"); + /* DIE GEGENPROBE, die alles trägt: Funny war im selben Raum, hat + dieselben Nachrichten ungelesen -- und ist NICHT gemeint. Leuchtete + es bei ihr auch, wäre das Zeichen wertlos. */ + ok(beiFunny?.erwaehnt === false, + "Gegenprobe: Funny war dabei, aber nicht gemeint — bei ihr leuchtet nichts"); + ok((beiFunny?.ungelesen || 0) > 0, + ` und sie hat sehr wohl Ungelesenes (${beiFunny?.ungelesen}) — es liegt nicht daran`); +} + +/* GELESEN HEISST WEG. Sonst leuchtet es für immer. */ +{ + /* 99999 IST ABSICHTLICH ZU GROSS -- und genau daran ist eine echte + Luecke aufgefallen (20.09.2026): Der Server nahm jede Zahl an. + Wer einmal "gelesen bis 999999" schickte, sah in diesem Gespraech + nie wieder einen Zaehler und nie wieder ein @-Zeichen; alles + Kuenftige galt als gelesen, bevor es geschrieben war. Niemand + haette den Zusammenhang je gefunden. + + Der Wert bleibt deshalb stehen: Er ist ab jetzt die Gegenprobe. */ + await ruf(`/workspace/api/chat/raeume/${raumId}/gelesen`, aVan.keks, "PUT", { bis: 99999 }); + { + const d = new DatabaseSync(process.env.WORKSPACE_DB); + const stand = d.prepare( + "SELECT gelesen_bis FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ?") + .get(raumId, idVan).gelesen_bis; + const hoechste = d.prepare( + "SELECT COALESCE(MAX(id),0) AS n FROM chat_nachrichten WHERE raum_id = ?").get(raumId).n; + d.close(); + ok(stand === hoechste, + `„gelesen bis 99999" wird auf die letzte echte Nachricht begrenzt (${stand} statt 99999, höchste ist ${hoechste})`); + } + const beiVan = json(await ruf("/workspace/api/chat/raeume", aVan.keks)) + .raeume?.find((r) => r.id === raumId); + ok(beiVan?.erwaehnt === false, "nach dem Lesen ist das Zeichen weg"); +} + +/* ===================================================================== + 4. WAS AUF DEM HANDY STEHT + ===================================================================== */ +melde("\n=== 4. Die Meldung sagt, dass DU gemeint bist ==="); +{ + const { pushTextFuer } = await import("./workspace-chat.js"); + const n = { von: "VanVan", text: "schaust du mal drauf?" }; + const erw = pushTextFuer("erwaehnung", n, raumId); + const normal = pushTextFuer("neu", n, raumId); + ok(/hat dich erwähnt/.test(erw.titel), `-> ${erw.titel}`); + ok(erw.titel !== normal.titel, + ` und das ist etwas anderes als: ${normal.titel}`); + ok(erw.text.includes("schaust du mal"), + " der Anfang der Nachricht steht darunter — man muss nicht öffnen"); + ok(erw.ziel === normal.ziel && erw.ziel.includes(String(raumId)), + " und beide führen in dasselbe Gespräch"); + + /* Die Art muss in der Liste stehen, sonst lässt sie sich nicht + abschalten -- und eine Meldung, die man nicht abschalten kann, + schaltet irgendwann alle ab. */ + const { ARTEN } = await import("./workspace-push.js"); + const art = ARTEN.find((a) => a.schluessel === "chat_erwaehnung"); + ok(!!art, 'chat_erwaehnung steht in den Benachrichtigungs-Einstellungen'); + ok(art?.vorgabe === true, " und ist von sich aus an"); + ok(ARTEN.find((a) => a.schluessel === "chat_nachricht")?.schluessel !== art?.schluessel, + " getrennt von der gewöhnlichen Chatnachricht — zwei Schalter, zwei Entscheidungen"); +} + +/* ===================================================================== + 5. BEIDE RECHNUNGEN, NEBENEINANDER + ===================================================================== */ +melde("\n=== 5. Server und Browser rechnen gleich ==="); +{ + /* Der Browser hat eine eigene, kurze Fassung derselben Regeln -- + er muss sofort hervorheben können, ohne zu fragen. Genau deshalb + wird sie hier gegen das Original gestellt: Zwei Rechnungen für + dieselbe Frage laufen sonst irgendwann auseinander, und dann hebt + die Seite jemanden hervor, den niemand benachrichtigt hat. */ + /* ZEILENENDEN VEREINHEITLICHEN -- und das ist kein Schoenheitsfehler. + + Der erste Anlauf suchte das Ende der Funktion mit einem Muster, + das auf einem Zeilenumbruch endete. Die Dateien dieses Hauses + stehen unter Windows aber mit CRLF auf der Platte; nach " }" + kommt also erst ein Wagenruecklauf -- und das Muster fand + NICHTS. Die Pruefung stuerzte ab, was hier Glueck war: Haette + sie mit dem leeren Ergebnis weitergerechnet, waere sie gruen + geworden und haette null Proben verglichen. */ + const chatJs = readFileSync(join(HIER, "..", "workspace", "assets", "js", "chat.js"), "utf8") + .replace(/\r\n/g, "\n"); + ok(/erwaehnungenFinden/.test(chatJs), + "der Browser hat eine Fassung derselben Auflösung"); + + /* Sie wird aus der Datei geholt und ausgeführt -- nicht nachgebaut. + Ein Nachbau würde prüfen, ob ICH zweimal dasselbe schreiben kann. */ + const anfang = chatJs.indexOf("function erwaehnungenFinden"); + ok(anfang > 0, " und sie steht als eigene Funktion da"); + if (anfang > 0) { + /* Den Block bis zur schließenden Klammer auf Spaltenhöhe holen. */ + const rest = chatJs.slice(anfang); + const ende = rest.indexOf("\n }\n"); + /* DRITTER AUSGANG: Findet der Schnitt sein Ende nicht, wird das + GESAGT -- statt mit einem Stummel weiterzurechnen und dabei + null Proben zu vergleichen. */ + ok(ende > 100, `das Ende der Funktion ist auffindbar (Zeichen ${ende})`); + const quelle = rest.slice(0, ende + 4); + // eslint-disable-next-line no-new-func + const browserFassung = new Function(`${quelle}; return erwaehnungenFinden;`)(); + + const leute = [ + { id: 1, name: "Dogfather" }, { id: 2, name: "VanVan" }, { id: 3, name: "Tili" }, + { id: 4, name: "Miss" }, + ]; + const proben = [ + "@VanVan kannst du?", "@vanvan klein", "Hey @Tili, und @Miss!", + "filipe@dogfather.com", "@Tilikum", "@Missgeschick", "x@VanVan", + "(@Miss)", "@Tili @Tili", "nichts hier", "@VanVan-Team", + ]; + let gleich = 0; + for (const t of proben) { + const a = JSON.stringify(erwaehnungenFinden(t, leute)); + const b = JSON.stringify(browserFassung(t, leute)); + if (a === b) gleich++; + else console.log(` ungleich bei ${JSON.stringify(t)}:\n Server: ${a}\n Browser: ${b}`); + } + ok(gleich === proben.length && proben.length >= 10, + `beide kommen bei allen ${proben.length} Proben auf dasselbe Ergebnis`); + } +} + + +/* ===================================================================== + 6. AM ECHTEN BILDSCHIRM + + Alles bisher hat bewiesen, dass der Richtige vermerkt wird. Das ist + die Hälfte. Die andere: Sieht man es? Und kommt man mit der Tastatur + dorthin, ohne mitten im Satz zur Maus zu greifen? + + Es gibt einen Weg, auf dem alles Bisherige grün ist und die Funktion + trotzdem nicht benutzbar: wenn die Liste hinter der Schreibzeile + liegt, wenn Enter statt zu wählen die halbe Nachricht abschickt, + oder wenn der Name am Ende grau bleibt wie jedes andere Wort. + ===================================================================== */ +melde("\n=== 6. Am echten Bildschirm ==="); +const BASIS = `http://127.0.0.1:${PORT}`; +const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await pw.chromium.launch(); +try { + async function alsRolle(rolle, code, breite = 1280, wohin = `chat.html?raum=${raumId}`) { + const ctx = await browser.newContext({ viewport: { width: breite, height: 900 } }); + const seite = await ctx.newPage(); + await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" }); + await seite.click(`.rolle[data-rolle="${rolle}"]`); + await seite.fill("#code", code); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 25000 }); + await seite.goto(`${BASIS}/workspace/${wohin}`, { waitUntil: "networkidle" }); + await seite.waitForTimeout(1100); + return { ctx, seite }; + } + + const dogi = await alsRolle("admin", "CODE-DOGI-0001"); + const s = dogi.seite; + ok(await s.locator("#text").isVisible(), "das Schreibfeld steht da"); + + /* ---- Die Liste erscheint beim Tippen --------------------------- */ + const vorher = await s.locator(".chat-nachricht").count(); + await s.click("#text"); + await s.type("#text", "Hallo @", { delay: 40 }); + await s.waitForTimeout(400); + const liste = s.locator("#erwaehn-liste"); + ok(await liste.isVisible(), 'ein @ oeffnet die Liste'); + const namen = await liste.locator(".erwaehn-liste__name").allInnerTexts(); + ok(namen.length === 2, `sie zeigt die anderen zwei aus diesem Raum (${namen.join(", ")})`); + ok(!namen.includes("Filipe"), + " und NICHT mich selbst — ein Eintrag, der nichts bewirkt, gehört nicht hinein"); + + /* Liegt sie auch wirklich sichtbar, oder hinter der Schreibzeile? */ + const lage = await s.evaluate(() => { + const l = document.getElementById("erwaehn-liste"); + const f = document.getElementById("text"); + const a = l.getBoundingClientRect(), b = f.getBoundingClientRect(); + return { unten: a.bottom, oben: b.top, breit: a.width, hoch: a.height, + imBild: a.top >= 0 && a.left >= 0 && a.right <= innerWidth }; + }); + ok(lage.unten <= lage.oben + 12, + `sie steht ÜBER der Schreibzeile (${Math.round(lage.unten)} ≤ ${Math.round(lage.oben)})`); + ok(lage.imBild && lage.breit > 100 && lage.hoch > 40, + ` und vollständig im Bild (${Math.round(lage.breit)}×${Math.round(lage.hoch)})`); + + /* ---- Tippen filtert --------------------------------------------- */ + await s.type("#text", "Van", { delay: 40 }); + await s.waitForTimeout(350); + const gefiltert = await liste.locator(".erwaehn-liste__name").allInnerTexts(); + ok(gefiltert.length === 1 && gefiltert[0] === "VanVan", + `Weitertippen grenzt ein (${gefiltert.join(", ") || "nichts"})`); + + /* ---- Die Berührfläche ------------------------------------------- */ + const hoch = await liste.locator(".erwaehn-liste__eintrag").first() + .evaluate((e) => e.getBoundingClientRect().height); + ok(hoch >= 44, `ein Eintrag ist ${Math.round(hoch)} px hoch — am Daumen treffbar`); + + /* ---- Enter WÄHLT, es schickt nicht ab ---------------------------- */ + await s.keyboard.press("Enter"); + await s.waitForTimeout(400); + const nachEnter = await s.inputValue("#text"); + ok(nachEnter === "Hallo @VanVan ", + `Enter setzt den Namen ein: ${JSON.stringify(nachEnter)}`); + ok(!(await liste.isVisible()), " und die Liste ist wieder zu"); + /* DER ENTSCHEIDENDE PUNKT: Der halbe Satz darf NICHT rausgegangen + sein. Ohne diese Prüfung wäre der häufigste Handgriff überhaupt + eine versehentlich abgeschickte Nachricht. */ + const raus = await s.locator(".chat-nachricht").count(); + /* GEGEN DEN AUSGANGSWERT, NICHT GEGEN NULL. Im Raum stehen schon + die Nachrichten aus Abschnitt 2 -- der erste Anlauf verglich mit + 0 und wurde rot, obwohl nichts abgeschickt worden war. Eine + Pruefung, die den Anfangszustand nicht kennt, misst ihn mit. */ + ok(raus === vorher, `nichts wurde abgeschickt (${raus}, vorher ${vorher})`); + + /* ---- Und jetzt wirklich abschicken ------------------------------- */ + await s.type("#text", "schaust du mal?", { delay: 20 }); + await s.keyboard.press("Enter"); + await s.waitForTimeout(1400); + const blase = s.locator(".chat-nachricht").last(); + ok(await blase.isVisible(), "die Nachricht steht im Verlauf"); + const marke = blase.locator(".chat-erwaehnung"); + ok(await marke.count() === 1, "der Name ist im Satz hervorgehoben"); + ok((await marke.innerText()).trim() === "@VanVan", + ` und zwar genau: ${(await marke.innerText()).trim()}`); + /* GEGENPROBE: Beim Absender ist es NICHT der eigene Name -- die + stärkere Hervorhebung gehört nur dem, der gemeint ist. */ + ok(await marke.getAttribute("data-ich") === null, + "Gegenprobe: beim Absender ist es nicht die eigene Marke"); + + /* ---- Und bei der, die gemeint ist -------------------------------- */ + const van = await alsRolle("scout", "CODE-SCOU-0001"); + const vs = van.seite; + await vs.waitForTimeout(900); + const vMarke = vs.locator(".chat-nachricht").last().locator(".chat-erwaehnung"); + ok(await vMarke.count() === 1, "VanVan sieht den Namen ebenfalls hervorgehoben"); + ok(await vMarke.getAttribute("data-ich") === "ja", + " und bei ihr deutlicher — sie ist gemeint"); + /* NICHT NUR FARBE: Im Kontrastmodus fällt jede Tönung weg. Geprüft + wird, dass auch etwas anderes anders ist. */ + const anders = await vMarke.evaluate((e) => { + const m = getComputedStyle(e); + const nachbar = e.parentElement; + const n = getComputedStyle(nachbar); + return { gewicht: m.fontWeight, gewichtNachbar: n.fontWeight, + schatten: m.boxShadow !== "none", grund: m.backgroundColor }; + }); + ok(Number(anders.gewicht) > Number(anders.gewichtNachbar) || anders.schatten, + ` erkennbar nicht nur an der Farbe (Gewicht ${anders.gewicht} statt ${anders.gewichtNachbar}, Rahmen: ${anders.schatten})`); + + /* ---- Das Zeichen in der Gesprächsliste --------------------------- + + ERST ZURÜCK ZUR LISTE, DANN EINE FRISCHE ERWÄHNUNG. Der erste + Anlauf prüfte es direkt nach dem Lesen und wurde rot -- zu Recht: + VanVan hatte den Raum offen, also hat sie es gesehen, und ein + Zeichen für etwas Gelesenes wäre falsch. Die Prüfung mass die + eigene Reihenfolge, nicht das Verhalten. + + Jetzt der Fall, um den es wirklich geht: Sie ist woanders, und es + kommt etwas für sie herein. */ + await vs.goto(`${BASIS}/workspace/chat.html`, { waitUntil: "networkidle" }); + await vs.waitForTimeout(900); + const vorSchon = await vs.locator(".chat-raum__erwaehnt").count(); + ok(vorSchon === 0, + `solange nichts Neues da ist, leuchtet auch nichts (${vorSchon})`); + + await s.click("#text"); + await s.type("#text", "@VanVan noch eine Sache", { delay: 15 }); + await s.keyboard.press("Enter"); + await s.waitForTimeout(1500); + await vs.reload({ waitUntil: "networkidle" }); + await vs.waitForTimeout(1200); + + const zeichen = vs.locator(".chat-raum__erwaehnt"); + ok(await zeichen.count() === 1, `in ihrer Gesprächsliste steht das @-Zeichen (${await zeichen.count()})`); + ok((await zeichen.first().getAttribute("aria-label") || "").includes("erwähnt"), + " mit einem Satz für Vorleseprogramme statt eines nackten Zeichens"); + + /* GEGENPROBE BEI FUNNY: Sie sitzt im selben Raum und hat dieselben + Nachrichten ungelesen -- und ist nicht gemeint. Leuchtete es bei + ihr auch, wäre das Zeichen wertlos. Am Bildschirm, nicht nur in + der Antwort des Servers. */ + const funny = await alsRolle("scout", "CODE-SCOU-0002", 1280, "chat.html"); + await funny.seite.waitForTimeout(1200); + const beiFunny = await funny.seite.locator(".chat-raum__erwaehnt").count(); + const zahlFunny = await funny.seite.locator(".chat-raum__zahl").count(); + ok(beiFunny === 0, `Gegenprobe: bei Funny leuchtet nichts (${beiFunny})`); + ok(zahlFunny >= 1, + ` obwohl sie Ungelesenes hat (${zahlFunny} Gespräch(e) mit Zahl) — es liegt nicht daran`); + await funny.ctx.close(); + + /* ---- Die Gegenprobe der Liste: ein @ ohne Treffer -------------- */ + await s.click("#text"); + await s.type("#text", "@Zzz", { delay: 30 }); + await s.waitForTimeout(350); + ok(!(await liste.isVisible()), + "Gegenprobe: passt niemand, bleibt die Liste zu"); + /* Und Enter schickt dann wieder ab, wie immer. */ + await s.keyboard.press("Enter"); + await s.waitForTimeout(1200); + const jetztRaus = await s.locator(".chat-nachricht").count(); + ok(jetztRaus > vorher + 1, ` und Enter schickt wieder ab (${jetztRaus}, vorher ${vorher})`); + + await dogi.ctx.close(); + await van.ctx.close(); +} finally { + await browser.close(); +} + +console.log(`\n${fehler ? fehler + " FEHLER" : "Alles in Ordnung"} — ${geprueft} Prüfungen\n`); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +process.exit(fehler ? 1 : 0); diff --git a/server/workspace-chat.js b/server/workspace-chat.js index f1e664d4..f920f08b 100644 --- a/server/workspace-chat.js +++ b/server/workspace-chat.js @@ -47,6 +47,7 @@ import { fuehrtTeamDogi, MODI_KATEGORIEN, ROLLEN_GRUPPE, } from "./workspace.js"; +import { erwaehnteIds } from "./chat-erwaehnung.js"; import { KATALOG, ERLAUBT, VORSCHLAG, FAVORITEN_MAX } from "./workspace-reaktionen.js"; /* Der Treff-Chat: eigener Raum, eigene Nachtruhe, keine Anrufe. @@ -505,6 +506,32 @@ chatRouter.get("/workspace/api/chat/raeume", (req, res) => { WHERE raum_id = ? AND id > ? AND id > ? AND person_id <> ?`) .get(r.id, r.gelesen_bis, r.geloescht_bis, req.person.id).n; + /* STEHT IN DEN UNGELESENEN EIN "@" AN MICH? (20.09.2026) + + Eine Zahl sagt "hier ist etwas". Sie sagt nicht, ob es an + DICH war. Wer morgens vier Raeume mit Zahlen sieht, macht + den lautesten zuerst auf -- und genau dort steht selten das, + was auf ihn wartet. Das Zeichen daneben dreht die Reihenfolge + um. + + DIESELBEN ZWEI GRENZEN wie bei der Zahl darueber: was schon + gelesen ist, und was weggeraeumt wurde. Ohne die zweite + leuchtete ein Gespraech wieder auf, das jemand bewusst + beiseitegelegt hat. + + UND `weg_am IS NULL`: Eine zurueckgenommene Nachricht spricht + niemanden mehr an. Der Eintrag bleibt stehen (er gehoert zur + Nachricht), zaehlt hier aber nicht mehr mit -- gerechnet wird + beim Lesen, nicht beim Zuruecknehmen, damit es keine zweite + Stelle gibt, die man vergessen kann. */ + const erwaehnt = !!db().prepare(` + SELECT 1 FROM chat_erwaehnungen e + JOIN chat_nachrichten n ON n.id = e.nachricht_id + WHERE e.person_id = ? AND n.raum_id = ? + AND n.id > ? AND n.id > ? AND n.weg_am IS NULL + LIMIT 1`) + .get(req.person.id, r.id, r.gelesen_bis, r.geloescht_bis); + return { id: r.id, art: r.art, @@ -531,6 +558,7 @@ chatRouter.get("/workspace/api/chat/raeume", (req, res) => { von: letzte.von, erstellt: letzte.erstellt, } : null, ungelesen, + erwaehnt, }; }); /* WELCHE KANAELE ES NOCH NICHT GIBT -- und nur fuer die, die sie @@ -1042,11 +1070,45 @@ chatRouter.post("/workspace/api/chat/raeume/:id/nachrichten", gleicheHerkunft, anhang: null, }; + /* ==== WER IST MIT "@" GEMEINT? (20.09.2026) ===================== + + Aufgeloest wird HIER, gegen die Teilnehmer DIESES Raums -- nicht + im Browser. Der Browser schickt nur Text. Wer daraus eine + Person wird, entscheidet der Server, und damit gilt: + + * Ein von Hand getipptes "@Anna" wirkt genauso wie eines aus + der Auswahlliste. Wer schnell schreibt, soll nicht bestraft + werden. + * Niemand kann jemanden ansprechen, der gar nicht im Raum + sitzt -- auch nicht, indem er die Schnittstelle direkt + anspricht. + + Die Regeln stehen in chat-erwaehnung.js, samt Begruendung je + Regel. Sie sind bewusst ohne Abhaengigkeiten, damit die + Pruefung sie lesen kann, ohne einen Server hochzufahren. */ + const alleLeute = teilnehmerVon(raumId); + const erwaehnt = erwaehnteIds(text, alleLeute) + /* SICH SELBST ANSPRECHEN LOEST NICHTS AUS. Man schreibt den + eigenen Namen durchaus ("@Anna hat recht, ich auch") -- eine + Meldung an sich selbst waere Unsinn. */ + .filter((pid) => pid !== req.person.id); + if (erwaehnt.length) { + const einf = d.prepare( + "INSERT OR IGNORE INTO chat_erwaehnungen (nachricht_id, person_id) VALUES (?,?)"); + for (const pid of erwaehnt) einf.run(id, pid); + } + /* Sofort an alle, die gerade zusehen -- und per Benachrichtigung an die, die es nicht tun. Beides läuft über chatEreignis(), - damit die Entscheidung "wer bekommt was" an einer Stelle steht. */ - const empfaenger = teilnehmerVon(raumId).filter((t) => t.id !== req.person.id); - chatEreignis(raumId, nachricht, empfaenger.map((t) => t.id)); + damit die Entscheidung "wer bekommt was" an einer Stelle steht. + Auch die Erwaehnung: Sie ist keine ZWEITE Meldung, sondern eine + ANDERE -- wer angesprochen wurde, bekommt statt "Nachricht von + Anna" ein "Anna hat dich erwaehnt". Zwei Meldungen fuer eine + Nachricht waeren der schnellste Weg, dass jemand beide + abschaltet. */ + const empfaenger = alleLeute.filter((t) => t.id !== req.person.id); + chatEreignis(raumId, nachricht, empfaenger.map((t) => t.id), "neu", + new Set(erwaehnt)); res.status(201).json({ nachricht }); } catch (fehler) { @@ -1696,12 +1758,42 @@ chatRouter.put("/workspace/api/chat/raeume/:id/gelesen", gleicheHerkunft, return res.status(400).json({ fehler: "ungueltig" }); } if (!istDrin(raumId, req.person)) return res.status(404).json({ fehler: "nicht_gefunden" }); + + /* ==== NICHT WEITER ALS BIS ZUR LETZTEN NACHRICHT (20.09.2026) ==== + + Hier stand nur `Number.isInteger(bis)`. Damit liess sich der + Stand auf JEDE Zahl setzen -- auch auf eine, die weit hinter + allem liegt, was es gibt. + + Die Folge ist still und dauerhaft: Wer einmal "gelesen bis + 999999" schickt, hat in diesem Gespraech nie wieder einen + Zaehler und nie wieder ein @-Zeichen. Alles Kuenftige ist + schon gelesen, bevor es geschrieben wurde. Niemand wuerde den + Zusammenhang je finden -- man merkt nur, dass "die + Benachrichtigungen nicht gehen". + + Gefunden beim Bauen der Erwaehnungen: Die Pruefung setzte zum + Aufraeumen 99999 und wunderte sich danach, warum das frische + "@" nicht leuchtete. Der Fehler lag nicht in der Erwaehnung. + + Der Browser schickt heute immer die Nummer der letzten + gesehenen Nachricht -- er ist also nicht der Grund. Aber eine + Grenze, die nur davon lebt, dass der Aufrufer sich benimmt, + ist keine. + + ABGELEITET STATT GERATEN: Weiter als bis zur juengsten + Nachricht dieses Raums kann niemand gelesen haben. */ + const neueste = db().prepare( + "SELECT COALESCE(MAX(id), 0) AS n FROM chat_nachrichten WHERE raum_id = ?") + .get(raumId).n; + const stand = Math.max(0, Math.min(bis, neueste)); + /* NUR VORWÄRTS. Ein zu spät eintreffender Aufruf einer älteren Ansicht darf den Stand nicht zurückdrehen -- sonst stünde eine gelesene Nachricht wieder als ungelesen da. */ db().prepare(`UPDATE chat_teilnehmer SET gelesen_bis = ? WHERE raum_id = ? AND person_id = ? AND gelesen_bis < ?`) - .run(bis, raumId, req.person.id, bis); + .run(stand, raumId, req.person.id, stand); res.json({ ok: true }); } catch (fehler) { console.error("[chat] Gelesen:", fehler?.message); @@ -1863,7 +1955,15 @@ chatRouter.delete("/workspace/api/chat/nachrichten/:id", gleicheHerkunft, (req, * Eine Person kann an zwei Geräten sitzen. */ const zuschauer = new Map(); -function chatEreignis(raumId, nachricht, benachrichtigen, art = "neu") { +/** + * @param erwaehnte Wer in DIESER Nachricht mit "@" angesprochen wurde. + * Diese Menge bekommt eine ANDERE Meldung, nicht eine zweite + * (20.09.2026). Sie steht hier und nicht an der Aufrufstelle, + * weil der Kommentar unten es verlangt: Die Entscheidung "wer + * bekommt was" gehoert an EINE Stelle. + */ +function chatEreignis(raumId, nachricht, benachrichtigen, art = "neu", + erwaehnte = new Set()) { /* 1. An alle offenen Verbindungen der Beteiligten. */ const leute = teilnehmerVon(raumId).map((t) => t.id); for (const personId of leute) { @@ -1898,7 +1998,12 @@ function chatEreignis(raumId, nachricht, benachrichtigen, art = "neu") { sehen und behandelte deshalb JEDES Ereignis als Chatnachricht. Bei einem Anruf kam dabei woertlich „Nachricht von [object Object]" heraus. */ - void meldeNeueNachricht(personId, raumId, nachricht, art); + /* ANGESPROCHEN ODER NUR DABEI? Der Unterschied entscheidet ueber + Wortlaut UND Art -- und die Art kann jeder einzeln abschalten. + Wer den Chat stumm stellt, weil dort viel geredet wird, will + trotzdem wissen, wenn ihn jemand direkt anspricht. */ + void meldeNeueNachricht(personId, raumId, nachricht, + art === "neu" && erwaehnte.has(personId) ? "erwaehnung" : art); } } @@ -1946,9 +2051,30 @@ export function pushTextFuer(art, nachricht, raumId) { }; } + const wer = typeof nachricht.von === "string" + ? nachricht.von : (nachricht.von?.name || "jemandem"); + + /* ---- ANGESPROCHEN IST ETWAS ANDERES ALS DABEI (20.09.2026) ---- + + "Nachricht von Anna" sagt nicht, dass sie DICH meint. In einem + Raum mit acht Leuten ist das der Unterschied zwischen "kann + warten" und "gleich nachsehen" -- und genau diesen Unterschied + soll das Markieren mit "@" machen. Steht er nicht in der Meldung, + ist das Markieren nur Farbe. + + Der Anfang der Nachricht bleibt darunter stehen, wie sonst auch: + Wer auf dem Sperrbildschirm liest, soll nicht oeffnen muessen, um + zu erfahren, ob es wichtig war. */ + if (art === "erwaehnung") { + return { + titel: `${wer} hat dich erwähnt`, + text: String(nachricht.text || "").slice(0, 120), + ziel, + }; + } + return { - titel: `Nachricht von ${typeof nachricht.von === "string" - ? nachricht.von : (nachricht.von?.name || "jemandem")}`, + titel: `Nachricht von ${wer}`, /* Der Anfang der Nachricht steht in der Meldung -- eine Benachrichtigung, die nur "Neue Nachricht" sagt, zwingt zum Öffnen, um zu erfahren, ob es wichtig war. */ @@ -1987,8 +2113,14 @@ async function meldeNeueNachricht(personId, raumId, nachricht, art = "neu") { wirklich klingelt. */ /* ALS EIGENE ART, wenn es ein Anruf ist. Sie umgeht die Ruhezeit und laesst sich getrennt abschalten -- siehe workspace-push.js. */ - await benachrichtige(personId, art === "anruf" ? "anruf" : "chat_nachricht", - pushTextFuer(art, nachricht, raumId)); + /* DREI ARTEN, DREI SCHALTER. Jede laesst sich einzeln abschalten + (siehe ARTEN in workspace-push.js) -- und genau darum geht es: + Wer den lauten Chat stumm stellt, verliert damit weder Anrufe + noch die Faelle, in denen ihn jemand direkt anspricht. */ + const pushArt = art === "anruf" ? "anruf" + : art === "erwaehnung" ? "chat_erwaehnung" + : "chat_nachricht"; + await benachrichtige(personId, pushArt, pushTextFuer(art, nachricht, raumId)); } catch (fehler) { console.error("[chat] Benachrichtigung:", fehler?.message); } diff --git a/server/workspace-push.js b/server/workspace-push.js index fc36cd79..ddc025bc 100644 --- a/server/workspace-push.js +++ b/server/workspace-push.js @@ -87,6 +87,25 @@ export const ARTEN = [ die am haeufigsten kommt, und wer die Liste ueberfliegt, sucht zuerst sie. */ { schluessel: "chat_nachricht", name: "Neue Nachricht im Chat", vorgabe: true }, + /* ---- ANGESPROCHEN WERDEN IST EINE EIGENE ART (20.09.2026) ------ + + Filipe: "ich will das man die leute mit @ markieren kann im chat." + + Sie steht direkt hinter "Neue Nachricht", weil sie daneben + gehoert -- und sie ist trotzdem getrennt, aus demselben Grund, + aus dem Anrufe getrennt sind: Es sind zwei verschiedene + Entscheidungen. + + In einem Raum mit acht Leuten ist "jemand hat geschrieben" oft + Hintergrund. "Jemand meint DICH" ist es nie. Wer das Erste stumm + stellt, will das Zweite meist behalten -- und muesste sonst + beides zusammen abschalten oder beides zusammen ertragen. + + `vorgabe: true`, wie alles, was auf ein Ereignis antwortet. Und + KEINE Ausnahme von der Ruhezeit: Ein Anruf wartet auf eine + Antwort, ein "@Anna" nicht. Wer um halb vier angesprochen wird, + liest es um sieben -- das reicht. */ + { schluessel: "chat_erwaehnung", name: "Jemand erwähnt dich im Chat (@)", vorgabe: true }, /* ---- ANRUFE SIND EINE EIGENE ART (18.09.2026) ------------------ Filipe: „sie bekommt nur eine benarichtigung das eine neue diff --git a/server/workspace.js b/server/workspace.js index f8d7b66e..17d7528c 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -3624,6 +3624,49 @@ export function db() { CREATE INDEX IF NOT EXISTS idx_chat_nachrichten_raum ON chat_nachrichten (raum_id, id); + /* ERWAEHNUNGEN -- "@Name" IM CHAT (20.09.2026). + + Filipe: "ich will das man die leute mit @ markieren kann im + chat." + + WARUM EINE EIGENE TABELLE UND NICHT NUR FARBIGER TEXT: + Markieren ist kein Schmuck, sondern eine Ansprache. Wer + gemeint ist, soll es MERKEN -- auch wenn er den Raum gerade + nicht offen hat und auch dann noch, wenn er ihn Stunden + spaeter aufmacht. Dafuer muss irgendwo stehen, wer gemeint + war. + + WARUM NICHT AUS DEM TEXT NACHRECHNEN: Man koennte beim Lesen + jedes Mal "@" suchen. Dann muesste man aber bei jedem Aufbau + der Gespraechsliste den Text JEDER ungelesenen Nachricht + durchgehen -- bei einem lebhaften Raum sind das hunderte. Hier + steht das Ergebnis einmal und wird nie wieder gerechnet. + + WER GEMEINT IST, ENTSCHEIDET DER SERVER -- nicht der Browser. + Der Browser schickt nur Text; wer daraus eine Person wird, + loest erwaehnungenFinden() gegen die TEILNEHMER DES RAUMS + auf. So kann niemand jemanden ansprechen, der gar nicht dabei + ist, und ein von Hand getipptes "@Anna" wirkt genauso wie + eines aus der Auswahlliste. + + ON DELETE CASCADE an beiden Enden, wie bei den Reaktionen: + Verschwindet die Nachricht oder der Mensch, geht der Eintrag + mit. Eine Erwaehnung ohne Nachricht waere ein Hinweis, der + nirgendwohin fuehrt. + + Der zusammengesetzte Primaerschluessel sagt "jede Person hoechstens + einmal je Nachricht": Wer dreimal "@Anna" in einen Satz + schreibt, spricht sie trotzdem nur einmal an. */ + CREATE TABLE IF NOT EXISTS chat_erwaehnungen ( + nachricht_id INTEGER NOT NULL REFERENCES chat_nachrichten(id) ON DELETE CASCADE, + person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE, + PRIMARY KEY (nachricht_id, person_id) + ); + /* Die Frage, die im Betrieb gestellt wird, lautet "bin ICH + irgendwo erwaehnt?" -- deshalb liegt person_id vorn. */ + CREATE INDEX IF NOT EXISTS idx_chat_erwaehnungen_person + ON chat_erwaehnungen (person_id, nachricht_id); + /* REAKTIONEN (14.09.2026). Filipe: "ich will dass man auf die nachrichten auch reagieren diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index 5189b2e3..40c27813 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,15 +16,15 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +