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