Chat: die GIF-Kiste des Rudels -- und eine Luecke in der Sicherung

Filipe: "dan will ich auch dass man im chat auch sprachnachrichten und
gifts reinschicken kann. also nur die modis rechte linke hand und
dogfather."

ICH LESE "gifts" ALS GIFs -- die bewegten Bildchen -- und sage das
ausdruecklich, statt es stillschweigend anzunehmen. Im Zusammenhang
("im chat reinschicken", direkt neben Sprachnachrichten) passt nichts
anderes. Sollte er Geschenke gemeint haben, sagt er es, und dann baue
ich das.

EINE EIGENE KISTE STATT GIPHY ODER TENOR
----------------------------------------
Beide brauchen ein Konto und einen Schluessel und bekommen bei jedem
Tastendruck mit, wonach hier gesucht wird -- an eine fremde Firma, aus
einem Arbeitsplatz heraus, in dem sonst nichts nach aussen geht. Dazu
kosten sie ab einer Menge Geld. Beides steht quer zu dem, wie dieses
Haus gebaut ist.

Die Kiste laeuft auf demselben Server wie alles andere, kostet nichts
und wird mit der Zeit besser statt schlechter: Was darin liegt, hat das
Team ausgesucht. Zehn gute GIFs, die alle kennen, sind im Alltag mehr
wert als zehn Millionen fremde -- ein Katalog mit allem ist nicht mehr
Auswahl, sondern weniger.

HABEN WIR DAS SCHON? WIRD AM INHALT BEANTWORTET
-----------------------------------------------
Der Schluessel ist der SHA-256 der Datei. Ueber den NAMEN zu
vergleichen waere die naheliegende Abkuerzung -- und sie versagt genau
dort, wo Dateien "giphy.gif", "giphy(1).gif" und "download.gif"
heissen, also fast immer. Dasselbe Bild waere dreimal dieselbe Kachel.

Dasselbe GIF zweimal hineinzulegen ist deshalb KEIN Fehler: Es ist
drin, und genau das wollte man. Eine Absage waere formal richtig und im
Erleben falsch.

DAS WICHTIGSTE STUECK: ES WIRD KOPIERT, NICHT VERWIESEN
-------------------------------------------------------
Eine Nachricht zurueckzunehmen entfernt ihre Datei von der Platte. Laege
ein Kachel-GIF in demselben Ordner und mehrere Nachrichten zeigten
darauf, naehme das Zuruecknehmen EINER Nachricht das GIF fuer alle
anderen mit -- und in drei Gespraechen stuende ab da ein kaputtes Bild.
Das ist die Sorte Fehler, die erst Wochen spaeter auffaellt und dann
nicht mehr zu reparieren ist.

Deshalb liegt die Kiste in einem eigenen Ordner (chat-gifs/), und beim
Verschicken wird kopiert. Danach ist es ein ganz normaler Anhang: im
Verlauf, in der Suche, zuruecknehmbar, unter derselben Nachtruhe. Keine
zweite Sorte Nachricht mit eigenen Rechten und eigenem Loeschen.

Geprueft wird genau das: GIF hineinlegen, verschicken, aus der Kiste
nehmen -- und danach steht das verschickte immer noch im Gespraech.

"ALSO NUR" GILT AUCH HIER
-------------------------
Ansehen, hineinlegen, verschicken: alle drei nur fuer Team Dogi und
DogFather (istTeamDogi). Der Satz stand im selben Atemzug wie die
Sprachnachrichten; es gibt keinen Grund, warum er fuer das eine gelten
sollte und fuer das andere nicht. Gemessen auch am Knopf vorbei:
403/403/403.

AUGENSCHONEND -- HIER EINE ECHTE FRAGE, KEINE FORMSACHE
--------------------------------------------------------
Zwoelf gleichzeitig laufende Bildchen sind das Gegenteil von ruhig. Wer
"Bewegung reduzieren" gesetzt hat, bekommt deshalb STEHENDE Bilder: das
erste Einzelbild, im Browser auf eine Zeichenflaeche gemalt (kostet
keine zweite Datei), und es laeuft erst, wenn man es anfasst oder mit
der Tastatur darauf steht.

