ui phase 10
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { ticketKeys } from '../keys';
|
||||
import type { TicketDetail } from '../types';
|
||||
|
||||
/**
|
||||
* The composer's "discard and retype" affordance on a failed bubble (§3.3) — removes it from the cached
|
||||
* thread so the caller can restore its text into the composer draft. Not a mutation: a message that never
|
||||
* left the client has nothing to tell the server.
|
||||
*/
|
||||
export function useDiscardFailedMessage() {
|
||||
const queryClient = useQueryClient();
|
||||
return (ticketId: number, clientMessageId: string): void => {
|
||||
const key = ticketKeys.detail(ticketId);
|
||||
queryClient.setQueryData<TicketDetail>(key, (current) =>
|
||||
current
|
||||
? { ...current, messages: current.messages.filter((m) => m.clientMessageId !== clientMessageId) }
|
||||
: current,
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ticketKeys } from '../keys';
|
||||
import { ticketsApi } from '../apis';
|
||||
import type { PostMessageResult, TicketDetail, TicketMessage } from '../types';
|
||||
import type { PostMessageResult, TicketDetail } from '../types';
|
||||
import { useTicketViewer } from './useTicketViewer';
|
||||
|
||||
interface PostMessageVars {
|
||||
@@ -10,53 +10,65 @@ interface PostMessageVars {
|
||||
clientMessageId: string;
|
||||
}
|
||||
|
||||
interface PostMessageContext {
|
||||
previous?: TicketDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* The optimistic message send — the interaction that must feel instant (phase §3.5).
|
||||
* The optimistic message send — the interaction that must feel instant, and a failure must never lose the
|
||||
* typed text (phase §3.3/§5 invariant).
|
||||
*
|
||||
* `onMutate` appends a **pending** bubble to `detail(id)` (after `cancelQueries` + a snapshot) so it shows
|
||||
* immediately with a "sending" state. `onError` **rolls the thread back to the snapshot** (removing the
|
||||
* pending bubble) and rejects — the composer keeps the typed draft and offers retry (never retype). `onSuccess`
|
||||
* replaces the pending bubble **by `clientMessageId`** with the server message (so it never double-renders).
|
||||
* `onSettled` invalidates the thread + the inbox lists (last-activity/unread move). The composer clears the
|
||||
* draft **only** in its own `onSuccess`.
|
||||
* `onMutate` is idempotent on `clientMessageId`: a **fresh** send appends a pending bubble; a **retry** (the
|
||||
* same id already sitting in the cache with `sendStatus: 'failed'`) flips it back to `sending` in place
|
||||
* instead of appending a duplicate — so retry and first-send are the same call. `onError` does **not** roll
|
||||
* the thread back — it flips the bubble to `sendStatus: 'failed'` and leaves it in the thread with its typed
|
||||
* body, so the failed-bubble UI (retry / discard-and-retype) always has something to act on. `onSuccess`
|
||||
* replaces the bubble **by `clientMessageId`** with the server message. `onSettled` invalidates the thread,
|
||||
* the inbox lists (last-activity/unread move), and the chrome unread-total badge.
|
||||
*/
|
||||
export function usePostMessage(ticketId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
const { role } = useTicketViewer();
|
||||
const key = ticketKeys.detail(ticketId);
|
||||
|
||||
return useMutation<PostMessageResult, unknown, PostMessageVars, PostMessageContext>({
|
||||
return useMutation<PostMessageResult, unknown, PostMessageVars>({
|
||||
mutationFn: ({ body, clientMessageId }) => ticketsApi.postMessage(ticketId, { body, clientMessageId }),
|
||||
|
||||
onMutate: async ({ body, clientMessageId }) => {
|
||||
const key = ticketKeys.detail(ticketId);
|
||||
await queryClient.cancelQueries({ queryKey: key });
|
||||
const previous = queryClient.getQueryData<TicketDetail>(key);
|
||||
if (previous) {
|
||||
const pending: TicketMessage = {
|
||||
id: null,
|
||||
clientMessageId,
|
||||
ticketId,
|
||||
body,
|
||||
authorRole: role,
|
||||
createdAt: new Date().toISOString(),
|
||||
isMine: true,
|
||||
sendStatus: 'sending',
|
||||
};
|
||||
queryClient.setQueryData<TicketDetail>(key, { ...previous, messages: [...previous.messages, pending] });
|
||||
}
|
||||
return { previous };
|
||||
const current = queryClient.getQueryData<TicketDetail>(key);
|
||||
if (!current) return;
|
||||
const alreadyPending = current.messages.some((m) => m.clientMessageId === clientMessageId);
|
||||
queryClient.setQueryData<TicketDetail>(key, {
|
||||
...current,
|
||||
messages: alreadyPending
|
||||
? current.messages.map((m) =>
|
||||
m.clientMessageId === clientMessageId ? { ...m, body, sendStatus: 'sending' as const } : m,
|
||||
)
|
||||
: [
|
||||
...current.messages,
|
||||
{
|
||||
id: null,
|
||||
clientMessageId,
|
||||
ticketId,
|
||||
body,
|
||||
authorRole: role,
|
||||
createdAt: new Date().toISOString(),
|
||||
isMine: true,
|
||||
sendStatus: 'sending' as const,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
|
||||
onError: (_err, _vars, context) => {
|
||||
if (context?.previous) queryClient.setQueryData(ticketKeys.detail(ticketId), context.previous);
|
||||
onError: (_err, { clientMessageId }) => {
|
||||
const current = queryClient.getQueryData<TicketDetail>(key);
|
||||
if (!current) return;
|
||||
queryClient.setQueryData<TicketDetail>(key, {
|
||||
...current,
|
||||
messages: current.messages.map((m) =>
|
||||
m.clientMessageId === clientMessageId ? { ...m, sendStatus: 'failed' as const } : m,
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
onSuccess: (result, { clientMessageId }) => {
|
||||
const key = ticketKeys.detail(ticketId);
|
||||
const current = queryClient.getQueryData<TicketDetail>(key);
|
||||
if (current) {
|
||||
queryClient.setQueryData<TicketDetail>(key, {
|
||||
@@ -71,8 +83,9 @@ export function usePostMessage(ticketId: number) {
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.detail(ticketId) });
|
||||
queryClient.invalidateQueries({ queryKey: key });
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.unreadTotal() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useIsAuthenticated } from '@/hooks';
|
||||
import { ticketsApi } from '../apis';
|
||||
import { ticketKeys } from '../keys';
|
||||
import { TICKETS_GC_TIME, TICKETS_LIST_STALE_TIME } from '../constants';
|
||||
|
||||
/**
|
||||
* The chrome support-entry unread badge (§3.1) — a single number summed across the caller's tickets, or
|
||||
* `null` when there's no signal to show (the real path, until REQ-059 lands). Callers must render the
|
||||
* badge only when this is a positive number — never a fake "0 unread" placeholder. Gated on
|
||||
* authentication, same posture as `useUnreadCount`.
|
||||
*/
|
||||
export function useSupportUnreadTotal(): number | null {
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
const { data } = useQuery({
|
||||
queryKey: ticketKeys.unreadTotal(),
|
||||
queryFn: () => ticketsApi.getUnreadTotal(),
|
||||
enabled: isAuthenticated,
|
||||
staleTime: TICKETS_LIST_STALE_TIME,
|
||||
gcTime: TICKETS_GC_TIME,
|
||||
});
|
||||
return data ?? null;
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ticketsApi } from '../apis';
|
||||
import { ticketKeys } from '../keys';
|
||||
import { TICKETS_GC_TIME, TICKET_THREAD_STALE_TIME } from '../constants';
|
||||
import { TICKETS_GC_TIME, TICKET_THREAD_REFETCH_INTERVAL, TICKET_THREAD_STALE_TIME } from '../constants';
|
||||
import { useTicketViewer } from './useTicketViewer';
|
||||
|
||||
/**
|
||||
* The full ticket thread (header + participants + messages, user view — internal messages already stripped).
|
||||
* A single cached `detail(id)` entry: the contract returns the whole thread in one call (no message
|
||||
* pagination). The viewer id (from `/me`, or the mock fallback) drives which bubbles are "mine".
|
||||
* `usePostMessage` mutates this same entry optimistically.
|
||||
* `usePostMessage` mutates this same entry optimistically. **Polls while mounted** (§3.2) — TanStack Query
|
||||
* scopes `refetchInterval` to active observers, so this only ticks while a thread screen is open; no
|
||||
* `refetchIntervalInBackground` (the global polling posture stays polite — §5).
|
||||
*/
|
||||
export function useTicket(ticketId: number | undefined) {
|
||||
const { userId } = useTicketViewer();
|
||||
@@ -18,5 +20,6 @@ export function useTicket(ticketId: number | undefined) {
|
||||
enabled: ticketId != null && ticketId > 0,
|
||||
staleTime: TICKET_THREAD_STALE_TIME,
|
||||
gcTime: TICKETS_GC_TIME,
|
||||
refetchInterval: TICKET_THREAD_REFETCH_INTERVAL,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ticketsApi } from '../apis';
|
||||
import { ticketKeys } from '../keys';
|
||||
import { TICKETS_GC_TIME, TICKET_THREAD_STALE_TIME } from '../constants';
|
||||
import { TICKETS_GC_TIME, TICKET_THREAD_REFETCH_INTERVAL, TICKET_THREAD_STALE_TIME } from '../constants';
|
||||
import type { TicketMessage } from '../types';
|
||||
import { useTicketViewer } from './useTicketViewer';
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useTicketViewer } from './useTicketViewer';
|
||||
* Just the messages of a thread — a `select` over the same `detail(id)` cache the header reads (mirrors the
|
||||
* f8 `useBookingSessions = select over detail`). One network fetch feeds both; the message list re-renders on
|
||||
* a new message without re-rendering the thread header. Optimistic sends mutate `detail(id)`, so the list
|
||||
* updates instantly.
|
||||
* updates instantly. Polls while mounted, same as `useTicket` (§3.2) — both observers share one cache entry.
|
||||
*/
|
||||
export function useTicketThread(ticketId: number | undefined) {
|
||||
const { userId } = useTicketViewer();
|
||||
@@ -19,6 +19,7 @@ export function useTicketThread(ticketId: number | undefined) {
|
||||
enabled: ticketId != null && ticketId > 0,
|
||||
staleTime: TICKET_THREAD_STALE_TIME,
|
||||
gcTime: TICKETS_GC_TIME,
|
||||
refetchInterval: TICKET_THREAD_REFETCH_INTERVAL,
|
||||
select: (detail): TicketMessage[] => detail.messages,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user