feat(dashboard): close legacy parity gaps
This commit is contained in:
1 parent
92287c0a9a
commit
4b3f00729f
21 files changed
+397
-28
No files matched your search
@@ -3,6 +3,7 @@ import {
|
||||
ConversationsResponseSchema,
|
||||
ConversationDetailResponseSchema,
|
||||
ErrorResponseSchema,
|
||||
ProcessedPostsResponseSchema,
|
||||
SendReplyRequestSchema,
|
||||
StartConversationRequestSchema,
|
||||
StartConversationResponseSchema,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
SuggestedReplyResponseSchema,
|
||||
type ActiveChat,
|
||||
type ConversationDetailResponse,
|
||||
type ProcessedPost,
|
||||
type SuccessResponse,
|
||||
} from '../../../../../packages/api-contracts/src/index.js';
|
||||
|
||||
@@ -30,6 +32,8 @@ export interface ConversationApi {
|
||||
list(): Promise<ActiveChat[]>;
|
||||
detail(id: string): Promise<ConversationDetailResponse>;
|
||||
suggestReply(id: string): Promise<string>;
|
||||
suggestMediaAsk(id: string): Promise<string>;
|
||||
processedPosts(username: string): Promise<ProcessedPost[]>;
|
||||
sendReply(id: string, message: string): Promise<SuccessResponse>;
|
||||
startConversation(username: string, message: string): Promise<ActiveChat>;
|
||||
}
|
||||
@@ -53,6 +57,19 @@ export function createConversationApi(fetchImplementation?: typeof fetch): Conve
|
||||
);
|
||||
return response.suggestedReply;
|
||||
},
|
||||
async suggestMediaAsk(id) {
|
||||
const response = await parseResponse(
|
||||
await request(`${conversationPath(id)}/suggest-media-ask`, { method: 'POST' }),
|
||||
SuggestedReplyResponseSchema,
|
||||
);
|
||||
return response.suggestedReply;
|
||||
},
|
||||
async processedPosts(username) {
|
||||
return parseResponse(
|
||||
await request(`/api/posts/by-author/${encodeURIComponent(username.trim().replace(/^u\//i, ''))}`),
|
||||
ProcessedPostsResponseSchema,
|
||||
);
|
||||
},
|
||||
async sendReply(id, message) {
|
||||
const body = SendReplyRequestSchema.parse({ message: message.trim() });
|
||||
return parseResponse(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 { useConversationDetailQuery, useSendReplyMutation, useSuggestMediaAskMutation, useSuggestReplyMutation } from './conversation-queries.js';
|
||||
import { SendConfirmationDialog } from './send-confirmation-dialog.js';
|
||||
|
||||
interface ConversationDetailProps {
|
||||
@@ -38,6 +38,7 @@ function ConversationComposer({ conversation }: { conversation: ConversationDeta
|
||||
const [wasSent, setWasSent] = useState(false);
|
||||
const sendButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const suggestionMutation = useSuggestReplyMutation(conversation.roomId ?? conversation.username);
|
||||
const mediaAskMutation = useSuggestMediaAskMutation(conversation.roomId ?? conversation.username);
|
||||
const sendMutation = useSendReplyMutation(conversation.roomId ?? conversation.username);
|
||||
const canSend = draft.trim().length > 0 && !sendMutation.isPending;
|
||||
|
||||
@@ -51,6 +52,11 @@ function ConversationComposer({ conversation }: { conversation: ConversationDeta
|
||||
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, {
|
||||
@@ -69,6 +75,9 @@ function ConversationComposer({ conversation }: { conversation: ConversationDeta
|
||||
<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 u/{conversation.username}</label>
|
||||
<textarea
|
||||
@@ -82,6 +91,7 @@ function ConversationComposer({ conversation }: { conversation: ConversationDeta
|
||||
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 u/{conversation.username}</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { conversationApi } from './conversation-api.js';
|
||||
export const conversationQueryKeys = {
|
||||
all: ['conversations'] as const,
|
||||
detail: (id: string) => ['conversations', 'detail', id] as const,
|
||||
processedPosts: (username: string) => ['conversations', 'processed-posts', username] as const,
|
||||
};
|
||||
|
||||
export function useConversationsQuery() {
|
||||
@@ -22,6 +23,18 @@ export function useSuggestReplyMutation(id: string) {
|
||||
return useMutation({ mutationFn: () => conversationApi.suggestReply(id) });
|
||||
}
|
||||
|
||||
export function useSuggestMediaAskMutation(id: string) {
|
||||
return useMutation({ mutationFn: () => conversationApi.suggestMediaAsk(id) });
|
||||
}
|
||||
|
||||
export function useProcessedPostsQuery(username: string) {
|
||||
return useQuery({
|
||||
queryKey: conversationQueryKeys.processedPosts(username),
|
||||
queryFn: () => conversationApi.processedPosts(username),
|
||||
enabled: username.length > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendReplyMutation(id: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -1,22 +1,38 @@
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { ConversationsPage } from './conversations-page.js';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { ConversationsPage, type ConversationFilters } from './conversations-page.js';
|
||||
|
||||
export function ConversationRoute() {
|
||||
const navigate = useNavigate();
|
||||
const { conversationId } = useParams<{ conversationId: string }>();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const filterValue = searchParams.get('filter');
|
||||
const filters: ConversationFilters = {
|
||||
search: searchParams.get('q') ?? '',
|
||||
filter: filterValue === 'unread' || filterValue === 'awaiting-reply' ? filterValue : 'all',
|
||||
};
|
||||
|
||||
function selectConversation(nextConversationId: string | null) {
|
||||
const suffix = searchParams.size > 0 ? `?${searchParams.toString()}` : '';
|
||||
void navigate(
|
||||
nextConversationId
|
||||
? `/conversations/${encodeURIComponent(nextConversationId)}`
|
||||
: '/conversations',
|
||||
? `/conversations/${encodeURIComponent(nextConversationId)}${suffix}`
|
||||
: `/conversations${suffix}`,
|
||||
);
|
||||
}
|
||||
|
||||
function updateFilters(next: ConversationFilters) {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (next.search) params.set('q', next.search); else params.delete('q');
|
||||
if (next.filter !== 'all') params.set('filter', next.filter); else params.delete('filter');
|
||||
setSearchParams(params, { replace: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<ConversationsPage
|
||||
selectedConversationId={conversationId ?? null}
|
||||
onSelectedConversationChange={selectConversation}
|
||||
filters={filters}
|
||||
onFiltersChange={updateFilters}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -3,13 +3,21 @@ import type { ActiveChat } from '../../../../../packages/api-contracts/src/index
|
||||
import { ConversationDetail } from './conversation-detail.js';
|
||||
import { useConversationsQuery } from './conversation-queries.js';
|
||||
import { StartConversationForm } from './start-conversation-form.js';
|
||||
import { ProcessedUserLookup } from './processed-user-lookup.js';
|
||||
import './conversations.css';
|
||||
|
||||
type ConversationFilter = 'all' | 'unread' | 'awaiting-reply';
|
||||
export type ConversationFilter = 'all' | 'unread' | 'awaiting-reply';
|
||||
|
||||
export interface ConversationFilters {
|
||||
search: string;
|
||||
filter: ConversationFilter;
|
||||
}
|
||||
|
||||
export interface ConversationsPageProps {
|
||||
selectedConversationId?: string | null;
|
||||
onSelectedConversationChange?(conversationId: string | null): void;
|
||||
filters?: ConversationFilters;
|
||||
onFiltersChange?(filters: ConversationFilters): void;
|
||||
}
|
||||
|
||||
function conversationId(conversation: ActiveChat): string {
|
||||
@@ -44,23 +52,28 @@ function ConversationListItem({ conversation, isSelected, onSelect }: {
|
||||
);
|
||||
}
|
||||
|
||||
export function ConversationsPage({ selectedConversationId, onSelectedConversationChange }: ConversationsPageProps) {
|
||||
export function ConversationsPage({ selectedConversationId, onSelectedConversationChange, filters, onFiltersChange }: ConversationsPageProps) {
|
||||
const conversationsQuery = useConversationsQuery();
|
||||
const [internalSelectedId, setInternalSelectedId] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [filter, setFilter] = useState<ConversationFilter>('all');
|
||||
const [localFilters, setLocalFilters] = useState<ConversationFilters>({ search: '', filter: 'all' });
|
||||
const [isStartingConversation, setIsStartingConversation] = useState(false);
|
||||
const activeConversationId = selectedConversationId !== undefined ? selectedConversationId : internalSelectedId;
|
||||
const activeFilters = filters ?? localFilters;
|
||||
|
||||
function updateFilters(next: ConversationFilters) {
|
||||
if (filters === undefined) setLocalFilters(next);
|
||||
onFiltersChange?.(next);
|
||||
}
|
||||
|
||||
const visibleConversations = useMemo(() => {
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
const normalizedSearch = activeFilters.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);
|
||||
return matchesSearch && matchesFilter(conversation, activeFilters.filter);
|
||||
});
|
||||
}, [conversationsQuery.data, filter, search]);
|
||||
}, [activeFilters, conversationsQuery.data]);
|
||||
|
||||
function selectConversation(id: string | null) {
|
||||
if (selectedConversationId === undefined) setInternalSelectedId(id);
|
||||
@@ -86,6 +99,7 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
|
||||
<StartConversationForm onStarted={(id) => selectConversation(id)} />
|
||||
</section>
|
||||
) : null}
|
||||
<ProcessedUserLookup />
|
||||
|
||||
<div className="conversation-workspace">
|
||||
<section aria-label="Conversation inbox" className="conversation-master">
|
||||
@@ -93,16 +107,16 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
|
||||
<label className="sr-only" htmlFor="conversation-search">Search conversations</label>
|
||||
<input
|
||||
id="conversation-search"
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
onChange={(event) => updateFilters({ ...activeFilters, search: event.target.value })}
|
||||
placeholder="Search conversations"
|
||||
type="search"
|
||||
value={search}
|
||||
value={activeFilters.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}
|
||||
onChange={(event) => updateFilters({ ...activeFilters, filter: event.target.value as ConversationFilter })}
|
||||
value={activeFilters.filter}
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="unread">Unread</option>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useState } from 'react';
|
||||
import { useProcessedPostsQuery } from './conversation-queries.js';
|
||||
|
||||
export function ProcessedUserLookup() {
|
||||
const [input, setInput] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const postsQuery = useProcessedPostsQuery(username);
|
||||
|
||||
return (
|
||||
<section aria-labelledby="processed-user-heading" className="new-conversation-panel">
|
||||
<h2 id="processed-user-heading">Processed user lookup</h2>
|
||||
<form onSubmit={(event) => { event.preventDefault(); setUsername(input.trim().replace(/^u\//i, '')); }}>
|
||||
<label htmlFor="processed-user">Lookup Reddit username</label>
|
||||
<input id="processed-user" onChange={(event) => setInput(event.target.value)} value={input} />
|
||||
<button disabled={input.trim().length === 0} type="submit">Look up user</button>
|
||||
</form>
|
||||
{postsQuery.isPending && username ? <p role="status">Looking up processed posts…</p> : null}
|
||||
{postsQuery.isError ? <p role="alert">{postsQuery.error.message}</p> : null}
|
||||
{postsQuery.data ? (
|
||||
postsQuery.data.length === 0 ? <p>No processed posts found for u/{username}.</p> : (
|
||||
<ol aria-label={`Processed posts for u/${username}`}>
|
||||
{postsQuery.data.map((post) => <li key={post.id}><strong>{post.title}</strong><span>r/{post.subreddit} · {post.messageSent ? 'Message sent' : post.error ?? 'Processed'}</span></li>)}
|
||||
</ol>
|
||||
)
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user