ui phase 10

This commit is contained in:
hamid
2026-07-19 17:13:32 +03:30
parent b638e25a0e
commit b4b8c9ea79
48 changed files with 1643 additions and 290 deletions
@@ -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,
});
}