Chat: Fotos und PDFs schicken, Gespraeche wegraeumen

Filipe, mit Bildschirmfoto: "man muss die chats auch geloescht
bekommen!!! am besten waere es auch wenn man da auch im chat pdfs
schicken koennte. pdfs und fotos."

Zwei Entscheidungen vorher abgestimmt, weil sie den Bau bestimmen:
Geloescht wird NUR BEI MIR. Schicken duerfen ALLE im Gespraech, auch
Creator -- anders als in der Dateiablage, wo etwas in einem Bereich
landet, den mehrere sehen. Hier bekommt es genau der, mit dem man
ohnehin gerade spricht.

--- ANHAENGE ---

Drei Wege zur selben Sache, weil Leute unterschiedlich arbeiten: die
Bueroklammer, Hineinziehen und Einfuegen mit Strg+V (der Weg fuer einen
Screenshot -- der liegt in der Zwischenablage und nirgends als Datei).
Alle drei laufen durch dieselbe Funktion; drei Fassungen waeren drei
Gelegenheiten, dass eine die Pruefung vergisst.

Ein Foto wird GEZEIGT, ein PDF wird als Karte ANGEBOTEN. Das ist kein
Schoenheitsunterschied: Ein Bild erkennt man in einer Zehntelsekunde,
ein PDF muss man ohnehin oeffnen -- eine Vorschau davon waere ein
grauer Kasten, der so tut, als koennte man etwas lesen.

DIE SICHERHEIT STEHT IN dateiErkennen(). Dateiname und Content-Type
kommen vom Absender und sind frei erfunden; der Typ wird deshalb aus
den ersten Bytes bestimmt (PNG/GIF/WebP/JPEG/PDF, mit Breite und Hoehe
im selben Durchgang). SVG steht absichtlich NICHT in der Liste -- das
ist XML, das Skripte enthalten darf, ein als Bild getarntes Programm.
Ausgeliefert wird spaeter genau der ERKANNTE Typ, nie der eingeschickte,
dazu nosniff und "default-src 'none'; sandbox".

Auf der Platte bekommt jede Datei einen erzeugten Zufallsnamen, in
einem Ordner ausserhalb des Repos. Zuruecknehmen loescht die Datei
wirklich -- sonst waere sie im Verlauf weg und ueber die Adresse noch
da, also nur der Anschein einer Ruecknahme.

--- WEGRAEUMEN ---

Zwei Spalten in chat_teilnehmer statt einer, wegen eines Randfalls: Bei
einem Gespraech ohne jede Nachricht waere die Grenze 0 -- und 0 heisst
sonst "nie geloescht". geloescht_am unterscheidet die beiden.

Die Grenze wirkt in der Liste, im Verlauf, in der Vorschau, in der
SUCHE, bei den Anhaengen und in der Ungelesen-Zahl. Die Suche ist die
Stelle, an der so etwas typischerweise durchrutscht: aus der Liste weg,
ueber die Suche noch da.

--- DER FUND: 323 PIXEL ---

Ein Bild ist spaeter fertig als der Rest der Seite. Beim Oeffnen springt
der Verlauf ans Ende, dann kommt das Foto an und schiebt alles darunter
weg -- man steht ploetzlich mittendrin, ohne etwas angeklickt zu haben.

Das width/height-Attribut am <img>, wie man es ueberall liest, hilft
hier NICHT: Es gibt nur das Seitenverhaeltnis. Damit daraus eine Hoehe
wird, muss die Breite feststehen -- bei "width: auto" und einem nicht
geladenen Bild ist sie 0. Der Platz wird jetzt am KASTEN reserviert
(aspect-ratio + max-width aus den gemessenen Massen).

Und die Pruefung dazu hatte denselben Fehler wie das Auge: Sie mass in
einem Fenster, in dem das Bild laengst im Zwischenspeicher lag, und
meldete tadellose 0 px. Sie misst jetzt in einem FRISCHEN Browser --
der Fall, um den es geht, ist der erste.

--- SICHERUNG ---

chat-anhaenge/ ist im selben Zug in tools/sicherung-holen.sh
eingetragen, nicht spaeter, wenn es auffaellt: Genau so ist die Luecke
bei den Profilbildern entstanden. tools/wiederherstellung-proben.mjs
bestaetigt: "der Server benutzt 4 Datenordner", keiner fehlt.

