81 lines
2.9 KiB
TypeScript
81 lines
2.9 KiB
TypeScript
'use client';
|
|
import { FunctionComponent, useState } from 'react';
|
|
import Box from '@mui/material/Box';
|
|
import Typography from '@mui/material/Typography';
|
|
import { useTranslations } from 'next-intl';
|
|
import { useDiscardFailedMessage, usePostMessage } from '@/services/tickets';
|
|
import type { TicketMessage } from '@/services/tickets/types';
|
|
import { makeClientMessageId } from './clientMessageId';
|
|
import MessageComposer from './MessageComposer';
|
|
import TicketMessageList from './TicketMessageList';
|
|
|
|
export interface TicketConversationPanelProps {
|
|
ticketId: number;
|
|
/** Closed tickets show a notice instead of the composer. */
|
|
closed: boolean;
|
|
}
|
|
|
|
/**
|
|
* The interactive body of a thread — the message list + the sticky composer, owning the one
|
|
* `usePostMessage` mutation + draft state both share (so a failed bubble's retry/discard and the
|
|
* composer's own send are the same optimistic pipeline, never two disconnected ones). Mounted **keyed by
|
|
* `ticketId`** from `TicketThreadScreen` so navigating thread→thread (the App Router reuses the `[id]`
|
|
* subtree) remounts this whole panel — the draft/in-flight/failed state never crosses tickets (§5).
|
|
* @component TicketConversationPanel
|
|
*/
|
|
const TicketConversationPanel: FunctionComponent<TicketConversationPanelProps> = ({ ticketId, closed }) => {
|
|
const t = useTranslations('tickets');
|
|
const [draft, setDraft] = useState('');
|
|
const postMessage = usePostMessage(ticketId);
|
|
const discardFailedMessage = useDiscardFailedMessage();
|
|
const sending = postMessage.isPending;
|
|
|
|
const submit = () => {
|
|
const body = draft.trim();
|
|
if (!body || sending || closed) return;
|
|
postMessage.mutate(
|
|
{ body, clientMessageId: makeClientMessageId() },
|
|
{ onSuccess: () => setDraft('') }, // clear the draft ONLY on server confirm (§3.5)
|
|
);
|
|
};
|
|
|
|
const retry = (message: TicketMessage) => {
|
|
if (!message.clientMessageId) return;
|
|
postMessage.mutate({ body: message.body, clientMessageId: message.clientMessageId });
|
|
};
|
|
|
|
const discard = (message: TicketMessage) => {
|
|
if (!message.clientMessageId) return;
|
|
discardFailedMessage(ticketId, message.clientMessageId);
|
|
setDraft(message.body); // a failure never loses typed text — restore it for editing (§3.3)
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<TicketMessageList ticketId={ticketId} onRetry={retry} onDiscard={discard} />
|
|
|
|
<Box
|
|
sx={{
|
|
position: 'sticky',
|
|
bottom: 0,
|
|
bgcolor: 'var(--bal-bg-default)',
|
|
borderTop: '1px solid',
|
|
borderColor: 'divider',
|
|
pt: 1,
|
|
pb: 0.5,
|
|
}}
|
|
>
|
|
{closed ? (
|
|
<Typography variant="body2" sx={{ textAlign: 'center', color: 'var(--bal-text-secondary)', py: 1 }}>
|
|
{t('closed_notice')}
|
|
</Typography>
|
|
) : (
|
|
<MessageComposer value={draft} onChange={setDraft} onSubmit={submit} sending={sending} />
|
|
)}
|
|
</Box>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default TicketConversationPanel;
|