feat(chat): modifica dei messaggi e firma di chi risponde

Due mancanze emerse provando la chat a canali in produzione.

**Modifica dei messaggi** (migration 0022, additiva, già applicata a
prod). Modello Slack/Discord: si corregge un proprio messaggio senza
limite di tempo, il testo precedente non si conserva, accanto all'ora
compare «modificato». Scelta deliberata, annotata in STATUS.md.

Il punto delicato non è la scrittura ma la propagazione: il poll chiede
`created_at > since` e una modifica non cambia `created_at`, quindi
l'altra parte vedrebbe il testo vecchio fino a un reload. Il filtro ora
guarda anche `edited_at`, e il watermark del client è il massimo fra i
due su tutti i messaggi — senza, il server rispedirebbe lo stesso
messaggio a ogni giro per sempre. Il merge per id già esistente fa il
resto, quindi niente duplicati.

Il non-letto resta ancorato a `created_at` di proposito: correggere un
refuso non deve riaccendere il pallino di un canale già letto.

Si modifica solo ciò di cui si è autori — il controllo è su `author`,
non solo sulla proprietà dell'entità, e lo rifà il server.

**Firma in chat.** Il nome era la stringa "iamcavalli" cablata nel
pannello: il cliente leggeva il marchio dove si aspetta una persona. Ora
arriva da `settings` (nessuna migration) con foto via URL esterno, che
rispetta il vincolo LOCKED #5 — l'upload su volume non esiste, la
deroga per l'audit è scritta in CLAUDE.md ma non è mai stata costruita.
Avatar rotto o assente ricade sul monogramma.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 22:42:48 +02:00
parent 0d7186ba03
commit 94f4a54248
13 changed files with 735 additions and 82 deletions
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ExternalLink, IdCard, MessageSquare } from "lucide-react";
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import {
editConversationMessage,
markConversationRead,
replyToConversation,
} from "@/app/admin/conversazioni/actions";
@@ -274,7 +275,9 @@ function ActiveThread({ thread }: { thread: ConversationThread }) {
Nessun messaggio in questo canale.
</p>
) : (
visibleMessages.map((m) => <MessageBubble key={m.id} message={m} />)
visibleMessages.map((m) => (
<MessageBubble key={m.id} message={m} clientId={thread.clientId} />
))
)}
</div>
@@ -303,43 +306,143 @@ function ActiveThread({ thread }: { thread: ConversationThread }) {
function MessageBubble({
message,
clientId,
}: {
message: ConversationThread["messages"][number];
clientId: string;
}) {
const isAdmin = message.author === "admin";
// Solo task e deliverable: per una fase l'etichetta ripeterebbe il tab attivo.
const showEntity =
message.entityType === "task" || message.entityType === "deliverable";
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(message.body);
const [failed, setFailed] = useState(false);
const [saving, startSaving] = useTransition();
function startEditing() {
setDraft(message.body);
setFailed(false);
setEditing(true);
}
function save() {
const next = draft.trim();
if (!next || next === message.body) {
setEditing(false);
return;
}
const fd = new FormData();
fd.set("comment_id", message.id);
fd.set("body", next);
startSaving(async () => {
try {
await editConversationMessage(clientId, fd);
setEditing(false);
} catch {
setFailed(true);
}
});
}
return (
<div className={cn("flex", isAdmin && "justify-end")}>
<div
className={cn(
"max-w-[75%] p-3.5 rounded-2xl shadow-sm space-y-1",
isAdmin
? "bg-primary text-primary-foreground rounded-tr-sm"
: "bg-background border border-border text-foreground rounded-tl-sm"
)}
>
<div className="flex items-center gap-2">
<span
className={cn(
"text-[10px] font-bold",
isAdmin ? "text-primary-foreground/70" : "text-muted-foreground"
)}
>
{isAdmin ? "Tu (Admin)" : "Cliente"}
</span>
{showEntity && <EntityBadge label={message.entityLabel} />}
</div>
<p className="text-xs leading-relaxed whitespace-pre-wrap">{message.body}</p>
<p
<div className={cn("group flex", isAdmin && "justify-end")}>
<div className="max-w-[75%]">
<div
className={cn(
"text-[9px] font-mono",
isAdmin ? "text-primary-foreground/50" : "text-muted-foreground/60"
"p-3.5 rounded-2xl shadow-sm space-y-1",
isAdmin
? "bg-primary text-primary-foreground rounded-tr-sm"
: "bg-background border border-border text-foreground rounded-tl-sm"
)}
>
{formatMessageTime(message.created_at)}
</p>
<div className="flex items-center gap-2">
<span
className={cn(
"text-[10px] font-bold",
isAdmin ? "text-primary-foreground/70" : "text-muted-foreground"
)}
>
{isAdmin ? "Tu (Admin)" : "Cliente"}
</span>
{showEntity && <EntityBadge label={message.entityLabel} />}
</div>
{editing ? (
<Textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
save();
}
if (e.key === "Escape") {
e.preventDefault();
setEditing(false);
}
}}
rows={3}
autoFocus
maxLength={2000}
aria-label="Modifica il messaggio"
className="resize-none text-xs text-foreground bg-background"
/>
) : (
<p className="text-xs leading-relaxed whitespace-pre-wrap">
{message.body}
</p>
)}
<p
className={cn(
"text-[9px] font-mono",
isAdmin ? "text-primary-foreground/50" : "text-muted-foreground/60"
)}
>
{formatMessageTime(message.created_at)}
{message.edited_at && " · modificato"}
</p>
</div>
{/* Fuori dalla bolla: sopra `bg-primary` un testo tenue non si leggerebbe. */}
{isAdmin && (
<div className={cn("mt-1 flex items-center gap-3", "justify-end")}>
{editing ? (
<>
<button
type="button"
onClick={save}
disabled={saving}
className="text-[11px] font-semibold text-primary hover:underline disabled:opacity-50"
>
{saving ? "Salvo…" : "Salva"}
</button>
<button
type="button"
onClick={() => setEditing(false)}
className="text-[11px] font-medium text-muted-foreground hover:underline"
>
Annulla
</button>
{failed && (
<span className="text-[10px] text-destructive">
Non salvato, riprova
</span>
)}
</>
) : (
<button
type="button"
onClick={startEditing}
className="text-[11px] font-medium text-muted-foreground opacity-0 transition-opacity hover:underline focus-visible:opacity-100 group-hover:opacity-100 [@media(hover:none)]:opacity-100"
>
Modifica
</button>
)}
</div>
)}
</div>
</div>
);
+285 -50
View File
@@ -73,6 +73,7 @@ export function ChatPanel({ token, chat }: { token: string; chat: ChatData }) {
toggleExpanded,
} = useChatContext();
const preview = usePreview();
const { adminName, adminAvatarUrl } = chat;
const channels = useMemo(() => buildChannels(clientId, phases), [clientId, phases]);
const index = useMemo(() => buildChannelIndex(clientId, phases), [clientId, phases]);
@@ -103,6 +104,10 @@ export function ChatPanel({ token, chat }: { token: string; chat: ChatData }) {
// ── Non letti per canale ────────────────────────────────────────────────────
// Un canale ha novità se l'ultimo messaggio dell'admin è più recente di quando
// il cliente l'ha guardato. Senza ricevuta di lettura vale come mai letto.
//
// Si guarda created_at e NON edited_at, di proposito: correggere un refuso in
// un messaggio già letto non deve far riaccendere il pallino, altrimenti ogni
// correzione somiglia a un messaggio nuovo. Non è una svista da "sistemare".
const unreadChannels = useMemo(() => {
const lastAdminAt = new Map<string, number>();
for (const m of messages) {
@@ -135,10 +140,20 @@ export function ChatPanel({ token, chat }: { token: string; chat: ChatData }) {
// ── Polling ────────────────────────────────────────────────────────────────
// `since` in una ref: se fosse una dipendenza dell'effetto, ogni messaggio
// nuovo smonterebbe e rimonterebbe l'intervallo, rimettendo il timer a zero.
//
// Il watermark è il massimo fra created_at ed edited_at su TUTTI i messaggi,
// non il created_at dell'ultimo: una modifica arriva dal server perché il
// filtro guarda anche edited_at, ma se poi il watermark restasse indietro il
// server continuerebbe a rispedire lo stesso messaggio a ogni giro, per sempre.
// I messaggi non sono ordinati per edited_at, quindi va scorso l'intero elenco.
const sinceRef = useRef<string | null>(null);
useEffect(() => {
const latest = messages[messages.length - 1];
sinceRef.current = latest ? toDate(latest.created_at).toISOString() : null;
let max = 0;
for (const m of messages) {
max = Math.max(max, toDate(m.created_at).getTime());
if (m.edited_at) max = Math.max(max, toDate(m.edited_at).getTime());
}
sinceRef.current = max > 0 ? new Date(max).toISOString() : null;
}, [messages]);
useEffect(() => {
@@ -273,6 +288,45 @@ export function ChatPanel({ token, chat }: { token: string; chat: ChatData }) {
[token, clientId]
);
// ── Modifica ───────────────────────────────────────────────────────────────
// Ottimistica sfruttando il merge già in piedi: `polled` vince su `chat.messages`
// a parità di id, quindi per far comparire subito il testo nuovo basta spingerci
// dentro una copia. Se il PATCH fallisce si rispinge l'originale e il feed torna
// com'era — senza uno stato "in modifica" separato da riconciliare.
const applyEdit = useCallback(
async (id: string, newBody: string): Promise<boolean> => {
const original = messages.find((m) => m.id === id);
if (!original) return false;
setPolled((prev) => [
...prev,
{ ...original, body: newBody, edited_at: new Date().toISOString() },
]);
try {
const res = await fetch("/api/client/comment", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, comment_id: id, body: newBody }),
});
if (!res.ok) {
setPolled((prev) => [...prev, original]);
const data = await res.json().catch(() => ({}));
setError(data.error ?? "Modifica non riuscita");
return false;
}
const data = (await res.json()) as { comment?: ChatMessage };
if (data.comment) setPolled((prev) => [...prev, data.comment!]);
return true;
} catch {
setPolled((prev) => [...prev, original]);
setError("Errore di rete");
return false;
}
},
[messages, token]
);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const trimmed = body.trim();
@@ -439,6 +493,10 @@ export function ChatPanel({ token, chat }: { token: string; chat: ChatData }) {
index={index}
activeChannel={activeChannel}
clientId={clientId}
adminName={adminName}
adminAvatarUrl={adminAvatarUrl}
canEdit={!preview}
onEdit={applyEdit}
/>
)}
@@ -545,11 +603,20 @@ function MessageList({
index,
activeChannel,
clientId,
adminName,
adminAvatarUrl,
canEdit,
onEdit,
}: {
messages: ChatMessage[];
index: ReturnType<typeof buildChannelIndex>;
activeChannel: string;
clientId: string;
adminName: string;
adminAvatarUrl: string | null;
/** Falso in anteprima admin: si guarda, non si scrive. */
canEdit: boolean;
onEdit: (id: string, body: string) => Promise<boolean>;
}) {
// Divider di giornata e raggruppamento si calcolano in un passaggio solo,
// prima del JSX: dentro una .map() servirebbero variabili riassegnate a ogni
@@ -571,67 +638,235 @@ function MessageList({
return (
<>
{items.map(({ message: m, at, showDivider, grouped }) => {
const isClient = m.author === "client";
const name = isClient ? "Tu" : "iamcavalli";
// Dentro un canale-fase, i messaggi storici su un task o un deliverable
// restano riconoscibili: il canale li raccoglie, il badge dice su cosa erano.
const entityLabel =
activeChannel !== clientId ? index.entityLabel.get(m.entity_id) : undefined;
return (
<div key={m.id}>
{showDivider && (
<div className="my-4 flex items-center gap-3">
<div className="h-px flex-1 bg-border" />
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
{formatDayDivider(at)}
</span>
<div className="h-px flex-1 bg-border" />
</div>
)}
<div className={cn("flex gap-3", grouped ? "mt-1" : "mt-5")}>
{grouped ? (
<div className="w-9 shrink-0" aria-hidden="true" />
) : (
<Avatar label={name} tone={isClient ? "client" : "admin"} />
)}
<div className="min-w-0 flex-1">
{!grouped && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-bold text-foreground">{name}</span>
{entityLabel && (
<span className="max-w-[160px] truncate rounded-full bg-muted px-2 py-0.5 text-[10px] text-muted-foreground">
{entityLabel}
</span>
)}
<span className="text-[10px] text-muted-foreground">{formatTime(at)}</span>
</div>
)}
<p className="mt-1 whitespace-pre-wrap text-sm leading-relaxed text-foreground">
{m.body}
</p>
</div>
</div>
</div>
);
})}
{items.map(({ message: m, at, showDivider, grouped }) => (
<MessageRow
key={m.id}
message={m}
at={at}
showDivider={showDivider}
grouped={grouped}
adminName={adminName}
adminAvatarUrl={adminAvatarUrl}
// Dentro un canale-fase, i messaggi storici su un task o un deliverable
// restano riconoscibili: il canale li raccoglie, il badge dice su cosa erano.
entityLabel={
activeChannel !== clientId ? index.entityLabel.get(m.entity_id) : undefined
}
// Si modifica solo ciò che si è scritto. Il server ricontrolla comunque:
// qui è solo per non mostrare un pulsante che verrebbe rifiutato.
canEdit={canEdit && m.author === "client"}
onEdit={onEdit}
/>
))}
</>
);
}
function Avatar({ label, tone }: { label: string; tone: "client" | "admin" }) {
/**
* Una riga del feed. È un componente a sé — e non JSX inline dentro la .map() —
* perché la modifica ha bisogno di stato per messaggio: bozza, salvataggio in
* corso, errore. Tenerlo qui evita di risollevarlo nel genitore, dove sarebbe
* una mappa id -> stato da riconciliare a ogni poll.
*/
function MessageRow({
message: m,
at,
showDivider,
grouped,
entityLabel,
adminName,
adminAvatarUrl,
canEdit,
onEdit,
}: {
message: ChatMessage;
at: Date;
showDivider: boolean;
grouped: boolean;
entityLabel?: string;
adminName: string;
adminAvatarUrl: string | null;
canEdit: boolean;
onEdit: (id: string, body: string) => Promise<boolean>;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(m.body);
const [saving, setSaving] = useState(false);
const [failed, setFailed] = useState(false);
const isClient = m.author === "client";
const name = isClient ? "Tu" : adminName;
// La bozza si riempie all'apertura, non con un effetto sincronizzato su m.body:
// così un poll che arriva mentre stai scrivendo non ti cancella quello che hai
// digitato.
function startEditing() {
setDraft(m.body);
setFailed(false);
setEditing(true);
}
async function save() {
const next = draft.trim();
if (!next || next === m.body) {
setEditing(false);
return;
}
setSaving(true);
const ok = await onEdit(m.id, next);
setSaving(false);
if (ok) setEditing(false);
else setFailed(true);
}
return (
<div>
{showDivider && (
<div className="my-4 flex items-center gap-3">
<div className="h-px flex-1 bg-border" />
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
{formatDayDivider(at)}
</span>
<div className="h-px flex-1 bg-border" />
</div>
)}
<div className={cn("group flex gap-3", grouped ? "mt-1" : "mt-5")}>
{grouped ? (
<div className="w-9 shrink-0" aria-hidden="true" />
) : (
<Avatar
label={name}
tone={isClient ? "client" : "admin"}
src={isClient ? null : adminAvatarUrl}
/>
)}
<div className="min-w-0 flex-1">
{!grouped && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-bold text-foreground">{name}</span>
{entityLabel && (
<span className="max-w-[160px] truncate rounded-full bg-muted px-2 py-0.5 text-[10px] text-muted-foreground">
{entityLabel}
</span>
)}
<span className="text-[10px] text-muted-foreground">{formatTime(at)}</span>
</div>
)}
{editing ? (
<div className="mt-1">
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void save();
}
if (e.key === "Escape") {
e.preventDefault();
setEditing(false);
}
}}
rows={2}
autoFocus
maxLength={2000}
aria-label="Modifica il messaggio"
className="w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:border-primary focus:outline-none"
/>
<div className="mt-1 flex items-center gap-3">
<button
type="button"
onClick={() => void save()}
disabled={saving}
className="text-[11px] font-semibold text-primary hover:underline disabled:opacity-50"
>
{saving ? "Salvo…" : "Salva"}
</button>
<button
type="button"
onClick={() => setEditing(false)}
className="text-[11px] font-medium text-muted-foreground hover:underline"
>
Annulla
</button>
<span className="text-[10px] text-muted-foreground">
Esc per annullare
</span>
{failed && (
<span className="text-[10px] text-destructive">
Non salvato, riprova
</span>
)}
</div>
</div>
) : (
<>
<p className="mt-1 whitespace-pre-wrap text-sm leading-relaxed text-foreground">
{m.body}
{m.edited_at && (
<span className="ml-1.5 text-[10px] text-muted-foreground">
(modificato)
</span>
)}
</p>
{canEdit && (
<button
type="button"
onClick={startEditing}
// Compare al passaggio del mouse, ma resta raggiungibile da
// tastiera e sempre visibile dove il puntatore non esiste —
// il portale si apre soprattutto dal telefono.
className="mt-0.5 text-[11px] font-medium text-muted-foreground opacity-0 transition-opacity hover:underline focus-visible:opacity-100 group-hover:opacity-100 [@media(hover:none)]:opacity-100"
>
Modifica
</button>
)}
</>
)}
</div>
</div>
</div>
);
}
function Avatar({
label,
tone,
src,
}: {
label: string;
tone: "client" | "admin";
src?: string | null;
}) {
// Un URL esterno può sparire o essere sbagliato: in quel caso si torna al
// monogramma invece di lasciare un cerchio vuoto.
const [broken, setBroken] = useState(false);
const showImage = !!src && !broken;
return (
<span
aria-hidden="true"
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-[11px] font-bold",
"flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full text-[11px] font-bold",
tone === "client"
? "bg-accent text-accent-foreground"
: "bg-primary text-primary-foreground"
)}
>
{monogram(label)}
{showImage ? (
// next/image richiederebbe di dichiarare gli host in next.config: qui
// l'URL lo incolla l'utente e non è noto in fase di build.
// eslint-disable-next-line @next/next/no-img-element
<img
src={src as string}
alt=""
className="h-full w-full object-cover"
onError={() => setBroken(true)}
/>
) : (
monogram(label)
)}
</span>
);
}