feat(dashboard): add conversations workspace
This commit is contained in:
1 parent
40e5f4c818
commit
8d4eae7ab5
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user