feat(conversazioni): scrivere per primo, taggare il cliente, notificarlo via mail
Tre cose che mancavano all'inbox admin, tutte senza migration. 1. Dall'inbox era impossibile aprire una conversazione: getConversations() costruiva la lista dai commenti, quindi un cliente compariva solo dopo aver scritto lui. Ora la lista parte da `clients` e i commenti la arricchiscono. Ordine: prima chi ha scritto (per recenza), in coda i clienti muti in alfabetico, cosi' l'inbox resta un inbox. 2. Menzioni «@Nome», rinviate dalla chat a canali. Modello senza schema: il tag si riconosce confrontando il testo con i nomi noti del cliente (nome intero, nome di battesimo, brand), insensibile ad accenti e maiuscole. Il body resta quello che l'admin ha scritto, quindi la menzione sopravvive alla modifica di un messaggio e resta leggibile ovunque finisca, mail compresa. Confini di parola su ENTRAMBI i lati: senza quello a sinistra, «scrivimi a mario@teckell.it» conteneva un tag «@Teckell». 3. Un tag manda una mail. E' l'unico messaggio che esce dal portale: per il resto il cliente entra quando gli pare, ma il tag e' la dichiarazione che quel messaggio non puo' aspettare il prossimo accesso. Nessuno scheduler -- parte dalla stessa azione che scrive il messaggio, fuori transazione: se Resend e' giu' il messaggio in chat resta comunque scritto. Destinatari: whitelist OTP + email della scheda, deduplicati. Con zero indirizzi il compositore lo dice PRIMA, invece di lasciar credere che sia partita una mail che non partira'. Il pulsante della mail punta a `?chat=<canale>`, validato lato server e passato come prop: leggerlo nel browser vorrebbe dire renderizzare il pannello chiuso e riaprirlo dopo l'idratazione. La casella di risposta diventa controllata (ReplyComposer): il suggerimento del tag deve leggere il testo mentre lo scrivi e reinserirlo al caret giusto. Invio manda, Shift+Invio va a capo -- come nel pannello del cliente. Verificato: `npm run build` e `eslint` puliti, parser delle menzioni provato su 9 casi. NON verificato a schermo ne' contro il DB. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,13 +5,13 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ExternalLink, IdCard, MessageSquare } from "lucide-react";
|
||||
import { SearchInput } from "@/components/ui/SearchInput";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MentionText } from "@/components/ui/MentionText";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ReplyComposer } from "./ReplyComposer";
|
||||
import {
|
||||
editConversationMessage,
|
||||
markConversationRead,
|
||||
replyToConversation,
|
||||
} from "@/app/admin/conversazioni/actions";
|
||||
import type {
|
||||
ConversationSummary,
|
||||
@@ -93,7 +93,7 @@ export function ConversationsView({
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center gap-3 text-muted-foreground">
|
||||
<MessageSquare className="w-10 h-10 opacity-40" strokeWidth={1.5} />
|
||||
<p className="text-sm">Nessun messaggio dai clienti ancora.</p>
|
||||
<p className="text-sm">Scegli un cliente per aprire la conversazione.</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -126,18 +126,28 @@ function ConversationListItem({
|
||||
>
|
||||
{conv.name}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-muted-foreground shrink-0">
|
||||
{formatListTime(conv.lastMessageAt)}
|
||||
</span>
|
||||
{conv.lastMessageAt && (
|
||||
<span className="text-[10px] font-mono text-muted-foreground shrink-0">
|
||||
{formatListTime(conv.lastMessageAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground truncate">
|
||||
{conv.lastMessageAuthor === "admin" && (
|
||||
<span className="text-muted-foreground/70">Tu: </span>
|
||||
{conv.lastMessageAt ? (
|
||||
<>
|
||||
{conv.lastMessageAuthor === "admin" && (
|
||||
<span className="text-muted-foreground/70">Tu: </span>
|
||||
)}
|
||||
{conv.lastMessage}
|
||||
</>
|
||||
) : (
|
||||
<span className="italic text-muted-foreground/70">
|
||||
Nessun messaggio — scrivi tu per primo
|
||||
</span>
|
||||
)}
|
||||
{conv.lastMessage}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<EntityBadge label={conv.lastEntityLabel} />
|
||||
{conv.lastMessageAt ? <EntityBadge label={conv.lastEntityLabel} /> : <span />}
|
||||
{conv.unread && (
|
||||
<span className="flex items-center gap-1.5 shrink-0">
|
||||
{conv.unreadCount > 1 && (
|
||||
@@ -272,34 +282,29 @@ function ActiveThread({ thread }: { thread: ConversationThread }) {
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto p-6 bg-muted/20 space-y-4">
|
||||
{visibleMessages.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-10">
|
||||
Nessun messaggio in questo canale.
|
||||
Nessun messaggio in questo canale. Scrivi tu per primo.
|
||||
</p>
|
||||
) : (
|
||||
visibleMessages.map((m) => (
|
||||
<MessageBubble key={m.id} message={m} clientId={thread.clientId} />
|
||||
<MessageBubble
|
||||
key={m.id}
|
||||
message={m}
|
||||
clientId={thread.clientId}
|
||||
mentionCandidates={thread.mentionCandidates}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reply box — il canale viaggia col messaggio */}
|
||||
<form
|
||||
action={replyToConversation.bind(null, thread.clientId)}
|
||||
className="p-4 border-t border-border bg-card flex items-end gap-3"
|
||||
>
|
||||
<input type="hidden" name="entity_type" value={isGeneral ? "general" : "phase"} />
|
||||
<input type="hidden" name="entity_id" value={activeChannel} />
|
||||
<Textarea
|
||||
name="body"
|
||||
rows={2}
|
||||
required
|
||||
placeholder={`Rispondi in ${activeLabel}...`}
|
||||
aria-label={`Rispondi nel canale ${activeLabel}`}
|
||||
className="flex-1 resize-none"
|
||||
/>
|
||||
<Button type="submit" size="sm" className="shrink-0">
|
||||
Invia
|
||||
</Button>
|
||||
</form>
|
||||
<ReplyComposer
|
||||
clientId={thread.clientId}
|
||||
channelKey={activeChannel}
|
||||
channelLabel={activeLabel}
|
||||
isGeneral={isGeneral}
|
||||
mentionCandidates={thread.mentionCandidates}
|
||||
notifyEmailCount={thread.notifyEmailCount}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -307,9 +312,11 @@ function ActiveThread({ thread }: { thread: ConversationThread }) {
|
||||
function MessageBubble({
|
||||
message,
|
||||
clientId,
|
||||
mentionCandidates,
|
||||
}: {
|
||||
message: ConversationThread["messages"][number];
|
||||
clientId: string;
|
||||
mentionCandidates: string[];
|
||||
}) {
|
||||
const isAdmin = message.author === "admin";
|
||||
// Solo task e deliverable: per una fase l'etichetta ripeterebbe il tab attivo.
|
||||
@@ -391,7 +398,17 @@ function MessageBubble({
|
||||
/>
|
||||
) : (
|
||||
<p className="text-xs leading-relaxed whitespace-pre-wrap">
|
||||
{message.body}
|
||||
<MentionText
|
||||
body={message.body}
|
||||
candidates={mentionCandidates}
|
||||
// Sulla bolla admin il fondo è `bg-primary`: un chip tenue ci
|
||||
// sparirebbe sopra, quindi si scava invece di colorare.
|
||||
mentionClassName={
|
||||
isAdmin
|
||||
? "rounded bg-primary-foreground/20 px-1 font-semibold"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useRef, useState, useTransition } from "react";
|
||||
import { AtSign } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { replyToConversation } from "@/app/admin/conversazioni/actions";
|
||||
|
||||
/**
|
||||
* Casella di risposta dell'inbox, con il tag del cliente.
|
||||
*
|
||||
* È controllata — e non più un `<form action>` con una textarea libera — perché
|
||||
* il suggerimento del tag deve leggere il testo mentre lo scrivi e riscriverlo
|
||||
* al punto giusto. Il canale continua a viaggiare col messaggio: `entity_id` è
|
||||
* il canale aperto, `entity_type` si deriva da esso.
|
||||
*/
|
||||
export function ReplyComposer({
|
||||
clientId,
|
||||
channelKey,
|
||||
channelLabel,
|
||||
isGeneral,
|
||||
mentionCandidates,
|
||||
notifyEmailCount,
|
||||
}: {
|
||||
clientId: string;
|
||||
channelKey: string;
|
||||
channelLabel: string;
|
||||
isGeneral: boolean;
|
||||
/** I nomi con cui si può taggare questo cliente, dal più completo al più corto. */
|
||||
mentionCandidates: string[];
|
||||
/** Quante mail partirebbero davvero con un tag. Zero = si avvisa, non si tace. */
|
||||
notifyEmailCount: number;
|
||||
}) {
|
||||
const [body, setBody] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sending, startSending] = useTransition();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Posizione dell'ultima "@" ancora aperta e testo digitato dopo: null quando
|
||||
// non c'è nessun tag in corso di scrittura.
|
||||
const [trigger, setTrigger] = useState<{ at: number; query: string } | null>(null);
|
||||
|
||||
const suggestions = useMemo(() => {
|
||||
if (!trigger) return [];
|
||||
const q = trigger.query.toLowerCase();
|
||||
// Il nome intero per primo: è quello che si vuole quasi sempre.
|
||||
return mentionCandidates.filter((c) => c.toLowerCase().startsWith(q)).slice(0, 4);
|
||||
}, [trigger, mentionCandidates]);
|
||||
|
||||
const open = suggestions.length > 0;
|
||||
|
||||
function syncTrigger(value: string, caret: number) {
|
||||
const before = value.slice(0, caret);
|
||||
const at = before.lastIndexOf("@");
|
||||
if (at === -1) return setTrigger(null);
|
||||
// Una "@" vale solo a inizio parola: "email@dominio.it" non apre il menu.
|
||||
const prev = at > 0 ? before[at - 1] : " ";
|
||||
if (!/\s/.test(prev)) return setTrigger(null);
|
||||
const query = before.slice(at + 1);
|
||||
// Un tag non attraversa un a capo, e oltre il nome più lungo non c'è più
|
||||
// niente da suggerire.
|
||||
if (query.includes("\n")) return setTrigger(null);
|
||||
setTrigger({ at, query });
|
||||
}
|
||||
|
||||
function insert(candidate: string) {
|
||||
if (!trigger) return;
|
||||
const el = textareaRef.current;
|
||||
const caret = el?.selectionStart ?? body.length;
|
||||
const next = `${body.slice(0, trigger.at)}@${candidate} ${body.slice(caret)}`;
|
||||
const nextCaret = trigger.at + candidate.length + 2;
|
||||
setBody(next);
|
||||
setTrigger(null);
|
||||
requestAnimationFrame(() => {
|
||||
el?.focus();
|
||||
el?.setSelectionRange(nextCaret, nextCaret);
|
||||
});
|
||||
}
|
||||
|
||||
/** Il pulsante @: inserisce la chiocciola e apre subito il menu. */
|
||||
function startMention() {
|
||||
const el = textareaRef.current;
|
||||
const caret = el?.selectionStart ?? body.length;
|
||||
const needsSpace = caret > 0 && !/\s/.test(body[caret - 1]);
|
||||
const prefix = needsSpace ? " @" : "@";
|
||||
const next = `${body.slice(0, caret)}${prefix}${body.slice(caret)}`;
|
||||
const nextCaret = caret + prefix.length;
|
||||
setBody(next);
|
||||
setTrigger({ at: nextCaret - 1, query: "" });
|
||||
requestAnimationFrame(() => {
|
||||
el?.focus();
|
||||
el?.setSelectionRange(nextCaret, nextCaret);
|
||||
});
|
||||
}
|
||||
|
||||
function send() {
|
||||
const text = body.trim();
|
||||
if (!text || sending) return;
|
||||
const fd = new FormData();
|
||||
fd.set("entity_type", isGeneral ? "general" : "phase");
|
||||
fd.set("entity_id", channelKey);
|
||||
fd.set("body", text);
|
||||
setError(null);
|
||||
startSending(async () => {
|
||||
try {
|
||||
await replyToConversation(clientId, fd);
|
||||
setBody("");
|
||||
setTrigger(null);
|
||||
} catch {
|
||||
setError("Messaggio non inviato, riprova");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-border bg-card p-4">
|
||||
<div className="relative flex items-end gap-3">
|
||||
{open && (
|
||||
<ul
|
||||
role="listbox"
|
||||
aria-label="Tagga il cliente"
|
||||
className="absolute bottom-full left-0 z-20 mb-2 w-64 overflow-hidden rounded-lg border border-border bg-card shadow-lg"
|
||||
>
|
||||
{suggestions.map((candidate, i) => (
|
||||
<li key={candidate}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={i === 0}
|
||||
// onMouseDown e non onClick: il click toglierebbe il fuoco alla
|
||||
// textarea prima di leggerne il caret, e l'inserimento finirebbe
|
||||
// nel posto sbagliato.
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
insert(candidate);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-muted",
|
||||
i === 0 && "bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<AtSign className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate font-medium text-foreground">{candidate}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={body}
|
||||
onChange={(e) => {
|
||||
setBody(e.target.value);
|
||||
syncTrigger(e.target.value, e.target.selectionStart ?? e.target.value.length);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
const el = e.currentTarget;
|
||||
syncTrigger(el.value, el.selectionStart ?? el.value.length);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (open && (e.key === "Enter" || e.key === "Tab")) {
|
||||
e.preventDefault();
|
||||
insert(suggestions[0]);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape" && open) {
|
||||
e.preventDefault();
|
||||
setTrigger(null);
|
||||
return;
|
||||
}
|
||||
// Invio manda, Shift+Invio va a capo: il comportamento che il
|
||||
// pannello del cliente ha già.
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
}}
|
||||
rows={2}
|
||||
placeholder={`Rispondi in ${channelLabel}...`}
|
||||
aria-label={`Rispondi nel canale ${channelLabel}`}
|
||||
className="flex-1 resize-none"
|
||||
/>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end gap-2">
|
||||
<Button type="button" size="sm" onClick={send} disabled={sending || !body.trim()}>
|
||||
{sending ? "Invio…" : "Invia"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={startMention}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-semibold text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<AtSign className="h-3 w-3" /> Tagga
|
||||
</button>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{notifyEmailCount > 0
|
||||
? `Un tag gli manda una mail (${notifyEmailCount} ${notifyEmailCount === 1 ? "indirizzo" : "indirizzi"}).`
|
||||
: "Nessuna email a registro: il tag resta solo in chat."}
|
||||
</span>
|
||||
{error && <span className="text-[11px] text-destructive">{error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -58,7 +58,10 @@ export async function InboxBand() {
|
||||
{conv.brand_name}
|
||||
</h3>
|
||||
<span className="text-[10px] text-muted-foreground font-mono tabular-nums shrink-0">
|
||||
{relativeTime(conv.lastMessageAt)}
|
||||
{/* `unread` implica almeno un messaggio: il null non capita,
|
||||
ma il tipo lo ammette da quando la lista include anche i
|
||||
clienti con cui non si è ancora scritto. */}
|
||||
{conv.lastMessageAt ? relativeTime(conv.lastMessageAt) : ""}
|
||||
</span>
|
||||
{conv.unreadCount > 1 && (
|
||||
<span className="text-[10px] font-bold text-emerald-700 dark:text-emerald-400 shrink-0">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ClientView } from '@/lib/client-view';
|
||||
import { mentionCandidates } from '@/lib/mentions';
|
||||
import type { ChatData } from '@/lib/chat-channels';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { PhaseTimeline } from './phase-timeline';
|
||||
@@ -23,9 +24,18 @@ interface ClientDashboardProps {
|
||||
embedded?: boolean;
|
||||
/** Anteprima admin: sola lettura, approvazioni e chat disattivate. */
|
||||
preview?: boolean;
|
||||
/** Canale da aprire all'arrivo (`?chat=`), gia' validato dalla pagina. */
|
||||
initialChatChannel?: string | null;
|
||||
}
|
||||
|
||||
export function ClientDashboard({ view, token, chat, embedded = false, preview = false }: ClientDashboardProps) {
|
||||
export function ClientDashboard({
|
||||
view,
|
||||
token,
|
||||
chat,
|
||||
embedded = false,
|
||||
preview = false,
|
||||
initialChatChannel = null,
|
||||
}: ClientDashboardProps) {
|
||||
// Determine payment display mode based on active offers.
|
||||
// Solo i retainer ATTIVI cambiano la modalità: uno sospeso continuerebbe
|
||||
// altrimenti a intestare i pagamenti "Totale Pagamento Mensile" e a
|
||||
@@ -43,7 +53,11 @@ export function ClientDashboard({ view, token, chat, embedded = false, preview =
|
||||
|
||||
return (
|
||||
<PreviewProvider preview={preview}>
|
||||
<ChatProvider phases={view.phases} clientId={view.client.id}>
|
||||
<ChatProvider
|
||||
phases={view.phases}
|
||||
clientId={view.client.id}
|
||||
initialChannel={initialChatChannel}
|
||||
>
|
||||
<div className={embedded ? "" : "min-h-screen bg-background"}>
|
||||
{/* Header portale — iamcavalli · Client Portal | brand | area protetta */}
|
||||
{!embedded && (
|
||||
@@ -161,7 +175,11 @@ export function ClientDashboard({ view, token, chat, embedded = false, preview =
|
||||
</footer>
|
||||
|
||||
{/* Floating chat panel — FAB + slide-in panel */}
|
||||
<ChatPanel token={token} chat={chat} />
|
||||
<ChatPanel
|
||||
token={token}
|
||||
chat={chat}
|
||||
mentionCandidates={mentionCandidates(view.client)}
|
||||
/>
|
||||
</div>
|
||||
</ChatProvider>
|
||||
</PreviewProvider>
|
||||
|
||||
@@ -10,12 +10,13 @@ import {
|
||||
type ChatData,
|
||||
type ChatMessage,
|
||||
} from "@/lib/chat-channels";
|
||||
import { MentionText } from "@/components/ui/MentionText";
|
||||
import { useChatContext } from "./ChatProvider";
|
||||
import { usePreview } from "./PreviewProvider";
|
||||
|
||||
// TODO: la mail di notifica al cliente ("hai un messaggio non letto") è un giro a
|
||||
// sé: serve uno scheduler, che nel repo non esiste ancora. Hook point naturale:
|
||||
// una query su client_channel_reads incrociata con comments.author = 'admin'.
|
||||
// La notifica via mail al cliente esiste, ma solo per i messaggi in cui l'admin
|
||||
// lo tagga (@Nome): parte da replyToConversation, senza scheduler. Una notifica
|
||||
// per OGNI messaggio non letto sarebbe un'altra cosa e richiederebbe un job.
|
||||
|
||||
const POLL_MS = 20_000;
|
||||
/** Oltre questo scarto due messaggi dello stesso autore non si raggruppano più. */
|
||||
@@ -60,7 +61,16 @@ function monogram(label: string): string {
|
||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
}
|
||||
|
||||
export function ChatPanel({ token, chat }: { token: string; chat: ChatData }) {
|
||||
export function ChatPanel({
|
||||
token,
|
||||
chat,
|
||||
mentionCandidates,
|
||||
}: {
|
||||
token: string;
|
||||
chat: ChatData;
|
||||
/** I nomi con cui l'admin può taggare questo cliente — vedi lib/mentions.ts. */
|
||||
mentionCandidates: string[];
|
||||
}) {
|
||||
const {
|
||||
isOpen,
|
||||
expanded,
|
||||
@@ -495,6 +505,7 @@ export function ChatPanel({ token, chat }: { token: string; chat: ChatData }) {
|
||||
clientId={clientId}
|
||||
adminName={adminName}
|
||||
adminAvatarUrl={adminAvatarUrl}
|
||||
mentionCandidates={mentionCandidates}
|
||||
canEdit={!preview}
|
||||
onEdit={applyEdit}
|
||||
/>
|
||||
@@ -605,6 +616,7 @@ function MessageList({
|
||||
clientId,
|
||||
adminName,
|
||||
adminAvatarUrl,
|
||||
mentionCandidates,
|
||||
canEdit,
|
||||
onEdit,
|
||||
}: {
|
||||
@@ -614,6 +626,7 @@ function MessageList({
|
||||
clientId: string;
|
||||
adminName: string;
|
||||
adminAvatarUrl: string | null;
|
||||
mentionCandidates: string[];
|
||||
/** Falso in anteprima admin: si guarda, non si scrive. */
|
||||
canEdit: boolean;
|
||||
onEdit: (id: string, body: string) => Promise<boolean>;
|
||||
@@ -647,6 +660,7 @@ function MessageList({
|
||||
grouped={grouped}
|
||||
adminName={adminName}
|
||||
adminAvatarUrl={adminAvatarUrl}
|
||||
mentionCandidates={mentionCandidates}
|
||||
// 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={
|
||||
@@ -676,6 +690,7 @@ function MessageRow({
|
||||
entityLabel,
|
||||
adminName,
|
||||
adminAvatarUrl,
|
||||
mentionCandidates,
|
||||
canEdit,
|
||||
onEdit,
|
||||
}: {
|
||||
@@ -686,6 +701,7 @@ function MessageRow({
|
||||
entityLabel?: string;
|
||||
adminName: string;
|
||||
adminAvatarUrl: string | null;
|
||||
mentionCandidates: string[];
|
||||
canEdit: boolean;
|
||||
onEdit: (id: string, body: string) => Promise<boolean>;
|
||||
}) {
|
||||
@@ -803,7 +819,7 @@ function MessageRow({
|
||||
) : (
|
||||
<>
|
||||
<p className="mt-1 whitespace-pre-wrap text-sm leading-relaxed text-foreground">
|
||||
{m.body}
|
||||
<MentionText body={m.body} candidates={mentionCandidates} />
|
||||
{m.edited_at && (
|
||||
<span className="ml-1.5 text-[10px] text-muted-foreground">
|
||||
(modificato)
|
||||
|
||||
@@ -36,16 +36,31 @@ export function ChatProvider({
|
||||
children,
|
||||
phases,
|
||||
clientId,
|
||||
initialChannel,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
phases: ClientView["phases"];
|
||||
clientId: string;
|
||||
/**
|
||||
* Canale richiesto da fuori con `?chat=<key>` — è così che il pulsante della
|
||||
* mail di tag porta il cliente dentro la conversazione giusta invece che sulla
|
||||
* dashboard, dove la chat resterebbe chiusa e il messaggio non letto.
|
||||
*
|
||||
* Arriva dal server, già validato: leggerlo qui da `window.location` vorrebbe
|
||||
* dire aprire il pannello in un effetto, cioè renderizzarlo chiuso e riaprirlo
|
||||
* subito dopo l'idratazione.
|
||||
*/
|
||||
initialChannel?: string | null;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
// Un canale richiesto vale anche come "apri il pannello": chi arriva da quel
|
||||
// link sta venendo a leggere un messaggio, non a guardare la timeline.
|
||||
const [isOpen, setIsOpen] = useState(!!initialChannel);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
// Il canale "Generale" è identificato dall'id del cliente: stessa convenzione
|
||||
// di comments.entity_id, così non serve tradurre nulla in scrittura.
|
||||
const [activeChannel, setActiveChannel] = useState<string>(clientId);
|
||||
const [activeChannel, setActiveChannel] = useState<string>(
|
||||
initialChannel ?? clientId
|
||||
);
|
||||
|
||||
const openChat = useCallback(
|
||||
(phaseId?: string) => {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Fragment } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { splitMentions } from "@/lib/mentions";
|
||||
|
||||
/**
|
||||
* Corpo di un messaggio di chat con le menzioni evidenziate.
|
||||
*
|
||||
* Sostituisce `{m.body}` dentro il paragrafo: mantiene `whitespace-pre-wrap` sul
|
||||
* genitore, quindi gli a capo continuano a funzionare senza dover spezzare il
|
||||
* testo in righe qui dentro.
|
||||
*/
|
||||
export function MentionText({
|
||||
body,
|
||||
candidates,
|
||||
mentionClassName,
|
||||
}: {
|
||||
body: string;
|
||||
candidates: string[];
|
||||
/** Il contrasto cambia col fondo della bolla: lo decide chi la disegna. */
|
||||
mentionClassName?: string;
|
||||
}) {
|
||||
const parts = splitMentions(body, candidates);
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) =>
|
||||
part.type === "mention" ? (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
"rounded px-1 font-semibold",
|
||||
mentionClassName ??
|
||||
"bg-primary/10 text-primary dark:bg-primary/20"
|
||||
)}
|
||||
>
|
||||
{part.value}
|
||||
</span>
|
||||
) : (
|
||||
<Fragment key={i}>{part.value}</Fragment>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user