diff --git a/server/workspace-chat.js b/server/workspace-chat.js index 7ded71d7..56465ee6 100644 --- a/server/workspace-chat.js +++ b/server/workspace-chat.js @@ -300,14 +300,36 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => { Browser nicht mit zehntausend Zeilen bewerfen; ältere holt man über `vor`. */ const vor = Number(req.query.vor) || 0; + /* MIT DEM ZITAT IN EINER ABFRAGE (09.09.2026, Punkt 15). + + Der zweite Verbund holt die Nachricht, auf die geantwortet + wurde. Sie einzeln nachzuladen waere je Antwort eine weitere + Abfrage -- bei 200 Zeilen im schlimmsten Fall 200 Stueck. + + `LEFT` und nicht `INNER`: Die meisten Nachrichten sind keine + Antworten, und die duerfen nicht verschwinden. */ const reihen = db().prepare(` SELECT n.id, n.person_id, n.text, n.erstellt, n.weg_am, p.name AS von, p.rolle, - p.bild AS von_bild + p.bild AS von_bild, + n.antwort_auf, + a.text AS zitat_text, a.weg_am AS zitat_weg, ap.name AS zitat_von FROM chat_nachrichten n LEFT JOIN personen p ON p.id = n.person_id + LEFT JOIN chat_nachrichten a ON a.id = n.antwort_auf AND a.raum_id = n.raum_id + LEFT JOIN personen ap ON ap.id = a.person_id WHERE n.raum_id = ? ${vor ? "AND n.id < ?" : ""} ORDER BY n.id DESC LIMIT 200`).all(...(vor ? [raumId, vor] : [raumId])); + /* DER GELESEN-STAND, BEVOR ER GESETZT WIRD. + + Die Oberflaeche zieht daraus die Linie "ab hier ist es neu". Sie + muss ihn deshalb beim OEFFNEN bekommen -- eine Zeile spaeter + (nach dem PUT auf /gelesen) waere er immer die letzte Nachricht, + und die Linie staende nie irgendwo. */ + const gelesenBis = db().prepare( + "SELECT gelesen_bis FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ?") + .get(raumId, req.person.id)?.gelesen_bis ?? 0; + const raum = db().prepare("SELECT id, art, name FROM chat_raeume WHERE id = ?").get(raumId); const leute = teilnehmerVon(raumId); @@ -318,6 +340,7 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => { teilnehmer: leute.map((t) => ({ id: t.id, name: t.name, rolle: t.rolle, bild: t.bild, leitung: !!t.leitung })), }, + gelesen_bis: gelesenBis, /* Aufsteigend zurück -- gelesen wird von oben nach unten. */ nachrichten: reihen.reverse().map((n) => ({ id: n.id, von_id: n.person_id, von: n.von || "Gelöscht", rolle: n.rolle, @@ -326,6 +349,17 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => { zurueckgenommen: !!n.weg_am, erstellt: n.erstellt, selbst: n.person_id === req.person.id, + /* Das Zitat wird GEKUERZT geschickt, nicht ganz: Es ist ein + Hinweis auf die Stelle, nicht ihre Wiederholung. Und eine + zurueckgenommene Nachricht bleibt auch im Zitat + zurueckgenommen -- sonst waere das Zuruecknehmen ueber ein + Zitat zu umgehen. */ + antwort: n.antwort_auf ? { + id: n.antwort_auf, + von: n.zitat_von || "Gelöscht", + text: n.zitat_weg ? null : String(n.zitat_text || "").slice(0, 140), + zurueckgenommen: !!n.zitat_weg, + } : null, })), }); } catch (fehler) { @@ -348,19 +382,43 @@ chatRouter.post("/workspace/api/chat/raeume/:id/nachrichten", gleicheHerkunft, return res.status(400).json({ fehler: `Länger als ${TEXT_MAX} Zeichen geht nicht.` }); } + /* WORAUF GEANTWORTET WIRD, MUSS IN DEMSELBEN RAUM LIEGEN. + + Ohne diese Pruefung koennte jemand die Kennung einer Nachricht + aus einem fremden Gespraech mitschicken -- und beim Empfaenger + stuende ein Zitat aus einem Raum, den er nie gesehen hat. Die + Abfrage bindet deshalb `raum_id` mit ein; passt es nicht, wird + das Zitat still weggelassen statt die Nachricht abzulehnen. + Der Text ist das Wichtige, das Zitat die Zugabe. */ + let antwortAuf = Number(req.body?.antwort_auf) || null; + if (antwortAuf) { + const da = db().prepare( + "SELECT 1 FROM chat_nachrichten WHERE id = ? AND raum_id = ?").get(antwortAuf, raumId); + if (!da) antwortAuf = null; + } + const d = db(); const n = jetzt(); - d.prepare("INSERT INTO chat_nachrichten (raum_id, person_id, text, erstellt) VALUES (?,?,?,?)") - .run(raumId, req.person.id, text, n); + d.prepare("INSERT INTO chat_nachrichten (raum_id, person_id, text, erstellt, antwort_auf) VALUES (?,?,?,?,?)") + .run(raumId, req.person.id, text, n, antwortAuf); const id = d.prepare("SELECT last_insert_rowid() AS id").get().id; d.prepare("UPDATE chat_raeume SET letzte_am = ? WHERE id = ?").run(n, raumId); /* Wer schreibt, hat das Eigene gelesen. */ d.prepare("UPDATE chat_teilnehmer SET gelesen_bis = ? WHERE raum_id = ? AND person_id = ?") .run(id, raumId, req.person.id); + const zitat = antwortAuf ? d.prepare(` + SELECT a.text, a.weg_am, p.name AS von FROM chat_nachrichten a + LEFT JOIN personen p ON p.id = a.person_id WHERE a.id = ?`).get(antwortAuf) : null; + const nachricht = { id, raum_id: raumId, von_id: req.person.id, von: req.person.name, rolle: req.person.rolle, text, erstellt: n, zurueckgenommen: false, + antwort: zitat ? { + id: antwortAuf, von: zitat.von || "Gelöscht", + text: zitat.weg_am ? null : String(zitat.text || "").slice(0, 140), + zurueckgenommen: !!zitat.weg_am, + } : null, }; /* Sofort an alle, die gerade zusehen -- und per Benachrichtigung @@ -376,6 +434,90 @@ chatRouter.post("/workspace/api/chat/raeume/:id/nachrichten", gleicheHerkunft, } }); +/* ===================================================================== + SUCHE (09.09.2026, Punkt 15) + + Filipe: "ich will dass du diese seite viel krasser und detaillierter + machst ... alles reinsetzt was wir noch gebrauchen koennten." + + Ein Chat ohne Suche ist ab dem zweiten Monat ein Archiv, in dem man + nichts findet. Das ist die Funktion, die am haeufigsten fehlt und am + meisten Zeit spart -- vor Reaktionen, vor Anhaengen, vor allem + anderen. + + DIE EINGRENZUNG AUF DIE EIGENEN RAEUME IST DIE GANZE SICHERHEIT + DIESER FUNKTION. Sie steht deshalb im JOIN und nicht in einer + nachtraeglichen Pruefung: `chat_teilnehmer` wird mit der eigenen + Personenkennung verbunden, und was dort nicht drinsteht, kommt gar + nicht erst aus der Datenbank. Ein Filter, der erst hinterher + aussortiert, ist eine Zeile davon entfernt, vergessen zu werden. + + OHNE VOLLTEXTINDEX, MIT ABSICHT. `LIKE '%...%'` kann keinen Index + benutzen und liest die Tabelle. Bei einem Team dieser Groesse sind + das einige tausend Zeilen -- gemessen unter einer Millisekunde. Ein + FTS5-Index waere eine zweite Tabelle, die synchron gehalten werden + muss, und genau daran gehen solche Sachen kaputt. Wenn der Verlauf + einmal sechsstellig wird, ist das der richtige Zeitpunkt dafuer, + nicht heute. + + `escape` FUER DIE PLATZHALTER: In `LIKE` sind `%` und `_` Zeichen + mit Bedeutung. Wer nach "100 %" sucht, wuerde sonst alles finden. + ===================================================================== */ +chatRouter.get("/workspace/api/chat/suche", (req, res) => { + try { + const roh = String(req.query.q ?? "").trim(); + if (roh.length < 2) return res.json({ frage: roh, treffer: [], zuKurz: true }); + if (roh.length > 120) return res.status(400).json({ fehler: "zu_lang" }); + + /* AUCH DER BACKSLASH SELBST. Er ist hier das Fluchtzeichen + (`ESCAPE '\'`); wer ihn eingibt, wuerde sonst das naechste + Zeichen entwerten und im Zweifel ein ungueltiges Muster erzeugen. + Drei Zeichen haben in `LIKE` eine Bedeutung, nicht zwei. */ + const muster = "%" + roh.replace(/[\\%_]/g, (z) => "\\" + z) + "%"; + const reihen = db().prepare(` + SELECT n.id, n.raum_id, n.text, n.erstellt, + p.name AS von, p.rolle, + r.art, r.name AS raum_name + FROM chat_nachrichten n + JOIN chat_teilnehmer t ON t.raum_id = n.raum_id AND t.person_id = ? + JOIN chat_raeume r ON r.id = n.raum_id + LEFT JOIN personen p ON p.id = n.person_id + WHERE n.weg_am IS NULL + AND n.text LIKE ? ESCAPE '\\' + ORDER BY n.id DESC LIMIT 60`).all(req.person.id, muster); + + res.json({ + frage: roh, + treffer: reihen.map((n) => { + const leute = teilnehmerVon(n.raum_id); + return { + id: n.id, raum_id: n.raum_id, + raum: raumName({ id: n.raum_id, art: n.art, name: n.raum_name }, leute, req.person.id), + von: n.von || "Gelöscht", rolle: n.rolle, + erstellt: n.erstellt, + /* Der Ausschnitt wird um den Treffer herum geschnitten, nicht + vom Anfang: Bei einer langen Nachricht stuende sonst der + Anfang da und das gesuchte Wort waere nicht dabei. */ + text: ausschnitt(n.text, roh), + }; + }), + }); + } catch (fehler) { + console.error("[chat] Suche:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/** Ein Stueck Text um die Fundstelle herum. */ +function ausschnitt(text, frage) { + const t = String(text || ""); + const i = t.toLowerCase().indexOf(frage.toLowerCase()); + if (i < 0 || t.length <= 160) return t.slice(0, 160); + const von = Math.max(0, i - 50); + const bis = Math.min(t.length, i + frage.length + 90); + return (von > 0 ? "… " : "") + t.slice(von, bis) + (bis < t.length ? " …" : ""); +} + /** Bis hierher gelesen. */ chatRouter.put("/workspace/api/chat/raeume/:id/gelesen", gleicheHerkunft, express.json({ limit: "4kb" }), (req, res) => { diff --git a/server/workspace.js b/server/workspace.js index 7f94a96e..46db0f46 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -376,6 +376,25 @@ function umstellungen(d) { Aenderung vergessen. Genau diese Sorte Doppelung hat mich heute schon dreimal aufgehalten. */ ["push_einstellungen", "wert", "TEXT"], + + /* ANTWORTEN MIT ZITAT (09.09.2026, Punkt 15). + + Filipe: "ich will dass du diese seite viel krasser und + detaillierter machst ... alles reinsetzt was wir noch gebrauchen + koennten." + + In einem Gespraech zu zweit weiss man meistens, worauf sich + etwas bezieht. In einer Gruppe nicht -- dort laufen drei Faeden + parallel, und "ja, mach das" kann alles heissen. Die Spalte + zeigt auf die Nachricht, auf die geantwortet wird. + + KEIN FREMDSCHLUESSEL AUF chat_nachrichten: Wird die zitierte + Nachricht zurueckgenommen, bleibt ihre Zeile ohnehin stehen + (nur der Text wird geleert) -- und wird der ganze Raum + geloescht, geht die Antwort per CASCADE ueber `raum_id` + ohnehin mit. Ein zweiter Schluessel brauchte nur eine zweite + Gelegenheit fuer eine Sperre. */ + ["chat_nachrichten", "antwort_auf", "INTEGER"], ]) { try { const vorhanden = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((s) => s.name); diff --git a/workspace/assets/css/chat.css b/workspace/assets/css/chat.css index d8500a1b..e84682cb 100644 --- a/workspace/assets/css/chat.css +++ b/workspace/assets/css/chat.css @@ -636,3 +636,161 @@ .chat-raum__knopf[data-an="ja"] { box-shadow: none; border: 1px solid CanvasText; } .chat-raum__zeichen, .chat-nachricht__zeichen { background: Canvas; box-shadow: none; } } + +/* ===================================================================== + DREI DINGE, DIE DEM CHAT GEFEHLT HABEN (Punkt 15, 09.09.2026) + + Filipe: "ich will dass du diese seite viel krasser und detaillierter + machst, ich will dass du dich informierst und alles reinsetzt was + wir noch gebrauchen könnten." + + Nicht alles, sondern das, was hier täglich fehlt. Der Chat konnte + schon Räume, Verlauf, Gelesen-Stand, Live-Zustellung, Gruppen und + Zurücknehmen. Was fehlte: + + 1. SUCHE IN DEN NACHRICHTEN. Ein Chat ohne sie ist ab dem zweiten + Monat ein Archiv, in dem man nichts findet. Das Suchfeld gab es + — es durchsuchte aber nur die NAMEN der Gespräche. + 2. ANTWORTEN MIT ZITAT. Zu zweit weiß man meist, worauf sich etwas + bezieht. In einer Gruppe laufen drei Fäden parallel, und "ja, + mach das" kann alles heißen. + 3. DIE LINIE "AB HIER NEU". Wer nach zwei Tagen zurückkommt, sucht + sonst die Stelle, an der er aufgehört hat, indem er Uhrzeiten + liest. + + Bewusst NICHT gebaut: Anhänge (dafür gibt es den Dateien-Bereich mit + Rechten und Ablauf), Reaktionen (eine vierte Sache, bevor die drei + sich bewährt haben) und Tipp-Anzeigen (sie kosten dauernd Verkehr + und sagen nichts, was man nicht in zwei Sekunden sieht). + ===================================================================== */ + +/* ---- Das Zitat in der Blase ---------------------------------------- */ +.chat-zitat { + display: block; width: 100%; text-align: left; + margin: 0 0 7px; padding: 6px 10px 6px 11px; + border: 0; border-radius: 0; + /* Der senkrechte Strich links ist das Zeichen für ein Zitat -- in + jedem Mailprogramm seit dreißig Jahren dasselbe. */ + box-shadow: inset 3px 0 0 color-mix(in srgb, var(--akzent) 60%, transparent); + background: rgba(255, 255, 255, .05); + color: var(--text-leise); + font: inherit; cursor: pointer; + transition: background var(--tempo); +} +.chat-zitat:hover { background: rgba(255, 255, 255, .09); } +.chat-zitat__von { + display: block; + font-size: .74rem; font-weight: 700; + color: color-mix(in srgb, var(--akzent) 70%, #ffffff); +} +.chat-zitat__text { + display: block; + font-size: .8rem; line-height: 1.35; + /* Zwei Zeilen reichen: Es ist ein Hinweis auf die Stelle, nicht ihre + Wiederholung. */ + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; + overflow: hidden; +} + +/* Die zitierte Nachricht, wenn man hinspringt -- kurz hervorgehoben. + Ein Sprung ohne Markierung lässt einen raten, welche Zeile gemeint + war. */ +.chat-nachricht[data-hervor="ja"] .chat-nachricht__blase { + box-shadow: 0 0 0 2px color-mix(in srgb, var(--akzent) 65%, transparent); + transition: box-shadow .4s ease; +} +@media (prefers-reduced-motion: reduce) { + .chat-nachricht[data-hervor="ja"] .chat-nachricht__blase { transition: none; } +} + +/* ---- Die Linie "ab hier neu" --------------------------------------- */ +.chat-neu { + display: flex; align-items: center; gap: 10px; + margin: 14px 2px 10px; + color: #ff8f98; + font-size: .72rem; font-weight: 700; + letter-spacing: .1em; text-transform: uppercase; +} +.chat-neu::before, .chat-neu::after { + content: ""; flex: 1; height: 1px; + background: linear-gradient(90deg, transparent, rgba(255, 143, 152, .5)); +} +.chat-neu::after { background: linear-gradient(90deg, rgba(255, 143, 152, .5), transparent); } +.chat-neu__text { flex: none; } + +/* ---- Die Antwortleiste über dem Eingabefeld ------------------------ */ +.antwort-leiste { + display: flex; align-items: flex-start; gap: 10px; + margin-bottom: 8px; padding: 8px 10px 8px 11px; + box-shadow: inset 3px 0 0 var(--akzent); + background: rgba(255, 255, 255, .05); +} +.antwort-leiste[hidden] { display: none; } +.antwort-leiste__inhalt { min-width: 0; flex: 1; } +.antwort-leiste__schild { + /* 0,72 rem = 11,52 px. Mit 0,7 waeren es 11,2 und damit unter der + Grundlinie, die pruef-css-klassen haelt. */ + font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; + color: var(--text-still); +} +.antwort-leiste__von { + margin-left: 6px; + font-size: .78rem; font-weight: 700; + color: color-mix(in srgb, var(--akzent) 70%, #ffffff); +} +.antwort-leiste__text { + display: block; + font-size: .82rem; color: var(--text-leise); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.antwort-leiste__weg { + flex: none; width: 28px; height: 28px; + border: 0; border-radius: 50%; + background: rgba(255, 255, 255, .07); + color: var(--text-leise); font-size: .9rem; cursor: pointer; +} +.antwort-leiste__weg:hover { background: rgba(255, 255, 255, .13); color: var(--text); } + +/* ---- Fundstellen in Nachrichten ------------------------------------ */ +.chat-funde { padding: 4px 10px 12px; } +.chat-funde[hidden] { display: none; } +.chat-funde__schild { + margin: 8px 0 6px; + font-size: .72rem; font-weight: 700; + letter-spacing: .09em; text-transform: uppercase; + color: var(--text-still); +} +.chat-funde__liste { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; } +.chat-funde__leer { font-size: .84rem; color: var(--text-still); } +.chat-fund { + display: grid; gap: 2px; + width: 100%; padding: 8px 11px; + text-align: left; border: 0; + clip-path: polygon(7px 0, 100% 0, 100% calc(100% - 7px), calc(100% - 7px) 100%, 0 100%, 0 7px); + background: rgba(255, 255, 255, .045); + color: var(--text); font: inherit; cursor: pointer; + transition: background var(--tempo); +} +.chat-fund:hover { background: rgba(255, 255, 255, .09); } +.chat-fund__kopf { display: flex; justify-content: space-between; gap: 8px; } +.chat-fund__raum { + font-size: .78rem; font-weight: 700; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.chat-fund__wann { flex: none; font-size: .72rem; color: var(--text-still); } +.chat-fund__von { font-size: .72rem; color: var(--text-still); } +.chat-fund__text { + font-size: .82rem; color: var(--text-leise); line-height: 1.35; + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; + overflow: hidden; +} + +/* Der Antworten-Knopf am Fuß einer Nachricht -- genauso leise wie + "zurücknehmen" daneben. Er ist ein Angebot, keine Aufforderung. */ +.chat-nachricht__antwort-knopf { + border: 0; background: none; padding: 0; + font: inherit; font-size: .72rem; + color: var(--text-still); cursor: pointer; + text-decoration: underline; text-underline-offset: 2px; +} +.chat-nachricht__antwort-knopf:hover { color: var(--text-leise); } diff --git a/workspace/assets/js/chat.js b/workspace/assets/js/chat.js index 8940d98b..8be96aab 100644 --- a/workspace/assets/js/chat.js +++ b/workspace/assets/js/chat.js @@ -112,7 +112,14 @@ $('liste-leer').hidden = raeume.length > 0; /* Das Suchfeld erst, wenn die Liste lang genug ist, um darin zu suchen. Vorher ist es ein Feld ohne Zweck. */ - $('suche-zeile').hidden = raeume.length < 8; + /* IMMER SICHTBAR SEIT DEM 09.09.2026. + + Hier stand `raeume.length < 8` mit der Begruendung, ein Suchfeld + ueber drei Eintraegen sei Zierde. Das stimmte, solange es nur + NAMEN durchsuchte. Seit es auch die Nachrichten durchsucht, ist + es das Gegenteil: Zwei Gespraeche koennen tausend Nachrichten + haben, und genau dann braucht man es am dringendsten. */ + $('suche-zeile').hidden = false; for (const r of zeigen) { const li = el('li', 'chat-raum'); @@ -180,6 +187,72 @@ } } + /* Bis wohin war beim Oeffnen gelesen -- daraus entsteht die Linie + "ab hier ist es neu". Und worauf gerade geantwortet wird. */ + let gelesenBeimOeffnen = 0; + let antwortAuf = null; + + /* ---------- In Nachrichten suchen (09.09.2026, Punkt 15) ----------------- */ + + let suchLauf = 0; + + async function nachrichtenSuchen() { + const frage = ($('suche').value || '').trim(); + const kasten = $('funde'); + const liste = $('funde-liste'); + if (!kasten) return; + + if (frage.length < 2) { kasten.hidden = true; liste.textContent = ''; return; } + + /* JEDE ANFRAGE BEKOMMT EINE NUMMER, und nur die neueste darf + zeichnen. Beim Tippen laufen mehrere gleichzeitig; ohne das + koennte die Antwort auf "Pa" nach der auf "Patrick" ankommen und + die richtige Liste ueberschreiben. Das sieht dann aus, als + fuende die Suche das Falsche. */ + const meine = ++suchLauf; + let daten; + try { + const a = await hole(`/workspace/api/chat/suche?q=${encodeURIComponent(frage)}`); + if (!a.ok) return; + daten = await a.json(); + } catch { return; } + if (meine !== suchLauf) return; + + liste.textContent = ''; + kasten.hidden = false; + $('funde-schild').textContent = daten.treffer.length + ? `In Nachrichten · ${daten.treffer.length}` + : 'In Nachrichten'; + + if (!daten.treffer.length) { + /* Ein leeres Ergebnis wird GESAGT. Ein Kasten, der einfach leer + bleibt, sieht aus, als laufe die Suche noch. */ + liste.append(el('li', 'chat-funde__leer', 'Nichts gefunden.')); + return; + } + + for (const t of daten.treffer) { + const li = el('li'); + const b = el('button', 'chat-fund'); + b.type = 'button'; + const kopf = el('span', 'chat-fund__kopf'); + kopf.append(el('span', 'chat-fund__raum', t.raum)); + kopf.append(el('span', 'chat-fund__wann', wann(t.erstellt))); + b.append(kopf); + b.append(el('span', 'chat-fund__von', t.von)); + b.append(el('span', 'chat-fund__text', t.text)); + b.addEventListener('click', async () => { + await raumOeffnen(t.raum_id); + /* Erst oeffnen, dann springen -- vorher gibt es die Zeile + nicht. Steht sie nicht im geladenen Ausschnitt, sagt + `zurStelle` das. */ + zurStelle(t.id); + }); + li.append(b); + liste.append(li); + } + } + /* ---------- Ein Gespräch öffnen ------------------------------------------ */ async function raumOeffnen(id, { sanft = false } = {}) { @@ -189,6 +262,12 @@ const daten = await a.json(); offen = daten.raum; nachrichten = daten.nachrichten; + /* DER STAND VON VORHIN, nicht der von jetzt. `gelesenMelden()` + weiter unten setzt ihn gleich auf die letzte Nachricht -- die + Linie muss also aus dem Wert gezogen werden, der beim OEFFNEN + galt. Danach wuerde sie nie irgendwo stehen. */ + gelesenBeimOeffnen = daten.gelesen_bis ?? 0; + antwortAuf = null; } catch { return; } $('verlauf-titel').textContent = offen.name; @@ -219,7 +298,23 @@ let letzterTag = ''; let letzterVon = null; + let linieGesetzt = false; for (const n of nachrichten) { + /* DIE LINIE "AB HIER IST ES NEU" (09.09.2026, Punkt 15). + + Sie steht vor der ersten Nachricht, die beim Oeffnen noch + nicht gelesen war -- und nur EINMAL. Wer nach zwei Tagen + zurueckkommt, sucht sonst die Stelle, an der er aufgehoert + hat, indem er Uhrzeiten liest. + + Nicht bei eigenen Nachrichten: Was man selbst geschrieben hat, + ist nie "neu fuer einen". */ + if (!linieGesetzt && gelesenBeimOeffnen && n.id > gelesenBeimOeffnen && !n.selbst) { + linieGesetzt = true; + const linie = el('div', 'chat-neu'); + linie.append(el('span', 'chat-neu__text', 'Ab hier neu')); + ziel.append(linie); + } /* Ein Datumstrenner, wenn ein neuer Tag beginnt. Ohne ihn steht die Antwort von vorgestern direkt unter der von heute. */ const tag = new Date(n.erstellt).toDateString(); @@ -267,6 +362,23 @@ blase.append(el('p', 'chat-nachricht__von', n.von)); } + /* DAS ZITAT STEHT IN DER BLASE, NICHT DARUEBER. So wandert es beim + Rollen mit und gehoert sichtbar zur Antwort -- eine Zeile + darueber liest sich als eigene Nachricht. */ + if (n.antwort) { + const z = el('button', 'chat-zitat'); + z.type = 'button'; + z.append(el('span', 'chat-zitat__von', n.antwort.von)); + z.append(el('span', 'chat-zitat__text', n.antwort.zurueckgenommen + ? 'Nachricht zurückgenommen' : n.antwort.text)); + z.title = 'Zur zitierten Nachricht springen'; + /* Ein Zitat, das nicht zur Stelle fuehrt, ist ein Bild von + einer Nachricht. Der Klick springt hin und hebt sie kurz + hervor. */ + z.addEventListener('click', () => zurStelle(n.antwort.id)); + blase.append(z); + } + if (n.zurueckgenommen) { blase.append(el('p', 'chat-nachricht__text chat-nachricht__weg', 'Nachricht zurückgenommen')); @@ -283,6 +395,15 @@ zeit.title = wann(n.erstellt, true); fuss.append(zeit); + /* Antworten geht auf JEDE Nachricht, auch die eigene -- in einer + Gruppe bezieht man sich durchaus auf sich selbst. Nur auf eine + zurueckgenommene nicht: Das Zitat waere leer. */ + if (!n.zurueckgenommen && n.id > 0) { + const ant = el('button', 'chat-nachricht__antwort-knopf', 'antworten'); + ant.type = 'button'; + ant.addEventListener('click', () => antwortSetzen(n)); + fuss.append(ant); + } if (n.selbst && !n.zurueckgenommen) { const weg = el('button', 'chat-nachricht__weg-knopf', 'zurücknehmen'); weg.type = 'button'; @@ -296,6 +417,37 @@ ziel.scrollTop = ziel.scrollHeight; } + /* ---------- Antworten und Springen (09.09.2026, Punkt 15) ---------------- */ + + function antwortSetzen(n) { + antwortAuf = { id: n.id, von: n.von, text: n.text }; + const leiste = $('antwort-leiste'); + if (!leiste) return; + leiste.hidden = false; + $('antwort-von').textContent = n.von; + $('antwort-text').textContent = n.text; + $('text').focus(); + } + + function antwortLoeschen() { + antwortAuf = null; + const leiste = $('antwort-leiste'); + if (leiste) leiste.hidden = true; + } + + /** Zur zitierten Nachricht springen und sie kurz hervorheben. + * + * Steht sie nicht (mehr) im geladenen Ausschnitt, wird das GESAGT -- + * ein Klick, der nichts tut, laesst einen glauben, der Knopf sei + * kaputt. */ + function zurStelle(id) { + const el2 = document.querySelector(`.chat-nachricht[data-id="${id}"]`); + if (!el2) { melde('Diese Nachricht liegt weiter oben – bitte nach oben blättern.'); return; } + el2.scrollIntoView({ block: 'center', behavior: 'smooth' }); + el2.dataset.hervor = 'ja'; + setTimeout(() => { delete el2.dataset.hervor; }, 2200); + } + function tagName(iso) { const d = new Date(iso); const heute = new Date(); @@ -316,13 +468,23 @@ /* SOFORT ANZEIGEN. Die vorläufige Nachricht bekommt eine negative Nummer -- damit lässt sie sich später zweifelsfrei durch die echte ersetzen, ohne mit einer Server-Nummer zu kollidieren. */ + /* Das Antwortziel wird HIER festgehalten, nicht erst im + `await`. Wer waehrend des Sendens auf eine andere Nachricht + antwortet, wuerde sonst das falsche Zitat mitschicken -- die + Anfrage laeuft, die Variable hat sich schon geaendert. */ + const zielAntwort = antwortAuf; + const vorlaeufig = { id: -Date.now(), von_id: ich.id, von: ich.name, rolle: ich.rolle, text, erstellt: new Date().toISOString(), selbst: true, unterwegs: true, + antwort: zielAntwort + ? { id: zielAntwort.id, von: zielAntwort.von, text: zielAntwort.text } + : null, }; nachrichten.push(vorlaeufig); verlaufZeichnen(); feld.value = ''; + antwortLoeschen(); hoeheAnpassen(); melde(''); @@ -330,7 +492,7 @@ const a = await hole(`/workspace/api/chat/raeume/${offen.id}/nachrichten`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text }), + body: JSON.stringify({ text, antwort_auf: zielAntwort?.id ?? null }), }); if (!a.ok) { const d = await a.json().catch(() => ({})); @@ -617,6 +779,14 @@ } /* ---------- Start -------------------------------------------------------- */ + /* Der Verwerfen-Knopf der Antwortleiste. Zusaetzlich beendet Escape + die Antwort -- wer im Eingabefeld steht, greift eher zur Taste als + zur Maus. */ + $('antwort-weg')?.addEventListener('click', antwortLoeschen); + $('text')?.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && antwortAuf) { e.preventDefault(); antwortLoeschen(); } + }); + $('schreiben').addEventListener('submit', abschicken); $('text').addEventListener('input', hoeheAnpassen); @@ -631,7 +801,7 @@ } }); - $('suche').addEventListener('input', raeumeZeichnen); + $('suche').addEventListener('input', () => { raeumeZeichnen(); nachrichtenSuchen(); }); $('neu-oeffnen').addEventListener('click', async () => { await partnerLaden(); $('neu-dialog').showModal(); diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 97f749c5..7c3257dc 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -14,10 +14,10 @@ - - - - + + + +
@@ -217,10 +217,10 @@ - - - - - + + + + +