Files
RedditBot/apps/dashboard/src/features/conversations/conversation-detail.tsx
T

200 lines
8.2 KiB
TypeScript

import { useCallback, useRef, useState } from 'react';
import type { ConversationDetailResponse } from '../../../../../packages/api-contracts/src/index.js';
import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
import { OriginPostCard } from '../../components/origin-post.js';
import { useConversationDetailQuery, useSendReplyMutation, useSuggestMediaAskMutation, useSuggestReplyMutation } from './conversation-queries.js';
import { SendConfirmationDialog } from './send-confirmation-dialog.js';
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
interface ConversationDetailProps {
conversationId: string;
onClose(): void;
}
function formatMessageTime(timestamp: number): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp);
}
function mediaPath(localPath: string): string {
return `/media/${localPath.split(/[\\/]/).map(encodeURIComponent).join('/')}`;
}
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.eventId ?? `${message.timestamp}-${message.sender}-${message.text}`}>
<div>
<strong>{message.isSelf
? `You${conversation.owner ? ` (u/${conversation.owner.redditUsername})` : ''}`
: <RedditUsernameLink username={conversation.username} />}</strong>
<time dateTime={new Date(message.timestamp).toISOString()}>{formatMessageTime(message.timestamp)}</time>
</div>
<p>{message.text}</p>
{message.media?.length ? (
<div className="conversation-message-images">
{message.media.map((media) => (
<a
aria-label={`View original image ${message.isSelf ? 'sent by you' : `received from ${redditUsernameLabel(conversation.username)}`}`}
href={mediaPath(media.localPath)}
key={`${message.timestamp}-${media.localPath}`}
rel="noreferrer"
target="_blank"
>
<img
alt={`Image ${message.isSelf ? 'sent by you' : `received from ${redditUsernameLabel(conversation.username)}`}`}
loading="lazy"
src={mediaPath(media.localPath)}
/>
</a>
))}
</div>
) : null}
</li>
))}
</ol>
);
}
function ConversationComposer({ conversation, conversationId }: { conversation: ConversationDetailResponse; conversationId: string }) {
const [draft, setDraft] = useState('');
const [isConfirming, setIsConfirming] = useState(false);
const [wasSent, setWasSent] = useState(false);
const sendButtonRef = useRef<HTMLButtonElement>(null);
const suggestionMutation = useSuggestReplyMutation(conversationId);
const mediaAskMutation = useSuggestMediaAskMutation(conversationId);
const sendMutation = useSendReplyMutation(conversationId);
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 suggestMediaAsk() {
setWasSent(false);
mediaAskMutation.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>
<button disabled={mediaAskMutation.isPending} onClick={suggestMediaAsk} type="button">
{mediaAskMutation.isPending ? 'Generating media ask…' : 'Suggest media ask'}
</button>
</div>
<label className="sr-only" htmlFor="conversation-reply">Reply to {redditUsernameLabel(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}
{mediaAskMutation.isError ? <div className="conversation-inline-error" role="alert">{mediaAskMutation.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 <RedditUsernameLink username={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><RedditUsernameLink username={conversation.username} /></h2>
<AccountOwnerBadge owner={conversation.owner} />
{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 ? (
<div className="triggering-post">
<OriginPostCard post={{
title: conversation.triggeringPost.title ?? conversation.triggeringPost.id,
url: conversation.triggeringPost.url,
imageUrl: conversation.triggeringPost.imageUrl,
subreddit: conversation.triggeringPost.subreddit,
}} />
</div>
) : null}
<MessageHistory conversation={conversation} />
<ConversationComposer conversation={conversation} conversationId={conversationId} />
</article>
);
}