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,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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user