Fuer alle anderen laufen sie. Eine GIF-Auswahl, in der sich nichts
bewegt, ist eine, in der man nicht sieht, was man verschickt -- deshalb
steht dazu eine Gegenprobe in der Pruefung.

NEBENBEFUND, UND ER WIEGT SCHWERER ALS DIE GIFS
-----------------------------------------------
Weil die Kiste einen neuen Datenordner braucht, lief
tools/wiederherstellung-proben.mjs -- und meldete, dass der Ordner
"material" seit dem 22.09. NICHT gesichert wird. 2,4 MB
Materialbibliothek auf dem Server, nach einem Plattenausfall weg, ohne
dass die taegliche Meldung je etwas gesagt haette. Genau die Luecke,
wegen der diese Probe ueberhaupt gebaut wurde -- und sie hat sie
gefunden, weil sie die Ordner aus dem QUELLTEXT liest statt aus einer
gepflegten Liste.

Eingetragen. Danach frisch geholt und zurueckgespielt: 18 von 18 gruen,
"DIE SICHERUNG LAESST SICH ZURUECKSPIELEN" (vorher 3 Fehler).

Dabei war die Gegenprobe daneben selbst kaputt: Sie prueft, ob ein
erfundener Ordner als fehlend erkannt wird, und verlangte dafuer
`=== 1`. Das setzt voraus, dass sonst nichts fehlt. An dem Tag, an dem
wirklich etwas fehlte, wurde sie rot und zeigte damit auf sich selbst
statt auf den Fund -- zwei Meldungen, eine Ursache, und die zweite
lenkt von der ersten ab. Jetzt zaehlt sie die Differenz.

