diff --git a/server/pruef-chat-aufloesen.mjs b/server/pruef-chat-aufloesen.mjs index 70db66f9..5fec354c 100644 --- a/server/pruef-chat-aufloesen.mjs +++ b/server/pruef-chat-aufloesen.mjs @@ -383,6 +383,123 @@ console.log("=== Anhaenge ==="); "der Anhang-Ordner selbst steht noch (die Pruefung misst die Datei, nicht den Ordner)"); } +/* ======================================================================= + 7b. REAKTIONEN — auf die Nachricht, ohne zu antworten (14.09.2026) + ======================================================================= + Filipe: "ich will dass man auf die nachrichten auch reagieren kann + mit einem emoji, die nachricht selbst ohne zu antworten." + + GEMESSEN WIRD DER BESTAND, nicht die Antwort. Ein "ok" sagt nur, + dass der Server nicht gestolpert ist; ob die Reaktion wirklich steht + (und beim zweiten Klick wirklich weg ist), steht in der Datenbank. + ======================================================================= */ +console.log(""); +console.log("=== Reaktionen ==="); +{ + const g = await (await rufe(eins.keks, "/workspace/api/chat/gruppe", + { mit: [idZwei, idDrei], name: "Reaktionsrunde" }, "POST")).json(); + const n = await (await rufe(eins.keks, + `/workspace/api/chat/raeume/${g.raum_id}/nachrichten`, { text: "Moin" }, "POST")).json(); + const nid = n.nachricht?.id; + ok(Number.isInteger(nid), `eine Nachricht zum Reagieren (#${nid})`); + + const auswahl = await holen(eins.keks, "/workspace/api/chat/reaktionen/auswahl"); + ok(Array.isArray(auswahl.zeichen) && auswahl.zeichen.length >= 4, + `die Auswahl kommt vom Server (${(auswahl.zeichen || []).length} Zeichen)`); + const daumen = auswahl.zeichen[0]; + const herz = auswahl.zeichen[1]; + + const zaehle = () => d.prepare( + "SELECT COUNT(*) AS n FROM chat_reaktionen WHERE nachricht_id = ?").get(nid).n; + + /* ---- Setzen ---------------------------------------------------- */ + const a1 = await (await rufe(zwei.keks, + `/workspace/api/chat/nachrichten/${nid}/reaktion`, { zeichen: daumen }, "PUT")).json(); + ok(zaehle() === 1, `Ben setzt ein Zeichen -- es steht in der Datenbank (${zaehle()})`); + ok(a1.reaktionen?.[0]?.anzahl === 1 && a1.reaktionen?.[0]?.selbst === true, + "die Antwort nennt Anzahl und dass es seines ist"); + + /* ---- Derselbe Mensch, ZWEITES Zeichen -- das geht --------------- */ + await rufe(zwei.keks, `/workspace/api/chat/nachrichten/${nid}/reaktion`, + { zeichen: herz }, "PUT"); + ok(zaehle() === 2, `dasselbe Mensch darf ein ZWEITES Zeichen geben (${zaehle()})`); + + /* ---- Derselbe Mensch, DASSELBE Zeichen -- nimmt zurueck --------- */ + const a3 = await (await rufe(zwei.keks, + `/workspace/api/chat/nachrichten/${nid}/reaktion`, { zeichen: daumen }, "PUT")).json(); + ok(zaehle() === 1, `zweimal dasselbe nimmt es wieder weg (${zaehle()})`); + ok(a3.gesetzt === false, "und die Antwort sagt, dass es weg ist"); + + /* ---- Mehrere Menschen, dasselbe Zeichen ------------------------- */ + await rufe(drei.keks, `/workspace/api/chat/nachrichten/${nid}/reaktion`, + { zeichen: herz }, "PUT"); + const stand = await (await rufe(eins.keks, + `/workspace/api/chat/nachrichten/${nid}/reaktion`, { zeichen: herz }, "PUT")).json(); + const herzEintrag = (stand.reaktionen || []).find((r) => r.zeichen === herz); + ok(herzEintrag?.anzahl === 3, `drei Menschen, ein Zeichen, Anzahl 3 (${herzEintrag?.anzahl})`); + ok(Array.isArray(herzEintrag?.wer) && herzEintrag.wer.length === 3, + `und die Namen stehen dabei (${(herzEintrag?.wer || []).join(", ")})`); + + /* ---- Was NICHT geht --------------------------------------------- */ + const fremd = await rufe(hand.keks, `/workspace/api/chat/nachrichten/${nid}/reaktion`, + { zeichen: daumen }, "PUT"); + ok(fremd.status === 404, + `wer nicht im Raum ist, kommt nicht heran (HTTP ${fremd.status})`); + + const unsinn = await rufe(zwei.keks, `/workspace/api/chat/nachrichten/${nid}/reaktion`, + { zeichen: "" }, "PUT"); + ok(unsinn.status === 400, + `ein erfundenes Zeichen wird abgewiesen (HTTP ${unsinn.status})`); + ok(zaehle() === 3, `und aendert nichts am Bestand (${zaehle()})`); + + /* ---- Eine zurueckgenommene Nachricht ----------------------------- */ + const weg = await (await rufe(eins.keks, + `/workspace/api/chat/raeume/${g.raum_id}/nachrichten`, { text: "Weg gleich" }, "POST")).json(); + await rufe(eins.keks, `/workspace/api/chat/nachrichten/${weg.nachricht.id}`, + undefined, "DELETE"); + const drauf = await rufe(zwei.keks, + `/workspace/api/chat/nachrichten/${weg.nachricht.id}/reaktion`, { zeichen: daumen }, "PUT"); + ok(drauf.status === 409, + `auf eine zurueckgenommene Nachricht geht es nicht (HTTP ${drauf.status})`); + + /* ---- Und sie ueberleben das Neuladen ----------------------------- */ + const verlauf = await holen(zwei.keks, + `/workspace/api/chat/raeume/${g.raum_id}/nachrichten`); + const wieder = (verlauf.nachrichten || []).find((x) => x.id === nid); + ok(Array.isArray(wieder?.reaktionen) && wieder.reaktionen.length === 1, + `beim Neuladen sind sie wieder da (${(wieder?.reaktionen || []).length} Sorte)`); + ok(wieder?.reaktionen?.[0]?.selbst === true, + "und Ben sieht, dass eines davon seines ist"); + + /* GEGENPROBE, DIE WIRKLICH UNTERSCHEIDET. + + Der erste Entwurf pruefte "bei Cem ist ueberall selbst=true" -- und + das war trivial wahr, weil es zu dem Zeitpunkt nur EINE Sorte gab. + Eine Angabe, die nie `false` sein kann, sagt nichts. + + Also bekommt Ayla ein drittes Zeichen, das Cem NICHT gegeben hat. + Jetzt muss bei Cem eines auf `true` und eines auf `false` stehen -- + in derselben Antwort. */ + const lachen = auswahl.zeichen[2]; + await rufe(eins.keks, `/workspace/api/chat/nachrichten/${nid}/reaktion`, + { zeichen: lachen }, "PUT"); + const beiDrei = await holen(drei.keks, + `/workspace/api/chat/raeume/${g.raum_id}/nachrichten`); + const seins = (beiDrei.nachrichten || []).find((x) => x.id === nid); + const meins = (seins?.reaktionen || []).find((r) => r.zeichen === herz); + const fremdes = (seins?.reaktionen || []).find((r) => r.zeichen === lachen); + ok(meins?.selbst === true && fremdes?.selbst === false, + `bei Cem: sein Herz ist "selbst", Aylas Lachen nicht ` + + `(${meins?.selbst} / ${fremdes?.selbst})`); + + /* ---- Die Nachricht loeschen nimmt die Reaktionen mit ------------- */ + const vorherAnzahl = zaehle(); + ok(vorherAnzahl > 0, `vorher stehen ${vorherAnzahl} Reaktionen da`); + d.prepare("DELETE FROM chat_nachrichten WHERE id = ?").run(nid); + ok(zaehle() === 0, + `mit der Nachricht verschwinden die Reaktionen (${zaehle()} uebrig)`); +} + /* ======================================================================= 8. Und wie es auf dem Bildschirm aussieht ======================================================================= @@ -459,9 +576,75 @@ console.log("=== Das Neu-Fenster ==="); if (process.env.SCHIRM) { await seite.screenshot({ path: join(HIER_BILD, "pruef-chat-neu.png") }); } + await seite.click("#neu-zu"); await seite.waitForTimeout(250); + /* ---- REAGIEREN AUF DEM BILDSCHIRM (14.09.2026) ------------------ + Das Recht und der Weg sind oben geprueft. Ob die Oberflaeche + beides verbindet, ist eine eigene Frage -- genau daran ist am + 11.09. der Aufloesen-Knopf gescheitert: Recht richtig, Knopf + unsichtbar. */ + const rRaum = await (await rufe(hand.keks, "/workspace/api/chat/gruppe", + { mit: [idEins], name: "Reagieren sichtbar" }, "POST")).json(); + await rufe(hand.keks, `/workspace/api/chat/raeume/${rRaum.raum_id}/nachrichten`, + { text: "Reagier mal drauf" }, "POST"); + await seite.goto(BASIS + `/workspace/chat.html?raum=${rRaum.raum_id}`, + { waitUntil: "networkidle" }); + await seite.waitForTimeout(1500); + + const knopfDa = await seite.$$eval(".chat-nachricht__reag-knopf", (b) => b.length); + ok(knopfDa > 0, `der Knopf "reagieren" steht an der Nachricht (${knopfDa})`); + + await seite.click(".chat-nachricht__reag-knopf"); + await seite.waitForTimeout(600); + const tafel = await seite.$$eval(".reagieren-tafel__zeichen", + (b) => b.map((x) => x.textContent.trim())); + ok(tafel.length >= 4, `die Auswahl klappt auf (${tafel.length} Zeichen)`); + const tafelLage = await seite.evaluate(() => { + const t = document.querySelector(".reagieren-tafel"); + const z = document.querySelector(".reagieren-tafel__zeichen"); + const k = (e) => { if (!e) return null; const r = e.getBoundingClientRect(); + const st = getComputedStyle(e); + return { b: Math.round(r.width), h: Math.round(r.height), d: st.display, + sicht: st.visibility, o: st.opacity, eltern: e.parentElement?.className }; }; + return { tafel: k(t), zeichen: k(z) }; + }); + ok(tafelLage.zeichen && tafelLage.zeichen.b > 0 && tafelLage.zeichen.h > 0, + `die Zeichen haben eine Groesse (${JSON.stringify(tafelLage)})`); + + if (process.env.SCHIRM) { + await seite.locator(".chat-nachricht").last().screenshot( + { path: join(HIER_BILD, "pruef-chat-auswahl.png") }); + } + + await seite.click(".reagieren-tafel__zeichen"); + await seite.waitForTimeout(900); + const stueck = await seite.evaluate(() => { + const b = document.querySelector(".reaktionen__stueck"); + return b ? { text: b.textContent.trim(), selbst: b.dataset.selbst, + titel: b.title } : null; + }); + ok(!!stueck, `unter der Nachricht steht die Reaktion ("${stueck?.text || "nichts"}")`); + ok(stueck?.selbst === "ja", "und sie ist als die eigene gekennzeichnet"); + ok((stueck?.titel || "").includes("Vanessa"), + `im Titel steht, wer reagiert hat ("${(stueck?.titel || "").slice(0, 40)}…")`); + ok((await seite.$$(".reagieren-tafel")).length === 0, + "die Auswahl schliesst sich nach dem Klick von selbst"); + + if (process.env.SCHIRM) { + await seite.locator(".chat-nachricht").last().screenshot( + { path: join(HIER_BILD, "pruef-chat-reaktion.png") }); + } + + /* ZWEITER KLICK NIMMT SIE ZURUECK -- an derselben Stelle, wo sie + steht. Ohne diese Zeile hiesse alles oben nur, dass man etwas + hinzufuegen kann. */ + await seite.click(".reaktionen__stueck"); + await seite.waitForTimeout(900); + ok((await seite.$$(".reaktionen__stueck")).length === 0, + "ein Klick darauf nimmt sie wieder weg"); + /* ---- UND DER KNOPF SELBST ----------------------------------------- Bis hierher ist nur das RECHT geprueft (darf_aufloesen) und der Weg dahinter. Ob die Oberflaeche beides verbindet, war unbewiesen diff --git a/server/workspace-chat.js b/server/workspace-chat.js index 94fad61d..b4a088c3 100644 --- a/server/workspace-chat.js +++ b/server/workspace-chat.js @@ -874,7 +874,14 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => { || (a.anhang_art === "bild" ? "Foto" : a.anhang_art === "pdf" ? "PDF" : ""), })), /* Aufsteigend zurück -- gelesen wird von oben nach unten. */ - nachrichten: reihen.reverse().map((n) => ({ + /* Die Reaktionen in EINER Abfrage fuer alle Nachrichten -- nicht + je Zeile eine. Bei 200 Zeilen waeren das 200 Abfragen fuer eine + Ansicht, die man beim Scrollen staendig neu holt. */ + nachrichten: (() => { + const gedreht = reihen.reverse(); + const reakt = reaktionenZu(gedreht.map((n) => n.id), req.person.id); + return gedreht.map((n) => ({ + reaktionen: reakt.get(n.id) || [], id: n.id, von_id: n.person_id, von: n.von || "Gelöscht", rolle: n.rolle, von_bild: n.von_bild ? `/workspace/api/steckbrief/bild/${n.von_bild}` : null, text: n.weg_am ? null : n.text, @@ -904,7 +911,8 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => { breite: n.anhang_breite || 0, hoehe: n.anhang_hoehe || 0, weg: `/workspace/api/chat/anhang/${n.id}`, } : null, - })), + })); + })(), }); } catch (fehler) { console.error("[chat] Verlauf:", fehler?.message); @@ -1193,6 +1201,120 @@ function darfAufloesen(raum, person) { && !!raum.erstellt_von && raum.erstellt_von === person.id; } +/* ===================================================================== + REAKTIONEN — auf die Nachricht, ohne zu antworten (14.09.2026) + + Filipe: "ich will dass man auf die nachrichten auch reagieren kann + mit einem emoji, die nachricht selbst ohne zu antworten." + + EINE FESTE AUSWAHL, KEIN FREIES FELD. Sechs Zeichen, hier + aufgeschrieben. Drei Gruende, und der erste ist der wichtigste: + + * Eine feste Liste laesst sich pruefen. Was hier nicht drinsteht, + kommt nicht in die Datenbank -- kein beliebiger Text, keine + Zeichenketten von 4 KB, keine unsichtbaren Steuerzeichen. + * Alle sehen dasselbe. Ein selbst getipptes Zeichen sieht auf + einem anderen Geraet anders aus oder gar nicht. + * Sechs Zeichen kann man ueberfliegen. Eine volle Emoji-Tafel ist + eine Suchaufgabe, und dann tippt man doch wieder "ok". + + DIE LISTE STEHT AUF DEM SERVER und wird an die Oberflaeche geliefert + (/reaktionen/auswahl). Stuende sie im Browser, gaebe es sie zweimal + -- und die zweite Fassung waere die, in der ein Zeichen fehlt, das + der Server noch annimmt. + ===================================================================== */ +export const REAKTIONEN = ["👍", "❤️", "😂", "😮", "🔥", "🙏"]; + +chatRouter.get("/workspace/api/chat/reaktionen/auswahl", (req, res) => { + res.json({ zeichen: REAKTIONEN }); +}); + +/** Alle Reaktionen zu einer Menge von Nachrichten -- in EINER Abfrage. + * + * Je Nachricht einzeln zu fragen waere bei 200 Nachrichten 200 + * Abfragen fuer eine Ansicht. Zurueck kommt eine Karte: + * nachricht_id -> [{ zeichen, anzahl, selbst, wer }]. + * + * `wer` ist bewusst dabei: "drei Daumen" sagt weniger als "Ayla, Ben + * und Cem". Es sind Namen aus DEMSELBEN Raum -- wer die Nachricht + * sieht, sieht die Leute ohnehin. + */ +function reaktionenZu(ids, personId) { + const karte = new Map(); + if (!ids.length) return karte; + const platz = ids.map(() => "?").join(","); + const reihen = db().prepare(` + SELECT r.nachricht_id, r.zeichen, r.person_id, p.name + FROM chat_reaktionen r + LEFT JOIN personen p ON p.id = r.person_id + WHERE r.nachricht_id IN (${platz}) + ORDER BY r.wann`).all(...ids); + for (const z of reihen) { + if (!karte.has(z.nachricht_id)) karte.set(z.nachricht_id, new Map()); + const proNachricht = karte.get(z.nachricht_id); + if (!proNachricht.has(z.zeichen)) { + proNachricht.set(z.zeichen, { zeichen: z.zeichen, anzahl: 0, selbst: false, wer: [] }); + } + const eintrag = proNachricht.get(z.zeichen); + eintrag.anzahl++; + if (z.person_id === personId) eintrag.selbst = true; + eintrag.wer.push(z.name || "Gelöscht"); + } + const fertig = new Map(); + for (const [id, proNachricht] of karte) fertig.set(id, [...proNachricht.values()]); + return fertig; +} + +/* REAGIEREN IST EIN UMSCHALTER, kein Hinzufuegen. + Zweimal dasselbe Zeichen heisst: wieder weg. Das erspart einen + zweiten Weg zum Zuruecknehmen -- und einen zweiten Weg, den man + vergisst abzusichern. */ +chatRouter.put("/workspace/api/chat/nachrichten/:id/reaktion", gleicheHerkunft, + express.json({ limit: "2kb" }), (req, res) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); + const zeichen = String(req.body?.zeichen ?? ""); + if (!REAKTIONEN.includes(zeichen)) { + return res.status(400).json({ fehler: "unbekanntes_zeichen" }); + } + + const d = db(); + const n = d.prepare( + "SELECT id, raum_id, weg_am FROM chat_nachrichten WHERE id = ?").get(id); + if (!n) return res.status(404).json({ fehler: "nicht_gefunden" }); + /* Die Raumzugehoerigkeit ist die ganze Sicherheit dieses Wegs -- + dieselbe Pruefung wie beim Lesen des Verlaufs. */ + if (!istDrin(n.raum_id, req.person)) return res.status(404).json({ fehler: "nicht_gefunden" }); + /* Auf eine zurueckgenommene Nachricht wird nicht reagiert. Dort + steht nichts mehr, worauf man reagieren koennte. */ + if (n.weg_am) return res.status(409).json({ fehler: "zurueckgenommen" }); + + const da = d.prepare(`SELECT 1 FROM chat_reaktionen + WHERE nachricht_id = ? AND person_id = ? AND zeichen = ?`) + .get(id, req.person.id, zeichen); + if (da) { + d.prepare(`DELETE FROM chat_reaktionen + WHERE nachricht_id = ? AND person_id = ? AND zeichen = ?`) + .run(id, req.person.id, zeichen); + } else { + d.prepare(`INSERT INTO chat_reaktionen (nachricht_id, person_id, zeichen, wann) + VALUES (?,?,?,?)`).run(id, req.person.id, zeichen, jetzt()); + } + + const stand = reaktionenZu([id], req.person.id).get(id) || []; + /* ALLE IM RAUM SEHEN ES SOFORT -- ohne Benachrichtigung aufs + Handy. Ein Daumen ist keine Nachricht; wer dafuer eine Meldung + bekaeme, schaltet Meldungen ab. */ + chatEreignis(n.raum_id, { id, raum_id: n.raum_id, reaktionen: stand }, [], "reaktion"); + + res.json({ id, reaktionen: stand, gesetzt: !da }); + } catch (fehler) { + console.error("[chat] Reaktion:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + /* ===================================================================== EINE GRUPPE ODER EINEN KANAL AUFLOESEN — FUER ALLE (11.09.2026) diff --git a/server/workspace.js b/server/workspace.js index a036e463..a774a9b4 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -2853,6 +2853,36 @@ export function db() { CREATE INDEX IF NOT EXISTS idx_chat_nachrichten_raum ON chat_nachrichten (raum_id, id); + /* REAKTIONEN (14.09.2026). + + Filipe: "ich will dass man auf die nachrichten auch reagieren + kann mit einem emoji, die nachricht selbst ohne zu antworten." + + DER SCHLUESSEL IST DIE ANTWORT AUF "WIE OFT?": Eine Person darf + dieselbe Nachricht mit VERSCHIEDENEN Zeichen bedenken, aber + jedes nur EINMAL. Genau das sagt der zusammengesetzte + Primaerschluessel -- und zwar so, dass die Datenbank es + durchsetzt und nicht eine Abfrage davor, die man vergessen + kann. Ein zweiter Klick auf dasselbe Zeichen nimmt es zurueck. + + ON DELETE CASCADE an beiden Enden: Wird die Nachricht geloescht + oder der Mensch entfernt, geht die Reaktion mit. Eine Reaktion + ohne Nachricht waere eine Zeile, die niemand je wiederfindet. + + WARUM KEIN ZAEHLER IN chat_nachrichten: Eine mitgefuehrte Zahl + muesste bei jedem Hin und Her stimmen -- und sie ist genau die + Art Angabe, die irgendwann nicht mehr stimmt und die niemand + nachrechnet. Gezaehlt wird beim Lesen. */ + CREATE TABLE IF NOT EXISTS chat_reaktionen ( + nachricht_id INTEGER NOT NULL REFERENCES chat_nachrichten(id) ON DELETE CASCADE, + person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE, + zeichen TEXT NOT NULL, + wann TEXT NOT NULL, + PRIMARY KEY (nachricht_id, person_id, zeichen) + ); + CREATE INDEX IF NOT EXISTS idx_chat_reaktionen_nachricht + ON chat_reaktionen (nachricht_id); + /* Creator-Profil (Onboarding aus dem Konzept). Eine Zeile je Creator, entsteht erst beim ersten Speichern. admin_notiz ist bewusst Teil dieser Tabelle, wird aber nur an diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index f336b67a..df6b4d39 100644 --- a/workspace/app.webmanifest +++ b/workspace/app.webmanifest @@ -13,25 +13,25 @@ "theme_color": "#06090f", "icons": [ { - "src": "/assets/img/app-symbole/workspace-192.png?v=202609141201", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609141346", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609141201", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609141346", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609141201", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609141346", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609141201", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609141346", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/chat.css b/workspace/assets/css/chat.css index c958dc60..af1a1e47 100644 --- a/workspace/assets/css/chat.css +++ b/workspace/assets/css/chat.css @@ -826,13 +826,21 @@ /* Der Antworten-Knopf am Fuß einer Nachricht -- genauso leise wie "zurücknehmen" daneben. Er ist ein Angebot, keine Aufforderung. */ -.chat-nachricht__antwort-knopf { +/* "reagieren" steht seit dem 14.09.2026 daneben und sieht genauso aus + -- dieselbe Sorte Handgriff, dieselbe Zeile. Deshalb DIESELBE Regel + und keine vierte Abschrift: Neben antworten, anheften und kopieren + waere sie die vierte fast gleiche, und die vierte ist die, die + irgendwann eine Zeile anders hat. */ +.chat-nachricht__antwort-knopf, +.chat-nachricht__reag-knopf { border: 0; background: none; padding: 0; font: inherit; font-size: .72rem; color: inherit; cursor: pointer; text-decoration: underline; text-underline-offset: 2px; } -.chat-nachricht__antwort-knopf:hover { color: var(--text); } +.chat-nachricht__antwort-knopf:hover, +.chat-nachricht__reag-knopf:hover { color: var(--text); } +.chat-nachricht__reag-knopf:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; } /* ===================================================================== CHAT: SECHS ERGÄNZUNGEN (09.09.2026) @@ -968,6 +976,69 @@ sich zwei auto-Raender den freien Platz und reissen sie auseinander. Welche sichtbar sind, entscheidet der Server; eine Regel, die das aushaelt, ist besser als eine Zahl, die fuer einen Fall stimmt. */ +/* ===================================================================== + REAKTIONEN (14.09.2026) + + Filipe: "ich will dass man auf die nachrichten auch reagieren kann + mit einem emoji, die nachricht selbst ohne zu antworten." + + GEDECKT, NICHT BUNT. Die Zeichen bringen ihre eigene Farbe mit -- + ein farbiger Rahmen darum waere zu viel fuer eine Zeile, die unter + jeder zweiten Nachricht stehen kann. Die eigene Reaktion ist am + Rahmen zu erkennen, nicht an einem Leuchten. + ===================================================================== */ +.reaktionen { + display: flex; flex-wrap: wrap; gap: 5px; + margin: 7px 0 0; +} +.reaktionen__stueck { + display: inline-flex; align-items: center; gap: 5px; + padding: 2px 8px 3px; + border: 1px solid var(--rand); border-radius: 999px; + background: rgba(255, 255, 255, .04); + font: inherit; font-size: .8rem; line-height: 1.4; + color: var(--text-leise); cursor: pointer; + transition: border-color var(--tempo), background var(--tempo); +} +.reaktionen__stueck:hover { border-color: var(--rand-hell); } +/* Die EIGENE Reaktion: ein Rahmen in der Hausfarbe. Damit sieht man + auf einen Blick, wo man selbst schon geklickt hat -- und ein zweiter + Klick nimmt sie zurueck. */ +.reaktionen__stueck[data-selbst="ja"] { + border-color: color-mix(in srgb, var(--akzent) 55%, transparent); + background: color-mix(in srgb, var(--akzent) 12%, transparent); + color: var(--text); +} +.reaktionen__stueck:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; } +.reaktionen__zeichen { font-size: .95rem; line-height: 1; } +.reaktionen__zahl { font-variant-numeric: tabular-nums; } + +/* Die Auswahl klappt AN der Nachricht auf, nicht in einem Fenster. + Wer ein Zeichen anklickt, ist in zwei Sekunden fertig; ein Dialog + dafuer waere mehr Weg als Zweck. */ +.reagieren-tafel { + display: flex; gap: 2px; + margin: 7px 0 0; + padding: 4px; + border: 1px solid var(--rand); border-radius: 999px; + background: var(--flaeche-hoch, rgba(255, 255, 255, .05)); + width: max-content; max-width: 100%; + box-shadow: 0 6px 18px -10px rgba(0, 0, 0, .8); +} +.reagieren-tafel__zeichen { + appearance: none; border: 0; border-radius: 999px; + padding: 4px 7px; + background: none; cursor: pointer; + font-size: 1.05rem; line-height: 1; + transition: background var(--tempo), transform var(--tempo); +} +.reagieren-tafel__zeichen:hover { background: rgba(255, 255, 255, .09); transform: scale(1.12); } +.reagieren-tafel__zeichen:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; } +@media (prefers-reduced-motion: reduce) { + .reagieren-tafel__zeichen:hover { transform: none; } + .reagieren-tafel__zeichen, .reaktionen__stueck { transition: none; } +} + .chat__kopf-knoepfe { flex: none; margin-left: auto; display: flex; align-items: center; gap: 6px; diff --git a/workspace/assets/js/chat.js b/workspace/assets/js/chat.js index f8adcb0c..ca8d2f77 100644 --- a/workspace/assets/js/chat.js +++ b/workspace/assets/js/chat.js @@ -428,6 +428,124 @@ } 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 reaktionsZeichen = null; + let offeneAuswahl = null; + + async function zeichenHolen() { + if (reaktionsZeichen) return reaktionsZeichen; + try { + const a = await hole('/workspace/api/chat/reaktionen/auswahl'); + if (!a.ok) return null; + const d = await a.json(); + reaktionsZeichen = Array.isArray(d.zeichen) && d.zeichen.length ? d.zeichen : null; + return reaktionsZeichen; + } catch { return null; } + } + + function auswahlSchliessen() { + if (offeneAuswahl) { offeneAuswahl.remove(); offeneAuswahl = null; } + } + + async function auswahlZeigen(knopf, nachrichtId) { + /* Ein zweiter Klick auf denselben Knopf schliesst wieder -- sonst + staendig eine offene Tafel, die man wegklicken muss. */ + const schonOffen = offeneAuswahl && offeneAuswahl.dataset.fuer === String(nachrichtId); + auswahlSchliessen(); + if (schonOffen) return; + + const zeichen = await zeichenHolen(); + if (!zeichen) { melde('Die Auswahl ließ sich nicht laden.'); return; } + + const tafel = el('div', 'reagieren-tafel'); + tafel.dataset.fuer = String(nachrichtId); + tafel.setAttribute('role', 'group'); + tafel.setAttribute('aria-label', 'Mit einem Zeichen reagieren'); + for (const z of zeichen) { + const b = el('button', 'reagieren-tafel__zeichen', z); + b.type = 'button'; + b.setAttribute('aria-label', `Mit ${z} reagieren`); + b.addEventListener('click', () => { auswahlSchliessen(); reagieren(nachrichtId, z); }); + tafel.append(b); + } + knopf.closest('.chat-nachricht')?.append(tafel); + offeneAuswahl = tafel; + tafel.querySelector('button')?.focus(); + } + + /* 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. @@ -573,6 +691,21 @@ ant.type = 'button'; ant.addEventListener('click', () => antwortSetzen(n)); fuss.append(ant); + + /* REAGIEREN (14.09.2026). + Filipe: "ich will dass man auf die nachrichten auch reagieren + kann mit einem emoji, die nachricht selbst ohne zu + antworten." + + Der Knopf steht neben "antworten", weil es dieselbe Sorte + Handgriff ist -- nur der kleinere. Die Auswahl klappt an der + Nachricht auf und nicht in einem Fenster: Wer ein Zeichen + anklickt, ist in zwei Sekunden fertig; ein Dialog dafuer + waere mehr Weg als Zweck. */ + const reag = el('button', 'chat-nachricht__reag-knopf', 'reagieren'); + reag.type = 'button'; + reag.addEventListener('click', () => auswahlZeigen(reag, n.id)); + fuss.append(reag); } if (n.selbst && !n.zurueckgenommen) { const weg = el('button', 'chat-nachricht__weg-knopf', 'zurücknehmen'); @@ -616,6 +749,14 @@ fuss.append(kopie); } blase.append(fuss); + + /* DIE REAKTIONEN STEHEN UNTER DER NACHRICHT, nicht in der + Fusszeile: Sie sind Inhalt, keine Bedienung. Jedes Zeichen ein + eigener Knopf -- ein Klick darauf setzt oder nimmt die eigene + Reaktion, genau wie in der Auswahl. */ + if (Array.isArray(n.reaktionen) && n.reaktionen.length) { + blase.append(reaktionenBauen(n)); + } zeile.append(blase); ziel.append(zeile); } @@ -1027,6 +1168,19 @@ 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. */ diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index e083d0bc..f4d837e8 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -12,19 +12,19 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - + + + + + + - + @@ -252,10 +252,10 @@ - - - - - + + + + + diff --git a/workspace/automation.html b/workspace/automation.html index b851a851..50845d72 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -147,10 +147,10 @@ - - - - - + + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index dd9748e2..e94f8f83 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -181,14 +181,14 @@ - - - - - - - - - + + + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index a36a2361..b4cf3378 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -89,11 +89,11 @@ - - - - - - + + + + + + diff --git a/workspace/chat.html b/workspace/chat.html index 66642183..ebd90d0c 100644 --- a/workspace/chat.html +++ b/workspace/chat.html @@ -12,26 +12,26 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -341,10 +341,10 @@ - - - - - + + + + + diff --git a/workspace/content.html b/workspace/content.html index 0fb60cff..028698b3 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -179,12 +179,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/crew-index.html b/workspace/crew-index.html index 9f1be1a4..ecabcceb 100644 --- a/workspace/crew-index.html +++ b/workspace/crew-index.html @@ -36,15 +36,15 @@ - - - + + + - + @@ -319,6 +319,6 @@ - + diff --git a/workspace/crew.webmanifest b/workspace/crew.webmanifest index c39b13fd..0eab8e78 100644 --- a/workspace/crew.webmanifest +++ b/workspace/crew.webmanifest @@ -13,25 +13,25 @@ "theme_color": "#06090f", "icons": [ { - "src": "/assets/img/app-symbole/crew-192.png?v=202609141201", + "src": "/assets/img/app-symbole/crew-192.png?v=202609141346", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-512.png?v=202609141201", + "src": "/assets/img/app-symbole/crew-512.png?v=202609141346", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609141201", + "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609141346", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609141201", + "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609141346", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/dateien.html b/workspace/dateien.html index c9e730db..6c4e8003 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -108,10 +108,10 @@ - - - - - + + + + + diff --git a/workspace/entwicklung.html b/workspace/entwicklung.html index 1b5a9156..7b26dbd1 100644 --- a/workspace/entwicklung.html +++ b/workspace/entwicklung.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -94,9 +94,9 @@ - - - - + + + + diff --git a/workspace/index.html b/workspace/index.html index dc9c998f..3d55c36f 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -30,9 +30,9 @@ - - - + + + @@ -401,6 +401,6 @@ - + diff --git a/workspace/kalender.html b/workspace/kalender.html index 765479bb..a514ff20 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -377,11 +377,11 @@ - - - - - - + + + + + + diff --git a/workspace/leistung.html b/workspace/leistung.html index d21e0dbf..917f7784 100644 --- a/workspace/leistung.html +++ b/workspace/leistung.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -393,10 +393,10 @@ - - - - - + + + + + diff --git a/workspace/personen.html b/workspace/personen.html index 9ef50a89..0b55ba81 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -423,10 +423,10 @@ - - - - - + + + + + diff --git a/workspace/profil.html b/workspace/profil.html index 75f7dfaf..0108d70d 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -214,11 +214,11 @@ - - - - - - + + + + + + diff --git a/workspace/rechte.html b/workspace/rechte.html index 5e98c93e..ec263537 100644 --- a/workspace/rechte.html +++ b/workspace/rechte.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -134,9 +134,9 @@ Beim ersten Anlauf fehlte die Zeile auf genau diesen beiden neuen Seiten. Gefunden hat das nicht das Auge, sondern pruef-css-klassen, die seit dem 07.09. jede Seite danach absucht. --> - - - - + + + + diff --git a/workspace/report.html b/workspace/report.html index 607756f4..c690f1a7 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -106,11 +106,11 @@ - - - - - - + + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index 3e7faa05..8a6410eb 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -173,10 +173,10 @@ - - - - - + + + + + diff --git a/workspace/start.html b/workspace/start.html index 78f51d46..69882746 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -12,21 +12,21 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - + + - + @@ -674,10 +674,10 @@

- - - - - + + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index c6b5f555..282f6785 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -85,11 +85,11 @@ - - - - - - + + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index 0aa711b1..10acf156 100644 --- a/workspace/steckbrief.html +++ b/workspace/steckbrief.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -126,10 +126,10 @@ - - - - - + + + + + diff --git a/workspace/talente.html b/workspace/talente.html index 0eaa18b5..14164afe 100644 --- a/workspace/talente.html +++ b/workspace/talente.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -94,9 +94,9 @@ - - - - + + + + diff --git a/workspace/team.html b/workspace/team.html index 93e35582..2b4f1859 100644 --- a/workspace/team.html +++ b/workspace/team.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + @@ -110,10 +110,10 @@ - - - - - + + + + + diff --git a/workspace/teamlage.html b/workspace/teamlage.html index 11b9c6cc..be807c91 100644 --- a/workspace/teamlage.html +++ b/workspace/teamlage.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + - - - - + + + + diff --git a/workspace/treff-moderation.html b/workspace/treff-moderation.html index f13eb55b..c56ab51f 100644 --- a/workspace/treff-moderation.html +++ b/workspace/treff-moderation.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -110,9 +110,9 @@ - - - - + + + + diff --git a/workspace/treff-regeln.html b/workspace/treff-regeln.html index 016490a7..1b7ccaf0 100644 --- a/workspace/treff-regeln.html +++ b/workspace/treff-regeln.html @@ -7,18 +7,18 @@ - - - - - - - + + + + + + + - + @@ -190,9 +190,9 @@ Beim ersten Anlauf fehlte die Zeile auf genau diesen beiden neuen Seiten. Gefunden hat das nicht das Auge, sondern pruef-css-klassen, die seit dem 07.09. jede Seite danach absucht. --> - - - - + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index 982f55ea..819d9c3c 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -12,22 +12,22 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -149,11 +149,11 @@ - + - - - - + + + + diff --git a/workspace/wissen.html b/workspace/wissen.html index e00f32e2..ade21046 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -190,10 +190,10 @@ - - - - - + + + + +