200 lines
8.2 KiB
TypeScript
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>
|
|
);
|
|
}
|