--- Pruefung ---

server/pruef-chat-anhaenge.mjs, neu, 60 Pruefungen, alle gruen.
Abschnitt 3 versucht ausdruecklich, hereinzukommen: HTML als bild.png,
SVG als foto.png, SVG als svg, eine Textdatei, ein PNG-Kopf ohne Inhalt
-- alle fuenf mit 415 abgewiesen, ein echtes JPEG danach mit 201
angenommen (sonst bewiese der Block nur, dass gar nichts durchkommt).
13 MB geben 413 mit lesbarem Text, nicht 500.

Konsolenfehler werden nach ZEITPUNKT getrennt, nicht nach Statusnummer:
Was waehrend eines absichtlichen Versuchs entsteht, ist gewollt. Nach
Nummer zu filtern waere bequem und wuerde ab morgen echte 404 mit
verschlucken.

Ausserdem gruen: pruef-chat, pruef-chat-optik, pruef-chat-ausbau,
pruef-css-klassen. Angesehen bei 1440 px und bei 390 px.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-09 12:51:54 +02:00
co-authored by Claude Opus 5
parent e28724564a
commit 14e9bbf7a5
25 changed files with 1795 additions and 212 deletions
+302 -3
View File
@@ -87,7 +87,16 @@
return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' });
}
const melde = (t) => { $('fehler').textContent = t || ''; };
/* Dieselbe Zeile trägt zwei Sorten Nachricht: eine Störung (rot) und
einen Zwischenstand wie "wird geschickt …" (ruhig). Beides rot zu
zeigen hiesse, dass man Rot irgendwann nicht mehr ernst nimmt --
und Rot ist hier das Einzige, was Aufmerksamkeit verlangen darf.
Die Farbe entscheidet das CSS über data-art, nicht diese Zeile. */
const melde = (t, art = 'fehler') => {
const z = $('fehler');
z.textContent = t || '';
z.dataset.art = t ? art : 'fehler';
};
/* ---------- Die Gesprächsliste ------------------------------------------- */
@@ -277,6 +286,8 @@
$('schreiben').hidden = false;
$('verlauf-leer').hidden = true;
$('chat').dataset.ansicht = 'verlauf';
/* Erst jetzt gibt es etwas zum Wegräumen. */
$('gespraech-weg').hidden = false;
/* Ein frisch geoeffnetes Gespraech steht immer am Ende -- man will
das Letzte sehen, nicht das Erste. */
@@ -411,10 +422,17 @@
blase.append(el('p', 'chat-nachricht__text chat-nachricht__weg',
'Nachricht zurückgenommen'));
} else {
/* DER ANHANG STEHT ÜBER DEM TEXT. Ein Begleitsatz gehört unter
das Bild, nicht darüber -- man sieht zuerst, worum es geht. */
if (n.anhang) blase.append(anhangBauen(n.anhang));
/* textContent, nie innerHTML: Eine Nachricht darf keine
Auszeichnung einschleusen. Zeilenumbrüche macht das CSS
(white-space: pre-wrap). */
blase.append(mitLinks(n.text));
(white-space: pre-wrap).
NUR WENN WIRKLICH TEXT DA IST: Eine Nachricht, die nur aus
einem Foto besteht, hätte sonst einen leeren Absatz darunter
-- und der nimmt Platz, den niemand erklären kann. */
if (n.text) blase.append(mitLinks(n.text));
}
const fuss = el('div', 'chat-nachricht__fuss');
@@ -497,6 +515,111 @@
: 'Zum neuesten';
}
/* ---------- Anhänge: Fotos und PDF (09.09.2026) -------------------------
Filipe: "pdfs und fotos".
Ein Foto wird GEZEIGT, ein PDF wird ANGEBOTEN. Das ist kein
Schönheitsunterschied: Ein Bild erkennt man in einer Zehntelsekunde,
ein PDF muss man ohnehin öffnen -- eine Vorschau davon wäre ein
grauer Kasten, der so tut, als könnte man etwas lesen.
`width`/`height` stehen am Bild, obwohl das CSS die Größe macht.
Sie sind das Seitenverhältnis: Damit hält der Browser den Platz
frei, BEVOR das Bild da ist. Ohne sie springt der ganze Verlauf,
sobald ein Bild fertig geladen ist -- und zwar genau dann, wenn
man gerade anfängt zu lesen. */
function grosse(bytes) {
const b = Number(bytes) || 0;
if (b < 1024) return `${b} B`;
if (b < 1024 * 1024) return `${Math.round(b / 1024)} kB`;
return `${(b / 1048576).toFixed(1)} MB`;
}
function anhangBauen(a) {
if (a.art === 'bild') {
const kasten = el('a', 'chat-bild');
kasten.href = a.weg;
kasten.target = '_blank';
kasten.rel = 'noopener noreferrer';
kasten.title = `${a.name} · ${grosse(a.groesse)} – in neuem Tab öffnen`;
/* DER PLATZ WIRD AM KASTEN RESERVIERT, NICHT AM BILD.
------------------------------------------------------------------
Erster Anlauf: width- und height-Attribut ans <img>, wie man es
überall liest. Gemessen war der Verlauf danach 323 px zu hoch --
genau die Höhe des Fotos.
Der Grund: Die Attribute geben dem Browser nur das SEITEN-
VERHÄLTNIS. Damit daraus eine Höhe wird, muss die Breite
feststehen. Bei `width: auto` und einem noch nicht geladenen
Bild ist sie 0 -- das Verhältnis hat nichts, womit es rechnen
könnte, der Kasten ist 0 px hoch, und wenn das Foto ankommt,
schiebt es alles darunter weg. Man springt beim Öffnen ans
Ende und steht eine Sekunde später mittendrin.
Deshalb bekommt der KASTEN das Verhältnis und eine Höchstbreite
(die Breite, die das Bild nach dem Deckel von 340 px Höhe
hätte). Damit steht die Breite von Anfang an fest, die Höhe
folgt daraus, und es wackelt nichts.
Gefunden hat das keine Überlegung, sondern eine Zahl: In der
Prüfung war der Rest 0 px -- dort lag das Bild schon im
Zwischenspeicher. Erst die Messung im Aufnahmeskript, beim
ERSTEN Laden, zeigte die 323. */
if (a.breite > 0 && a.hoehe > 0) {
const DECKEL = 340;
const breit = a.hoehe > DECKEL ? Math.round(a.breite * DECKEL / a.hoehe) : a.breite;
kasten.style.aspectRatio = `${a.breite} / ${a.hoehe}`;
kasten.style.maxWidth = `${breit}px`;
}
const bild = document.createElement('img');
bild.src = a.weg;
bild.alt = a.name || 'Foto';
bild.decoding = 'async';
if (a.breite > 0 && a.hoehe > 0) { bild.width = a.breite; bild.height = a.hoehe; }
/* KEIN loading="lazy". In einem Verlauf, der beim Öffnen ganz nach
unten springt, sind die sichtbaren Bilder die zuletzt
geschriebenen -- lazy verzögert genau die, die man sehen will,
und spart nichts, weil ein Chat selten hundert Fotos hat.
WENN EIN MASS FEHLT (seltene WebP-Formen), ist oben nichts
reserviert. Dann ist dieser Zuhörer die Sicherung: War man
unten, bleibt man unten. Er wird beim Bauen festgehalten, nicht
beim Laden gelesen -- bis dahin hat ein Bildlauf-Ereignis den
Zustand längst umgeworfen. */
const warUnten = unten;
bild.addEventListener('load', () => {
if (!warUnten) return;
const v = $('verlauf');
v.scrollTop = v.scrollHeight;
unten = true; neuUnten = 0;
springKnopfZeigen();
});
/* Ein Bild, das nicht lädt, hinterlässt sonst ein kaputtes
Symbol ohne Erklärung. */
bild.addEventListener('error', () => {
kasten.dataset.kaputt = 'ja';
kasten.textContent = 'Foto lässt sich gerade nicht laden';
});
kasten.append(bild);
return kasten;
}
const karte = el('a', 'chat-datei');
karte.href = a.weg;
karte.target = '_blank';
karte.rel = 'noopener noreferrer';
const zeichen = el('span', 'chat-datei__zeichen', 'PDF');
zeichen.setAttribute('aria-hidden', 'true');
const mitte = el('span', 'chat-datei__mitte');
mitte.append(el('span', 'chat-datei__name', a.name || 'Dokument'));
mitte.append(el('span', 'chat-datei__mass', `${grosse(a.groesse)} · öffnen`));
karte.append(zeichen, mitte);
karte.title = `${a.name} · ${grosse(a.groesse)}`;
return karte;
}
/* ---------- Links in Nachrichten (09.09.2026) ---------------------------
Eine Adresse im Text soll anklickbar sein. Gebaut wird sie als
ECHTER Knoten, nie ueber innerHTML: Sonst koennte ein Absender
@@ -1064,6 +1187,182 @@
history.replaceState(null, '', 'chat.html');
});
/* =======================================================================
ANHÄNGE SCHICKEN (09.09.2026)
Filipe: "am besten wäre es auch wenn man da auch im chat pdfs
schicken könnte. pdfs und fotos."
DREI WEGE ZU DERSELBEN SACHE, weil Leute unterschiedlich arbeiten:
die Büroklammer (immer da, auch am Handy), Hineinziehen (am
Rechner der schnellste Weg) und Einfügen mit Strg+V (der Weg für
einen Screenshot -- der liegt in der Zwischenablage und nirgendwo
als Datei).
Alle drei landen in derselben Funktion. Drei Fassungen desselben
Ablaufs wären drei Gelegenheiten, dass eine davon die Prüfung
vergisst.
GEPRÜFT WIRD TROTZDEM AUCH HIER, obwohl der Server es noch einmal
tut: Eine Absage nach zwölf Megabyte Wartezeit ist keine Absage,
das ist eine Strafe. Die Prüfung im Browser ist die Höflichkeit,
die auf dem Server ist die Sicherheit -- keine ersetzt die andere. */
const ANHANG_MAX = 12 * 1024 * 1024;
const ANHANG_TYPEN = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'application/pdf']);
let schicktGerade = false;
async function anhangSchicken(datei) {
if (!offen || !datei) return;
if (schicktGerade) { melde('Einen Moment – der letzte Anhang ist noch unterwegs.'); return; }
if (!ANHANG_TYPEN.has(datei.type)) {
melde('Das geht nicht – erlaubt sind Fotos (PNG, JPEG, WebP, GIF) und PDF.');
return;
}
if (datei.size > ANHANG_MAX) {
melde(`Zu groß – erlaubt sind ${Math.round(ANHANG_MAX / 1048576)} MB je Datei.`);
return;
}
schicktGerade = true;
$('anhang-auf').disabled = true;
melde(`„${datei.name}“ wird geschickt …`, 'lauf');
/* Ein angefangener Begleitsatz geht mit -- wer erst tippt und dann
das Bild anhängt, will beides in einer Nachricht. */
const begleittext = $('text').value.trim();
try {
const a = await hole(`/workspace/api/chat/raeume/${offen.id}/anhang`, {
method: 'POST',
headers: {
'Content-Type': datei.type,
/* Kopfzeilen sind streng genommen nur ASCII -- ohne die
Kodierung käme aus "Größe.pdf" unterwegs Unsinn. */
'X-Dateiname': encodeURIComponent(datei.name || 'anhang'),
'X-Text': encodeURIComponent(begleittext),
},
body: datei,
});
if (!a.ok) {
let grund = 'Ging nicht.';
try { grund = (await a.json()).fehler || grund; } catch { /* nichts Lesbares */ }
melde(grund);
return;
}
const { nachricht } = await a.json();
/* Nur eintragen, wenn sie nicht schon über den Strom kam --
sonst stünde dasselbe Foto zweimal da. */
if (!nachrichten.some((x) => x.id === nachricht.id)) {
nachrichten.push({ ...nachricht, selbst: true });
}
$('text').value = '';
entwurfWeg(offen.id);
hoeheAnpassen();
zaehlerZeigen();
unten = true; neuUnten = 0;
verlaufZeichnen();
melde('');
raeumeLaden();
} catch {
melde('Keine Verbindung – der Anhang wurde nicht geschickt.');
} finally {
schicktGerade = false;
$('anhang-auf').disabled = false;
}
}
$('anhang-auf').addEventListener('click', () => $('anhang-feld').click());
$('anhang-feld').addEventListener('change', () => {
const datei = $('anhang-feld').files?.[0];
/* Zurücksetzen, BEVOR geschickt wird: Sonst löst dieselbe Datei
beim zweiten Mal kein `change` aus, und man wundert sich, warum
nichts passiert. */
$('anhang-feld').value = '';
if (datei) void anhangSchicken(datei);
});
/* ---- Hineinziehen ------------------------------------------------------
`dragover` MUSS abgefangen werden, sonst öffnet der Browser die
Datei einfach als Seite -- der Verlauf wäre weg und das Bild
bildschirmfüllend da. */
{
const flaeche = document.querySelector('.chat__spalte--verlauf');
let tiefe = 0; /* dragenter/-leave feuert je Kind */
const zeigen = (ja) => {
const k = $('ablage');
if (k) k.hidden = !ja || !offen;
};
flaeche.addEventListener('dragenter', (e) => {
if (!e.dataTransfer?.types?.includes('Files')) return;
tiefe++; zeigen(true);
});
flaeche.addEventListener('dragover', (e) => {
if (!e.dataTransfer?.types?.includes('Files')) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
});
flaeche.addEventListener('dragleave', () => { if (--tiefe <= 0) { tiefe = 0; zeigen(false); } });
flaeche.addEventListener('drop', (e) => {
if (!e.dataTransfer?.files?.length) return;
e.preventDefault();
tiefe = 0; zeigen(false);
if (!offen) { melde('Zuerst ein Gespräch öffnen.'); return; }
void anhangSchicken(e.dataTransfer.files[0]);
});
/* Fällt etwas neben dem Fenster, darf der Browser es trotzdem nicht
öffnen. */
window.addEventListener('dragover', (e) => e.preventDefault());
window.addEventListener('drop', (e) => e.preventDefault());
}
/* ---- Einfügen (Strg+V) --------------------------------------------------
Der Weg für Screenshots. Ein kopiertes Bild hat keinen Namen --
deshalb bekommt es hier einen mit Datum, sonst hiesse jede
Bildschirmaufnahme "image.png". */
$('text').addEventListener('paste', (e) => {
const stuecke = [...(e.clipboardData?.items || [])];
const bild = stuecke.find((s) => s.kind === 'file' && ANHANG_TYPEN.has(s.type));
if (!bild) return; /* normaler Text: nichts tun */
const datei = bild.getAsFile();
if (!datei) return;
e.preventDefault();
const endung = (datei.type.split('/')[1] || 'png').replace('jpeg', 'jpg');
const stempel = new Date().toISOString().slice(0, 16).replace(/[-:]/g, '').replace('T', '-');
void anhangSchicken(new File([datei], datei.name && datei.name !== 'image.png'
? datei.name : `bildschirmfoto-${stempel}.${endung}`, { type: datei.type }));
});
/* =======================================================================
GESPRÄCH WEGRÄUMEN — NUR BEI MIR
Die Rückfrage sagt ausdrücklich, was NICHT passiert. "Wirklich
löschen?" liesse offen, ob der andere seinen Verlauf verliert --
und das ist genau die Frage, die man in dem Moment hat. */
$('gespraech-weg').addEventListener('click', async () => {
if (!offen) return;
const wer = offen.name;
if (!window.confirm(
`Gespräch mit ${wer} wegräumen?\n\n`
+ 'Es verschwindet nur bei dir. Der Verlauf beim Gegenüber bleibt '
+ 'vollständig.\n\nSchreibt euch jemand wieder, kommt das Gespräch '
+ 'mit dem Neuen zurück – das Alte bleibt weg.')) return;
try {
const a = await hole(`/workspace/api/chat/raeume/${offen.id}`, { method: 'DELETE' });
if (!a.ok) { melde('Ging nicht.'); return; }
entwurfWeg(offen.id);
offen = null;
nachrichten = [];
$('schreiben').hidden = true;
$('gespraech-weg').hidden = true;
$('verlauf-titel').textContent = 'Wähle ein Gespräch';
$('verlauf-unter').textContent = '';
$('verlauf-leer').hidden = false;
$('chat').dataset.ansicht = 'liste';
verlaufZeichnen();
history.replaceState(null, '', 'chat.html');
await raeumeLaden();
melde('');
} catch { melde('Keine Verbindung.'); }
});
/* Zurück an den Bildschirm: nachsehen, was inzwischen kam, und melden,
dass jetzt wirklich jemand hinsieht. */
document.addEventListener('visibilitychange', () => {