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:
+302
-3
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user