diff --git a/apps/dashboard/src/features/conversations/conversation-api.ts b/apps/dashboard/src/features/conversations/conversation-api.ts new file mode 100644 index 0000000..44ed190 --- /dev/null +++ b/apps/dashboard/src/features/conversations/conversation-api.ts @@ -0,0 +1,85 @@ +import type { ZodType } from 'zod'; +import { + ConversationsResponseSchema, + ConversationDetailResponseSchema, + ErrorResponseSchema, + SendReplyRequestSchema, + StartConversationRequestSchema, + StartConversationResponseSchema, + SuccessResponseSchema, + SuggestedReplyResponseSchema, + type ActiveChat, + type ConversationDetailResponse, + type SuccessResponse, +} from '../../../../../packages/api-contracts/src/index.js'; + +async function parseResponse(response: Response, schema: ZodType): Promise { + const payload: unknown = await response.json(); + if (!response.ok) { + const parsedError = ErrorResponseSchema.safeParse(payload); + throw new Error(parsedError.success ? parsedError.data.error : `Request failed (${response.status})`); + } + return schema.parse(payload); +} + +function conversationPath(id: string): string { + return `/api/conversations/${encodeURIComponent(id)}`; +} + +export interface ConversationApi { + list(): Promise; + detail(id: string): Promise; + suggestReply(id: string): Promise; + sendReply(id: string, message: string): Promise; + startConversation(username: string, message: string): Promise; +} + +export function createConversationApi(fetchImplementation?: typeof fetch): ConversationApi { + function request(input: RequestInfo | URL, init?: RequestInit): Promise { + return (fetchImplementation ?? globalThis.fetch)(input, init); + } + + return { + async list() { + return parseResponse(await request('/api/conversations'), ConversationsResponseSchema); + }, + async detail(id) { + return parseResponse(await request(conversationPath(id)), ConversationDetailResponseSchema); + }, + async suggestReply(id) { + const response = await parseResponse( + await request(`${conversationPath(id)}/suggest-reply`, { method: 'POST' }), + SuggestedReplyResponseSchema, + ); + return response.suggestedReply; + }, + async sendReply(id, message) { + const body = SendReplyRequestSchema.parse({ message: message.trim() }); + return parseResponse( + await request(`${conversationPath(id)}/send-reply`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + SuccessResponseSchema, + ); + }, + async startConversation(username, message) { + const body = StartConversationRequestSchema.parse({ + username: username.trim().replace(/^u\//i, ''), + message: message.trim(), + }); + const response = await parseResponse( + await request('/api/conversations/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + StartConversationResponseSchema, + ); + return response.chat; + }, + }; +} + +export const conversationApi = createConversationApi(); diff --git a/apps/dashboard/src/features/conversations/conversation-detail.tsx b/apps/dashboard/src/features/conversations/conversation-detail.tsx new file mode 100644 index 0000000..d29e2bd --- /dev/null +++ b/apps/dashboard/src/features/conversations/conversation-detail.tsx @@ -0,0 +1,162 @@ +import { useCallback, useRef, useState } from 'react'; +import type { ConversationDetailResponse } from '../../../../../packages/api-contracts/src/index.js'; +import { useConversationDetailQuery, useSendReplyMutation, useSuggestReplyMutation } from './conversation-queries.js'; +import { SendConfirmationDialog } from './send-confirmation-dialog.js'; + +interface ConversationDetailProps { + conversationId: string; + onClose(): void; +} + +function formatMessageTime(timestamp: number): string { + return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp); +} + +function MessageHistory({ conversation }: { conversation: ConversationDetailResponse }) { + if (!conversation.messages || conversation.messages.length === 0) { + return
No messages have been loaded for this conversation.
; + } + + return ( +
    + {conversation.messages.map((message) => ( +
  1. +
    + {message.isSelf ? 'You' : `u/${conversation.username}`} + +
    +

    {message.text}

    +
  2. + ))} +
+ ); +} + +function ConversationComposer({ conversation }: { conversation: ConversationDetailResponse }) { + const [draft, setDraft] = useState(''); + const [isConfirming, setIsConfirming] = useState(false); + const [wasSent, setWasSent] = useState(false); + const sendButtonRef = useRef(null); + const suggestionMutation = useSuggestReplyMutation(conversation.roomId ?? conversation.username); + const sendMutation = useSendReplyMutation(conversation.roomId ?? conversation.username); + const canSend = draft.trim().length > 0 && !sendMutation.isPending; + + const closeConfirmation = useCallback(() => { + setIsConfirming(false); + queueMicrotask(() => sendButtonRef.current?.focus()); + }, []); + + function suggestReply() { + setWasSent(false); + suggestionMutation.mutate(undefined, { onSuccess: (suggestion) => setDraft(suggestion) }); + } + + function sendReply() { + if (!canSend) return; + sendMutation.mutate(draft, { + onSuccess() { + setDraft(''); + setWasSent(true); + }, + onSettled: closeConfirmation, + }); + } + + return ( +
+
+

Reply

+ +
+ +