GEPRUEFT
--------
pruef-chat-anhaenge: 109 Pruefungen, 0 Fehler (vorher 86, davor 60).
pruef-chat, pruef-chat-aufloesen (126), pruef-chat-optik: gruen.
tools/wiederherstellung-proben: 18 von 18.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-23 02:07:56 +02:00
co-authored by Claude Opus 5
parent 2d14907d0d
commit 5e503463f5
46 changed files with 1568 additions and 592 deletions
+145
View File
@@ -2838,3 +2838,148 @@
.tonleiste__punkt { background: CanvasText; }
.chat__ton[data-laeuft="ja"] { border: 2px solid Highlight; }
}
/* =====================================================================
DIE GIF-KISTE (23.09.2026)
Filipe: „dan will ich auch dass man im chat auch sprachnachrichten
und gifts reinschicken kann."
AUGENSCHONEND IST HIER EINE ECHTE FRAGE, keine Formsache: Zwölf
gleichzeitig laufende Bildchen sind das Gegenteil von ruhig. Wer
`prefers-reduced-motion` gesetzt hat, bekommt deshalb stehende
Bilder (chat.js malt das erste Einzelbild auf eine Zeichenfläche)
und sieht eines erst laufen, wenn er es anfasst. Für alle anderen
laufen sie -- eine GIF-Auswahl, in der sich nichts bewegt, ist eine,
in der man nicht sieht, was man verschickt.
DIE TAFEL IST GEDECKELT (48 vh). Ohne Deckel wächst sie mit der
Kiste, und irgendwann steht sie über dem ganzen Verlauf.
===================================================================== */
.chat__gif {
flex: none;
display: inline-flex; align-items: center; justify-content: center;
/* Dieselben 40 px wie Büroklammer und Mikrofon daneben. */
width: 40px; height: 40px;
border: 0; border-radius: 50%;
background: transparent;
color: var(--text-leise);
font: 700 .68rem/1 var(--schrift, inherit);
letter-spacing: .04em;
cursor: pointer;
transition: background var(--tempo, .18s) ease, color var(--tempo, .18s) ease;
}
.chat__gif:hover { background: rgba(255, 255, 255, .07); color: var(--text); }
.chat__gif:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; }
.chat__gif[aria-expanded="true"] {
background: color-mix(in srgb, var(--akzent) 20%, transparent);
color: color-mix(in srgb, var(--akzent) 60%, #ffffff);
}
/* Dieselbe Verankerung wie Emoji-Tafel und Erwähnliste: über der
Schreibzeile, damit sie dort aufgeht, wo man schreibt. */
.gif-tafel {
position: absolute;
bottom: 100%; left: 0; right: 0;
z-index: 30;
margin-bottom: 8px;
padding: 10px;
max-height: 48vh;
overflow: auto;
border: 1px solid var(--rand);
border-radius: var(--radius-klein, 12px);
background: var(--flaeche, rgba(14, 20, 30, .98));
box-shadow: 0 12px 30px -12px rgba(0, 0, 0, .7);
}
.gif-tafel__kopf {
display: flex; align-items: center; gap: 8px;
margin: 0 0 9px;
}
.gif-tafel__suchschild {
/* Für Vorleseprogramme da, für das Auge nicht -- daneben steht schon
ein Platzhalter, und zwei Beschriftungen für ein Feld sind eine zu
viel. */
position: absolute; width: 1px; height: 1px;
overflow: hidden; clip-path: inset(50%); white-space: nowrap;
}
.gif-tafel__suche {
flex: 1 1 auto; min-width: 0;
padding: 8px 10px;
border: 1px solid var(--rand);
border-radius: 9px;
background: rgba(255, 255, 255, .04);
color: var(--text);
font-size: .9rem;
}
.gif-tafel__suche:focus-visible { outline: 2px solid var(--akzent); outline-offset: -1px; }
.gif-tafel__dazu {
flex: none;
/* 40 px hoch wie alles, was ein Daumen trifft. */
min-height: 40px;
padding: 0 12px;
border: 1px solid color-mix(in srgb, var(--akzent) 40%, transparent);
border-radius: 9px;
background: color-mix(in srgb, var(--akzent) 14%, transparent);
color: color-mix(in srgb, var(--akzent) 58%, #ffffff);
font-size: .82rem; font-weight: 600;
cursor: pointer;
}
.gif-tafel__dazu:hover { background: color-mix(in srgb, var(--akzent) 24%, transparent); }
.gif-tafel__dazu:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; }
.gif-tafel__gitter {
display: grid;
/* auto-fill statt auto-fit: Bei zwei GIFs sollen zwei Kacheln
dastehen und nicht zwei auf halbe Bildschirmbreite gezogene --
derselbe Unterschied wie bei der Personenwahl am 22.09. */
grid-template-columns: repeat(auto-fill, minmax(112px, 1fr));
gap: 8px;
}
.gif-kachel { position: relative; }
.gif-kachel__knopf {
display: block; width: 100%;
padding: 0;
border: 1px solid var(--rand);
border-radius: 10px;
background: rgba(255, 255, 255, .03);
overflow: hidden;
cursor: pointer;
/* Feste Höhe: Ohne sie springt das Gitter, während die Bilder
nacheinander ankommen -- und man tippt auf das, was gerade
weggerutscht ist. */
aspect-ratio: 1 / 1;
}
.gif-kachel__knopf:hover { border-color: color-mix(in srgb, var(--akzent) 50%, transparent); }
.gif-kachel__knopf:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; }
.gif-kachel__bild {
display: block; width: 100%; height: 100%;
object-fit: cover;
}
.gif-kachel__weg {
position: absolute; top: 4px; right: 4px;
/* 28 px, und das ist eine Abwägung: Ein Daumenziel von 44 px würde
hier die halbe Kachel verdecken und beim Verschicken im Weg
stehen. Ein Fehlgriff kostet nichts -- es kommt eine Rückfrage. */
width: 28px; height: 28px;
display: inline-flex; align-items: center; justify-content: center;
border: 0; border-radius: 50%;
background: rgba(8, 12, 20, .78);
color: #e6edf6;
font-size: .74rem; line-height: 1;
cursor: pointer;
}
.gif-kachel__weg:hover { background: rgba(8, 12, 20, .94); }
.gif-kachel__weg:focus-visible { outline: 2px solid var(--akzent); outline-offset: 1px; }
.gif-tafel__leer {
margin: 10px 4px 4px;
font-size: .86rem; line-height: 1.55;
color: var(--text-leise);
}
@media (forced-colors: active) {
.gif-tafel { border: 1px solid CanvasText; }
.gif-kachel__knopf { border: 1px solid CanvasText; }
.chat__gif[aria-expanded="true"] { border: 2px solid Highlight; }
}
+232
View File
@@ -786,6 +786,8 @@
Ueberlegung wie bei darf_anheften und darf_alles_loeschen. */
const mikro = $('ton-auf');
if (mikro) mikro.hidden = !daten.darf_ton;
const gifKnopf = $('gif-auf');
if (gifKnopf) gifKnopf.hidden = !daten.darf_gif;
/* 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
@@ -2797,6 +2799,236 @@
z.dataset.eng = rest <= 100 ? 'ja' : 'nein';
}
/* =====================================================================
DIE GIF-KISTE DES RUDELS (23.09.2026)
=====================================================================
Filipe: „dan will ich auch dass man im chat auch sprachnachrichten
und gifts reinschicken kann. also nur die modis rechte linke hand
und dogfather."
Ich lese „gifts" als GIFs -- die bewegten Bildchen -- und sage das
ausdruecklich, statt es stillschweigend anzunehmen.
WARUM EINE EIGENE KISTE UND KEIN GIPHY: Die Begruendung steht im
Server bei der Tabelle. Kurz: fremder Schluessel, fremde Firma,
jede Suche geht hinaus, und ab einer Menge kostet es. Was hier
liegt, hat das Team hineingelegt -- und zehn gute GIFs, die alle
kennen, sind im Alltag mehr wert als zehn Millionen fremde.
AUGENSCHONEND, UND DAS IST HIER EINE ECHTE FRAGE: Eine Wand aus
zwoelf gleichzeitig laufenden Bildchen ist das Gegenteil von
ruhig. Wer `prefers-reduced-motion` gesetzt hat, bekommt deshalb
STEHENDE Bilder (das erste Einzelbild, im Browser auf eine
Zeichenflaeche gemalt) und sieht ein GIF erst dann laufen, wenn er
es anfasst oder mit der Tastatur darauf steht. Fuer alle anderen
laufen sie -- eine GIF-Auswahl, in der sich nichts bewegt, ist
eine, in der man nicht sieht, was man verschickt.
===================================================================== */
let gifs = [];
let gifsGeholt = false;
const ruhigerModus = () => {
try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
catch { return false; }
};
async function gifsHolen({ frisch = false } = {}) {
if (gifsGeholt && !frisch) return;
try {
const a = await hole('/workspace/api/chat/gifs');
if (!a.ok) return;
const j = await a.json();
gifs = Array.isArray(j.gifs) ? j.gifs : [];
gifsGeholt = true;
} catch { /* ohne Kiste geht der Chat weiter */ }
}
/** Das erste Einzelbild auf eine Zeichenflaeche malen.
*
* So steht ein GIF still, ohne dass irgendwo eine zweite Datei
* liegen muesste. Der Browser malt beim Zeichnen das Bild, das
* gerade dran ist -- unmittelbar nach dem Laden ist das das erste. */
function standbildBauen(g) {
const flaeche = document.createElement('canvas');
flaeche.className = 'gif-kachel__bild';
const bild = new Image();
bild.addEventListener('load', () => {
flaeche.width = bild.naturalWidth || 1;
flaeche.height = bild.naturalHeight || 1;
try { flaeche.getContext('2d').drawImage(bild, 0, 0); } catch { /* egal */ }
});
bild.src = g.weg;
return flaeche;
}
function bewegtBauen(g) {
const bild = document.createElement('img');
bild.className = 'gif-kachel__bild';
bild.src = g.weg;
bild.alt = g.name || 'GIF';
bild.loading = 'lazy';
bild.decoding = 'async';
return bild;
}
function gifTafelZeichnen() {
const gitter = $('gif-gitter');
const leer = $('gif-leer');
if (!gitter) return;
const suche = ($('gif-suche')?.value || '').trim().toLowerCase();
const zeigen = suche
? gifs.filter((g) => String(g.name || '').toLowerCase().includes(suche))
: gifs;
gitter.replaceChildren();
if (leer) {
leer.hidden = zeigen.length > 0;
/* DER SATZ PASST ZUR LAGE. „Die Kiste ist leer" waere falsch,
wenn nur die Suche nichts findet -- und man legte ein GIF
hinein, das schon drin ist. */
leer.textContent = gifs.length === 0
? 'Die Kiste ist noch leer. Leg das erste GIF hinein – alle aus dem Team sehen es dann auch.'
: `Nichts gefunden zu „${suche}". Insgesamt liegen ${gifs.length} GIFs in der Kiste.`;
}
const ruhig = ruhigerModus();
for (const g of zeigen) {
const kachel = el('div', 'gif-kachel');
const knopf = el('button', 'gif-kachel__knopf');
knopf.type = 'button';
knopf.title = `${g.name}${g.von ? ' · von ' + g.von : ''}`;
knopf.setAttribute('aria-label', `GIF ${g.name} schicken`);
knopf.append(ruhig ? standbildBauen(g) : bewegtBauen(g));
if (ruhig) {
/* Beim Anfassen laeuft es doch -- sonst waehlt man blind. */
const bewegen = () => {
if (knopf.dataset.bewegt === 'ja') return;
knopf.dataset.bewegt = 'ja';
knopf.replaceChildren(bewegtBauen(g));
};
knopf.addEventListener('mouseenter', bewegen);
knopf.addEventListener('focus', bewegen);
}
knopf.addEventListener('click', () => void gifSchicken(g));
kachel.append(knopf);
/* HERAUSNEHMEN: Der Knopf steht an jeder Kachel und nicht in
einem Menue dahinter. Die Kiste gehoert allen; ein GIF, das
nicht mehr passt, muss man wegnehmen koennen, ohne zu suchen,
wie das geht. Der Server sagt Nein, wenn man nicht darf --
dann steht es als Satz da, nicht als nichts. */
const weg = el('button', 'gif-kachel__weg', '\u2715');
weg.type = 'button';
weg.title = 'Aus der Kiste nehmen';
weg.setAttribute('aria-label', `GIF ${g.name} aus der Kiste nehmen`);
weg.addEventListener('click', async (e) => {
e.stopPropagation();
if (!confirm(`„${g.name}" aus der Kiste nehmen? Verschickte GIFs bleiben stehen.`)) return;
try {
const a = await hole(`/workspace/api/chat/gifs/${g.id}`, { method: 'DELETE' });
const j = await a.json().catch(() => ({}));
if (!a.ok) { melde(sagWas(j.fehler, 'Ging nicht.')); return; }
gifs = Array.isArray(j.gifs) ? j.gifs : gifs.filter((x) => x.id !== g.id);
gifTafelZeichnen();
melde('Aus der Kiste genommen.', 'hinweis');
} catch { melde('Keine Verbindung.'); }
});
kachel.append(weg);
gitter.append(kachel);
}
}
async function gifSchicken(g) {
if (!offen || schicktGerade) return;
schicktGerade = true;
try {
const a = await hole(`/workspace/api/chat/raeume/${offen.id}/gif`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gif_id: g.id, text: $('text').value.trim() }),
});
const j = await a.json().catch(() => ({}));
if (!a.ok) { melde(sagWas(j.fehler, 'Ging nicht.')); return; }
/* DIE TAFEL GEHT ZU. Wer ein GIF geschickt hat, will sehen, wie
es ankommt -- und die Tafel steht genau davor. */
const tafel = $('gif-tafel');
if (tafel) { tafel.hidden = true; $('gif-auf')?.setAttribute('aria-expanded', 'false'); }
nachrichtUebernehmen(j.nachricht);
} catch {
melde('Keine Verbindung – das GIF wurde nicht geschickt.');
} finally {
schicktGerade = false;
}
}
/** Ein GIF in die Kiste legen. */
async function gifDazu(datei) {
if (!datei) return;
if (datei.type !== 'image/gif') { melde('In die Kiste kommen nur GIFs.'); return; }
let anzeige = null;
try {
let lauf = null;
anzeige = hochladeAnzeige($('anhang-fortschritt'), datei.name || 'GIF',
() => lauf && lauf.abbrechen());
lauf = hochladen('/workspace/api/chat/gifs', datei, {
kopf: {
'Content-Type': 'image/gif',
'X-Dateiname': encodeURIComponent(datei.name || 'GIF'),
},
beiFortschritt: anzeige.fortschritt,
beiVerarbeitung: anzeige.verarbeiten,
});
const ergebnis = await lauf.fertig;
if (ergebnis.abgebrochen) { anzeige.abschluss('Abgebrochen.'); anzeige = null; return; }
if (!ergebnis.ok) {
melde(ergebnis.netz ? 'Keine Verbindung – das GIF wurde nicht hinterlegt.'
: sagWas(ergebnis.daten?.fehler, 'Ging nicht.'));
return;
}
await gifsHolen({ frisch: true });
gifTafelZeichnen();
melde(ergebnis.daten?.schon
/* DASSELBE GIF ZWEIMAL IST KEIN FEHLER -- es ist drin, und
genau das wollte man. Verglichen wird am INHALT, nicht am
Namen: "giphy.gif", "giphy(1).gif" und "download.gif" sind
sonst drei Kacheln fuer ein Bild. */
? 'Das lag schon in der Kiste.'
: 'In die Kiste gelegt – alle aus dem Team sehen es jetzt auch.', 'hinweis');
} catch {
melde('Keine Verbindung – das GIF wurde nicht hinterlegt.');
} finally {
if (anzeige) anzeige.weg();
}
}
$('gif-auf')?.addEventListener('click', async () => {
const tafel = $('gif-tafel');
if (!tafel) return;
const auf = tafel.hidden;
if (auf) {
await gifsHolen();
gifTafelZeichnen();
}
tafel.hidden = !auf;
$('gif-auf').setAttribute('aria-expanded', String(auf));
if (auf) $('gif-suche')?.focus();
});
$('gif-suche')?.addEventListener('input', gifTafelZeichnen);
$('gif-dazu')?.addEventListener('click', () => $('gif-feld')?.click());
$('gif-feld')?.addEventListener('change', () => {
const datei = $('gif-feld').files?.[0];
$('gif-feld').value = '';
if (datei) void gifDazu(datei);
});
/* Ein Klick daneben schliesst die Kiste -- wie bei der Emoji-Tafel. */
document.addEventListener('click', (e) => {
const tafel = $('gif-tafel');
if (!tafel || tafel.hidden) return;
if (tafel.contains(e.target) || e.target.closest('#gif-auf')) return;
tafel.hidden = true;
$('gif-auf')?.setAttribute('aria-expanded', 'false');
});
/* ---------- Emoji-Auswahl -------------------------------------------------
Eine kleine, feste Auswahl statt einer vollstaendigen Tafel: Diese
dreissig decken den Alltag ab, und sie laden nichts nach. Eine