feat(dashboard): add conversations workspace

This commit is contained in:
jjbrunton committed 2026-08-08 15:04:04 +01:00
1 parent 69d560d5c5
commit 4c20a83c89
8 files changed
+840

No files matched your search

@@ -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<T>(response: Response, schema: ZodType<T>): Promise<T> {
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<ActiveChat[]>;
detail(id: string): Promise<ConversationDetailResponse>;
suggestReply(id: string): Promise<string>;
sendReply(id: string, message: string): Promise<SuccessResponse>;
startConversation(username: string, message: string): Promise<ActiveChat>;
}
export function createConversationApi(fetchImplementation?: typeof fetch): ConversationApi {
function request(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
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();
@@ -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 <div className="conversation-empty-detail">No messages have been loaded for this conversation.</div>;
}
return (
<ol aria-label="Message history" className="conversation-messages">
{conversation.messages.map((message) => (
<li className={message.isSelf ? 'is-self' : 'is-recipient'} key={`${message.timestamp}-${message.sender}-${message.text}`}>
<div>
<strong>{message.isSelf ? 'You' : `u/${conversation.username}`}</strong>
<time dateTime={new Date(message.timestamp).toISOString()}>{formatMessageTime(message.timestamp)}</time>
</div>
<p>{message.text}</p>
</li>
))}
</ol>
);
}
function ConversationComposer({ conversation }: { conversation: ConversationDetailResponse }) {
const [draft, setDraft] = useState('');
const [isConfirming, setIsConfirming] = useState(false);
const [wasSent, setWasSent] = useState(false);
const sendButtonRef = useRef<HTMLButtonElement>(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 (
<section aria-labelledby="reply-composer-heading" className="conversation-composer">
<div className="conversation-section-heading">
<h3 id="reply-composer-heading">Reply</h3>
<button disabled={suggestionMutation.isPending} onClick={suggestReply} type="button">
{suggestionMutation.isPending ? 'Generating…' : 'Suggest reply'}
</button>
</div>
<label className="sr-only" htmlFor="conversation-reply">Reply to u/{conversation.username}</label>
<textarea
id="conversation-reply"
onChange={(event) => {
setDraft(event.target.value);
setWasSent(false);
}}
placeholder="Write or generate a reply"
rows={5}
value={draft}
/>
{suggestionMutation.isError ? <div className="conversation-inline-error" role="alert">{suggestionMutation.error.message}</div> : null}
{sendMutation.isError ? <div className="conversation-inline-error" role="alert">{sendMutation.error.message}</div> : null}
{wasSent ? (
<div aria-label="Reply sent" className="conversation-inline-success" role="status">Reply sent to u/{conversation.username}</div>
) : null}
<div className="conversation-actions">
<span>{draft.length} characters</span>
<button
aria-label="Review and send reply"
className="conversation-primary"
disabled={!canSend}
onClick={() => setIsConfirming(true)}
ref={sendButtonRef}
type="button"
>
Review and send
</button>
</div>
{isConfirming ? (
<SendConfirmationDialog
draft={draft.trim()}
isPending={sendMutation.isPending}
onCancel={closeConfirmation}
onConfirm={sendReply}
/>
) : null}
</section>
);
}
export function ConversationDetail({ conversationId, onClose }: ConversationDetailProps) {
const detailQuery = useConversationDetailQuery(conversationId);
if (detailQuery.isPending) {
return <div aria-label="Loading conversation" className="conversation-detail-state" role="status">Loading conversation…</div>;
}
if (detailQuery.isError) {
return (
<div className="conversation-detail-state conversation-inline-error" role="alert">
<strong>Conversation unavailable</strong>
<span>{detailQuery.error.message}</span>
<button onClick={() => void detailQuery.refetch()} type="button">Retry conversation</button>
</div>
);
}
const conversation = detailQuery.data;
return (
<article className="conversation-detail">
<header>
<div>
<span className="conversation-eyebrow">Conversation</span>
<h2>u/{conversation.username}</h2>
{conversation.unread ? <span className="conversation-badge">Unread</span> : null}
</div>
<button aria-label="Close conversation" onClick={onClose} type="button">Close</button>
</header>
{conversation.syncInProgress ? (
<div className="conversation-sync-notice" role="status">
A {conversation.syncInProgress.kind ?? 'chat'} sync is in progress. Messages may be stale.
</div>
) : null}
{conversation.triggeringPost ? (
<a
aria-label={conversation.triggeringPost.title ?? `Open triggering post ${conversation.triggeringPost.id}`}
className="triggering-post"
href={conversation.triggeringPost.url}
rel="noreferrer"
target="_blank"
>
<span>Started from {conversation.triggeringPost.subreddit ? `r/${conversation.triggeringPost.subreddit}` : 'Reddit'}</span>
<strong>{conversation.triggeringPost.title ?? conversation.triggeringPost.id}</strong>
</a>
) : null}
<MessageHistory conversation={conversation} />
<ConversationComposer conversation={conversation} />
</article>
);
}
@@ -0,0 +1,47 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { conversationApi } from './conversation-api.js';
export const conversationQueryKeys = {
all: ['conversations'] as const,
detail: (id: string) => ['conversations', 'detail', id] as const,
};
export function useConversationsQuery() {
return useQuery({ queryKey: conversationQueryKeys.all, queryFn: conversationApi.list });
}
export function useConversationDetailQuery(id: string | null) {
return useQuery({
queryKey: conversationQueryKeys.detail(id ?? ''),
queryFn: () => conversationApi.detail(id ?? ''),
enabled: id !== null,
});
}
export function useSuggestReplyMutation(id: string) {
return useMutation({ mutationFn: () => conversationApi.suggestReply(id) });
}
export function useSendReplyMutation(id: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (message: string) => conversationApi.sendReply(id, message),
async onSuccess() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all }),
queryClient.invalidateQueries({ queryKey: conversationQueryKeys.detail(id) }),
]);
},
});
}
export function useStartConversationMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ username, message }: { username: string; message: string }) =>
conversationApi.startConversation(username, message),
async onSuccess() {
await queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all });
},
});
}
@@ -0,0 +1,151 @@
import { useMemo, useState } from 'react';
import type { ActiveChat } from '../../../../../packages/api-contracts/src/index.js';
import { ConversationDetail } from './conversation-detail.js';
import { useConversationsQuery } from './conversation-queries.js';
import { StartConversationForm } from './start-conversation-form.js';
import './conversations.css';
type ConversationFilter = 'all' | 'unread' | 'awaiting-reply';
export interface ConversationsPageProps {
selectedConversationId?: string | null;
onSelectedConversationChange?(conversationId: string | null): void;
}
function conversationId(conversation: ActiveChat): string {
return conversation.roomId ?? conversation.username;
}
function matchesFilter(conversation: ActiveChat, filter: ConversationFilter): boolean {
if (filter === 'unread') return conversation.unread;
if (filter === 'awaiting-reply') return conversation.isFromUs;
return true;
}
function ConversationListItem({ conversation, isSelected, onSelect }: {
conversation: ActiveChat;
isSelected: boolean;
onSelect(): void;
}) {
const state = conversation.unread ? 'Unread' : conversation.isFromUs ? 'Awaiting reply' : 'Up to date';
return (
<li>
<button
aria-label={`u/${conversation.username}, ${state}`}
aria-pressed={isSelected}
className={isSelected ? 'conversation-list-item is-selected' : 'conversation-list-item'}
onClick={onSelect}
type="button"
>
<span className="conversation-list-heading"><strong>u/{conversation.username}</strong><small>{state}</small></span>
<span>{conversation.lastMessageSnippet || 'No message preview'}</span>
</button>
</li>
);
}
export function ConversationsPage({ selectedConversationId, onSelectedConversationChange }: ConversationsPageProps) {
const conversationsQuery = useConversationsQuery();
const [internalSelectedId, setInternalSelectedId] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [filter, setFilter] = useState<ConversationFilter>('all');
const [isStartingConversation, setIsStartingConversation] = useState(false);
const activeConversationId = selectedConversationId !== undefined ? selectedConversationId : internalSelectedId;
const visibleConversations = useMemo(() => {
const normalizedSearch = search.trim().toLowerCase();
return (conversationsQuery.data ?? []).filter((conversation) => {
const matchesSearch = normalizedSearch.length === 0
|| conversation.username.toLowerCase().includes(normalizedSearch)
|| conversation.lastMessageSnippet.toLowerCase().includes(normalizedSearch);
return matchesSearch && matchesFilter(conversation, filter);
});
}, [conversationsQuery.data, filter, search]);
function selectConversation(id: string | null) {
if (selectedConversationId === undefined) setInternalSelectedId(id);
onSelectedConversationChange?.(id);
}
return (
<div className="conversations-page">
<header className="conversations-header">
<div>
<span className="conversation-eyebrow">Inbox</span>
<h1>Conversations</h1>
<p>Review replies, draft carefully, and send only after confirmation.</p>
</div>
<button className="conversation-primary" onClick={() => setIsStartingConversation((current) => !current)} type="button">
{isStartingConversation ? 'Close new conversation' : 'New conversation'}
</button>
</header>
{isStartingConversation ? (
<section aria-labelledby="new-conversation-heading" className="new-conversation-panel">
<h2 id="new-conversation-heading">Start a conversation</h2>
<StartConversationForm onStarted={(id) => selectConversation(id)} />
</section>
) : null}
<div className="conversation-workspace">
<section aria-label="Conversation inbox" className="conversation-master">
<div className="conversation-toolbar">
<label className="sr-only" htmlFor="conversation-search">Search conversations</label>
<input
id="conversation-search"
onChange={(event) => setSearch(event.target.value)}
placeholder="Search conversations"
type="search"
value={search}
/>
<label className="sr-only" htmlFor="conversation-filter">Filter conversations</label>
<select
id="conversation-filter"
onChange={(event) => setFilter(event.target.value as ConversationFilter)}
value={filter}
>
<option value="all">All</option>
<option value="unread">Unread</option>
<option value="awaiting-reply">Awaiting reply</option>
</select>
</div>
{conversationsQuery.isPending ? (
<div aria-label="Loading conversations" className="conversation-list-state" role="status">Loading conversations…</div>
) : conversationsQuery.isError ? (
<div className="conversation-list-state conversation-inline-error" role="alert">
<strong>Could not load conversations</strong>
<span>{conversationsQuery.error.message}</span>
<button onClick={() => void conversationsQuery.refetch()} type="button">Retry conversations</button>
</div>
) : conversationsQuery.data.length === 0 ? (
<div className="conversation-list-state"><strong>No conversations yet.</strong><span>Start one when you are ready.</span></div>
) : visibleConversations.length === 0 ? (
<div className="conversation-list-state"><strong>No matching conversations.</strong><span>Try a different search or filter.</span></div>
) : (
<ul className="conversation-list">
{visibleConversations.map((conversation) => (
<ConversationListItem
conversation={conversation}
isSelected={conversationId(conversation) === activeConversationId}
key={conversationId(conversation)}
onSelect={() => selectConversation(conversationId(conversation))}
/>
))}
</ul>
)}
</section>
<section aria-label="Selected conversation" className="conversation-detail-panel">
{activeConversationId ? (
<ConversationDetail conversationId={activeConversationId} key={activeConversationId} onClose={() => selectConversation(null)} />
) : (
<div className="conversation-detail-state">
<strong>Select a conversation</strong>
<span>Message history and reply tools will appear here.</span>
</div>
)}
</section>
</div>
</div>
);
}
@@ -0,0 +1,77 @@
.conversations-page {
width: min(86rem, 100%);
margin: 0 auto;
padding: 1.5rem 1rem 4rem;
}
.conversations-header {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.conversations-header h1 { margin-bottom: .4rem; }
.conversations-header p { margin-bottom: 0; color: var(--muted); }
.conversation-eyebrow { display: block; margin-bottom: .4rem; color: var(--accent); font-size: .72rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }
.conversation-primary { border-color: transparent !important; color: #06140b !important; background: var(--accent) !important; font-weight: 750; }
.conversations-page button, .conversations-page input, .conversations-page select, .conversations-page textarea { font: inherit; }
.conversations-page button { min-height: 2.5rem; padding: .5rem .8rem; border: 1px solid var(--border); border-radius: .6rem; color: #e9f1ec; background: var(--surface-raised); }
.conversations-page button:disabled { cursor: wait; opacity: .58; }
.new-conversation-panel { padding: 1rem; margin-bottom: 1rem; background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; }
.new-conversation-panel h2 { margin-bottom: 1rem; }
.start-conversation-form { display: grid; gap: .8rem; }
.conversation-field { display: grid; gap: .35rem; }
.conversation-field label { color: var(--muted); font-size: .8rem; font-weight: 700; }
.conversation-field input, .conversation-field textarea, .conversation-toolbar input, .conversation-toolbar select, .conversation-composer textarea {
width: 100%; padding: .7rem .75rem; color: #e9f1ec; background: #0b1410; border: 1px solid var(--border); border-radius: .55rem;
}
.conversation-field input:focus, .conversation-field textarea:focus, .conversation-toolbar input:focus, .conversation-toolbar select:focus, .conversation-composer textarea:focus { outline: 2px solid var(--accent); outline-offset: 2px; }
.conversation-workspace { display: grid; gap: .75rem; }
.conversation-master, .conversation-detail-panel { min-width: 0; background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; overflow: hidden; }
.conversation-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .5rem; padding: .75rem; border-bottom: 1px solid var(--border); }
.conversation-list { display: grid; gap: .35rem; max-height: 46rem; padding: .5rem; margin: 0; overflow: auto; list-style: none; }
.conversation-list-item { display: grid; gap: .4rem; width: 100%; padding: .8rem !important; text-align: left; background: transparent !important; border-color: transparent !important; }
.conversation-list-item:hover { background: var(--surface-raised) !important; }
.conversation-list-item.is-selected { background: var(--accent-dark) !important; border-color: #28593d !important; }
.conversation-list-item > span:last-child { overflow: hidden; color: var(--muted); font-size: .82rem; text-overflow: ellipsis; white-space: nowrap; }
.conversation-list-heading { display: flex; align-items: center; justify-content: space-between; gap: .5rem; }
.conversation-list-heading small { color: var(--accent); }
.conversation-list-state, .conversation-detail-state { display: grid; place-items: center; align-content: center; gap: .5rem; min-height: 16rem; padding: 1.5rem; color: var(--muted); text-align: center; }
.conversation-list-state strong, .conversation-detail-state strong { color: #e9f1ec; }
.conversation-detail { min-height: 32rem; }
.conversation-detail > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; padding: 1rem; border-bottom: 1px solid var(--border); }
.conversation-detail h2 { display: inline; margin-right: .6rem; }
.conversation-badge { display: inline-flex; padding: .2rem .45rem; color: #aaf0c4; background: #163a26; border-radius: 999px; font-size: .7rem; font-weight: 800; }
.conversation-sync-notice { padding: .75rem 1rem; color: #ffe1a0; background: #2a2111; border-bottom: 1px solid #59451e; }
.triggering-post { display: grid; gap: .2rem; margin: 1rem; padding: .75rem; color: inherit; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .65rem; text-decoration: none; }
.triggering-post span { color: var(--muted); font-size: .75rem; }
.conversation-messages { display: grid; gap: .75rem; max-height: 30rem; padding: 1rem; margin: 0; overflow: auto; list-style: none; }
.conversation-messages li { max-width: 85%; padding: .7rem .8rem; border-radius: .8rem; }
.conversation-messages li.is-self { justify-self: end; background: var(--accent-dark); }
.conversation-messages li.is-recipient { justify-self: start; background: var(--surface-raised); }
.conversation-messages li > div { display: flex; justify-content: space-between; gap: 1rem; margin-bottom: .35rem; }
.conversation-messages time { color: var(--muted); font-size: .7rem; }
.conversation-messages p { margin: 0; line-height: 1.45; white-space: pre-wrap; }
.conversation-empty-detail { padding: 2rem; color: var(--muted); text-align: center; }
.conversation-composer { display: grid; gap: .65rem; padding: 1rem; border-top: 1px solid var(--border); }
.conversation-section-heading, .conversation-actions { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
.conversation-section-heading h3 { margin: 0; }
.conversation-actions > span { color: var(--muted); font-size: .75rem; }
.conversation-inline-error, .conversation-inline-success { padding: .65rem .75rem; border-radius: .55rem; }
.conversation-inline-error { color: #ffd1ce; background: #461b18; border: 1px solid #80352f; }
.conversation-inline-success { color: #b9f3ce; background: #163a26; border: 1px solid #28593d; }
.conversation-dialog-backdrop { position: fixed; inset: 0; z-index: 70; display: grid; place-items: center; padding: 1rem; background: rgba(0, 0, 0, .74); }
.conversation-dialog { width: min(30rem, 100%); padding: 1.25rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .9rem; box-shadow: 0 2rem 5rem rgba(0, 0, 0, .45); }
.conversation-dialog p { color: var(--muted); }
.conversation-dialog blockquote { max-height: 12rem; padding: .75rem; margin: 1rem 0; overflow: auto; background: #0b1410; border-left: 3px solid var(--accent); white-space: pre-wrap; }
.conversation-dialog .conversation-actions { justify-content: flex-end; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (min-width: 52rem) {
.conversations-page { padding: 3rem 2rem 5rem; }
.start-conversation-form { grid-template-columns: minmax(12rem, .5fr) minmax(18rem, 1fr) auto; align-items: end; }
.conversation-workspace { grid-template-columns: minmax(17rem, .75fr) minmax(28rem, 1.5fr); }
}
@@ -0,0 +1,38 @@
import { useEffect, useRef } from 'react';
interface SendConfirmationDialogProps {
draft: string;
isPending: boolean;
onCancel(): void;
onConfirm(): void;
}
export function SendConfirmationDialog({ draft, isPending, onCancel, onConfirm }: SendConfirmationDialogProps) {
const cancelButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
cancelButtonRef.current?.focus();
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape' && !isPending) onCancel();
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isPending, onCancel]);
return (
<div aria-labelledby="send-confirmation-title" aria-modal="true" className="conversation-dialog-backdrop" role="dialog">
<div className="conversation-dialog">
<span className="conversation-eyebrow">Confirm message</span>
<h2 id="send-confirmation-title">Send this reply?</h2>
<p>This sends immediately through Reddit chat.</p>
<blockquote>{draft}</blockquote>
<div className="conversation-actions">
<button disabled={isPending} onClick={onCancel} ref={cancelButtonRef} type="button">Cancel</button>
<button className="conversation-primary" disabled={isPending} onClick={onConfirm} type="button">
{isPending ? 'Sending…' : 'Confirm send'}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,64 @@
import { useState } from 'react';
import { useStartConversationMutation } from './conversation-queries.js';
interface StartConversationFormProps {
onStarted(conversationId: string): void;
}
export function StartConversationForm({ onStarted }: StartConversationFormProps) {
const [username, setUsername] = useState('');
const [message, setMessage] = useState('');
const [successUsername, setSuccessUsername] = useState<string | null>(null);
const mutation = useStartConversationMutation();
const canSubmit = username.trim().length > 0 && message.trim().length > 0 && !mutation.isPending;
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!canSubmit) return;
mutation.mutate(
{ username, message },
{
onSuccess(chat) {
setSuccessUsername(chat.username);
setUsername('');
setMessage('');
onStarted(chat.roomId ?? chat.username);
},
},
);
}
return (
<form className="start-conversation-form" onSubmit={submit}>
<div className="conversation-field">
<label htmlFor="new-conversation-username">Reddit username</label>
<input
autoComplete="off"
id="new-conversation-username"
onChange={(event) => setUsername(event.target.value)}
placeholder="username"
value={username}
/>
</div>
<div className="conversation-field">
<label htmlFor="new-conversation-message">Opening message</label>
<textarea
id="new-conversation-message"
onChange={(event) => setMessage(event.target.value)}
placeholder="Write a personal opening message"
rows={4}
value={message}
/>
</div>
{mutation.isError ? <div className="conversation-inline-error" role="alert">{mutation.error.message}</div> : null}
{successUsername ? (
<div aria-label="Conversation started" className="conversation-inline-success" role="status">
Conversation started with u/{successUsername}
</div>
) : null}
<button className="conversation-primary" disabled={!canSubmit} type="submit">
{mutation.isPending ? 'Starting…' : 'Start conversation'}
</button>
</form>
);
}
+216
View File
@@ -0,0 +1,216 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { ConversationsPage } from '../../apps/dashboard/src/features/conversations/conversations-page.js';
interface ChatFixtureOptions {
username: string;
roomId: string;
unread?: boolean;
isFromUs?: boolean;
lastMessageSnippet?: string;
}
function chatFixture(options: ChatFixtureOptions) {
return {
username: options.username,
roomName: options.username,
roomId: options.roomId,
url: `https://chat.reddit.com/room/${options.roomId}`,
lastMessageTime: 1_786_000_000_000,
lastMessageSnippet: options.lastMessageSnippet ?? `Latest from ${options.username}`,
isFromUs: options.isFromUs ?? false,
unread: options.unread ?? false,
};
}
const alice = chatFixture({ username: 'alice', roomId: 'room-alice', unread: true });
const bob = chatFixture({ username: 'bob', roomId: 'room-bob', isFromUs: true });
function detailFixture(chat = alice) {
return {
...chat,
messages: [
{ sender: 'me', text: 'Hello Alice', timestamp: 1_785_999_900_000, isSelf: true },
{ sender: 'alice', text: 'Good to hear from you', timestamp: 1_786_000_000_000, isSelf: false },
],
triggeringPost: {
id: 'post-1',
subreddit: 'casualconversation',
title: 'Looking for a chat',
url: 'https://reddit.com/r/casualconversation/comments/post-1',
sentAt: 1_785_999_800_000,
},
syncInProgress: null,
};
}
const server = setupServer();
function installConversationList() {
server.use(
http.get('/api/conversations', () => HttpResponse.json([alice, bob])),
http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture())),
);
}
function renderConversations() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
render(
<QueryClientProvider client={queryClient}>
<ConversationsPage />
</QueryClientProvider>,
);
return queryClient;
}
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
describe('ConversationsPage', () => {
it('shows loading and a clear empty state', async () => {
server.use(http.get('/api/conversations', () => HttpResponse.json([])));
renderConversations();
expect(screen.getByRole('status', { name: 'Loading conversations' })).toBeInTheDocument();
expect(await screen.findByText('No conversations yet.')).toBeInTheDocument();
});
it('recovers from a list error without replacing the page', async () => {
let requests = 0;
server.use(
http.get('/api/conversations', () => {
requests += 1;
if (requests === 1) return HttpResponse.json({ error: 'Chat service unavailable' }, { status: 503 });
return HttpResponse.json([alice]);
}),
);
const user = userEvent.setup();
renderConversations();
const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent('Chat service unavailable');
await user.click(within(alert).getByRole('button', { name: 'Retry conversations' }));
expect(await screen.findByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
});
it('searches and filters the master list, then opens conversation detail', async () => {
installConversationList();
const user = userEvent.setup();
renderConversations();
expect(await screen.findByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
await user.type(screen.getByRole('searchbox', { name: 'Search conversations' }), 'bob');
expect(screen.queryByRole('button', { name: /u\/alice/ })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /u\/bob/ })).toBeInTheDocument();
await user.clear(screen.getByRole('searchbox', { name: 'Search conversations' }));
await user.selectOptions(screen.getByRole('combobox', { name: 'Filter conversations' }), 'unread');
expect(screen.getByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /u\/bob/ })).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /u\/alice/ }));
expect(await screen.findByRole('heading', { name: 'u/alice' })).toBeInTheDocument();
expect(screen.getByText('Good to hear from you')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Looking for a chat' })).toHaveAttribute('href', expect.stringContaining('post-1'));
});
it('turns a suggestion into an editable draft and confirms sending without losing failed input', async () => {
installConversationList();
let sendRequests = 0;
server.use(
http.post('/api/conversations/room-alice/suggest-reply', () =>
HttpResponse.json({ suggestedReply: 'That sounds lovely, what happened next?' }),
),
http.post('/api/conversations/room-alice/send-reply', async ({ request }) => {
sendRequests += 1;
const body = await request.json() as { message: string };
expect(body.message).toBe('My edited reply');
return HttpResponse.json({ error: 'Reddit is temporarily unavailable' }, { status: 503 });
}),
);
const user = userEvent.setup();
renderConversations();
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
await screen.findByRole('heading', { name: 'u/alice' });
await user.click(screen.getByRole('button', { name: 'Suggest reply' }));
const composer = await screen.findByRole('textbox', { name: 'Reply to u/alice' });
expect(composer).toHaveValue('That sounds lovely, what happened next?');
await user.clear(composer);
await user.type(composer, 'My edited reply');
const sendButton = screen.getByRole('button', { name: 'Review and send reply' });
await user.click(sendButton);
expect(screen.getByRole('dialog', { name: 'Send this reply?' })).toHaveTextContent('My edited reply');
await user.keyboard('{Escape}');
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(sendButton).toHaveFocus();
expect(sendRequests).toBe(0);
await user.click(sendButton);
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Confirm send' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Reddit is temporarily unavailable');
expect(composer).toHaveValue('My edited reply');
server.use(
http.post('/api/conversations/room-alice/send-reply', () => {
sendRequests += 1;
return HttpResponse.json({ success: true });
}),
);
await user.click(sendButton);
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Confirm send' }));
expect(await screen.findByRole('status', { name: 'Reply sent' })).toHaveTextContent('Reply sent to u/alice');
expect(composer).toHaveValue('');
expect(sendRequests).toBe(2);
});
it('starts a conversation and preserves both fields after a recoverable failure', async () => {
installConversationList();
let startRequests = 0;
server.use(
http.post('/api/conversations/start', () => {
startRequests += 1;
return HttpResponse.json({ error: 'Invitation cooldown active' }, { status: 429 });
}),
);
const user = userEvent.setup();
renderConversations();
await user.click(screen.getByRole('button', { name: 'New conversation' }));
const username = screen.getByRole('textbox', { name: 'Reddit username' });
const message = screen.getByRole('textbox', { name: 'Opening message' });
await user.type(username, 'charlie');
await user.type(message, 'Hello Charlie');
await user.click(screen.getByRole('button', { name: 'Start conversation' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Invitation cooldown active');
expect(username).toHaveValue('charlie');
expect(message).toHaveValue('Hello Charlie');
server.use(
http.post('/api/conversations/start', () => {
startRequests += 1;
return HttpResponse.json({ success: true, chat: chatFixture({ username: 'charlie', roomId: 'room-charlie' }) });
}),
);
await user.click(screen.getByRole('button', { name: 'Start conversation' }));
expect(await screen.findByRole('status', { name: 'Conversation started' })).toHaveTextContent('Conversation started with u/charlie');
expect(username).toHaveValue('');
expect(message).toHaveValue('');
expect(startRequests).toBe(2);
});
});