feat: dashboard UI/UX overhaul from full review

Shared foundation:
- New Modal component on native <dialog>.showModal() (focus trap, inert
  background, Escape, focus restore, scroll lock); all eight dialogs and
  both globalThis.confirm calls migrated to it
- Design tokens for success/warning/danger surfaces; pill danger variant;
  .sr-only utility; toast layering above dialog backdrops; not-allowed
  cursor for validity-disabled buttons

Conversations/attention: auto-scroll with jump-to-latest, per-conversation
draft persistence, suggestion undo, 30s auto-refresh with refresh controls,
unread emphasis and counts, blur-consistent share dialog, empty-accounts
guidance, collapsed user lookup, relative timestamps, danger pills for
critical items

Media/library/outreach: click-blurred-to-reveal, lightbox with arrow
navigation and captions, dirty-form preservation across refetches, upload
validation with thumbnail preview, save feedback, result counts with
load-more and clear-filters, ticking countdowns from a single clock

Overview/performance/operations/settings: mutation errors inside the
confirm dialog, live sending-window countdown, offline-aware loading
states, per-panel loading/error with retry, 7/30/90-day range toggle,
console scroll yielding with shown/retained counts, risky-save matching
by subreddit name instead of index, useBlocker unsaved-changes guard,
single-pass validation with focus-to-first-error, access panel JSON
validation and input reset

Tests: 806 -> 844 passing; both typechecks clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jamie BruntonandClaude Fable 5 committed 2026-08-10 09:07:42 +01:00
1 parent 82be197b79
commit e984bf20ac
46 files changed
+2462 -495

No files matched your search

+1 -1
View File
@@ -16,7 +16,7 @@ export function useStatsQuery() {
}
export function useTimersQuery() {
return useQuery({ queryKey: dashboardQueryKeys.timers, queryFn: dashboardApi.getTimers });
return useQuery({ queryKey: dashboardQueryKeys.timers, queryFn: dashboardApi.getTimers, refetchInterval: 15_000 });
}
export function useSetRunningMutation() {
@@ -1,39 +1,34 @@
import { useEffect, useRef } from 'react';
import { Modal } from './modal.js';
interface ConfirmationDialogProps {
action: 'start' | 'stop';
errorMessage?: string | null;
isPending: boolean;
onCancel(): void;
onConfirm(): void;
}
export function ConfirmationDialog({ action, isPending, onCancel, onConfirm }: ConfirmationDialogProps) {
const cancelButtonRef = useRef<HTMLButtonElement>(null);
export function ConfirmationDialog({ action, errorMessage = null, isPending, onCancel, onConfirm }: ConfirmationDialogProps) {
const isStarting = action === 'start';
useEffect(() => {
cancelButtonRef.current?.focus();
}, []);
return (
<div aria-labelledby="confirmation-title" aria-modal="true" className="dialog-backdrop" role="dialog">
<div className="dialog-card">
<span className="eyebrow">Confirm action</span>
<h2 id="confirmation-title">{isStarting ? 'Start bot?' : 'Stop bot?'}</h2>
<p>
{isStarting
? 'The bot will resume automated processing.'
: 'The bot will pause after its current operation.'}
</p>
<div className="dialog-actions">
<button className="button secondary" disabled={isPending} onClick={onCancel} ref={cancelButtonRef} type="button">
Cancel
</button>
<button className={isStarting ? 'button primary' : 'button danger'} disabled={isPending} onClick={onConfirm} type="button">
{isPending ? 'Applying…' : `Confirm ${action}`}
</button>
</div>
<Modal className="dialog-card" isDismissLocked={isPending} labelledBy="confirmation-title" onClose={onCancel}>
<span className="eyebrow">Confirm action</span>
<h2 id="confirmation-title">{isStarting ? 'Start bot?' : 'Stop bot?'}</h2>
<p>
{isStarting
? 'The bot will resume automated processing.'
: 'The bot will pause after its current operation.'}
</p>
{errorMessage ? <p className="dialog-error" role="alert">{errorMessage}</p> : null}
<div className="dialog-actions">
<button autoFocus className="button secondary" disabled={isPending} onClick={onCancel} type="button">
Cancel
</button>
<button className={isStarting ? 'button primary' : 'button danger'} disabled={isPending} onClick={onConfirm} type="button">
{isPending ? 'Applying…' : `Confirm ${action}`}
</button>
</div>
</div>
</Modal>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { useEffect, useRef, useState, type PropsWithChildren, type ReactNode } from 'react';
interface ModalProps {
/** Class applied to the <dialog> element itself (it is the visible card). */
className?: string;
/** Blocks Escape/backdrop dismissal while a mutation is in flight. */
isDismissLocked?: boolean;
/** Close when the backdrop (the ::backdrop area of the dialog) is pressed. */
closeOnBackdrop?: boolean;
label?: string;
labelledBy?: string;
onClose(): void;
}
/**
* Shared modal built on native <dialog>.showModal(): the browser provides
* focus trapping, inert background, and Escape handling. Render conditionally;
* mounting opens the dialog and unmounting restores focus to the opener.
*/
export function Modal({
children,
className,
closeOnBackdrop = false,
isDismissLocked = false,
label,
labelledBy,
onClose,
}: PropsWithChildren<ModalProps>): ReactNode {
const dialogRef = useRef<HTMLDialogElement>(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
const dismissLockedRef = useRef(isDismissLocked);
dismissLockedRef.current = isDismissLocked;
// Captured at first render, before autoFocus moves focus into the dialog.
const [previouslyFocused] = useState(() => document.activeElement);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (!dialog.open) {
if (typeof dialog.showModal === 'function') dialog.showModal();
else dialog.setAttribute('open', '');
}
return () => {
if (previouslyFocused instanceof HTMLElement && previouslyFocused.isConnected) {
previouslyFocused.focus();
}
};
}, [previouslyFocused]);
function requestClose(): void {
if (!dismissLockedRef.current) onCloseRef.current();
}
return (
<dialog
aria-label={label}
aria-labelledby={labelledBy}
className={className ? `modal ${className}` : 'modal'}
onCancel={(event) => {
event.preventDefault();
requestClose();
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
requestClose();
}
}}
onMouseDown={(event) => {
if (closeOnBackdrop && event.target === event.currentTarget) requestClose();
}}
ref={dialogRef}
>
{children}
</dialog>
);
}
+29 -63
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { useState, type ReactNode } from 'react';
import { Modal } from './modal.js';
export interface OriginPost {
title: string;
@@ -36,27 +37,6 @@ function PostOriginMeta({ post }: { post: OriginPost }) {
export function OriginPostCard({ post }: { post: OriginPost }) {
const [isImageRevealed, setIsImageRevealed] = useState(false);
const [isImagePreviewOpen, setIsImagePreviewOpen] = useState(false);
const previewTriggerRef = useRef<HTMLButtonElement>(null);
const previewCloseRef = useRef<HTMLButtonElement>(null);
const closeImagePreview = useCallback(() => {
setIsImagePreviewOpen(false);
queueMicrotask(() => previewTriggerRef.current?.focus());
}, []);
useEffect(() => {
if (!isImagePreviewOpen) return;
previewCloseRef.current?.focus();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') closeImagePreview();
if (event.key === 'Tab') {
event.preventDefault();
previewCloseRef.current?.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [closeImagePreview, isImagePreviewOpen]);
return (
<>
@@ -64,11 +44,14 @@ export function OriginPostCard({ post }: { post: OriginPost }) {
{post.imageUrl ? (
<div className="origin-post-image">
<button
aria-label={`View source image full screen: ${post.title}`}
aria-label={isImageRevealed
? `View source image full screen: ${post.title}`
: `Reveal source image: ${post.title}`}
className="origin-post-image-preview-trigger"
disabled={!isImageRevealed}
onClick={() => setIsImagePreviewOpen(true)}
ref={previewTriggerRef}
onClick={() => {
if (isImageRevealed) setIsImagePreviewOpen(true);
else setIsImageRevealed(true);
}}
type="button"
>
<img alt={`Source post: ${post.title}`} className={isImageRevealed ? undefined : 'is-blurred'} loading="lazy" src={post.imageUrl} />
@@ -86,22 +69,21 @@ export function OriginPostCard({ post }: { post: OriginPost }) {
<a aria-label={post.title} className="origin-post-copy" href={post.url} rel="noreferrer" target="_blank">
<PostOriginMeta post={post} />
<strong>{post.title}</strong>
<span>Open original post</span>
<span>Open original post <span aria-hidden="true">↗</span></span>
</a>
</div>
{isImagePreviewOpen && post.imageUrl ? (
<div
aria-label={`Source image: ${post.title}`}
aria-modal="true"
className="origin-image-preview-backdrop"
onMouseDown={(event) => { if (event.target === event.currentTarget) closeImagePreview(); }}
role="dialog"
<Modal
className="origin-image-preview"
closeOnBackdrop
label={`Source image: ${post.title}`}
onClose={() => setIsImagePreviewOpen(false)}
>
<div className="origin-image-preview-content">
<img alt={`Source post: ${post.title}`} src={post.imageUrl} />
<button aria-label="Close source image preview" onClick={closeImagePreview} ref={previewCloseRef} type="button">×</button>
<button aria-label="Close source image preview" autoFocus onClick={() => setIsImagePreviewOpen(false)} type="button">×</button>
</div>
</div>
</Modal>
) : null}
</>
);
@@ -109,50 +91,34 @@ export function OriginPostCard({ post }: { post: OriginPost }) {
export function OriginPostButton({ children, post }: { children: ReactNode; post: OriginPost }) {
const [isOpen, setIsOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!isOpen) return;
closeRef.current?.focus();
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setIsOpen(false);
};
document.addEventListener('keydown', closeOnEscape);
return () => document.removeEventListener('keydown', closeOnEscape);
}, [isOpen]);
function close() {
setIsOpen(false);
queueMicrotask(() => triggerRef.current?.focus());
}
return (
<>
<button className="origin-post-trigger" onClick={() => setIsOpen(true)} ref={triggerRef} type="button">
<button className="origin-post-trigger" onClick={() => setIsOpen(true)} type="button">
{children}
</button>
{isOpen ? (
<div
aria-labelledby="origin-post-dialog-title"
aria-modal="true"
className="origin-post-backdrop"
onMouseDown={(event) => { if (event.target === event.currentTarget) close(); }}
role="dialog"
<Modal
className="origin-post-dialog"
closeOnBackdrop
labelledBy="origin-post-dialog-title"
onClose={() => setIsOpen(false)}
>
<article className="origin-post-dialog">
<article>
<div className="origin-post-dialog-heading">
<span>Conversation origin</span>
<button aria-label="Close source post" onClick={close} ref={closeRef} type="button">×</button>
<button aria-label="Close source post" autoFocus onClick={() => setIsOpen(false)} type="button">×</button>
</div>
{post.imageUrl ? <img alt={`Source post: ${post.title}`} loading="lazy" src={post.imageUrl} /> : null}
<div className="origin-post-dialog-copy">
<PostOriginMeta post={post} />
<h2 id="origin-post-dialog-title">{post.title}</h2>
<a className="button primary" href={post.url} rel="noreferrer" target="_blank">Open Reddit post</a>
<a className="button primary" href={post.url} rel="noreferrer" target="_blank">
Open Reddit post <span aria-hidden="true">↗</span><span className="sr-only">(opens in a new tab)</span>
</a>
</div>
</article>
</div>
</Modal>
) : null}
</>
);
@@ -2,15 +2,31 @@ import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import type { AttentionItem } from '../../../../../packages/api-contracts/src/index.js';
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
import { formatExactTime, formatShortRelativeTime } from '../conversations/relative-time.js';
import { attentionApi } from './attention-api.js';
import './attention.css';
function formatTime(timestamp: number): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp);
function relativeTime(timestamp: number, now: number) {
return (
<time dateTime={new Date(timestamp).toISOString()} title={formatExactTime(timestamp)}>
{formatShortRelativeTime(timestamp, now)}
</time>
);
}
function actionHint(kind: AttentionItem['kind']): string {
switch (kind) {
case 'account-login-required':
return 'No linked conversation — check Settings.';
case 'runtime-failed':
case 'invitation-cooldown':
default:
return 'No linked conversation — check Operations.';
}
}
function itemAction(item: AttentionItem) {
if (!item.conversationId) return null;
if (!item.conversationId) return <span className="attention-item-hint">{actionHint(item.kind)}</span>;
return <Link className="button secondary" to={`/conversations/${encodeURIComponent(item.conversationId)}`}>Open conversation</Link>;
}
@@ -18,14 +34,51 @@ export function AttentionPage() {
const query = useQuery({ queryKey: ['attention'], queryFn: attentionApi.getAttention, refetchInterval: 30_000 });
if (query.isPending) return <div className="page-state" role="status">Loading attention inbox…</div>;
if (query.isError) return <div className="page-state error-state" role="alert"><strong>Attention inbox unavailable</strong><span>{query.error.message}</span><button className="button secondary" onClick={() => void query.refetch()} type="button">Retry</button></div>;
const now = Date.now();
const critical = query.data.items.filter((item) => item.severity === 'critical').length;
return (
<div className="page attention-page">
<header className="page-header"><div><span className="eyebrow">Operator queue</span><h1>Needs attention</h1><p>Boundaries, human handoffs, delivery failures, account problems, and Reddit cooldowns in one actionable inbox.</p></div><button className="button secondary" onClick={() => void query.refetch()} type="button">Refresh</button></header>
<section aria-label="Attention totals" className="status-strip"><div><span className={`status-dot ${query.data.items.length === 0 ? 'running' : 'stopped'}`} aria-hidden="true" /><div><small>Open items</small><strong>{query.data.items.length}</strong></div></div><span className={critical > 0 ? 'pill warning' : 'pill success'}>{critical} critical</span><span className="status-note">Updated {formatTime(query.data.generatedAt)}</span></section>
<header className="page-header">
<div>
<span className="eyebrow">Operator queue</span>
<h1>Needs attention</h1>
<p>Boundaries, human handoffs, delivery failures, account problems, and Reddit cooldowns in one actionable inbox.</p>
</div>
<div className="attention-refresh">
<button
aria-busy={query.isFetching || undefined}
className="button secondary"
disabled={query.isFetching}
onClick={() => void query.refetch()}
type="button"
>
{query.isFetching ? 'Refreshing…' : 'Refresh'}
</button>
<span className="attention-refresh-note">Auto-refreshes every 30s</span>
</div>
</header>
<section aria-label="Attention totals" className="status-strip">
<div>
<span aria-hidden="true" className={`attention-status-dot ${query.data.items.length === 0 ? 'is-clear' : 'is-open'}`} />
<div><small>Open items</small><strong>{query.data.items.length}</strong></div>
</div>
<span className={critical > 0 ? 'pill danger' : 'pill success'}>{critical} critical</span>
<span className="status-note">Updated {relativeTime(query.data.generatedAt, now)}</span>
</section>
{query.data.items.length === 0 ? <div className="empty-state panel"><strong>Nothing needs attention.</strong><span>Automation and account health have no outstanding operator items.</span></div> : (
<ol aria-label="Attention items" className="attention-list">{query.data.items.map((item) => (
<li className={`attention-item is-${item.severity}`} key={item.id}><div className="attention-item-heading"><div><span className={`pill ${item.severity === 'critical' ? 'warning' : 'neutral'}`}>{item.kind.replaceAll('-', ' ')}</span><h2>{item.title}</h2></div><time dateTime={new Date(item.occurredAt).toISOString()}>{formatTime(item.occurredAt)}</time></div><p>{item.detail}</p>{item.owner ? <AccountOwnerBadge owner={item.owner} /> : null}{itemAction(item)}</li>
<li className={`attention-item is-${item.severity}`} key={item.id}>
<div className="attention-item-heading">
<div>
<span className={`pill ${item.severity === 'critical' ? 'danger' : 'warning'}`}>{item.kind.replaceAll('-', ' ')}</span>
<h2>{item.title}</h2>
</div>
{relativeTime(item.occurredAt, now)}
</div>
<p>{item.detail}</p>
{item.owner ? <AccountOwnerBadge owner={item.owner} /> : null}
{itemAction(item)}
</li>
))}</ol>
)}
</div>
@@ -6,3 +6,9 @@
.attention-item-heading time { color: var(--muted); font-size: .8rem; text-align: right; }
.attention-item p { margin: 0; color: var(--muted); }
.attention-item .button { justify-self: start; text-decoration: none; }
.attention-item-hint { justify-self: start; color: var(--muted); font-size: .82rem; font-style: italic; }
.attention-refresh { display: grid; gap: .3rem; justify-items: end; }
.attention-refresh-note { color: var(--muted); font-size: .72rem; }
.attention-status-dot { width: .7rem; height: .7rem; border-radius: 50%; }
.attention-status-dot.is-clear { background: var(--success-text); box-shadow: 0 0 0 .35rem rgba(170, 240, 196, .12); }
.attention-status-dot.is-open { background: var(--danger); box-shadow: 0 0 0 .35rem rgba(255, 138, 132, .1); }
@@ -0,0 +1,19 @@
/**
* In-memory store of composer drafts keyed by conversation id, so switching
* conversations and back restores what the operator was typing.
*/
const drafts = new Map<string, string>();
export function getStoredComposerDraft(conversationId: string): string {
return drafts.get(conversationId) ?? '';
}
export function storeComposerDraft(conversationId: string, draft: string): void {
if (draft.length === 0) drafts.delete(conversationId);
else drafts.set(conversationId, draft);
}
/** Test helper: clear all stored drafts between test cases. */
export function resetComposerDrafts(): void {
drafts.clear();
}
@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { ConversationDetailResponse, MediaLibraryAsset } from '../../../../../packages/api-contracts/src/index.js';
import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
import { OriginPostCard } from '../../components/origin-post.js';
@@ -13,84 +13,132 @@ import {
import { SendConfirmationDialog } from './send-confirmation-dialog.js';
import { MediaShareConfirmationDialog } from './media-share-confirmation-dialog.js';
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
import { formatExactTime } from './relative-time.js';
import { getStoredComposerDraft, storeComposerDraft } from './composer-drafts.js';
/** Reddit chat rejects messages longer than roughly this many characters. */
export const REDDIT_CHAT_MAX_MESSAGE_LENGTH = 10_000;
const CHARACTER_WARNING_MARGIN = 500;
const SUCCESS_NOTICE_DISMISS_MS = 5_000;
const AT_BOTTOM_THRESHOLD_PX = 48;
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('/')}`;
}
/** Auto-dismisses a success flag after a few seconds. */
function useAutoDismiss(isVisible: boolean, dismiss: () => void) {
useEffect(() => {
if (!isVisible) return;
const timer = window.setTimeout(dismiss, SUCCESS_NOTICE_DISMISS_MS);
return () => window.clearTimeout(timer);
}, [isVisible, dismiss]);
}
function MessageHistory({ conversation }: { conversation: ConversationDetailResponse }) {
const [revealedMedia, setRevealedMedia] = useState<Set<string>>(() => new Set());
const listRef = useRef<HTMLOListElement>(null);
const isAtBottomRef = useRef(true);
const [isAtBottom, setIsAtBottom] = useState(true);
const messageCount = conversation.messages?.length ?? 0;
const scrollToBottom = useCallback(() => {
const list = listRef.current;
if (!list) return;
list.scrollTop = list.scrollHeight;
isAtBottomRef.current = true;
setIsAtBottom(true);
}, []);
// Stick to the latest message on mount and when new messages arrive,
// unless the operator has scrolled up to read history.
useEffect(() => {
if (isAtBottomRef.current) scrollToBottom();
}, [messageCount, scrollToBottom]);
function handleScroll() {
const list = listRef.current;
if (!list) return;
const atBottom = list.scrollHeight - list.scrollTop - list.clientHeight <= AT_BOTTOM_THRESHOLD_PX;
isAtBottomRef.current = atBottom;
setIsAtBottom(atBottom);
}
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>
{message.isAi ? <span aria-label="Sent by AI" className="conversation-ai-badge">AI</span> : null}
<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) => {
const mediaKey = `${message.timestamp}-${media.localPath}`;
const sender = message.isSelf ? 'sent by you' : `received from ${redditUsernameLabel(conversation.username)}`;
const isRevealed = revealedMedia.has(mediaKey);
return (
<div className="conversation-message-image" key={mediaKey}>
<img
alt={`Image ${sender}`}
className={isRevealed ? undefined : 'is-blurred'}
loading="lazy"
src={mediaPath(media.localPath)}
/>
<button
aria-pressed={isRevealed}
className="conversation-image-reveal"
onClick={() => setRevealedMedia((current) => {
const next = new Set(current);
if (next.has(mediaKey)) next.delete(mediaKey);
else next.add(mediaKey);
return next;
})}
type="button"
>
{isRevealed ? 'Blur conversation image' : 'Reveal conversation image'}
</button>
</div>
);
})}
<div className="conversation-messages-shell">
<ol aria-label="Message history" className="conversation-messages" onScroll={handleScroll} ref={listRef}>
{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>
{message.isAi ? <span aria-label="Sent by AI" className="conversation-ai-badge">AI</span> : null}
<time dateTime={new Date(message.timestamp).toISOString()}>{formatExactTime(message.timestamp)}</time>
</div>
) : null}
</li>
))}
</ol>
<p>{message.text}</p>
{message.media?.length ? (
<div className="conversation-message-images">
{message.media.map((media) => {
const mediaKey = `${message.timestamp}-${media.localPath}`;
const sender = message.isSelf ? 'sent by you' : `received from ${redditUsernameLabel(conversation.username)}`;
const isRevealed = revealedMedia.has(mediaKey);
return (
<div className="conversation-message-image" key={mediaKey}>
<img
alt={`Image ${sender}`}
className={isRevealed ? undefined : 'is-blurred'}
loading="lazy"
src={mediaPath(media.localPath)}
/>
<button
aria-pressed={isRevealed}
className="conversation-image-reveal"
onClick={() => setRevealedMedia((current) => {
const next = new Set(current);
if (next.has(mediaKey)) next.delete(mediaKey);
else next.add(mediaKey);
return next;
})}
type="button"
>
{isRevealed ? 'Blur conversation image' : 'Reveal conversation image'}
</button>
</div>
);
})}
</div>
) : null}
</li>
))}
</ol>
{!isAtBottom ? (
<button className="conversation-jump-latest" onClick={scrollToBottom} type="button">
Jump to latest
</button>
) : null}
</div>
);
}
function ConversationComposer({ conversation, conversationId }: { conversation: ConversationDetailResponse; conversationId: string }) {
const [draft, setDraft] = useState('');
const [draft, setDraft] = useState(() => getStoredComposerDraft(conversationId));
const [restorableDraft, setRestorableDraft] = useState<string | null>(null);
const [isConfirming, setIsConfirming] = useState(false);
const [wasSent, setWasSent] = useState(false);
const [selectedAssetId, setSelectedAssetId] = useState<string | null>(null);
const [areSavedImagesRevealed, setAreSavedImagesRevealed] = useState(false);
const [isMediaShareOpen, setIsMediaShareOpen] = useState(false);
const [isConfirmingMedia, setIsConfirmingMedia] = useState(false);
const [wasMediaShared, setWasMediaShared] = useState(false);
const sendButtonRef = useRef<HTMLButtonElement>(null);
@@ -98,12 +146,23 @@ function ConversationComposer({ conversation, conversationId }: { conversation:
const suggestionMutation = useSuggestReplyMutation(conversationId);
const mediaAskMutation = useSuggestMediaAskMutation(conversationId);
const sendMutation = useSendReplyMutation(conversationId);
const mediaOptionsQuery = useConversationMediaShareOptionsQuery(conversationId);
const mediaOptionsQuery = useConversationMediaShareOptionsQuery(conversationId, isMediaShareOpen);
const mediaShareMutation = useShareConversationMediaMutation(conversationId);
const mediaOptions = mediaOptionsQuery.data?.assets ?? [];
const selectedAsset: MediaLibraryAsset | null = mediaOptions.find((asset) => asset.id === selectedAssetId) ?? null;
const canSend = draft.trim().length > 0 && !sendMutation.isPending;
const automationActive = conversation.automation.status === 'active' && conversation.automation.remainingTurns > 0;
const isNearCharacterLimit = draft.length >= REDDIT_CHAT_MAX_MESSAGE_LENGTH - CHARACTER_WARNING_MARGIN;
const updateDraft = useCallback((next: string) => {
setDraft(next);
storeComposerDraft(conversationId, next);
}, [conversationId]);
const dismissSent = useCallback(() => setWasSent(false), []);
const dismissMediaShared = useCallback(() => setWasMediaShared(false), []);
useAutoDismiss(wasSent, dismissSent);
useAutoDismiss(wasMediaShared, dismissMediaShared);
const closeConfirmation = useCallback(() => {
setIsConfirming(false);
@@ -115,21 +174,29 @@ function ConversationComposer({ conversation, conversationId }: { conversation:
queueMicrotask(() => mediaShareSummaryRef.current?.focus());
}, []);
function applySuggestion(suggestion: string) {
const priorDraft = draft;
updateDraft(suggestion);
if (priorDraft.trim().length > 0 && priorDraft !== suggestion) setRestorableDraft(priorDraft);
else setRestorableDraft(null);
}
function suggestReply() {
setWasSent(false);
suggestionMutation.mutate(undefined, { onSuccess: (suggestion) => setDraft(suggestion) });
suggestionMutation.mutate(undefined, { onSuccess: applySuggestion });
}
function suggestMediaAsk() {
setWasSent(false);
mediaAskMutation.mutate(undefined, { onSuccess: (suggestion) => setDraft(suggestion) });
mediaAskMutation.mutate(undefined, { onSuccess: applySuggestion });
}
function sendReply() {
if (!canSend) return;
sendMutation.mutate(draft, {
onSuccess() {
setDraft('');
updateDraft('');
setRestorableDraft(null);
setWasSent(true);
},
onSettled: closeConfirmation,
@@ -168,24 +235,48 @@ function ConversationComposer({ conversation, conversationId }: { conversation:
<label className="sr-only" htmlFor="conversation-reply">Reply to {redditUsernameLabel(conversation.username)}</label>
<textarea
id="conversation-reply"
maxLength={REDDIT_CHAT_MAX_MESSAGE_LENGTH}
onChange={(event) => {
setDraft(event.target.value);
updateDraft(event.target.value);
setRestorableDraft(null);
setWasSent(false);
}}
placeholder="Write or generate a reply"
rows={5}
value={draft}
/>
{restorableDraft !== null ? (
<div className="conversation-inline-notice" role="status">
<span>The suggestion replaced your draft.</span>
<button
onClick={() => {
updateDraft(restorableDraft);
setRestorableDraft(null);
}}
type="button"
>
Undo
</button>
</div>
) : null}
{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}
<details className="conversation-media-share">
<summary ref={mediaShareSummaryRef}>Share a saved image</summary>
<details className="conversation-media-share" open={isMediaShareOpen}>
<summary
onClick={(event) => {
event.preventDefault();
setIsMediaShareOpen((current) => !current);
}}
ref={mediaShareSummaryRef}
>
Share a saved image
</summary>
<p>Only enabled, unused images matching the latest inbound request are available.</p>
{mediaOptionsQuery.isPending ? <span role="status">Loading saved images…</span> : null}
{mediaOptionsQuery.isLoading ? <span role="status">Loading saved images…</span> : null}
{mediaOptionsQuery.isError ? <div className="conversation-inline-error" role="alert">{mediaOptionsQuery.error.message}</div> : null}
{mediaOptionsQuery.data?.reason ? <div className="conversation-media-unavailable">{mediaOptionsQuery.data.reason}</div> : null}
{mediaOptions.length > 0 ? (
@@ -193,6 +284,9 @@ function ConversationComposer({ conversation, conversationId }: { conversation:
<button className="conversation-media-reveal" onClick={() => setAreSavedImagesRevealed((current) => !current)} type="button">
{areSavedImagesRevealed ? 'Blur saved images' : 'Reveal saved images'}
</button>
{!areSavedImagesRevealed ? (
<p className="conversation-media-hint">Reveal images to select one.</p>
) : null}
<div aria-label="Saved images available to share" className="conversation-media-options" role="radiogroup">
{mediaOptions.map((asset) => (
<label className={selectedAssetId === asset.id ? 'is-selected' : ''} key={asset.id}>
@@ -230,7 +324,9 @@ function ConversationComposer({ conversation, conversationId }: { conversation:
{wasMediaShared ? <div className="conversation-inline-success" role="status">Saved image shared with {redditUsernameLabel(conversation.username)}.</div> : null}
</details>
<div className="conversation-actions">
<span>{draft.length} characters</span>
<span className={isNearCharacterLimit ? 'conversation-char-counter is-warning' : 'conversation-char-counter'}>
{draft.length} / {REDDIT_CHAT_MAX_MESSAGE_LENGTH}
</span>
<button
aria-label="Review and send reply"
className="conversation-primary"
@@ -252,6 +348,7 @@ function ConversationComposer({ conversation, conversationId }: { conversation:
) : null}
{isConfirmingMedia && selectedAsset ? (
<MediaShareConfirmationDialog
areImagesRevealed={areSavedImagesRevealed}
asset={selectedAsset}
isPending={mediaShareMutation.isPending}
onCancel={closeMediaConfirmation}
@@ -8,8 +8,14 @@ export const conversationQueryKeys = {
mediaShareOptions: (id: string) => ['conversations', 'media-share-options', id] as const,
};
export const CONVERSATION_REFRESH_INTERVAL_MS = 30_000;
export function useConversationsQuery() {
return useQuery({ queryKey: conversationQueryKeys.all, queryFn: conversationApi.list });
return useQuery({
queryKey: conversationQueryKeys.all,
queryFn: conversationApi.list,
refetchInterval: CONVERSATION_REFRESH_INTERVAL_MS,
});
}
export function useConversationDetailQuery(id: string | null) {
@@ -17,6 +23,7 @@ export function useConversationDetailQuery(id: string | null) {
queryKey: conversationQueryKeys.detail(id ?? ''),
queryFn: () => conversationApi.detail(id ?? ''),
enabled: id !== null,
refetchInterval: CONVERSATION_REFRESH_INTERVAL_MS,
});
}
@@ -28,10 +35,11 @@ export function useSuggestMediaAskMutation(id: string) {
return useMutation({ mutationFn: () => conversationApi.suggestMediaAsk(id) });
}
export function useConversationMediaShareOptionsQuery(id: string) {
export function useConversationMediaShareOptionsQuery(id: string, enabled: boolean) {
return useQuery({
queryKey: conversationQueryKeys.mediaShareOptions(id),
queryFn: () => conversationApi.mediaShareOptions(id),
enabled,
});
}
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { ActiveChat } from '../../../../../packages/api-contracts/src/index.js';
import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
import { ConversationDetail } from './conversation-detail.js';
@@ -12,6 +12,8 @@ import { formatExactTime, formatShortRelativeTime } from './relative-time.js';
export type ConversationFilter = 'all' | 'unread' | 'awaiting-reply' | 'received-images';
const SMALL_VIEWPORT_QUERY = '(max-width: 51.9999rem)';
export interface ConversationFilters {
search: string;
filter: ConversationFilter;
@@ -39,10 +41,14 @@ function ConversationListItem({ conversation, isSelected, now, onSelect }: {
onSelect(): void;
}) {
const state = conversation.unread ? 'Unread' : conversation.isFromUs ? 'Awaiting reply' : 'Up to date';
const relativeTime = formatShortRelativeTime(conversation.lastMessageTime, now);
const itemClasses = ['conversation-list-item'];
if (isSelected) itemClasses.push('is-selected');
if (conversation.unread) itemClasses.push('is-unread');
return (
<li className={isSelected ? 'conversation-list-item is-selected' : 'conversation-list-item'}>
<li className={itemClasses.join(' ')}>
<button
aria-label={`${redditUsernameLabel(conversation.username)}, ${state}${conversation.sellerDetected ? ', Seller' : ''}${conversation.hasReceivedImages ? ', Image received' : ''}`}
aria-label={`${redditUsernameLabel(conversation.username)}, ${state}, ${relativeTime}${conversation.sellerDetected ? ', Seller' : ''}${conversation.hasReceivedImages ? ', Image received' : ''}`}
aria-pressed={isSelected}
className="conversation-list-target"
onClick={onSelect}
@@ -53,7 +59,7 @@ function ConversationListItem({ conversation, isSelected, now, onSelect }: {
<span className="conversation-list-meta">
<small>{state}</small>
<time dateTime={new Date(conversation.lastMessageTime).toISOString()} title={formatExactTime(conversation.lastMessageTime)}>
{formatShortRelativeTime(conversation.lastMessageTime, now)}
{relativeTime}
</time>
</span>
</span>
@@ -71,6 +77,7 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
const [localFilters, setLocalFilters] = useState<ConversationFilters>({ search: '', filter: 'all', showSellers: false });
const [isStartingConversation, setIsStartingConversation] = useState(false);
const [now, setNow] = useState(() => Date.now());
const detailPanelRef = useRef<HTMLElement>(null);
const activeConversationId = selectedConversationId !== undefined ? selectedConversationId : internalSelectedId;
const activeFilters = filters ?? localFilters;
@@ -79,6 +86,15 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
return () => window.clearInterval(interval);
}, []);
// On small viewports the detail panel sits below the list; bring it into
// view when a conversation is picked so the selection isn't invisible.
useEffect(() => {
if (!activeConversationId) return;
if (typeof window.matchMedia !== 'function') return;
if (!window.matchMedia(SMALL_VIEWPORT_QUERY).matches) return;
detailPanelRef.current?.scrollIntoView?.({ behavior: 'smooth', block: 'start' });
}, [activeConversationId]);
function updateFilters(next: ConversationFilters) {
if (filters === undefined) setLocalFilters(next);
onFiltersChange?.(next);
@@ -98,6 +114,11 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
.sort((left, right) => right.lastMessageTime - left.lastMessageTime);
}, [activeFilters, conversationsQuery.data]);
const unreadCount = useMemo(
() => (conversationsQuery.data ?? []).filter((conversation) => conversation.unread).length,
[conversationsQuery.data],
);
function selectConversation(id: string | null) {
if (selectedConversationId === undefined) setInternalSelectedId(id);
onSelectedConversationChange?.(id);
@@ -111,9 +132,22 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
<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>
<div className="conversations-header-actions">
<div className="conversation-refresh">
<button
aria-busy={conversationsQuery.isFetching || undefined}
disabled={conversationsQuery.isFetching}
onClick={() => void conversationsQuery.refetch()}
type="button"
>
{conversationsQuery.isFetching ? 'Refreshing…' : 'Refresh'}
</button>
<span className="conversation-refresh-note">Auto-refreshes every 30s</span>
</div>
<button className="conversation-primary" onClick={() => setIsStartingConversation((current) => !current)} type="button">
{isStartingConversation ? 'Close new conversation' : 'New conversation'}
</button>
</div>
</header>
{isStartingConversation ? (
@@ -146,6 +180,7 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
<option value="awaiting-reply">Awaiting reply</option>
<option value="received-images">Received images</option>
</select>
<span className="conversation-unread-count">{unreadCount} unread</span>
<label className="conversation-seller-toggle">
<input
checked={activeFilters.showSellers}
@@ -182,7 +217,7 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
)}
</section>
<section aria-label="Selected conversation" className="conversation-detail-panel">
<section aria-label="Selected conversation" className="conversation-detail-panel" ref={detailPanelRef}>
{activeConversationId ? (
<ConversationDetail conversationId={activeConversationId} key={activeConversationId} onClose={() => selectConversation(null)} />
) : (
@@ -14,6 +14,9 @@
}
.conversations-header h1 { margin-bottom: .4rem; }
.conversations-header-actions { display: flex; flex-wrap: wrap; align-items: center; gap: .75rem; }
.conversation-refresh { display: grid; gap: .25rem; justify-items: end; }
.conversation-refresh-note { color: var(--muted); font-size: .72rem; }
.conversations-header p { margin-bottom: 0; color: var(--muted); }
.conversation-eyebrow { display: block; margin-bottom: .4rem; color: var(--accent); font-size: .72rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }
.conversation-primary { border-color: transparent !important; color: #06140b !important; background: var(--accent) !important; font-weight: 750; }
@@ -22,6 +25,12 @@
.conversations-page button:disabled { cursor: wait; opacity: .58; }
.new-conversation-panel { padding: 1rem; margin-bottom: 1rem; background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; }
.new-conversation-panel h2 { margin-bottom: 1rem; }
.processed-user-lookup summary { cursor: pointer; }
.processed-user-lookup summary h2 { display: inline-block; margin: 0; vertical-align: middle; }
.processed-user-lookup[open] summary { margin-bottom: 1rem; }
.processed-user-lookup form + p, .processed-user-lookup ol { margin-top: .8rem; }
.conversation-form-hint { margin: 0; color: var(--muted); }
.conversation-form-hint a { color: var(--accent); }
.start-conversation-form { display: grid; gap: .8rem; }
.conversation-field { display: grid; gap: .35rem; }
.conversation-field label { color: var(--muted); font-size: .8rem; font-weight: 700; }
@@ -32,12 +41,16 @@
.conversation-workspace { display: grid; gap: .75rem; }
.conversation-master, .conversation-detail-panel { min-width: 0; background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; overflow: hidden; }
.conversation-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .5rem; padding: .75rem; border-bottom: 1px solid var(--border); }
.conversation-unread-count { grid-column: 1 / -1; color: var(--accent); font-size: .75rem; font-weight: 800; }
.conversation-seller-toggle { grid-column: 1 / -1; display: inline-flex; align-items: center; gap: .45rem; color: var(--muted); font-size: .78rem; font-weight: 700; }
.conversation-seller-toggle input { width: auto; accent-color: var(--accent); }
.conversation-list { display: grid; gap: .35rem; max-height: 46rem; padding: .5rem; margin: 0; overflow: auto; list-style: none; }
.conversation-list-item { position: relative; display: grid; gap: .4rem; width: 100%; padding: .8rem; text-align: left; background: transparent; border: 1px solid transparent; border-radius: .6rem; }
.conversation-list-item:hover { background: var(--surface-raised); }
.conversation-list-item.is-selected { background: var(--accent-dark); border-color: #28593d; }
.conversation-list-item.is-unread { padding-left: 1.4rem; }
.conversation-list-item.is-unread::before { content: ''; position: absolute; top: 1.2rem; left: .55rem; width: .45rem; height: .45rem; background: var(--accent); border-radius: 50%; }
.conversation-list-item.is-unread .conversation-list-heading strong { font-weight: 900; }
.conversation-list-target { position: absolute; inset: 0; z-index: 0; min-height: 0 !important; padding: 0 !important; background: transparent !important; border: 0 !important; }
.conversation-list-target:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.conversation-list-item > span { position: relative; z-index: 1; pointer-events: none; }
@@ -57,6 +70,8 @@
.conversation-badge { display: inline-flex; padding: .2rem .45rem; color: #aaf0c4; background: #163a26; border-radius: 999px; font-size: .7rem; font-weight: 800; }
.conversation-sync-notice { padding: .75rem 1rem; color: #ffe1a0; background: #2a2111; border-bottom: 1px solid #59451e; }
.triggering-post { margin: 1rem; padding: .75rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .65rem; }
.conversation-messages-shell { position: relative; }
.conversation-jump-latest { position: absolute; right: 1rem; bottom: .75rem; z-index: 2; padding: .35rem .7rem !important; min-height: 2rem !important; color: #06140b !important; background: var(--accent) !important; border-color: transparent !important; border-radius: 999px !important; font-size: .78rem; font-weight: 800; box-shadow: 0 .4rem 1.2rem rgba(0, 0, 0, .35); }
.conversation-messages { display: grid; gap: .75rem; max-height: 30rem; padding: 1rem; margin: 0; overflow: auto; list-style: none; }
.conversation-messages li { max-width: 85%; padding: .7rem .8rem; border-radius: .8rem; }
.conversation-messages li.is-self { justify-self: end; background: var(--accent-dark); }
@@ -75,7 +90,10 @@
.conversation-section-heading, .conversation-actions { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
.conversation-section-heading h3 { margin: 0; }
.conversation-actions > span { color: var(--muted); font-size: .75rem; }
.conversation-inline-error, .conversation-inline-success { padding: .65rem .75rem; border-radius: .55rem; }
.conversation-char-counter.is-warning { color: var(--warning-text) !important; font-weight: 800; }
.conversation-inline-error, .conversation-inline-success, .conversation-inline-notice { padding: .65rem .75rem; border-radius: .55rem; }
.conversation-inline-notice { display: flex; align-items: center; justify-content: space-between; gap: .75rem; color: var(--warning-text); background: var(--warning-bg); border: 1px solid var(--warning-border); }
.conversation-inline-notice button { min-height: 2rem !important; padding: .3rem .65rem !important; }
.conversation-inline-error { color: #ffd1ce; background: #461b18; border: 1px solid #80352f; }
.conversation-inline-success { color: #b9f3ce; background: #163a26; border: 1px solid #28593d; }
.conversation-media-share { display: grid; gap: .7rem; padding: .75rem; background: #0b1410; border: 1px solid var(--border); border-radius: .65rem; }
@@ -83,6 +101,7 @@
.conversation-media-share summary { color: #e9f1ec; font-weight: 800; cursor: pointer; }
.conversation-media-share p, .conversation-media-unavailable { margin: 0; color: var(--muted); font-size: .8rem; }
.conversation-media-reveal { justify-self: start; }
.conversation-media-hint { color: var(--warning-text) !important; font-size: .78rem !important; font-weight: 700; }
.conversation-media-options { display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); gap: .65rem; }
.conversation-media-options label { display: grid; gap: .35rem; padding: .35rem; overflow: hidden; border: 1px solid var(--border); border-radius: .55rem; cursor: pointer; }
.conversation-media-options label.is-selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
@@ -91,9 +110,11 @@
.conversation-media-options img { display: block; width: 100%; height: 8rem; object-fit: contain; background: #050907; border-radius: .35rem; transition: filter 160ms ease, transform 160ms ease; }
.conversation-media-options img.is-blurred { filter: blur(22px) brightness(.55); transform: scale(1.1); }
.conversation-media-options span { color: var(--muted); font-size: .7rem; text-transform: capitalize; }
.conversation-media-confirmation img { display: block; width: 100%; max-height: 55vh; margin: 1rem 0; object-fit: contain; background: #050907; border-radius: .55rem; }
.conversation-dialog-backdrop { position: fixed; inset: 0; z-index: 70; display: grid; place-items: center; padding: 1rem; background: rgba(0, 0, 0, .74); }
.conversation-dialog { width: min(30rem, 100%); padding: 1.25rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .9rem; box-shadow: 0 2rem 5rem rgba(0, 0, 0, .45); }
.conversation-media-confirmation img { display: block; width: 100%; max-height: 55vh; margin: 1rem 0; object-fit: contain; background: #050907; border-radius: .55rem; transition: filter 160ms ease; }
.conversation-media-confirmation img.is-blurred { filter: blur(24px) brightness(.55); }
.conversation-media-confirmation .conversation-message-image { overflow: hidden; border-radius: .55rem; }
dialog.conversation-dialog { width: min(30rem, calc(100% - 2rem)); }
dialog.conversation-media-confirmation { width: min(34rem, calc(100% - 2rem)); }
.conversation-dialog p { color: var(--muted); }
.conversation-dialog blockquote { max-height: 12rem; padding: .75rem; margin: 1rem 0; overflow: auto; background: #0b1410; border-left: 3px solid var(--accent); white-space: pre-wrap; }
.conversation-dialog .conversation-actions { justify-content: flex-end; }
@@ -110,13 +131,13 @@
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 0.875rem;
border: 1px solid var(--border-color, #d7dce2);
border: 1px solid var(--border);
border-radius: 0.625rem;
background: rgba(54, 116, 181, 0.08);
}
.conversation-automation-status span {
color: var(--muted-color, #606975);
color: var(--muted);
text-align: right;
}
@@ -150,7 +171,7 @@
border: 1px solid rgba(99, 102, 241, 0.35);
border-radius: 999px;
background: rgba(99, 102, 241, 0.12);
color: #5558c9;
color: #a5b4fc;
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.06em;
@@ -1,39 +1,56 @@
import { useEffect, useRef } from 'react';
import { useState } from 'react';
import type { MediaLibraryAsset } from '../../../../../packages/api-contracts/src/index.js';
import { Modal } from '../../components/modal.js';
interface MediaShareConfirmationDialogProps {
asset: MediaLibraryAsset;
/** Whether the operator already revealed the saved images in the composer. */
areImagesRevealed: boolean;
isPending: boolean;
onCancel(): void;
onConfirm(): void;
}
export function MediaShareConfirmationDialog({ asset, isPending, onCancel, onConfirm }: MediaShareConfirmationDialogProps) {
const cancelButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
cancelButtonRef.current?.focus();
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape' && !isPending) onCancel();
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isPending, onCancel]);
export function MediaShareConfirmationDialog({ asset, areImagesRevealed, isPending, onCancel, onConfirm }: MediaShareConfirmationDialogProps) {
const [isRevealed, setIsRevealed] = useState(areImagesRevealed);
return (
<div aria-labelledby="media-share-confirmation-title" aria-modal="true" className="conversation-dialog-backdrop" role="dialog">
<div className="conversation-dialog conversation-media-confirmation">
<span className="conversation-eyebrow">Confirm image</span>
<h2 id="media-share-confirmation-title">Share this saved image?</h2>
<p>This sends immediately through Reddit chat, then asks them to reciprocate without pressure.</p>
<img alt={`Saved image tagged ${asset.tags.join(', ')}`} src={asset.urlPath} />
<div className="conversation-actions">
<button disabled={isPending} onClick={onCancel} ref={cancelButtonRef} type="button">Cancel</button>
<button className="conversation-primary" disabled={isPending} onClick={onConfirm} type="button">
{isPending ? 'Sharing…' : 'Confirm image share'}
</button>
</div>
<Modal
className="dialog-card conversation-dialog conversation-media-confirmation"
isDismissLocked={isPending}
labelledBy="media-share-confirmation-title"
onClose={onCancel}
>
<span className="conversation-eyebrow">Confirm image</span>
<h2 id="media-share-confirmation-title">Share this saved image?</h2>
<p>This sends immediately through Reddit chat, then asks them to reciprocate without pressure.</p>
<div className="conversation-message-image">
<img
alt={`Saved image tagged ${asset.tags.join(', ')}`}
className={isRevealed ? undefined : 'is-blurred'}
src={asset.urlPath}
/>
<button
aria-pressed={isRevealed}
className="conversation-image-reveal"
onClick={() => setIsRevealed((current) => !current)}
type="button"
>
{isRevealed ? 'Blur image' : 'Reveal image'}
</button>
</div>
</div>
<div className="conversation-actions">
<button autoFocus disabled={isPending} onClick={onCancel} type="button">Cancel</button>
<button
aria-busy={isPending || undefined}
className="conversation-primary"
disabled={isPending}
onClick={onConfirm}
type="button"
>
{isPending ? 'Sharing…' : 'Confirm image share'}
</button>
</div>
</Modal>
);
}
@@ -7,24 +7,34 @@ export function ProcessedUserLookup() {
const [input, setInput] = useState('');
const [username, setUsername] = useState('');
const postsQuery = useProcessedPostsQuery(username);
// Only show results that belong to the current lookup; switching the
// username changes the query key, which clears stale data automatically.
const showResults = postsQuery.data !== undefined && !postsQuery.isFetching;
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>
<details className="new-conversation-panel processed-user-lookup">
<summary>
<h2 id="processed-user-heading">Processed user lookup</h2>
</summary>
<form
className="start-conversation-form processed-user-form"
onSubmit={(event) => { event.preventDefault(); setUsername(input.trim().replace(/^u\//i, '')); }}
>
<div className="conversation-field">
<label htmlFor="processed-user">Lookup Reddit username</label>
<input autoComplete="off" id="processed-user" onChange={(event) => setInput(event.target.value)} value={input} />
</div>
<button className="conversation-primary" 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.isFetching && username ? <p role="status">Looking up processed posts...</p> : null}
{postsQuery.isError ? <p className="conversation-inline-error" role="alert">{postsQuery.error.message}</p> : null}
{showResults && postsQuery.data ? (
postsQuery.data.length === 0 ? <p>No processed posts found for <RedditUsernameLink username={username} />.</p> : (
<ol aria-label={`Processed posts for ${redditUsernameLabel(username)}`}>
{postsQuery.data.map((post) => <li key={`${post.owner?.accountId ?? 'legacy'}:${post.id}`}><strong>{post.title}</strong><AccountOwnerBadge owner={post.owner} /><span>r/{post.subreddit} · {post.messageSent ? 'Message sent' : post.error ?? 'Processed'}</span></li>)}
</ol>
)
) : null}
</section>
</details>
);
}
@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react';
import { Modal } from '../../components/modal.js';
interface SendConfirmationDialogProps {
draft: string;
@@ -8,31 +8,29 @@ interface SendConfirmationDialogProps {
}
export function SendConfirmationDialog({ draft, isPending, onCancel, onConfirm }: SendConfirmationDialogProps) {
const cancelButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
cancelButtonRef.current?.focus();
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape' && !isPending) onCancel();
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isPending, onCancel]);
return (
<div aria-labelledby="send-confirmation-title" aria-modal="true" className="conversation-dialog-backdrop" role="dialog">
<div className="conversation-dialog">
<span className="conversation-eyebrow">Confirm message</span>
<h2 id="send-confirmation-title">Send this reply?</h2>
<p>This sends immediately through Reddit chat.</p>
<blockquote>{draft}</blockquote>
<div className="conversation-actions">
<button disabled={isPending} onClick={onCancel} ref={cancelButtonRef} type="button">Cancel</button>
<button className="conversation-primary" disabled={isPending} onClick={onConfirm} type="button">
{isPending ? 'Sending…' : 'Confirm send'}
</button>
</div>
<Modal
className="dialog-card conversation-dialog"
isDismissLocked={isPending}
labelledBy="send-confirmation-title"
onClose={onCancel}
>
<span className="conversation-eyebrow">Confirm message</span>
<h2 id="send-confirmation-title">Send this reply?</h2>
<p>This sends immediately through Reddit chat.</p>
<blockquote>{draft}</blockquote>
<div className="conversation-actions">
<button autoFocus disabled={isPending} onClick={onCancel} type="button">Cancel</button>
<button
aria-busy={isPending || undefined}
className="conversation-primary"
disabled={isPending}
onClick={onConfirm}
type="button"
>
{isPending ? 'Sending…' : 'Confirm send'}
</button>
</div>
</div>
</Modal>
);
}
@@ -1,9 +1,12 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
import { useStartConversationMutation } from './conversation-queries.js';
import { useRedditAccountsQuery } from '../settings/settings-queries.js';
import { ownedConversationId } from './conversation-identity.js';
const SUCCESS_NOTICE_DISMISS_MS = 5_000;
interface StartConversationFormProps {
onStarted(conversationId: string): void;
}
@@ -19,6 +22,26 @@ export function StartConversationForm({ onStarted }: StartConversationFormProps)
const mutation = useStartConversationMutation();
const canSubmit = accountId.length > 0 && username.trim().length > 0 && message.trim().length > 0 && !mutation.isPending;
useEffect(() => {
if (successUsername === null) return;
const timer = window.setTimeout(() => setSuccessUsername(null), SUCCESS_NOTICE_DISMISS_MS);
return () => window.clearTimeout(timer);
}, [successUsername]);
if (accountsQuery.isPending) {
return <p className="conversation-form-hint" role="status">Loading Reddit accounts…</p>;
}
if (accountsQuery.isError) {
return <div className="conversation-inline-error" role="alert">{accountsQuery.error.message}</div>;
}
if (runningAccounts.length === 0) {
return (
<p className="conversation-form-hint">
No running accounts — start one in <Link to="/settings">Settings</Link>.
</p>
);
}
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!canSubmit) return;
@@ -1,18 +1,83 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState, type ChangeEvent } from 'react';
import type { MediaLibraryAsset, MediaLibraryTag, MediaLibraryUpdate } from '../../../../../packages/api-contracts/src/index.js';
import { MediaPreviewDialog } from '../media/media-preview-dialog.js';
import { useDeleteMediaLibraryMutation, useMediaLibraryQuery, useUpdateMediaLibraryMutation, useUploadMediaLibraryMutation } from './queries.js';
import './media-library.css';
const TAGS: readonly MediaLibraryTag[] = ['selfie', 'chest', 'topless', 'feet', 'penis'];
const MAX_UPLOAD_BYTES = 15 * 1024 * 1024;
const NOTICE_MS = 5_000;
export function MediaLibraryPage() {
const query = useMediaLibraryQuery();
const upload = useUploadMediaLibraryMutation();
const fileInputRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null);
const [fileError, setFileError] = useState<string | null>(null);
const [consent, setConsent] = useState(false);
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
useEffect(() => {
if (!file || typeof URL.createObjectURL !== 'function') {
setThumbnailUrl(null);
return;
}
const url = URL.createObjectURL(file);
setThumbnailUrl(url);
return () => {
if (typeof URL.revokeObjectURL === 'function') URL.revokeObjectURL(url);
};
}, [file]);
const uploadSucceeded = upload.isSuccess;
useEffect(() => {
if (!uploadSucceeded) return;
const timer = setTimeout(() => upload.reset(), NOTICE_MS);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uploadSucceeded]);
function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const picked = event.target.files?.[0] ?? null;
if (!picked) {
setFile(null);
setFileError(null);
return;
}
if (!picked.type.startsWith('image/')) {
setFile(null);
setFileError('Only image files can be uploaded.');
event.target.value = '';
return;
}
if (picked.size > MAX_UPLOAD_BYTES) {
setFile(null);
setFileError('Images must be 15 MB or smaller.');
event.target.value = '';
return;
}
setFileError(null);
setFile(picked);
}
if (query.isPending) return <div className="page-state" role="status"><span className="spinner" />Loading exchange library…</div>;
if (query.isError) return <div className="page-state error-state" role="alert"><strong>Exchange library unavailable</strong><span>{query.error.message}</span><button className="button secondary" onClick={() => void query.refetch()} type="button">Retry</button></div>;
if (query.isError) {
return (
<div className="page-state error-state" role="alert">
<strong>Exchange library unavailable</strong>
<span>{query.error.message}</span>
<button
aria-busy={query.isFetching || undefined}
className="button secondary"
disabled={query.isFetching}
onClick={() => void query.refetch()}
type="button"
>
{query.isFetching ? 'Refreshing…' : 'Retry'}
</button>
</div>
);
}
const assets = query.data ?? [];
return (
@@ -39,17 +104,26 @@ export function MediaLibraryPage() {
</div>
<label className="library-file-field">
<span>Choose image</span>
<input accept="image/jpeg,image/png,image/webp" onChange={(event) => setFile(event.target.files?.[0] ?? null)} type="file" />
<input accept="image/jpeg,image/png,image/webp" onChange={handleFileChange} ref={fileInputRef} type="file" />
{thumbnailUrl ? <img alt={`Preview of ${file?.name ?? 'selected image'}`} className="library-upload-thumbnail" src={thumbnailUrl} /> : null}
<small>{file ? file.name : 'No image selected'}</small>
</label>
{fileError ? <div className="media-notice error" role="alert">{fileError}</div> : null}
<label className="library-consent-field">
<input checked={consent} onChange={(event) => setConsent(event.target.checked)} type="checkbox" />
<span>I confirm every depicted adult consented to this image being stored and sent by the bot.</span>
</label>
<button
aria-busy={upload.isPending || undefined}
className="button primary"
disabled={!file || !consent || upload.isPending}
onClick={() => file && upload.mutate(file, { onSuccess: () => { setFile(null); setConsent(false); } })}
onClick={() => file && upload.mutate(file, {
onSuccess: () => {
setFile(null);
setConsent(false);
if (fileInputRef.current) fileInputRef.current.value = '';
},
})}
type="button"
>
{upload.isPending ? 'Classifying…' : 'Upload and classify'}
@@ -78,36 +152,81 @@ function LibraryAssetCard({ asset }: { asset: MediaLibraryAsset }) {
const deleteMutation = useDeleteMediaLibraryMutation();
const [revealed, setRevealed] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [isDirty, setIsDirty] = useState(false);
const [showSaved, setShowSaved] = useState(false);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [form, setForm] = useState<MediaLibraryUpdate>(() => ({
tags: asset.tags,
safety: asset.safety === 'nsfw' ? 'nsfw' : 'sfw',
enabled: asset.enabled,
}));
// Refetches must not clobber a form the operator has touched.
useEffect(() => {
if (isDirty) return;
setForm({ tags: asset.tags, safety: asset.safety === 'nsfw' ? 'nsfw' : 'sfw', enabled: asset.enabled });
}, [asset]);
}, [asset, isDirty]);
useEffect(() => {
if (!showSaved) return;
const timer = setTimeout(() => setShowSaved(false), NOTICE_MS);
return () => clearTimeout(timer);
}, [showSaved]);
// Armed delete confirmation reverts to the safe state if left alone.
useEffect(() => {
if (!confirmDelete) return;
const timer = setTimeout(() => setConfirmDelete(false), NOTICE_MS);
return () => clearTimeout(timer);
}, [confirmDelete]);
function editForm(update: (current: MediaLibraryUpdate) => MediaLibraryUpdate) {
setIsDirty(true);
setForm(update);
}
function toggleTag(tag: MediaLibraryTag) {
setForm((current) => ({
editForm((current) => ({
...current,
tags: current.tags.includes(tag) ? current.tags.filter((item) => item !== tag) : [...current.tags, tag],
}));
}
const alt = `Library image tagged ${asset.tags.join(', ') || 'unclassified'}`;
const needsReview = asset.classificationStatus === 'failed' || asset.safety === 'unknown' || (asset.confidence ?? 0) < 0.7;
return (
<article className={asset.enabled ? 'library-card is-enabled' : 'library-card'}>
<div className="library-image-frame">
<img alt={`Library image tagged ${asset.tags.join(', ') || 'unclassified'}`} className={revealed ? '' : 'is-blurred'} src={asset.urlPath} />
<button className="library-reveal" onClick={() => setRevealed((value) => !value)} type="button">{revealed ? 'Blur' : 'Reveal'}</button>
<button
aria-label={revealed ? `View ${asset.fileName} larger` : `Reveal library image ${asset.fileName}`}
className="library-image-button"
onClick={() => {
if (revealed) setIsPreviewOpen(true);
else setRevealed(true);
}}
type="button"
>
<img alt={alt} className={revealed ? '' : 'is-blurred'} src={asset.urlPath} />
</button>
<button
aria-label={revealed ? `Blur ${asset.fileName}` : `Reveal ${asset.fileName}`}
aria-pressed={revealed}
className="library-reveal"
onClick={() => setRevealed((value) => !value)}
type="button"
>
{revealed ? 'Blur' : 'Reveal'}
</button>
<span className={`library-safety is-${asset.safety}`}>{asset.safety.toUpperCase()}</span>
</div>
<div className="library-card-body">
<div className="library-card-title">
<strong>{asset.fileName}</strong>
<span className={asset.enabled ? 'pill success' : 'pill neutral'}>{asset.enabled ? 'Enabled' : 'Disabled'}</span>
<span className="library-card-pills">
{isDirty ? <span className="pill warning">Unsaved changes</span> : null}
<span className={asset.enabled ? 'pill success' : 'pill neutral'}>{asset.enabled ? 'Enabled' : 'Disabled'}</span>
</span>
</div>
{needsReview ? <div className="library-review-note" role="status">Review the AI result before enabling this image.</div> : null}
{asset.description ? <p>{asset.description}</p> : <p>AI classification was unavailable. Add tags manually.</p>}
<small>{asset.width} × {asset.height} · {formatBytes(asset.sizeBytes)}{asset.confidence === null ? '' : ` · ${Math.round(asset.confidence * 100)}% confidence`}</small>
<small>{asset.sendCount === 0 ? 'Never sent' : `Sent ${asset.sendCount} ${asset.sendCount === 1 ? 'time' : 'times'}${asset.lastSentAt ? ` · last ${formatDate(asset.lastSentAt)}` : ''}`}</small>
@@ -118,16 +237,63 @@ function LibraryAssetCard({ asset }: { asset: MediaLibraryAsset }) {
</div>
</fieldset>
<div className="library-controls">
<label>Safety<select onChange={(event) => setForm((current) => ({ ...current, safety: event.target.value as 'sfw' | 'nsfw' }))} value={form.safety}><option value="sfw">SFW</option><option value="nsfw">NSFW</option></select></label>
<label className="library-enable"><input checked={form.enabled} onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.checked }))} type="checkbox" /><span>Available to bot</span></label>
<label>Safety<select onChange={(event) => editForm((current) => ({ ...current, safety: event.target.value as 'sfw' | 'nsfw' }))} value={form.safety}><option value="sfw">SFW</option><option value="nsfw">NSFW</option></select></label>
<div className="library-enable-group">
<label className="library-enable"><input checked={form.enabled} onChange={(event) => editForm((current) => ({ ...current, enabled: event.target.checked }))} type="checkbox" /><span>Available to bot</span></label>
{needsReview ? <div className="library-review-note" role="status">Review the AI result before enabling this image.</div> : null}
</div>
</div>
{updateMutation.isError ? <div className="media-notice error" role="alert">{updateMutation.error.message}</div> : null}
{deleteMutation.isError ? <div className="media-notice error" role="alert">{deleteMutation.error.message}</div> : null}
<div className="library-card-actions">
<button className="button primary" disabled={form.tags.length === 0 || updateMutation.isPending} onClick={() => updateMutation.mutate({ id: asset.id, update: form })} type="button">{updateMutation.isPending ? 'Saving…' : 'Save changes'}</button>
{confirmDelete ? <><button className="button danger" disabled={deleteMutation.isPending} onClick={() => deleteMutation.mutate(asset.id)} type="button">Confirm delete</button><button className="button secondary" onClick={() => setConfirmDelete(false)} type="button">Cancel</button></> : <button className="button secondary" onClick={() => setConfirmDelete(true)} type="button">Delete</button>}
<button
aria-busy={updateMutation.isPending || undefined}
className="button primary"
disabled={form.tags.length === 0 || updateMutation.isPending}
onClick={() => updateMutation.mutate({ id: asset.id, update: form }, {
onSuccess: () => {
setIsDirty(false);
setShowSaved(true);
},
})}
type="button"
>
{updateMutation.isPending ? 'Saving…' : 'Save changes'}
</button>
{showSaved ? <span className="library-saved-notice" role="status">Saved</span> : null}
{confirmDelete ? (
<>
<button className="button secondary" onClick={() => setConfirmDelete(false)} type="button">Cancel</button>
<button
aria-busy={deleteMutation.isPending || undefined}
className="button danger"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate(asset.id)}
type="button"
>
Confirm delete
</button>
</>
) : (
<button className="button secondary" onClick={() => setConfirmDelete(true)} type="button">Delete</button>
)}
</div>
{form.tags.length === 0 ? <small className="library-save-hint">Select at least one tag to save</small> : null}
</div>
{isPreviewOpen ? (
<MediaPreviewDialog
entries={[{
id: asset.id,
src: asset.urlPath,
alt,
label: `Preview of ${asset.fileName}`,
captionTitle: asset.fileName,
captionSubtitle: `${asset.width} × ${asset.height}`,
}]}
index={0}
onClose={() => setIsPreviewOpen(false)}
/>
) : null}
</article>
);
}
@@ -22,18 +22,26 @@
.library-card { overflow: hidden; border: 1px solid var(--border); border-radius: .9rem; background: var(--surface); }
.library-card.is-enabled { border-color: #386d4d; box-shadow: inset 0 0 0 1px rgba(117, 214, 157, .08); }
.library-image-frame { position: relative; min-height: 17rem; overflow: hidden; background: #050907; }
.library-image-button { display: block; width: 100%; padding: 0; border: 0; background: transparent; cursor: zoom-in; }
.library-image-button:focus-visible { outline: 2px solid var(--accent); outline-offset: -4px; }
.library-image-frame img { display: block; width: 100%; height: 17rem; object-fit: contain; transition: filter 180ms ease, transform 180ms ease; }
.library-image-frame img.is-blurred { filter: blur(24px) brightness(.5); transform: scale(1.12); }
.library-reveal { position: absolute; right: .75rem; bottom: .75rem; padding: .45rem .65rem; color: white; border: 1px solid rgba(255,255,255,.35); border-radius: .55rem; background: rgba(0,0,0,.78); font-weight: 750; }
.library-safety { position: absolute; top: .75rem; left: .75rem; padding: .3rem .5rem; border-radius: 999px; color: #08110d; background: var(--muted); font-size: .68rem; font-weight: 900; letter-spacing: .08em; }
.library-safety.is-sfw { background: var(--accent); }
.library-safety.is-nsfw { color: #21132d; background: var(--library-violet); }
.library-safety.is-unknown { color: var(--warning-text); background: var(--warning-bg); border: 1px solid var(--warning-border); }
.library-card-body { display: grid; gap: .85rem; padding: 1rem; }
.library-card-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
.library-card-title strong { overflow-wrap: anywhere; }
.library-card-body p { margin: 0; color: var(--muted); line-height: 1.45; }
.library-card-body > small { color: var(--muted); }
.library-review-note { padding: .6rem .7rem; color: #ffe1a0; border: 1px solid #59451e; border-radius: .55rem; background: #2a2111; font-size: .82rem; }
.library-review-note { padding: .6rem .7rem; color: var(--warning-text); border: 1px solid var(--warning-border); border-radius: .55rem; background: var(--warning-bg); font-size: .82rem; }
.library-card-pills { display: flex; flex-wrap: wrap; gap: .35rem; justify-content: flex-end; }
.library-enable-group { display: grid; gap: .5rem; }
.library-save-hint { color: var(--warning-text); }
.library-saved-notice { align-self: center; color: var(--success-text); font-weight: 750; }
.library-upload-thumbnail { display: block; max-width: 8rem; max-height: 8rem; border: 1px solid var(--border); border-radius: .5rem; object-fit: cover; }
.library-card fieldset { padding: 0; margin: 0; border: 0; }
.library-card legend { margin-bottom: .55rem; color: var(--muted); font-size: .78rem; font-weight: 750; }
.library-tag-grid { display: flex; flex-wrap: wrap; gap: .45rem; }
@@ -15,6 +15,12 @@ export function mediaType(item: MediaItem): 'image' | 'gif' {
return item.fileName.toLowerCase().endsWith('.gif') ? 'gif' : 'image';
}
export function mediaAlt(item: MediaItem): string {
const kind = mediaType(item) === 'gif' ? 'GIF' : 'Image';
const date = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short' }).format(item.timestamp);
return `${kind} from u/${item.sender}, ${date}`;
}
export function filterMedia(items: MediaItem[], filters: MediaFilters, now: number): MediaItem[] {
const rangeMs = {
all: null,
@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react';
import { Modal } from '../../components/modal.js';
interface FullSyncDialogProps {
isPending: boolean;
@@ -7,27 +7,24 @@ interface FullSyncDialogProps {
}
export function FullSyncDialog({ isPending, onCancel, onConfirm }: FullSyncDialogProps) {
const cancelButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
cancelButtonRef.current?.focus();
}, []);
return (
<div aria-labelledby="full-sync-title" aria-modal="true" className="dialog-backdrop" role="dialog">
<div className="dialog-card">
<span className="eyebrow">Confirm expensive action</span>
<h2 id="full-sync-title">Run full media sync?</h2>
<p>This scans every conversation and can take several minutes. Routine refreshes should use Sync new media.</p>
<div className="dialog-actions">
<button className="button secondary" disabled={isPending} onClick={onCancel} ref={cancelButtonRef} type="button">
Cancel
</button>
<button className="button danger" disabled={isPending} onClick={onConfirm} type="button">
{isPending ? 'Syncing…' : 'Run full sync'}
</button>
</div>
<Modal
className="dialog-card"
isDismissLocked={isPending}
labelledBy="full-sync-title"
onClose={onCancel}
>
<span className="eyebrow">Confirm expensive action</span>
<h2 id="full-sync-title">Run full media sync?</h2>
<p>This scans every conversation and can take several minutes. Routine refreshes should use Sync new media.</p>
<div className="dialog-actions">
<button autoFocus className="button secondary" disabled={isPending} onClick={onCancel} type="button">
Cancel
</button>
<button aria-busy={isPending || undefined} className="button danger" disabled={isPending} onClick={onConfirm} type="button">
{isPending ? 'Syncing…' : 'Run full sync'}
</button>
</div>
</div>
</Modal>
);
}
@@ -2,7 +2,7 @@ import type { MediaItem } from '../../../../../packages/api-contracts/src/index.
import { Link } from 'react-router-dom';
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
import { createOwnedConversationId } from '../conversations/conversation-identity.js';
import { mediaConversation, mediaType } from './filters.js';
import { mediaAlt, mediaConversation, mediaType } from './filters.js';
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
interface MediaCardProps {
@@ -26,21 +26,23 @@ export function MediaCard({ item, isRevealed, onToggleReveal, onOpenPreview }: M
<article className="media-card">
<div className="media-preview">
<button
aria-label={`View media from ${item.sender} larger`}
aria-label={isRevealed ? `View media from ${item.sender} larger` : `Reveal media from ${item.sender}`}
className="media-preview-button"
disabled={!isRevealed}
onClick={onOpenPreview}
onClick={() => {
if (isRevealed) onOpenPreview();
else onToggleReveal();
}}
type="button"
>
<img alt={`Media from ${item.sender}`} className={isRevealed ? 'media-image' : 'media-image is-blurred'} loading="lazy" src={item.urlPath} />
<img alt={mediaAlt(item)} className={isRevealed ? 'media-image' : 'media-image is-blurred'} loading="lazy" src={item.urlPath} />
</button>
{!isRevealed ? <span className="privacy-label">Private media hidden</span> : null}
</div>
<div className="media-card-body">
<AccountOwnerBadge owner={item.owner} />
<div>
<strong><RedditUsernameLink showPrefix={false} username={item.sender} /></strong>
<span>{item.chatUsername
<strong><span className="media-link-prefix">From</span> <RedditUsernameLink showPrefix={false} username={item.sender} /></strong>
<span><span className="media-link-prefix">Conversation</span> {item.chatUsername
? <RedditUsernameLink showPrefix={false} username={item.chatUsername} />
: mediaConversation(item)}</span>
</div>
@@ -54,6 +56,7 @@ export function MediaCard({ item, isRevealed, onToggleReveal, onOpenPreview }: M
</Link>
) : null}
<button
aria-pressed={isRevealed}
className="button secondary media-reveal"
onClick={onToggleReveal}
type="button"
+100 -26
View File
@@ -1,8 +1,8 @@
import { useMemo, useState } from 'react';
import { filterMedia, mediaConversation, type MediaFilters } from './filters.js';
import { useEffect, useMemo, useState } from 'react';
import { filterMedia, mediaAlt, mediaConversation, type MediaFilters } from './filters.js';
import { FullSyncDialog } from './full-sync-dialog.js';
import { MediaCard } from './media-card.js';
import { MediaPreviewDialog } from './media-preview-dialog.js';
import { MediaPreviewDialog, type MediaPreviewEntry } from './media-preview-dialog.js';
import { useMediaQuery, useMediaSyncStatusQuery, useTriggerMediaSyncMutation } from './queries.js';
import './media.css';
@@ -13,6 +13,9 @@ export const initialMediaFilters: MediaFilters = {
dateRange: 'all',
};
const PAGE_SIZE = 48;
const SUCCESS_NOTICE_MS = 5_000;
function errorMessage(error: Error | null): string {
return error?.message ?? 'Something went wrong.';
}
@@ -36,9 +39,11 @@ export function MediaPage({ filters: controlledFilters, onFiltersChange }: Media
const [isRevealAll, setIsRevealAll] = useState(false);
const [isFullSyncDialogOpen, setIsFullSyncDialogOpen] = useState(false);
const [previewItemId, setPreviewItemId] = useState<string | null>(null);
const [visibleLimit, setVisibleLimit] = useState(PAGE_SIZE);
const items = mediaQuery.data ?? [];
const visibleItems = useMemo(() => filterMedia(items, filters, Date.now()), [items, filters]);
const displayedItems = visibleItems.slice(0, visibleLimit);
const conversations = useMemo(
() => [...new Set(items.map(mediaConversation))].sort((left, right) => left.localeCompare(right)),
[items],
@@ -48,12 +53,37 @@ export function MediaPage({ filters: controlledFilters, onFiltersChange }: Media
[items],
);
const isSyncBusy = statusQuery.data?.isSyncing === true || syncMutation.isPending;
const previewItem = items.find((item) => item.id === previewItemId) ?? null;
const isFiltered = filters.conversation !== 'all' || filters.sender !== 'all' || filters.type !== 'all' || filters.dateRange !== 'all';
const previewIndex = previewItemId === null ? -1 : visibleItems.findIndex((item) => item.id === previewItemId);
const previewEntries: MediaPreviewEntry[] = useMemo(
() => visibleItems.map((item) => ({
id: item.id,
src: item.urlPath,
alt: mediaAlt(item),
label: `Media from ${item.sender}`,
captionTitle: `u/${item.sender}`,
captionSubtitle: formatLastSync(item.timestamp),
})),
[visibleItems],
);
function updateFilter<Key extends keyof MediaFilters>(key: Key, value: MediaFilters[Key]) {
const next = { ...filters, [key]: value };
const isSyncSuccess = syncMutation.isSuccess;
useEffect(() => {
if (!isSyncSuccess) return;
const timer = setTimeout(() => syncMutation.reset(), SUCCESS_NOTICE_MS);
return () => clearTimeout(timer);
// syncMutation is a fresh object every render; keying on isSuccess is intentional.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSyncSuccess]);
function updateFilters(next: MediaFilters) {
if (controlledFilters === undefined) setLocalFilters(next);
onFiltersChange?.(next);
setVisibleLimit(PAGE_SIZE);
}
function updateFilter<Key extends keyof MediaFilters>(key: Key, value: MediaFilters[Key]) {
updateFilters({ ...filters, [key]: value });
}
function toggleItem(itemId: string) {
@@ -90,7 +120,15 @@ export function MediaPage({ filters: controlledFilters, onFiltersChange }: Media
<div className="page-state error-state" role="alert">
<strong>Media unavailable</strong>
<span>{errorMessage(mediaQuery.error)}</span>
<button className="button secondary" onClick={() => void mediaQuery.refetch()} type="button">Retry media</button>
<button
aria-busy={mediaQuery.isFetching || undefined}
className="button secondary"
disabled={mediaQuery.isFetching}
onClick={() => void mediaQuery.refetch()}
type="button"
>
{mediaQuery.isFetching ? 'Refreshing…' : 'Retry media'}
</button>
</div>
);
}
@@ -106,11 +144,9 @@ export function MediaPage({ filters: controlledFilters, onFiltersChange }: Media
<div className="header-actions">
{items.length > 0 ? (
<button
aria-pressed={isRevealAll}
className="button secondary"
onClick={() => {
setIsRevealAll((current) => !current);
setRevealedIds(new Set());
}}
onClick={() => setIsRevealAll((current) => !current)}
type="button"
>
{isRevealAll ? 'Blur all media' : 'Reveal all media'}
@@ -127,7 +163,7 @@ export function MediaPage({ filters: controlledFilters, onFiltersChange }: Media
>
Full sync
</button>
<button className="button primary" disabled={isSyncBusy} onClick={() => runSync(false)} type="button">
<button aria-busy={isSyncBusy || undefined} className="button primary" disabled={isSyncBusy} onClick={() => runSync(false)} type="button">
{isSyncBusy ? 'Syncing…' : 'Sync new media'}
</button>
</div>
@@ -138,7 +174,15 @@ export function MediaPage({ filters: controlledFilters, onFiltersChange }: Media
{statusQuery.isError ? (
<div role="alert">
<span>{errorMessage(statusQuery.error)}</span>
<button className="button secondary" onClick={() => void statusQuery.refetch()} type="button">Retry sync status</button>
<button
aria-busy={statusQuery.isFetching || undefined}
className="button secondary"
disabled={statusQuery.isFetching}
onClick={() => void statusQuery.refetch()}
type="button"
>
{statusQuery.isFetching ? 'Refreshing…' : 'Retry sync status'}
</button>
</div>
) : null}
{statusQuery.data ? (
@@ -188,22 +232,45 @@ export function MediaPage({ filters: controlledFilters, onFiltersChange }: Media
<option value="all">Any time</option><option value="24h">Past 24 hours</option><option value="7d">Past 7 days</option><option value="30d">Past 30 days</option>
</select>
</label>
<div className="media-filter-summary">
<span role="status">Showing {displayedItems.length} of {items.length} items</span>
{isFiltered ? (
<button className="button secondary" onClick={() => updateFilters(initialMediaFilters)} type="button">
Clear filters
</button>
) : null}
</div>
</section>
{visibleItems.length === 0 ? (
<section className="panel empty-state"><strong>No media matches these filters.</strong><span>Try broadening one or more filters.</span></section>
) : (
<section aria-label="Media gallery" className="media-grid">
{visibleItems.map((item) => (
<MediaCard
isRevealed={isRevealAll || revealedIds.has(item.id)}
item={item}
key={item.id}
onOpenPreview={() => setPreviewItemId(item.id)}
onToggleReveal={() => toggleItem(item.id)}
/>
))}
<section className="panel empty-state">
<strong>No media matches these filters.</strong>
<span>Try broadening one or more filters.</span>
<button className="button secondary" onClick={() => updateFilters(initialMediaFilters)} type="button">
Clear filters
</button>
</section>
) : (
<>
<section aria-label="Media gallery" className="media-grid">
{displayedItems.map((item) => (
<MediaCard
isRevealed={isRevealAll || revealedIds.has(item.id)}
item={item}
key={item.id}
onOpenPreview={() => setPreviewItemId(item.id)}
onToggleReveal={() => toggleItem(item.id)}
/>
))}
</section>
{visibleItems.length > displayedItems.length ? (
<div className="media-load-more">
<button className="button secondary" onClick={() => setVisibleLimit((current) => current + PAGE_SIZE)} type="button">
Load more ({visibleItems.length - displayedItems.length} remaining)
</button>
</div>
) : null}
</>
)}
</>
)}
@@ -215,7 +282,14 @@ export function MediaPage({ filters: controlledFilters, onFiltersChange }: Media
onConfirm={() => runSync(true)}
/>
) : null}
{previewItem ? <MediaPreviewDialog item={previewItem} onClose={() => setPreviewItemId(null)} /> : null}
{previewIndex >= 0 ? (
<MediaPreviewDialog
entries={previewEntries}
index={previewIndex}
onClose={() => setPreviewItemId(null)}
onNavigate={(nextIndex) => setPreviewItemId(visibleItems[nextIndex]?.id ?? null)}
/>
) : null}
</div>
);
}
@@ -1,37 +1,119 @@
import { useEffect, useRef } from 'react';
import type { MediaItem } from '../../../../../packages/api-contracts/src/index.js';
import { useEffect, useState } from 'react';
import { Modal } from '../../components/modal.js';
export interface MediaPreviewEntry {
id: string;
src: string;
alt: string;
/** Accessible name for the dialog. */
label: string;
/** Caption bar: sender (or file name) plus optional date line. */
captionTitle: string;
captionSubtitle?: string;
}
interface MediaPreviewDialogProps {
item: MediaItem;
entries: MediaPreviewEntry[];
index: number;
onNavigate?(index: number): void;
onClose(): void;
}
export function MediaPreviewDialog({ item, onClose }: MediaPreviewDialogProps) {
const closeButtonRef = useRef<HTMLButtonElement>(null);
/**
* Full-screen lightbox for conversation and library media. When more than one
* entry is provided, previous/next buttons and ArrowLeft/ArrowRight navigate
* the visible set.
*/
export function MediaPreviewDialog({ entries, index, onNavigate, onClose }: MediaPreviewDialogProps) {
const entry = entries[index];
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
closeButtonRef.current?.focus();
setIsLoaded(false);
}, [entry?.src]);
const hasPrevious = index > 0;
const hasNext = index < entries.length - 1;
const canNavigate = entries.length > 1 && onNavigate !== undefined;
// Arrow keys navigate regardless of which control inside the modal holds
// focus (a nav button can become disabled, dropping focus).
useEffect(() => {
if (!canNavigate) return;
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose();
if (event.key === 'ArrowLeft' && hasPrevious) {
event.preventDefault();
onNavigate?.(index - 1);
} else if (event.key === 'ArrowRight' && hasNext) {
event.preventDefault();
onNavigate?.(index + 1);
}
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
}, [canNavigate, hasPrevious, hasNext, index, onNavigate]);
if (!entry) return null;
function goPrevious() {
if (hasPrevious) onNavigate?.(index - 1);
}
function goNext() {
if (hasNext) onNavigate?.(index + 1);
}
return (
<div
aria-label={`Media from ${item.sender}`}
aria-modal="true"
className="media-preview-dialog-backdrop"
onClick={(event) => {
if (event.target === event.currentTarget) onClose();
}}
role="dialog"
>
<Modal className="media-preview-dialog" closeOnBackdrop label={entry.label} onClose={onClose}>
<div className="media-preview-dialog-content">
<img alt={`Media from ${item.sender}`} className="media-preview-dialog-image" src={item.urlPath} />
<button aria-label="Close media preview" className="media-preview-dialog-close" onClick={onClose} ref={closeButtonRef} type="button">×</button>
{!isLoaded ? (
<span className="media-preview-dialog-loading" role="status">
<span className="spinner" />
<span className="sr-only">Loading full-size media</span>
</span>
) : null}
<img
alt={entry.alt}
className={isLoaded ? 'media-preview-dialog-image' : 'media-preview-dialog-image is-loading'}
onLoad={() => setIsLoaded(true)}
src={entry.src}
/>
<button
aria-label="Close media preview"
autoFocus
className="media-preview-dialog-close"
onClick={onClose}
type="button"
>
×
</button>
{canNavigate ? (
<>
<button
aria-label="Previous media"
className="media-preview-dialog-nav is-previous"
disabled={!hasPrevious}
onClick={goPrevious}
type="button"
>
‹
</button>
<button
aria-label="Next media"
className="media-preview-dialog-nav is-next"
disabled={!hasNext}
onClick={goNext}
type="button"
>
›
</button>
</>
) : null}
<div className="media-preview-dialog-caption">
<strong>{entry.captionTitle}</strong>
{entry.captionSubtitle ? <span>{entry.captionSubtitle}</span> : null}
{canNavigate ? <span>{index + 1} of {entries.length}</span> : null}
</div>
</div>
</div>
</Modal>
);
}
+19 -4
View File
@@ -3,13 +3,15 @@
.media-sync-status > div[role="alert"] { width: 100%; justify-content: space-between; }
.media-sync-status .spinner { width: 1rem; height: 1rem; }
.media-notice { padding: .8rem 1rem; margin-bottom: 1rem; border-radius: .75rem; font-weight: 700; }
.media-notice.success { color: #aaf0c4; background: #163a26; border: 1px solid #28593d; }
.media-notice.error { color: #ffd1ce; background: #461b18; border: 1px solid #80352f; }
.media-notice.success { color: var(--success-text); background: var(--success-bg); border: 1px solid var(--success-border); }
.media-notice.error { color: var(--danger-text); background: var(--danger-bg); border: 1px solid var(--danger-border); }
.media-filters { display: grid; grid-template-columns: 1fr; gap: .8rem; margin-bottom: 1rem; }
.media-filters label { display: grid; gap: .35rem; color: var(--muted); font-size: .78rem; font-weight: 750; }
.media-filters select { width: 100%; min-height: 2.6rem; padding: .5rem .7rem; color: #e9f1ec; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .6rem; }
.media-filters select:focus-visible, .media-reveal:focus-visible, .media-conversation-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.media-filter-summary { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .6rem; grid-column: 1 / -1; color: var(--muted); font-size: .82rem; }
.media-grid { display: grid; grid-template-columns: 1fr; gap: 1rem; }
.media-load-more { display: flex; justify-content: center; margin-top: 1rem; }
.media-card { min-width: 0; overflow: hidden; background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; }
.media-preview { position: relative; display: grid; place-items: center; aspect-ratio: 4 / 3; overflow: hidden; background: #050907; }
.media-preview-button { width: 100%; height: 100%; padding: 0; border: 0; background: transparent; cursor: zoom-in; }
@@ -20,17 +22,30 @@
.media-card-body { display: grid; gap: .8rem; padding: .9rem; }
.media-card-body > div:first-child { display: flex; justify-content: space-between; gap: .75rem; }
.media-card-body > div:first-child span { color: var(--muted); }
.media-link-prefix { color: var(--muted); font-size: .72rem; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; }
.media-card-body dl { display: grid; gap: .4rem; margin: 0; }
.media-card-body dl div { display: flex; justify-content: space-between; gap: .75rem; }
.media-card-body dt { color: var(--muted); }
.media-card-body dd { margin: 0; text-align: right; }
.media-conversation-link { display: flex; align-items: center; justify-content: center; width: 100%; text-decoration: none; }
.media-reveal { width: 100%; }
.media-preview-dialog-backdrop { position: fixed; inset: 0; z-index: 70; display: flex; align-items: center; justify-content: center; padding: 1rem; background: rgba(0, 0, 0, .88); }
/* Full-screen lightbox rendered through the shared <dialog> Modal. */
dialog.media-preview-dialog { width: 100vw; height: 100vh; max-width: 100vw; max-height: 100vh; padding: 1rem; border: 0; background: transparent; }
dialog.media-preview-dialog::backdrop { background: rgba(0, 0, 0, .9); }
.media-preview-dialog-content { position: relative; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
.media-preview-dialog-image { display: block; width: auto; height: auto; max-width: 100%; max-height: 100%; object-fit: contain; }
.media-preview-dialog-image { display: block; width: auto; height: auto; max-width: 100%; max-height: calc(100% - 3.6rem); object-fit: contain; }
.media-preview-dialog-image.is-loading { opacity: 0; }
.media-preview-dialog-loading { position: absolute; display: grid; place-items: center; color: white; }
.media-preview-dialog-close { position: absolute; top: .5rem; right: .5rem; width: 2.75rem; height: 2.75rem; padding: 0; border: 1px solid rgba(255, 255, 255, .5); border-radius: 50%; color: white; background: rgba(0, 0, 0, .7); font-size: 2rem; line-height: 1; cursor: pointer; }
.media-preview-dialog-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.media-preview-dialog-nav { position: absolute; top: 50%; width: 2.75rem; height: 2.75rem; padding: 0; transform: translateY(-50%); border: 1px solid rgba(255, 255, 255, .5); border-radius: 50%; color: white; background: rgba(0, 0, 0, .7); font-size: 1.8rem; line-height: 1; cursor: pointer; }
.media-preview-dialog-nav:disabled { opacity: .35; }
.media-preview-dialog-nav.is-previous { left: .5rem; }
.media-preview-dialog-nav.is-next { right: .5rem; }
.media-preview-dialog-nav:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.media-preview-dialog-caption { position: absolute; bottom: 0; left: 50%; display: flex; flex-wrap: wrap; align-items: baseline; justify-content: center; gap: .35rem .8rem; max-width: 100%; padding: .55rem .9rem; transform: translateX(-50%); color: white; background: rgba(0, 0, 0, .72); border-radius: .6rem .6rem 0 0; font-size: .82rem; }
.media-preview-dialog-caption span { color: rgba(255, 255, 255, .75); }
@media (min-width: 40rem) {
.media-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -1,6 +1,7 @@
import { useEffect, useReducer, useRef, useState } from 'react';
import type { ActivityItem, ChatSyncStatus, DashboardEvent, RedditAccountOwner } from '../../../../../packages/api-contracts/src/index.js';
import { useDashboardRealtime } from '../../app/realtime-provider.js';
import { Modal } from '../../components/modal.js';
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
import { RedditUserText } from '../../components/reddit-user-text.js';
import { OriginPostButton } from '../../components/origin-post.js';
@@ -28,8 +29,8 @@ function ActivityView({ items }: { items: readonly ActivityItem[] }) {
<p className="feature-empty">No recent operator activity.</p>
) : (
<ol className="operations-activity-list">
{items.map((item) => (
<li key={`${item.timestamp}-${item.message}`}>
{items.map((item, index) => (
<li key={`${item.timestamp}-${index}`}>
<span className={`activity-marker ${item.type}`} aria-hidden="true" />
<AccountOwnerBadge owner={item.owner} />
<div><strong>{item.postUrl && item.postTitle ? (
@@ -86,6 +87,40 @@ export interface OperationsPageProps {
realtimeEnabled?: boolean;
}
const NOTICE_DISMISS_MS = 5_000;
const expensiveSyncCopy = {
full: {
title: 'Run a full cache sync?',
body: 'This rebuilds the entire chat cache and can take significantly longer than a delta sync.',
confirmLabel: 'Run full sync',
},
'fetch-all': {
title: 'Fetch every chat now?',
body: 'This is a legacy blocking operation that fetches every chat and may take a while.',
confirmLabel: 'Start fetching',
},
} as const;
function ConfirmSyncDialog({ kind, onCancel, onConfirm }: {
kind: 'full' | 'fetch-all';
onCancel(): void;
onConfirm(): void;
}) {
const copy = expensiveSyncCopy[kind];
return (
<Modal className="dialog-card" labelledBy="confirm-sync-title" onClose={onCancel}>
<span className="eyebrow">Confirm action</span>
<h2 id="confirm-sync-title">{copy.title}</h2>
<p>{copy.body}</p>
<div className="dialog-actions">
<button autoFocus className="button secondary" onClick={onCancel} type="button">Cancel</button>
<button className="button primary" onClick={onConfirm} type="button">{copy.confirmLabel}</button>
</div>
</Modal>
);
}
export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps) {
const realtime = useDashboardRealtime();
const activityQuery = useActivityQuery();
@@ -94,12 +129,16 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps)
const syncMutation = useTriggerChatSyncMutation();
const fetchAllMutation = useFetchAllChatsMutation();
const [consoleClearNotice, setConsoleClearNotice] = useState(false);
const [pendingSyncConfirm, setPendingSyncConfirm] = useState<'full' | 'fetch-all' | null>(null);
const [pauseAnnouncement, setPauseAnnouncement] = useState('');
const [isPinnedToBottom, setIsPinnedToBottom] = useState(true);
const [consoleState, dispatchConsole] = useReducer(
reduceLiveConsoleState,
createLiveConsoleState(500),
);
const connection = realtimeEnabled ? realtime.connection : { status: 'idle', isStale: true } as const;
const consoleEnd = useRef<HTMLDivElement>(null);
const consoleViewport = useRef<HTMLDivElement>(null);
useEffect(() => {
logsQuery.data?.forEach((entry, index) => {
@@ -136,22 +175,54 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps)
const visibleEntries = selectVisibleConsoleEntries(consoleState);
const syncBusy = syncQuery.data?.syncing === true || syncMutation.isPending || fetchAllMutation.isPending;
function confirmAndRun(kind: 'full' | 'fetch-all') {
const confirmed = globalThis.confirm(kind === 'full'
? 'Run a full chat cache sync? This can take significantly longer than a delta sync.'
: 'Fetch every chat now? This is a legacy blocking operation and may take a while.');
if (!confirmed) return;
function runExpensiveSync(kind: 'full' | 'fetch-all') {
setPendingSyncConfirm(null);
if (kind === 'full') syncMutation.mutate('full'); else fetchAllMutation.mutate();
}
const resetSyncMutation = syncMutation.reset;
const resetFetchAllMutation = fetchAllMutation.reset;
useEffect(() => {
if (!syncMutation.isSuccess) return;
const timer = globalThis.setTimeout(() => resetSyncMutation(), NOTICE_DISMISS_MS);
return () => globalThis.clearTimeout(timer);
}, [resetSyncMutation, syncMutation.isSuccess]);
useEffect(() => {
if (!fetchAllMutation.isSuccess) return;
const timer = globalThis.setTimeout(() => resetFetchAllMutation(), NOTICE_DISMISS_MS);
return () => globalThis.clearTimeout(timer);
}, [fetchAllMutation.isSuccess, resetFetchAllMutation]);
useEffect(() => {
if (!consoleClearNotice) return;
const timer = globalThis.setTimeout(() => setConsoleClearNotice(false), NOTICE_DISMISS_MS);
return () => globalThis.clearTimeout(timer);
}, [consoleClearNotice]);
function handleConsoleScroll() {
const viewport = consoleViewport.current;
if (!viewport) return;
const atBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight <= 16;
setIsPinnedToBottom(atBottom);
}
function jumpToLatest() {
setIsPinnedToBottom(true);
consoleEnd.current?.scrollIntoView({ block: 'nearest' });
}
useEffect(() => {
if (
consoleState.isAutoScrollEnabled &&
isPinnedToBottom &&
!consoleState.isPaused &&
typeof consoleEnd.current?.scrollIntoView === 'function'
) {
consoleEnd.current.scrollIntoView({ block: 'nearest' });
}
}, [consoleState.entries, consoleState.isAutoScrollEnabled, consoleState.isPaused]);
}, [consoleState.entries, consoleState.isAutoScrollEnabled, consoleState.isPaused, isPinnedToBottom]);
return (
<div className="page operations-page">
@@ -167,7 +238,7 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps)
</section>
<section aria-labelledby="sync-jobs-heading" className="panel feature-panel" role="region">
<div className="panel-heading"><div><span className="eyebrow">Background work</span><h2 id="sync-jobs-heading">Sync jobs</h2></div></div>
{syncQuery.isPending ? <p role="status">Loading sync state…</p> : syncQuery.isError ? <p role="alert">{syncQuery.error.message}</p> : <><SyncSummary status={syncQuery.data} /><div className="header-actions"><button className="button secondary" disabled={syncBusy} onClick={() => syncMutation.mutate('delta')} type="button">Sync latest chats</button><button className="button secondary" disabled={syncBusy} onClick={() => confirmAndRun('full')} type="button">Full cache sync</button><button className="button secondary" disabled={syncBusy} onClick={() => confirmAndRun('fetch-all')} type="button">Fetch all chats</button></div></>}
{syncQuery.isPending ? <p role="status">Loading sync state…</p> : syncQuery.isError ? <p role="alert">{syncQuery.error.message}</p> : <><SyncSummary status={syncQuery.data} /><div className="header-actions"><button className="button secondary" disabled={syncBusy} onClick={() => syncMutation.mutate('delta')} type="button">Sync latest chats</button><button className="button secondary" disabled={syncBusy} onClick={() => setPendingSyncConfirm('full')} type="button">Full cache sync</button><button className="button secondary" disabled={syncBusy} onClick={() => setPendingSyncConfirm('fetch-all')} type="button">Fetch all chats</button></div></>}
{syncMutation.isSuccess ? <p role="status">{syncMutation.data.kind === 'delta' ? 'Delta' : 'Full'} sync started. Progress is shown from server status.</p> : null}
{fetchAllMutation.isSuccess ? <p role="status">Fetched {fetchAllMutation.data.count} chats.</p> : null}
{syncMutation.isError ? <p role="alert">{syncMutation.error.message}</p> : null}
@@ -175,16 +246,28 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps)
</section>
</div>
<section aria-labelledby="live-console-heading" className="panel console-panel" role="region">
<div className="panel-heading"><div><span className="eyebrow">Raw diagnostics</span><h2 id="live-console-heading">Live console</h2></div><span className="console-count">{consoleState.entries.length} retained</span></div>
<div className="panel-heading"><div><span className="eyebrow">Raw diagnostics</span><h2 id="live-console-heading">Live console</h2></div><span className="console-count">{visibleEntries.length === consoleState.entries.length
? `${consoleState.entries.length} retained`
: `${visibleEntries.length} shown · ${consoleState.entries.length} retained`}</span></div>
<div className="console-controls">
<button aria-pressed={consoleState.isPaused} className="button secondary" onClick={() => dispatchConsole({ type: 'set-paused', paused: !consoleState.isPaused })} type="button">{consoleState.isPaused ? `Resume console (${consoleState.pendingEntries.length})` : 'Pause console'}</button>
<button
aria-pressed={consoleState.isPaused}
className="button secondary"
onClick={() => {
const paused = !consoleState.isPaused;
dispatchConsole({ type: 'set-paused', paused });
setPauseAnnouncement(paused ? 'Console paused.' : 'Console resumed.');
}}
type="button"
>{consoleState.isPaused ? `Resume console (${consoleState.pendingEntries.length})` : 'Pause console'}</button>
<button className="button secondary" onClick={() => { dispatchConsole({ type: 'clear' }); setConsoleClearNotice(true); }} type="button">Clear local console</button>
<label>Log level<select aria-label="Log level" onChange={(event) => dispatchConsole({ type: 'set-level', level: event.target.value as ConsoleLevelFilter })} value={consoleState.level}><option value="all">All levels</option><option value="debug">Debug</option><option value="info">Info</option><option value="warn">Warning</option><option value="error">Error</option></select></label>
<label>Search logs<input aria-label="Search logs" onChange={(event) => dispatchConsole({ type: 'set-search', search: event.target.value })} placeholder="Message or metadata" type="search" value={consoleState.search} /></label>
<label className="checkbox-control"><input checked={consoleState.isAutoScrollEnabled} onChange={(event) => dispatchConsole({ type: 'set-auto-scroll', enabled: event.target.checked })} type="checkbox" />Auto-scroll</label>
</div>
{consoleClearNotice ? <p role="status">Cleared from this browser view. Server logs were not deleted.</p> : null}
<div aria-live="polite" className="live-console" role="log">
<p aria-live="polite" className="sr-only" role="status">{pauseAnnouncement}</p>
<div className="live-console" onScroll={handleConsoleScroll} ref={consoleViewport} role="log">
{logsQuery.isPending ? <p>Loading logs…</p> : visibleEntries.length === 0 ? <p>No logs match the current filters.</p> : visibleEntries.map((entry) => {
const owner = accountOwnerFromLogData(entry.data);
return (
@@ -198,7 +281,17 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps)
})}
<div ref={consoleEnd} />
</div>
{!isPinnedToBottom ? (
<button className="button secondary console-jump" onClick={jumpToLatest} type="button">Jump to latest</button>
) : null}
</section>
{pendingSyncConfirm ? (
<ConfirmSyncDialog
kind={pendingSyncConfirm}
onCancel={() => setPendingSyncConfirm(null)}
onConfirm={() => runExpensiveSync(pendingSyncConfirm)}
/>
) : null}
</div>
);
}
@@ -1,6 +1,6 @@
.operations-page { display: grid; gap: .8rem; }
.connection-banner { display: flex; justify-content: space-between; gap: 1rem; padding: .8rem 1rem; color: #aaf0c4; background: #163a26; border: 1px solid #28593d; border-radius: .75rem; }
.connection-banner.stale { color: #ffe1a0; background: #2a2111; border-color: #59451e; }
.connection-banner { display: flex; justify-content: space-between; gap: 1rem; padding: .8rem 1rem; color: var(--success-text); background: var(--success-bg); border: 1px solid var(--success-border); border-radius: .75rem; }
.connection-banner.stale { color: var(--warning-text); background: var(--warning-bg); border-color: var(--warning-border); }
.operations-summary-grid { display: grid; gap: .8rem; }
.operations-activity-list { display: grid; gap: .8rem; padding: 0; margin: 0; list-style: none; }
.operations-activity-list li { display: flex; gap: .75rem; }
@@ -29,6 +29,8 @@
.console-entry.warn strong { color: var(--warning); }
.console-entry.error strong { color: var(--danger); }
.live-console > p { color: var(--muted); text-align: center; }
.console-panel { position: relative; }
.console-jump { position: absolute; right: 1.6rem; bottom: 1.6rem; box-shadow: 0 .5rem 1.5rem rgba(0, 0, 0, .4); }
@media (min-width: 56rem) {
.operations-summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.console-controls { grid-template-columns: auto minmax(8rem, .45fr) minmax(14rem, 1fr) auto; align-items: end; }
@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import type { OutreachCandidate, OutreachCandidateStatus } from '../../../../../packages/api-contracts/src/index.js';
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
@@ -18,8 +19,14 @@ const STAGES: ReadonlyArray<{
{ status: 'retryable', label: 'Retrying', description: 'Cooling down' },
];
const KNOWN_STATUSES = new Set<string>(STAGES.map((stage) => stage.status));
function statusLabel(status: OutreachCandidateStatus): string {
return STAGES.find((stage) => stage.status === status)?.label ?? status;
const stage = STAGES.find((candidate) => candidate.status === status);
if (stage) return stage.label;
// Unknown statuses fall back to a readable neutral label instead of the raw enum.
const readable = String(status).replaceAll(/[-_]/g, ' ');
return readable.charAt(0).toUpperCase() + readable.slice(1);
}
function formatWaitTime(timestamp: number, now: number): string {
@@ -32,13 +39,14 @@ function formatWaitTime(timestamp: number, now: number): string {
function CandidateCard({ candidate, now }: { candidate: OutreachCandidate; now: number }) {
const waitingUntil = candidate.availableAt > now;
const isKnownStatus = KNOWN_STATUSES.has(candidate.status);
return (
<li className={`outreach-candidate is-${candidate.status}`}>
<li className={`outreach-candidate ${isKnownStatus ? `is-${candidate.status}` : 'is-unknown'}`}>
<div className="candidate-status-rail" aria-hidden="true"><span /></div>
<article>
<div className="candidate-heading">
<div className="candidate-labels">
<span className={`candidate-status status-${candidate.status}`}>{statusLabel(candidate.status)}</span>
<span className={`candidate-status ${isKnownStatus ? `status-${candidate.status}` : 'status-neutral'}`}>{statusLabel(candidate.status)}</span>
<span>r/{candidate.subreddit}</span>
<span>{candidate.sourceType === 'image' ? 'Image post' : 'Chat post'}</span>
</div>
@@ -46,7 +54,10 @@ function CandidateCard({ candidate, now }: { candidate: OutreachCandidate; now:
queued {formatShortRelativeTime(candidate.discoveredAt, now)}
</time>
</div>
<a className="candidate-title" href={candidate.postUrl} rel="noreferrer" target="_blank">{candidate.title}</a>
<a className="candidate-title" href={candidate.postUrl} rel="noreferrer" target="_blank">
{candidate.title} <span aria-hidden="true">↗</span>
</a>
<span className="sr-only">(opens Reddit in a new tab)</span>
<div className="candidate-meta">
<RedditUsernameLink username={candidate.author} />
<span>{candidate.hasPreparedMessage ? 'Message prepared' : 'Not prepared yet'}</span>
@@ -66,16 +77,49 @@ export function OutreachPage() {
queryFn: outreachApi.getFunnel,
refetchInterval: 10_000,
});
if (query.isPending) return <div className="page-state" role="status">Loading outreach funnel…</div>;
if (query.isError) return <div className="page-state error-state" role="alert"><strong>Outreach funnel unavailable</strong><span>{query.error.message}</span><button className="button secondary" onClick={() => void query.refetch()} type="button">Retry</button></div>;
// Single client-side clock for every displayed offset: retry countdowns tick
// every second while visible, everything else every 10 seconds.
const [now, setNow] = useState(() => Date.now());
const hasVisibleCountdown = (query.data?.candidates ?? []).some((candidate) => candidate.availableAt > now);
useEffect(() => {
const interval = setInterval(() => setNow(Date.now()), hasVisibleCountdown ? 1_000 : 10_000);
return () => clearInterval(interval);
}, [hasVisibleCountdown]);
if (query.isPending) return <div className="page-state" role="status"><span className="spinner" />Loading outreach funnel…</div>;
if (query.isError) {
return (
<div className="page-state error-state" role="alert">
<strong>Outreach funnel unavailable</strong>
<span>{query.error.message}</span>
<button
aria-busy={query.isFetching || undefined}
className="button secondary"
disabled={query.isFetching}
onClick={() => void query.refetch()}
type="button"
>
{query.isFetching ? 'Refreshing…' : 'Retry'}
</button>
</div>
);
}
const { data } = query;
const now = data.generatedAt;
return (
<div className="page outreach-page">
<header className="page-header">
<div><span className="eyebrow">Live candidate queue</span><h1>Outreach funnel</h1><p>Follow every potential post from discovery to delivery, and see exactly where the remaining queue is waiting.</p></div>
<button className="button secondary" disabled={query.isFetching} onClick={() => void query.refetch()} type="button">{query.isFetching ? 'Refreshing…' : 'Refresh'}</button>
<button
aria-busy={query.isFetching || undefined}
className="button secondary"
disabled={query.isFetching}
onClick={() => void query.refetch()}
type="button"
>
{query.isFetching ? 'Refreshing…' : 'Refresh'}
</button>
</header>
<section aria-labelledby="active-funnel-heading" className="outreach-runway">
@@ -96,7 +140,13 @@ export function OutreachPage() {
</section>
<section aria-labelledby="active-candidates-heading" className="candidate-section">
<div className="panel-heading"><div><span className="eyebrow">Potential posts</span><h2 id="active-candidates-heading">Active queue</h2></div><span className="candidate-updated">{data.candidates.length < data.activeCount ? `Showing ${data.candidates.length} of ${data.activeCount} · ` : ''}Updated {formatShortRelativeTime(data.generatedAt, Date.now())}</span></div>
<div className="panel-heading">
<div><span className="eyebrow">Potential posts</span><h2 id="active-candidates-heading">Active queue</h2></div>
<span className="candidate-updated">
{data.candidates.length < data.activeCount ? `Showing ${data.candidates.length} of ${data.activeCount} · ` : ''}
Updated {formatShortRelativeTime(data.generatedAt, now)} · Auto-updates every 10s
</span>
</div>
{data.candidates.length === 0 ? (
<div className="empty-state panel"><strong>The active queue is clear.</strong><span>New potential posts will appear here as discovery finds them.</span></div>
) : (
@@ -32,15 +32,16 @@
.candidate-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: .75rem; }
.candidate-labels { display: flex; flex-wrap: wrap; align-items: center; gap: .45rem; color: var(--muted); font-size: .72rem; }
.candidate-status { padding: .25rem .45rem; color: #dce8e2; background: #2b3832; border-radius: .35rem; font-weight: 800; }
.status-neutral { color: #dce8e2; background: #2b3832; }
.status-screening { color: #d6edfc; background: #243a48; }
.status-ready, .status-sending { color: #bff4d2; background: #163a26; }
.status-retryable { color: #ffe1a0; background: #42351b; }
.status-ready, .status-sending { color: var(--success-text); background: var(--success-bg); }
.status-retryable { color: var(--warning-text); background: #42351b; }
.candidate-heading time { flex: 0 0 auto; color: var(--muted); font-size: .74rem; }
.candidate-title { width: fit-content; max-width: 100%; color: #eef6f1; font-size: 1rem; font-weight: 720; line-height: 1.35; text-decoration-color: #426655; text-underline-offset: .22em; overflow-wrap: anywhere; }
.candidate-title:hover { text-decoration-color: var(--accent); }
.candidate-meta { display: flex; flex-wrap: wrap; gap: .4rem .8rem; color: var(--muted); font-size: .76rem; }
.candidate-note { display: grid; gap: .18rem; margin: 0; padding: .55rem .7rem; color: #d9c99e; background: #251f13; border-left: 2px solid var(--warning); border-radius: .35rem; font-size: .78rem; overflow-wrap: anywhere; }
.candidate-note strong { color: #ffe1a0; font-size: .68rem; letter-spacing: .06em; text-transform: uppercase; }
.candidate-note strong { color: var(--warning-text); font-size: .68rem; letter-spacing: .06em; text-transform: uppercase; }
.outreach-candidate .account-owner-badge { margin-top: -.1rem; }
@media (min-width: 64rem) {
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { dashboardQueryKeys, useSetRunningMutation, useStatsQuery, useTimersQuery } from '../../api/queries.js';
import type { BotStats, Timers } from '../../api/contracts.js';
@@ -17,6 +17,27 @@ function formatTime(timestamp: number): string {
return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(timestamp);
}
function formatMetricValue(value: number): string {
return value.toLocaleString();
}
function formatCountdown(remainingMs: number): string {
const totalSeconds = Math.max(0, Math.ceil(remainingMs / 1_000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
}
/** Ticks a client clock so time-derived UI flips without a refetch. */
function useNow(intervalMs = 1_000): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = globalThis.setInterval(() => setNow(Date.now()), intervalMs);
return () => globalThis.clearInterval(timer);
}, [intervalMs]);
return now;
}
function MetricCard({ label, value, detail, metric }: { label: string; value: string | number; detail: string; metric: string }) {
return (
<article className="metric-card">
@@ -28,7 +49,8 @@ function MetricCard({ label, value, detail, metric }: { label: string; value: st
}
function TimersPanel({ timers }: { timers: Timers }) {
const isReady = timers.nextMessageAvailableAt === null || timers.nextMessageAvailableAt <= Date.now();
const now = useNow();
const isReady = timers.nextMessageAvailableAt === null || timers.nextMessageAvailableAt <= now;
return (
<section aria-labelledby="timers-heading" className="panel">
<div className="panel-heading">
@@ -41,7 +63,9 @@ function TimersPanel({ timers }: { timers: Timers }) {
<dl className="definition-list">
<div>
<dt>Next message</dt>
<dd>{isReady ? 'Available now' : formatTime(timers.nextMessageAvailableAt ?? Date.now())}</dd>
<dd>{isReady
? 'Available now'
: `Ready in ${formatCountdown((timers.nextMessageAvailableAt ?? now) - now)} (${formatTime(timers.nextMessageAvailableAt ?? now)})`}</dd>
</div>
<div>
<dt>Hourly allowance</dt>
@@ -129,8 +153,8 @@ function ActivityPanel({ stats }: { stats: BotStats }) {
</div>
) : (
<ol className="activity-list">
{stats.recentActivity.map((item) => (
<li key={`${item.timestamp}-${item.message}`}>
{stats.recentActivity.map((item, index) => (
<li key={`${item.timestamp}-${index}`}>
<span className={`activity-marker ${item.type}`} aria-hidden="true" />
<div><strong>{item.postUrl && item.postTitle ? (
<OriginPostButton post={{ title: item.postTitle, url: item.postUrl, imageUrl: item.imageUrl }}>
@@ -168,6 +192,15 @@ export function OverviewPage() {
}
if (statsQuery.isPending) {
if (statsQuery.isPaused) {
return (
<div className="page-state error-state" role="alert">
<strong>You appear to be offline</strong>
<span>The overview will load once the connection returns.</span>
<button className="button secondary" onClick={() => void statsQuery.refetch()} type="button">Retry overview</button>
</div>
);
}
return <div aria-label="Loading overview" className="page-state" role="status"><span className="spinner" />Loading overview…</div>;
}
@@ -194,6 +227,7 @@ export function OverviewPage() {
</div>
<div className="header-actions">
<button
aria-busy={isRefreshing ? 'true' : undefined}
aria-label={isRefreshing ? 'Refreshing overview' : 'Refresh overview'}
className="button secondary"
disabled={isRefreshing}
@@ -203,7 +237,6 @@ export function OverviewPage() {
{isRefreshing ? 'Refreshing…' : 'Refresh'}
</button>
<button
aria-label={`${action === 'start' ? 'Start' : 'Stop'} bot`}
className={action === 'start' ? 'button primary' : 'button danger'}
onClick={() => setConfirmationAction(action)}
type="button"
@@ -232,10 +265,10 @@ export function OverviewPage() {
) : null}
<section aria-label="Key metrics" className="metrics-grid">
<MetricCard detail="All recorded sends" label="Messages sent" metric="messages-sent" value={stats.totalMessagesSent} />
<MetricCard detail={`${stats.responseRate}% response rate`} label="Responses" metric="responses" value={stats.totalResponses} />
<MetricCard detail="Posts evaluated" label="Processed" metric="processed" value={stats.totalProcessed} />
<MetricCard detail={`Limit ${stats.maxMessagesPerHour}`} label="This hour" metric="messages-hour" value={stats.messagesThisHour} />
<MetricCard detail="All recorded sends" label="Messages sent" metric="messages-sent" value={formatMetricValue(stats.totalMessagesSent)} />
<MetricCard detail={`${stats.responseRate}% response rate`} label="Responses" metric="responses" value={formatMetricValue(stats.totalResponses)} />
<MetricCard detail="Posts evaluated" label="Processed" metric="processed" value={formatMetricValue(stats.totalProcessed)} />
<MetricCard detail={`Limit ${formatMetricValue(stats.maxMessagesPerHour)}`} label="This hour" metric="messages-hour" value={formatMetricValue(stats.messagesThisHour)} />
</section>
<AccountStatusPanel accounts={stats.accounts} />
@@ -256,12 +289,12 @@ export function OverviewPage() {
</div>
<RecentMessagesPanel />
{controlMutation.isError ? <div className="toast error" role="alert">{errorMessage(controlMutation.error)}</div> : null}
{confirmationAction ? (
<ConfirmationDialog
action={confirmationAction}
errorMessage={controlMutation.isError ? errorMessage(controlMutation.error) : null}
isPending={controlMutation.isPending}
onCancel={() => setConfirmationAction(null)}
onCancel={() => { setConfirmationAction(null); controlMutation.reset(); }}
onConfirm={confirmControl}
/>
) : null}
@@ -20,9 +20,20 @@ export function RecentMessagesPanel() {
return (
<section aria-labelledby="recent-messages-heading" className="panel">
<div className="panel-heading"><div><span className="eyebrow">Durable records</span><h2 id="recent-messages-heading">Recent sent messages</h2></div></div>
{query.isPending ? <p role="status">Loading recent messages...</p> : null}
{query.isError ? <div role="alert"><span>{query.error.message}</span><button className="button secondary" onClick={() => void query.refetch()} type="button">Retry messages</button></div> : null}
{query.data?.length === 0 ? <p>No sent messages recorded yet.</p> : null}
{query.isPending ? <div className="panel-loading" role="status"><span className="spinner" />Loading recent messages…</div> : null}
{query.isError ? (
<div className="error-panel" role="alert">
<strong>Recent messages unavailable</strong>
<span>{query.error.message}</span>
<button className="button secondary" onClick={() => void query.refetch()} type="button">Retry messages</button>
</div>
) : null}
{query.data?.length === 0 ? (
<div className="empty-state">
<strong>No sent messages recorded yet.</strong>
<span>Delivered messages will appear here.</span>
</div>
) : null}
{query.data && query.data.length > 0 ? (
<ol aria-label="Recent sent messages" className="activity-list">
{query.data.map((message) => (
@@ -1,3 +1,5 @@
import { useState, type ReactNode } from 'react';
import type { UseQueryResult } from '@tanstack/react-query';
import type { z } from 'zod';
import type {
DailyTrendResponseSchema,
@@ -24,16 +26,43 @@ type DailyTrend = z.infer<typeof DailyTrendResponseSchema>[number];
type Funnel = z.infer<typeof FunnelResponseSchema>;
type AiUsageSummary = z.infer<typeof AiUsageSummaryResponseSchema>;
function formatTrendDate(isoDate: string): string {
const parsed = new Date(`${isoDate}T00:00:00`);
if (Number.isNaN(parsed.getTime())) return isoDate;
return new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short' }).format(parsed);
}
/** Renders one panel's own loading, error, and data states independently of its siblings. */
function PanelBody<T>({ query, label, children }: {
query: UseQueryResult<T, Error>;
label: string;
children: (data: T) => ReactNode;
}) {
if (query.isPending) {
return <div className="panel-loading" role="status"><span className="spinner" />Loading {label}…</div>;
}
if (query.isError) {
return (
<div className="error-panel" role="alert">
<strong>{label} unavailable</strong>
<span>{query.error.message}</span>
<button className="button secondary" onClick={() => void query.refetch()} type="button">Retry {label}</button>
</div>
);
}
return <>{children(query.data)}</>;
}
function formatUsd(value: number): string {
return new Intl.NumberFormat(undefined, {
style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 4,
}).format(value);
}
function AiUsagePanel({ usage }: { usage: AiUsageSummary }) {
function AiUsagePanelBody({ usage }: { usage: AiUsageSummary }) {
const successRate = usage.requests > 0 ? Math.round((usage.successes / usage.requests) * 100) : 0;
return (
<section aria-labelledby="ai-usage-heading" className="panel feature-panel">
<>
<div className="panel-heading"><div><span className="eyebrow">OpenRouter · last {usage.days} days</span><h2 id="ai-usage-heading">AI cost and reliability</h2></div><span className="sample-size">{usage.requests} requests</span></div>
<div aria-label="AI usage totals" className="metrics-grid performance-metrics">
<article className="metric-card"><span>Reported cost</span><strong>{formatUsd(usage.costUsd)}</strong><small>OpenRouter usage accounting</small></article>
@@ -46,7 +75,7 @@ function AiUsagePanel({ usage }: { usage: AiUsageSummary }) {
<tr key={model.model}><th scope="row">{model.model}</th><td>{model.requests}</td><td>{model.successes}</td><td>{model.refusals}</td><td>{model.errors}</td><td>{model.totalTokens.toLocaleString()}</td><td>{formatUsd(model.costUsd)}</td><td>{model.averageLatencyMs.toLocaleString()} ms</td></tr>
))}</tbody></table></div>
)}
</section>
</>
);
}
@@ -60,7 +89,7 @@ function SampleSize({ totalSent }: { totalSent: number }) {
);
}
function FunnelPanel({ funnel }: { funnel: Funnel }) {
function FunnelPanelBody({ funnel }: { funnel: Funnel }) {
const stages = [
['Processed', funnel.processed],
['Sent', funnel.sent],
@@ -70,17 +99,28 @@ function FunnelPanel({ funnel }: { funnel: Funnel }) {
['Images', funnel.images],
] as const;
return (
<section aria-labelledby="funnel-heading" className="panel feature-panel">
<>
<div className="panel-heading"><div><span className="eyebrow">Qualified engagement</span><h2 id="funnel-heading">Outcome funnel</h2></div><span className="sample-size">{funnel.pending} awaiting maturity</span></div>
<ol className="funnel-list">
{stages.map(([label, value]) => <li key={label}><span>{label}</span><strong>{value}</strong></li>)}
{stages.map(([label, value], index) => {
const previousValue = index > 0 ? stages[index - 1][1] : null;
const conversion = previousValue !== null && previousValue > 0
? `${Math.round((value / previousValue) * 100)}% of previous`
: null;
return (
<li key={label}>
<span>{label}</span>
<span className="funnel-value"><strong>{value.toLocaleString()}</strong>{conversion ? <small className="interval">{conversion}</small> : null}</span>
</li>
);
})}
</ol>
<div className="guardrail-list" aria-label="Guardrail outcomes">
<span>Declined <strong>{funnel.declined}</strong></span>
<span>Boundary <strong>{funnel.boundary}</strong></span>
<span>Upsell <strong>{funnel.upsell}</strong></span>
</div>
</section>
</>
);
}
@@ -93,6 +133,14 @@ function PerformanceTable({
rows: readonly (SubredditPerformance | StrategyPerformance)[];
rowLabel: (row: SubredditPerformance | StrategyPerformance) => string;
}) {
if (rows.length === 0) {
return (
<div className="empty-state">
<strong>No sends recorded yet.</strong>
<span>Rows appear once messages have been sent.</span>
</div>
);
}
return (
<div className="table-scroll">
<table aria-label={label} className="performance-table">
@@ -117,12 +165,20 @@ function PerformanceTable({
);
}
function Trend({ rows }: { rows: readonly DailyTrend[] }) {
function Trend({ rows, rangeDays }: { rows: readonly DailyTrend[]; rangeDays: number }) {
if (rows.length === 0) {
return (
<div className="empty-state">
<strong>No sends recorded yet.</strong>
<span>Daily results appear once messages have been sent.</span>
</div>
);
}
return (
<ol aria-label="30 day response trend" className="trend-list">
<ol aria-label={`${rangeDays} day response trend`} className="trend-list">
{rows.map((row) => (
<li key={row.date}>
<time dateTime={row.date}>{row.date}</time>
<time dateTime={row.date}>{formatTrendDate(row.date)}</time>
<span className="trend-track" aria-hidden="true"><span style={{ width: `${Math.max(2, Math.min(100, row.responseRate))}%` }} /></span>
<strong>{row.responseRate}%</strong>
<span>{row.totalImages} images ({row.imageRate}%)</span>
@@ -152,33 +208,66 @@ function SuccessfulExamples({ examples }: { examples: readonly SuccessfulExample
);
}
const TREND_RANGES = [7, 30, 90] as const;
type TrendRange = (typeof TREND_RANGES)[number];
export function PerformancePage() {
const [rangeDays, setRangeDays] = useState<TrendRange>(30);
const subreddits = useSubredditPerformanceQuery();
const strategies = useStrategyPerformanceQuery();
const trend = useDailyTrendQuery(30);
const trend = useDailyTrendQuery(rangeDays);
const examples = useSuccessfulExamplesQuery();
const funnel = useFunnelQuery();
const aiUsage = useAiUsageQuery(30);
const queries = [subreddits, strategies, trend, examples, funnel, aiUsage];
if (queries.some((query) => query.isPending)) {
return <div className="page-state" role="status">Loading performance…</div>;
}
const failed = queries.find((query) => query.isError);
if (failed?.error) {
return <div className="page-state error-state" role="alert"><strong>Performance unavailable</strong><span>{failed.error.message}</span></div>;
}
const aiUsage = useAiUsageQuery(rangeDays);
return (
<div className="page performance-page">
<header className="page-header"><div><span className="eyebrow">Decision support</span><h1>Performance</h1><p>Compare response and received-image outcomes without hiding the sample sizes behind the percentages.</p></div></header>
{funnel.data ? <FunnelPanel funnel={funnel.data} /> : null}
{aiUsage.data ? <AiUsagePanel usage={aiUsage.data} /> : null}
<section aria-labelledby="trend-heading" className="panel feature-panel"><div className="panel-heading"><div><span className="eyebrow">Last 30 days</span><h2 id="trend-heading">Response trend</h2></div></div><Trend rows={trend.data ?? []} /></section>
<header className="page-header">
<div><span className="eyebrow">Decision support</span><h1>Performance</h1><p>Compare response and received-image outcomes without hiding the sample sizes behind the percentages.</p></div>
<div aria-label="Date range" className="header-actions" role="group">
{TREND_RANGES.map((days) => (
<button
aria-pressed={rangeDays === days}
className={rangeDays === days ? 'button primary' : 'button secondary'}
key={days}
onClick={() => setRangeDays(days)}
type="button"
>
{days} days
</button>
))}
</div>
</header>
<section aria-labelledby="funnel-heading-anchor" className="panel feature-panel">
<span className="sr-only" id="funnel-heading-anchor">Outcome funnel</span>
<PanelBody label="outcome funnel" query={funnel}>{(data) => <FunnelPanelBody funnel={data} />}</PanelBody>
</section>
<section aria-labelledby="ai-usage-heading-anchor" className="panel feature-panel">
<span className="sr-only" id="ai-usage-heading-anchor">AI cost and reliability</span>
<PanelBody label="AI usage" query={aiUsage}>{(data) => <AiUsagePanelBody usage={data} />}</PanelBody>
</section>
<section aria-labelledby="trend-heading" className="panel feature-panel">
<div className="panel-heading"><div><span className="eyebrow">Last {rangeDays} days</span><h2 id="trend-heading">Response trend</h2></div></div>
<PanelBody label="response trend" query={trend}>{(data) => <Trend rangeDays={rangeDays} rows={data} />}</PanelBody>
</section>
<div className="performance-grid">
<section aria-labelledby="subreddit-heading" className="panel feature-panel"><div className="panel-heading"><h2 id="subreddit-heading">By subreddit</h2></div><PerformanceTable label="Subreddit performance" rows={subreddits.data ?? []} rowLabel={(row) => 'subreddit' in row ? row.subreddit : row.strategyId} /></section>
<section aria-labelledby="strategy-heading" className="panel feature-panel"><div className="panel-heading"><h2 id="strategy-heading">By strategy</h2></div><PerformanceTable label="Strategy performance" rows={strategies.data ?? []} rowLabel={(row) => 'strategyId' in row ? row.strategyId : row.subreddit} /></section>
<section aria-labelledby="subreddit-heading" className="panel feature-panel">
<div className="panel-heading"><h2 id="subreddit-heading">By subreddit</h2></div>
<PanelBody label="subreddit performance" query={subreddits}>
{(data) => <PerformanceTable label="Subreddit performance" rowLabel={(row) => 'subreddit' in row ? row.subreddit : row.strategyId} rows={data} />}
</PanelBody>
</section>
<section aria-labelledby="strategy-heading" className="panel feature-panel">
<div className="panel-heading"><h2 id="strategy-heading">By strategy</h2></div>
<PanelBody label="strategy performance" query={strategies}>
{(data) => <PerformanceTable label="Strategy performance" rowLabel={(row) => 'strategyId' in row ? row.strategyId : row.subreddit} rows={data} />}
</PanelBody>
</section>
</div>
<section aria-labelledby="examples-heading" className="panel feature-panel"><div className="panel-heading"><div><span className="eyebrow">What worked</span><h2 id="examples-heading">Successful examples</h2></div></div><SuccessfulExamples examples={examples.data ?? []} /></section>
<section aria-labelledby="examples-heading" className="panel feature-panel">
<div className="panel-heading"><div><span className="eyebrow">What worked</span><h2 id="examples-heading">Successful examples</h2></div></div>
<PanelBody label="successful examples" query={examples}>{(data) => <SuccessfulExamples examples={data} />}</PanelBody>
</section>
</div>
);
}
@@ -12,10 +12,17 @@
.interval { display: block; margin-top: .2rem; color: var(--muted); white-space: nowrap; }
.funnel-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .6rem; padding: 0; margin: 0; list-style: none; }
.funnel-list li { display: flex; justify-content: space-between; gap: 1rem; padding: .8rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .65rem; }
.funnel-value { display: grid; gap: .1rem; text-align: right; }
.guardrail-list { display: flex; flex-wrap: wrap; gap: .6rem; margin-top: .7rem; color: var(--muted); }
.guardrail-list span { padding: .45rem .65rem; border: 1px solid var(--border); border-radius: 999px; }
.trend-list { display: grid; gap: .65rem; padding: 0; margin: 0; list-style: none; }
.trend-list li { display: grid; grid-template-columns: 6.5rem minmax(5rem, 1fr) 3rem 8rem 6rem; align-items: center; gap: .75rem; }
/* Narrow screens: wrap each day into two rows instead of forcing a wide grid. */
.trend-list li { display: grid; grid-template-columns: 4.5rem minmax(4rem, 1fr) 3rem; align-items: center; gap: .4rem .75rem; }
.trend-list li > span:not(.trend-track), .trend-list li .sample-size { grid-column: span 3; font-size: .78rem; }
@media (min-width: 48rem) {
.trend-list li { grid-template-columns: 6.5rem minmax(5rem, 1fr) 3rem 8rem 6rem; gap: .75rem; }
.trend-list li > span:not(.trend-track), .trend-list li .sample-size { grid-column: auto; font-size: inherit; }
}
.trend-track { height: .5rem; overflow: hidden; background: var(--border); border-radius: 999px; }
.trend-track span { display: block; height: 100%; background: var(--accent); border-radius: inherit; }
.example-grid { display: grid; gap: .75rem; }
@@ -1,4 +1,4 @@
import { useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
import {
useImportRedditSessionMutation,
@@ -27,16 +27,27 @@ export function AccessPanel() {
const accountsQuery = useRedditAccountsQuery();
const importMutation = useImportRedditSessionMutation();
const importInput = useRef<HTMLInputElement>(null);
const [importFileError, setImportFileError] = useState<string | null>(null);
const resetImportMutation = importMutation.reset;
useEffect(() => {
if (!importMutation.isSuccess) return;
const timer = globalThis.setTimeout(() => resetImportMutation(), 5_000);
return () => globalThis.clearTimeout(timer);
}, [importMutation.isSuccess, resetImportMutation]);
async function importSession(file: File | undefined) {
if (!file) return;
setImportFileError(null);
let exported: unknown;
try {
const exported: unknown = JSON.parse(await file.text());
importMutation.mutate(exported);
exported = JSON.parse(await file.text());
} catch {
importMutation.reset();
importMutation.mutate({});
setImportFileError("That file isn't valid JSON. Export a fresh session dump from the extension and try again.");
return;
}
importMutation.mutate(exported);
}
if (statusQuery.isPending) {
@@ -60,6 +71,13 @@ export function AccessPanel() {
<p className="settings-copy">Add each Reddit account with its own extension session dump. Content and sync state stay owned by that account in the database. Any account with an expired login pauses while waiting for a fresh session dump; other accounts keep running.</p>
{username ? <p className="settings-copy">Verified account: <strong><RedditUsernameLink username={username} /></strong>.</p> : null}
<div aria-label="Reddit accounts" className="reddit-account-list">
{accountsQuery.isPending ? <p className="settings-copy" role="status"><span className="spinner" /> Loading Reddit accounts…</p> : null}
{accountsQuery.isError ? (
<div className="settings-notice error" role="alert">
<span>Could not load Reddit accounts: {errorMessage(accountsQuery.error)}</span>{' '}
<button className="button secondary" onClick={() => void accountsQuery.refetch()} type="button">Retry accounts</button>
</div>
) : null}
{(accountsQuery.data ?? []).map((account) => (
<div className={account.isRunning ? 'reddit-account-row is-active' : 'reddit-account-row'} key={account.accountId}>
<div><strong><RedditUsernameLink username={account.redditUsername} /></strong><span>{account.isRunning ? 'Running independently' : 'Waiting for a refreshed session'}</span></div>
@@ -69,11 +87,24 @@ export function AccessPanel() {
{accountsQuery.isSuccess && accountsQuery.data.length === 0 ? <p className="settings-copy">No Reddit accounts have been added yet.</p> : null}
</div>
<div className="access-actions">
<input accept="application/json" aria-label="Reddit session export" className="sr-only" onChange={(event) => void importSession(event.target.files?.[0])} ref={importInput} type="file" />
<input
accept="application/json"
aria-label="Reddit session export"
className="sr-only"
onChange={(event) => {
const file = event.target.files?.[0];
// Reset the input so picking the same file again re-triggers change.
event.target.value = '';
void importSession(file);
}}
ref={importInput}
type="file"
/>
<button className="button primary" disabled={importMutation.isPending} onClick={() => importInput.current?.click()} type="button">{importMutation.isPending ? 'Adding account...' : 'Add or refresh account'}</button>
<button className="button secondary" disabled={statusQuery.isFetching} onClick={() => void statusQuery.refetch()} type="button">{statusQuery.isFetching ? 'Checking sessions...' : 'Recheck accounts'}</button>
<button aria-busy={statusQuery.isFetching ? 'true' : undefined} className="button secondary" disabled={statusQuery.isFetching} onClick={() => void statusQuery.refetch()} type="button">{statusQuery.isFetching ? 'Checking sessions...' : 'Recheck accounts'}</button>
</div>
<p className="settings-copy">Open Reddit Chat in Chrome for the account you want to add, wait for the extension badge to show OK, then upload its JSON dump. It contains login credentials; do not share it.</p>
{importFileError ? <div className="settings-notice error" role="alert">{importFileError}</div> : null}
{importMutation.isSuccess ? <div className="settings-notice success" role="status"><span>Reddit session imported and verified.</span> <span>Account runtime is running independently.</span></div> : null}
{importMutation.isError ? <div className="settings-notice error" role="alert">{errorMessage(importMutation.error)}</div> : null}
</section>
@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react';
import { Modal } from '../../components/modal.js';
interface RiskDialogProps {
reasons: string[];
@@ -8,27 +8,19 @@ interface RiskDialogProps {
}
export function RiskDialog({ reasons, isPending, onCancel, onConfirm }: RiskDialogProps) {
const cancelButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
cancelButtonRef.current?.focus();
}, []);
return (
<div aria-labelledby="risk-settings-title" aria-modal="true" className="dialog-backdrop" role="dialog">
<div className="dialog-card">
<span className="eyebrow">Confirm operational risk</span>
<h2 id="risk-settings-title">Confirm risky settings</h2>
<ul className="risk-list">
{reasons.map((reason) => <li key={reason}>{reason}</li>)}
</ul>
<div className="dialog-actions">
<button className="button secondary" disabled={isPending} onClick={onCancel} ref={cancelButtonRef} type="button">Cancel</button>
<button className="button danger" disabled={isPending} onClick={onConfirm} type="button">
{isPending ? 'Applying…' : 'Apply risky settings'}
</button>
</div>
<Modal className="dialog-card" isDismissLocked={isPending} labelledBy="risk-settings-title" onClose={onCancel}>
<span className="eyebrow">Confirm operational risk</span>
<h2 id="risk-settings-title">Confirm risky settings</h2>
<ul className="risk-list">
{reasons.map((reason) => <li key={reason}>{reason}</li>)}
</ul>
<div className="dialog-actions">
<button autoFocus className="button secondary" disabled={isPending} onClick={onCancel} type="button">Cancel</button>
<button className="button danger" disabled={isPending} onClick={onConfirm} type="button">
{isPending ? 'Applying…' : 'Apply risky settings'}
</button>
</div>
</div>
</Modal>
);
}
@@ -1,9 +1,17 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useBlocker } from 'react-router-dom';
import type { ConfigResponse, ConfigUpdateRequest } from '../../../../../packages/api-contracts/src/index.js';
import { Modal } from '../../components/modal.js';
import { AccessPanel } from './access-panel.js';
import { RiskDialog } from './risk-dialog.js';
import { useSaveSettingsMutation } from './settings-queries.js';
import { createSettingsDraft, isDraftDirty, validateSettingsDraft, type SettingsDraft } from './settings-model.js';
import {
changedDraftFields,
createSettingsDraft,
isDraftDirty,
validateSettingsDraft,
type SettingsDraft,
} from './settings-model.js';
import { BehaviourSection, MonitoringSection, RateLimitsSection } from './settings-sections.js';
import type { SettingsSection } from './settings-page.js';
@@ -21,13 +29,51 @@ function errorMessage(error: Error | null): string {
return error?.message ?? 'Something went wrong.';
}
function DiscardDraftDialog({ title, body, onDiscard, onStay }: {
title: string;
body: string;
onDiscard(): void;
onStay(): void;
}) {
return (
<Modal className="dialog-card" labelledBy="discard-draft-title" onClose={onStay}>
<span className="eyebrow">Unsaved changes</span>
<h2 id="discard-draft-title">{title}</h2>
<p>{body}</p>
<div className="dialog-actions">
<button autoFocus className="button secondary" onClick={onStay} type="button">Stay</button>
<button className="button danger" onClick={onDiscard} type="button">Discard changes</button>
</div>
</Modal>
);
}
export function SettingsEditor({ config, section }: SettingsEditorProps) {
const [persistedDraft, setPersistedDraft] = useState<SettingsDraft>(() => createSettingsDraft(config));
const [draft, setDraft] = useState<SettingsDraft>(persistedDraft);
const [errors, setErrors] = useState<Record<string, string>>({});
const [pendingRiskySave, setPendingRiskySave] = useState<PendingRiskySave | null>(null);
const [isResetConfirmOpen, setIsResetConfirmOpen] = useState(false);
const [serverChangedNotice, setServerChangedNotice] = useState(false);
const [focusFirstErrorToken, setFocusFirstErrorToken] = useState(0);
const saveMutation = useSaveSettingsMutation();
const isDirty = isDraftDirty(draft, persistedDraft);
const seededConfig = useRef(config);
// Reconcile server refetches: re-seed a clean editor, warn a dirty one.
useEffect(() => {
if (seededConfig.current === config) return;
seededConfig.current = config;
const serverDraft = createSettingsDraft(config);
if (!isDraftDirty(serverDraft, persistedDraft)) return;
setPersistedDraft(serverDraft);
if (isDraftDirty(draft, persistedDraft)) {
setServerChangedNotice(true);
} else {
setDraft(serverDraft);
setServerChangedNotice(false);
}
}, [config, draft, persistedDraft]);
useEffect(() => {
function warnBeforeUnload(event: BeforeUnloadEvent) {
@@ -39,6 +85,18 @@ export function SettingsEditor({ config, section }: SettingsEditorProps) {
return () => window.removeEventListener('beforeunload', warnBeforeUnload);
}, [isDirty]);
// Guard in-app navigation away from settings while the draft is dirty.
// Section switches only change the query string, so they stay unblocked.
const navigationBlocker = useBlocker(({ currentLocation, nextLocation }) =>
isDirty && currentLocation.pathname !== nextLocation.pathname);
// After a failed save attempt, move focus to the first invalid control on screen.
useEffect(() => {
if (focusFirstErrorToken === 0) return;
const firstInvalid = document.querySelector<HTMLElement>('[aria-invalid="true"]');
firstInvalid?.focus();
}, [focusFirstErrorToken]);
function applySavedResponse(response: Awaited<ReturnType<typeof saveMutation.mutateAsync>>) {
const nextConfig: ConfigResponse = {
...response.config,
@@ -50,6 +108,7 @@ export function SettingsEditor({ config, section }: SettingsEditorProps) {
setPersistedDraft(nextDraft);
setDraft(nextDraft);
setPendingRiskySave(null);
setServerChangedNotice(false);
}
function submit(payload: ConfigUpdateRequest) {
@@ -58,15 +117,32 @@ export function SettingsEditor({ config, section }: SettingsEditorProps) {
function updateDraft(nextDraft: SettingsDraft) {
saveMutation.reset();
setErrors({});
const editedFields = changedDraftFields(draft, nextDraft);
setErrors((currentErrors) => {
if (editedFields.length === 0) return currentErrors;
const next = { ...currentErrors };
for (const field of editedFields) delete next[field];
return next;
});
setDraft(nextDraft);
}
function resetDraft() {
setDraft(persistedDraft);
setErrors({});
setServerChangedNotice(false);
setIsResetConfirmOpen(false);
saveMutation.reset();
}
function prepareSave() {
saveMutation.reset();
const validation = validateSettingsDraft(draft);
setErrors(validation.errors);
if (!validation.data) return;
if (!validation.data) {
setFocusFirstErrorToken((token) => token + 1);
return;
}
const reasons: string[] = [];
if (persistedDraft.dryRun && !validation.data.dryRun) {
@@ -76,9 +152,15 @@ export function SettingsEditor({ config, section }: SettingsEditorProps) {
if (validation.data.maxMessagesPerHour > 20 && validation.data.maxMessagesPerHour > persistedLimit) {
reasons.push(`The limit will allow ${validation.data.maxMessagesPerHour} messages per hour.`);
}
const enablesAutonomousR4R = validation.data.payload.subreddits?.some((subreddit, index) => (
// Match persisted rows BY NAME: row insertion or reordering must never make
// an autonomous mode look pre-approved because of a shifted index.
const persistedModeByName = new Map(persistedDraft.subreddits.map((subreddit) => [
subreddit.name.trim().toLowerCase(),
subreddit.conversationMode,
]));
const enablesAutonomousR4R = validation.data.payload.subreddits?.some((subreddit) => (
(subreddit.conversation.mode === 'r4r' || subreddit.conversation.mode === 'r4r-media')
&& persistedDraft.subreddits[index]?.conversationMode !== subreddit.conversation.mode
&& persistedModeByName.get(subreddit.name.trim().toLowerCase()) !== subreddit.conversation.mode
));
if (enablesAutonomousR4R) {
reasons.push('Autonomous R4R will send conversational replies without approval for the selected subreddit. The hybrid mode can make one optional image request after rapport. Existing conversations keep their snapshotted policy.');
@@ -91,6 +173,8 @@ export function SettingsEditor({ config, section }: SettingsEditorProps) {
submit(validation.data.payload);
}
const errorCount = Object.keys(errors).length;
return (
<>
{section === 'monitoring' ? <MonitoringSection draft={draft} errors={errors} onChange={updateDraft} /> : null}
@@ -102,12 +186,18 @@ export function SettingsEditor({ config, section }: SettingsEditorProps) {
<div className="settings-save-bar">
<div aria-live="polite">
{isDirty ? <span role="status">You have unsaved changes.</span> : <span>All changes saved.</span>}
{serverChangedNotice ? <strong className="save-warning" role="status">Settings changed on the server — Reset to load them.</strong> : null}
{errorCount > 0 ? (
<strong className="save-error" role="alert">
{errorCount === 1 ? 'Resolve 1 validation error before saving.' : `Resolve ${errorCount} validation errors before saving.`}
</strong>
) : null}
{saveMutation.isSuccess ? <strong className="save-success">Settings saved.</strong> : null}
{saveMutation.isError ? <strong className="save-error" role="alert">{errorMessage(saveMutation.error)}</strong> : null}
</div>
<div className="header-actions">
<button className="button secondary" disabled={!isDirty || saveMutation.isPending} onClick={() => { setDraft(persistedDraft); setErrors({}); saveMutation.reset(); }} type="button">Reset changes</button>
<button aria-label={saveMutation.isPending ? 'Saving settings' : 'Save settings'} className="button primary" disabled={!isDirty || saveMutation.isPending} onClick={prepareSave} type="button">{saveMutation.isPending ? 'Saving…' : 'Save settings'}</button>
<button className="button secondary" disabled={(!isDirty && !serverChangedNotice) || saveMutation.isPending} onClick={() => { if (isDirty) setIsResetConfirmOpen(true); else resetDraft(); }} type="button">Reset changes</button>
<button aria-busy={saveMutation.isPending ? 'true' : undefined} aria-label={saveMutation.isPending ? 'Saving settings' : 'Save settings'} className="button primary" disabled={!isDirty || saveMutation.isPending} onClick={prepareSave} type="button">{saveMutation.isPending ? 'Saving…' : 'Save settings'}</button>
</div>
</div>
) : null}
@@ -120,6 +210,22 @@ export function SettingsEditor({ config, section }: SettingsEditorProps) {
reasons={pendingRiskySave.reasons}
/>
) : null}
{isResetConfirmOpen ? (
<DiscardDraftDialog
body="Your unsaved edits will be replaced with the last saved settings."
onDiscard={resetDraft}
onStay={() => setIsResetConfirmOpen(false)}
title="Discard unsaved settings?"
/>
) : null}
{navigationBlocker.state === 'blocked' ? (
<DiscardDraftDialog
body="Leaving this page will discard your unsaved settings."
onDiscard={() => navigationBlocker.proceed()}
onStay={() => navigationBlocker.reset()}
title="Discard unsaved settings?"
/>
) : null}
</>
);
}
@@ -8,6 +8,8 @@ import {
} from '../../../../../packages/api-contracts/src/index.js';
export interface SubredditDraft {
/** Stable client-side row identity for React keys; excluded from dirty comparison. */
rowId: string;
name: string;
enabled: boolean;
type: 'image' | 'chat';
@@ -63,6 +65,13 @@ const ValidatedSettingsSchema = z.strictObject({
}
});
let nextRowId = 0;
export function createSubredditRowId(): string {
nextRowId += 1;
return `subreddit-row-${nextRowId}`;
}
export function createSettingsDraft(config: ConfigResponse): SettingsDraft {
return {
minDelayMinutes: String(config.rateLimit.minDelayBetweenMessagesMs / 60_000),
@@ -73,6 +82,7 @@ export function createSettingsDraft(config: ConfigResponse): SettingsDraft {
recentWindowMinutes: String(config.reddit.recentWindowMinutes),
maxReadyCandidates: String(config.reddit.maxReadyCandidates),
subreddits: config.subreddits.map((subreddit) => ({
rowId: createSubredditRowId(),
name: subreddit.name,
enabled: subreddit.enabled,
type: subreddit.type,
@@ -107,18 +117,18 @@ export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationR
requiredErrors[`subreddits.${index}.rapportTurnsBeforeMediaAsk`] = 'Rapport turns is required.';
}
});
if (Object.keys(requiredErrors).length > 0) return { errors: requiredErrors };
let advanced: Record<string, unknown> | undefined;
let advancedError: string | undefined;
if (draft.advancedConfigJson.trim() !== '') {
try {
const value: unknown = JSON.parse(draft.advancedConfigJson);
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return { errors: { advancedConfigJson: 'Advanced configuration must be a JSON object.' } };
advancedError = 'Advanced configuration must be a JSON object.';
} else {
advanced = value as Record<string, unknown>;
}
advanced = value as Record<string, unknown>;
} catch {
return { errors: { advancedConfigJson: 'Advanced configuration is not valid JSON.' } };
advancedError = 'Advanced configuration is not valid JSON.';
}
}
@@ -149,16 +159,20 @@ export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationR
})),
});
// Run required-field and schema validation in one pass so every problem
// surfaces on the first save attempt. Required-field messages win per field.
const errors: Record<string, string> = {};
if (!parsed.success) {
const errors: Record<string, string> = {};
for (const issue of parsed.error.issues) {
const pathParts = [...issue.path];
if (pathParts[0] === 'subreddits' && pathParts[2] === 'conversation') pathParts.splice(2, 1);
const path = pathParts.join('.');
if (!(path in errors)) errors[path] = issue.message;
}
return { errors };
}
if (advancedError) errors.advancedConfigJson = advancedError;
Object.assign(errors, requiredErrors);
if (!parsed.success || Object.keys(errors).length > 0) return { errors };
const payload = ConfigUpdateRequestSchema.parse({
rateLimit: {
@@ -186,6 +200,46 @@ export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationR
};
}
function comparableDraft(draft: SettingsDraft): unknown {
return {
...draft,
subreddits: draft.subreddits.map(({ rowId: _rowId, ...subreddit }) => subreddit),
};
}
export function isDraftDirty(draft: SettingsDraft, persisted: SettingsDraft): boolean {
return JSON.stringify(draft) !== JSON.stringify(persisted);
return JSON.stringify(comparableDraft(draft)) !== JSON.stringify(comparableDraft(persisted));
}
/**
* Field keys (matching validation error keys) whose values differ between two drafts.
* Used to clear validation errors only for the fields the user edited.
*/
export function changedDraftFields(previous: SettingsDraft, next: SettingsDraft): string[] {
const changed: string[] = [];
const topLevelKeys = ['minDelayMinutes', 'maxMessagesPerHour', 'jitterSeconds', 'dryRun', 'prioritizeRecentPosts', 'recentWindowMinutes', 'maxReadyCandidates', 'advancedConfigJson'] as const;
for (const key of topLevelKeys) {
if (previous[key] !== next[key]) changed.push(key);
}
if (previous.subreddits.length !== next.subreddits.length) {
changed.push('subreddits');
// Row indices shift on add/remove, so any indexed subreddit error may be stale.
for (let index = 0; index < Math.max(previous.subreddits.length, next.subreddits.length); index += 1) {
for (const field of ['name', 'weight', 'maxAutomatedTurns', 'rapportTurnsBeforeMediaAsk'] as const) {
changed.push(`subreddits.${index}.${field}`);
}
}
return changed;
}
next.subreddits.forEach((subreddit, index) => {
const previousSubreddit = previous.subreddits[index];
if (!previousSubreddit) return;
for (const field of ['name', 'enabled', 'type', 'weight', 'tone', 'scripted', 'conversationMode', 'maxAutomatedTurns', 'rapportTurnsBeforeMediaAsk'] as const) {
if (previousSubreddit[field] !== subreddit[field]) {
changed.push(`subreddits.${index}.${field}`);
if (field === 'name') changed.push('subreddits');
}
}
});
return changed;
}
@@ -1,5 +1,5 @@
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
import type { SettingsDraft, SubredditDraft } from './settings-model.js';
import { createSubredditRowId, type SettingsDraft, type SubredditDraft } from './settings-model.js';
interface SectionProps {
draft: SettingsDraft;
@@ -130,7 +130,7 @@ export function BehaviourSection({ config, draft, errors, onChange }: SectionPro
...draft,
subreddits: [
...draft.subreddits,
{ name: '', enabled: true, type: 'image', weight: '1', tone: 'default', scripted: '', conversationMode: 'legacy', maxAutomatedTurns: '3', rapportTurnsBeforeMediaAsk: '3' },
{ rowId: createSubredditRowId(), name: '', enabled: true, type: 'image', weight: '1', tone: 'default', scripted: '', conversationMode: 'legacy', maxAutomatedTurns: '3', rapportTurnsBeforeMediaAsk: '3' },
],
})}
type="button"
@@ -141,7 +141,7 @@ export function BehaviourSection({ config, draft, errors, onChange }: SectionPro
{errors.subreddits ? <p className="field-error" role="alert">{errors.subreddits}</p> : null}
<div className="subreddit-list">
{draft.subreddits.map((subreddit, index) => (
<fieldset className="subreddit-row" key={index}>
<fieldset className="subreddit-row" key={subreddit.rowId}>
<legend>Subreddit {index + 1}</legend>
<label className="settings-field">
<span>Name</span>
@@ -28,13 +28,14 @@
.settings-save-bar { position: sticky; bottom: 1rem; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; padding: .9rem 1rem; margin-top: 1rem; background: rgba(23, 35, 30, .97); border: 1px solid var(--border); border-radius: .8rem; box-shadow: 0 1rem 3rem rgba(0, 0, 0, .3); }
.settings-save-bar > div:first-child { display: grid; gap: .15rem; color: var(--muted); }
.save-success { color: var(--accent); }
.save-warning { color: var(--warning); }
.risk-list { display: grid; gap: .5rem; padding-left: 1.2rem; color: var(--warning); }
.access-actions { display: flex; flex-wrap: wrap; gap: .75rem; }
.access-actions a { display: inline-flex; align-items: center; text-decoration: none; }
.settings-notice { padding: .75rem; border-radius: .65rem; }
.settings-notice.success { color: #aaf0c4; background: #163a26; }
.settings-notice.warning { color: #ffe1a0; background: #42351b; }
.settings-notice.error { color: #ffd1ce; background: #461b18; }
.settings-notice.success { color: var(--success-text); background: var(--success-bg); }
.settings-notice.warning { color: var(--warning-text); background: var(--warning-bg); }
.settings-notice.error { color: var(--danger-text); background: var(--danger-bg); }
@media (min-width: 42rem) {
.settings-navigation { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -53,7 +54,7 @@
.reddit-account-row {
align-items: center;
border: 1px solid var(--border-color, #d9d9d9);
border: 1px solid var(--border);
border-radius: .75rem;
display: flex;
gap: 1rem;
@@ -62,7 +63,7 @@
}
.reddit-account-row.is-active {
border-color: var(--accent, #ff6534);
border-color: var(--accent);
}
.reddit-account-row > div {
+31 -14
View File
@@ -12,13 +12,24 @@
--accent-dark: #153c28;
--danger: #ff8a84;
--warning: #f3c76b;
--success-text: #aaf0c4;
--success-bg: #163a26;
--success-border: #28593d;
--warning-text: #ffe1a0;
--warning-bg: #2a2111;
--warning-border: #59451e;
--danger-text: #ffd1ce;
--danger-bg: #461b18;
--danger-border: #80352f;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; }
button, input { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: wait; opacity: .6; }
button:disabled { cursor: not-allowed; opacity: .6; }
button[aria-busy="true"]:disabled { cursor: wait; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
a { color: inherit; }
.reddit-username-link { color: var(--accent); text-decoration: underline; text-decoration-thickness: .08em; text-underline-offset: .16em; }
.reddit-username-link:hover { color: #aaf0c4; }
@@ -66,10 +77,11 @@ h2 { margin-bottom: 0; font-size: 1.1rem; }
.status-dot.stopped { background: var(--danger); box-shadow: 0 0 0 .35rem rgba(255, 138, 132, .1); }
.pill { display: inline-flex; align-items: center; padding: .3rem .55rem; border-radius: 999px; font-size: .75rem; font-weight: 800; }
.pill.neutral { color: #d4dfda; background: #2b3832; }
.pill.success { color: #aaf0c4; background: #163a26; }
.pill.warning { color: #ffe1a0; background: #42351b; }
.pill.success { color: var(--success-text); background: var(--success-bg); }
.pill.warning { color: var(--warning-text); background: #42351b; }
.pill.danger { color: var(--danger-text); background: var(--danger-bg); }
.status-note { margin-left: auto; color: var(--muted); font-size: .85rem; }
.cooldown-banner { display: grid; gap: .25rem; padding: .9rem 1rem; margin-bottom: 1rem; color: #ffe1a0; background: #2a2111; border: 1px solid #59451e; border-radius: .8rem; }
.cooldown-banner { display: grid; gap: .25rem; padding: .9rem 1rem; margin-bottom: 1rem; color: var(--warning-text); background: var(--warning-bg); border: 1px solid var(--warning-border); border-radius: .8rem; }
.cooldown-banner span { font-size: .85rem; }
.metrics-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .75rem; margin-bottom: .75rem; }
.metric-card, .panel { background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; }
@@ -86,10 +98,10 @@ h2 { margin-bottom: 0; font-size: 1.1rem; }
.account-status-details dt { color: var(--muted); font-size: .72rem; }
.account-status-details dd { margin: 0; font-weight: 700; text-transform: capitalize; }
.account-status-note { color: var(--muted); }
.account-status-warning { display: grid; gap: .25rem; padding: .65rem; color: #ffe1a0; background: #2a2111; border: 1px solid #59451e; border-radius: .55rem; }
.account-status-warning { display: grid; gap: .25rem; padding: .65rem; color: var(--warning-text); background: var(--warning-bg); border: 1px solid var(--warning-border); border-radius: .55rem; }
.account-status-warning span { font-size: .8rem; }
.account-status-warning code { width: fit-content; padding: .15rem .3rem; color: inherit; background: rgba(0, 0, 0, .25); border-radius: .25rem; }
.account-status-warning.is-error { color: #ffd1ce; background: #461b18; border-color: #80352f; }
.account-status-warning.is-error { color: var(--danger-text); background: var(--danger-bg); border-color: var(--danger-border); }
.overview-grid { display: grid; gap: .75rem; }
.panel { padding: 1rem; }
.panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; }
@@ -105,9 +117,9 @@ h2 { margin-bottom: 0; font-size: 1.1rem; }
.activity-list small { color: var(--muted); }
.origin-post-trigger { padding: 0; color: inherit; background: transparent; border: 0; font: inherit; font-weight: inherit; text-align: left; text-decoration: underline; text-decoration-color: var(--accent); text-underline-offset: .2em; cursor: pointer; }
.origin-post-trigger:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.origin-post-backdrop { position: fixed; inset: 0; z-index: 80; display: grid; place-items: center; padding: 1rem; background: rgba(0, 0, 0, .82); }
.origin-post-dialog { width: min(34rem, 100%); max-height: calc(100vh - 2rem); overflow: auto; background: var(--surface-raised); border: 1px solid var(--border); border-radius: 1rem; box-shadow: 0 2rem 5rem rgba(0, 0, 0, .5); }
.origin-post-dialog > img { display: block; width: 100%; max-height: 28rem; object-fit: contain; background: #050907; }
dialog.origin-post-dialog { width: min(34rem, calc(100% - 2rem)); max-height: calc(100vh - 2rem); padding: 0; overflow: auto; background: var(--surface-raised); border: 1px solid var(--border); border-radius: 1rem; box-shadow: 0 2rem 5rem rgba(0, 0, 0, .5); }
dialog.origin-post-dialog::backdrop { background: rgba(0, 0, 0, .82); }
.origin-post-dialog img { display: block; width: 100%; max-height: 28rem; object-fit: contain; background: #050907; }
.origin-post-dialog-heading { display: flex; align-items: center; justify-content: space-between; padding: .65rem .8rem; color: var(--muted); border-bottom: 1px solid var(--border); font-size: .75rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
.origin-post-dialog-heading button { width: 2.25rem; height: 2.25rem; padding: 0; color: inherit; background: transparent; border: 0; font-size: 1.6rem; cursor: pointer; }
.origin-post-dialog-copy { display: grid; gap: .7rem; padding: 1rem; }
@@ -123,7 +135,8 @@ h2 { margin-bottom: 0; font-size: 1.1rem; }
.origin-post-image img.is-blurred { filter: blur(18px) brightness(.55); transform: scale(1.12); }
.origin-post-image .origin-post-image-reveal { position: absolute; right: .2rem; bottom: .2rem; min-height: auto; padding: .2rem .3rem; border: 1px solid rgba(255, 255, 255, .45); border-radius: .3rem; color: white; background: rgba(0, 0, 0, .76); font-size: .58rem; font-weight: 800; }
.origin-post-image-reveal:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.origin-image-preview-backdrop { position: fixed; inset: 0; z-index: 90; display: flex; align-items: center; justify-content: center; padding: 1rem; background: rgba(0, 0, 0, .92); }
dialog.origin-image-preview { width: 100vw; height: 100vh; max-width: 100vw; max-height: 100vh; padding: 1rem; background: transparent; }
dialog.origin-image-preview::backdrop { background: rgba(0, 0, 0, .92); }
.origin-image-preview-content { position: relative; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
.origin-image-preview-content img { display: block; width: auto; height: auto; max-width: 100%; max-height: 100%; object-fit: contain; }
.origin-image-preview-content button { position: absolute; top: .5rem; right: .5rem; width: 2.75rem; height: 2.75rem; padding: 0; border: 1px solid rgba(255, 255, 255, .5); border-radius: 50%; color: white; background: rgba(0, 0, 0, .7); font-size: 2rem; line-height: 1; cursor: pointer; }
@@ -139,12 +152,16 @@ h2 { margin-bottom: 0; font-size: 1.1rem; }
.error-panel, .panel-loading { display: grid; place-items: center; align-content: center; gap: .75rem; min-height: 14rem; text-align: center; color: var(--muted); }
.spinner { width: 1.4rem; height: 1.4rem; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.dialog-backdrop { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 1rem; background: rgba(0, 0, 0, .72); }
.dialog-card { width: min(28rem, 100%); padding: 1.4rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: 1rem; box-shadow: 0 2rem 5rem rgba(0, 0, 0, .4); }
dialog.modal { color: inherit; border: 0; background: transparent; }
dialog.modal::backdrop { background: rgba(0, 0, 0, .72); }
body:has(dialog.modal[open]) { overflow: hidden; }
dialog.dialog-card { width: min(28rem, calc(100% - 2rem)); padding: 1.4rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: 1rem; box-shadow: 0 2rem 5rem rgba(0, 0, 0, .4); }
.dialog-card p { color: var(--muted); }
.dialog-error { padding: .65rem .8rem; color: var(--danger-text); background: var(--danger-bg); border: 1px solid var(--danger-border); border-radius: .55rem; }
.dialog-actions { justify-content: flex-end; margin-top: 1.5rem; }
.toast { position: fixed; right: 1rem; bottom: 1rem; max-width: 25rem; padding: .8rem 1rem; border-radius: .7rem; }
.toast.error { color: #ffd1ce; background: #461b18; border: 1px solid #80352f; }
.toast { position: fixed; right: 1rem; bottom: 1rem; z-index: 110; display: flex; align-items: baseline; gap: .6rem; max-width: 25rem; padding: .8rem 1rem; border-radius: .7rem; }
.toast.error { color: var(--danger-text); background: var(--danger-bg); border: 1px solid var(--danger-border); }
.toast button { min-height: auto; padding: 0; color: inherit; background: transparent; border: 0; font-weight: 800; text-decoration: underline; }
.account-owner-badge {
align-self: flex-start;
+1
View File
@@ -2920,6 +2920,7 @@
"version": "13.0.3",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz",
"integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.0.0"
+57 -2
View File
@@ -2,7 +2,7 @@
import '@testing-library/jest-dom/vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import { act, cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
@@ -339,7 +339,7 @@ describe('dashboard foundation', () => {
expect(within(dialog).getByRole('img', { name: 'Source post: The source post title' })).toHaveAttribute(
'src', 'https://i.redd.it/source.jpg',
);
expect(within(dialog).getByRole('link', { name: 'Open Reddit post' })).toHaveAttribute(
expect(within(dialog).getByRole('link', { name: /Open Reddit post/ })).toHaveAttribute(
'href', 'https://www.reddit.com/r/example/comments/source/the_source_post_title/',
);
});
@@ -401,4 +401,59 @@ describe('dashboard foundation', () => {
expect(await screen.findByText('Running', { selector: '[data-bot-status]' })).toBeInTheDocument();
expect(startRequests).toBe(1);
});
it('shows a start failure inside the confirmation dialog instead of a stray toast', async () => {
installSuccessfulApi();
server.use(
http.post('/api/start', () => HttpResponse.json({ error: 'Browser runtime is not ready' }, { status: 503 })),
);
const user = userEvent.setup();
renderDashboard();
await user.click(await screen.findByRole('button', { name: 'Start bot' }));
const dialog = screen.getByRole('dialog', { name: 'Start bot?' });
await user.click(within(dialog).getByRole('button', { name: 'Confirm start' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('Browser runtime is not ready');
// The dialog stays open so the operator can retry or cancel.
expect(screen.getByRole('dialog', { name: 'Start bot?' })).toBeInTheDocument();
await user.click(within(dialog).getByRole('button', { name: 'Cancel' }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('flips the sending window from cooling down to ready without a refetch', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
let timerRequests = 0;
const startedAt = Date.now();
server.use(
http.get('/api/stats', () => HttpResponse.json(createStats())),
http.get('/api/timers', () => {
timerRequests += 1;
return HttpResponse.json({
nextMessageAvailableAt: startedAt + 5_000,
hourlyResetAt: startedAt + 3_600_000,
isHourlyLimitReached: false,
messagesRemaining: 6,
});
}),
http.get('/api/messages', () => HttpResponse.json([])),
);
renderDashboard();
expect(await screen.findByText('Cooling down')).toBeInTheDocument();
expect(screen.getByText(/^Ready in /)).toBeInTheDocument();
const requestsAfterLoad = timerRequests;
act(() => { vi.advanceTimersByTime(6_000); });
expect(screen.getByText('Ready')).toBeInTheDocument();
expect(screen.getByText('Available now')).toBeInTheDocument();
expect(screen.queryByText('Cooling down')).not.toBeInTheDocument();
expect(timerRequests).toBe(requestsAfterLoad);
} finally {
vi.useRealTimers();
}
});
});
+108
View File
@@ -0,0 +1,108 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, within } from '@testing-library/react';
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
import { MemoryRouter } from 'react-router-dom';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { AttentionPage } from '../../apps/dashboard/src/features/attention/attention-page.js';
const MINUTE_MS = 60_000;
function attentionResponse(items: unknown[]) {
return { generatedAt: Date.now() - MINUTE_MS, items };
}
const criticalItem = {
id: 'item-critical',
kind: 'human-required',
severity: 'critical',
title: 'Human handoff required',
detail: 'Automation stopped and needs an operator reply.',
occurredAt: Date.now() - (14 * MINUTE_MS),
conversationId: 'room-alice',
username: 'alice',
};
const warningItem = {
id: 'item-warning',
kind: 'invitation-cooldown',
severity: 'warning',
title: 'Invitation cooldown active',
detail: 'Reddit is rate limiting new chat invitations.',
occurredAt: Date.now() - (2 * 60 * MINUTE_MS),
};
const server = setupServer();
function renderAttention() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<AttentionPage />
</QueryClientProvider>
</MemoryRouter>,
);
return queryClient;
}
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
describe('AttentionPage', () => {
it('labels critical items and the critical count with the danger pill', async () => {
server.use(http.get('/api/attention', () => HttpResponse.json(attentionResponse([criticalItem, warningItem]))));
renderAttention();
const items = await screen.findByRole('list', { name: 'Attention items' });
const [first, second] = within(items).getAllByRole('listitem');
expect(within(first).getByText('human required')).toHaveClass('pill', 'danger');
expect(within(second).getByText('invitation cooldown')).toHaveClass('pill', 'warning');
expect(screen.getByText('1 critical')).toHaveClass('pill', 'danger');
});
it('shows relative times with exact timestamps as titles', async () => {
server.use(http.get('/api/attention', () => HttpResponse.json(attentionResponse([criticalItem]))));
renderAttention();
const time = await screen.findByText('14m ago');
expect(time.tagName).toBe('TIME');
expect(time).toHaveAttribute('dateTime', new Date(criticalItem.occurredAt).toISOString());
expect(time).toHaveAttribute('title');
expect(screen.getByText(/Auto-refreshes every 30s/)).toBeInTheDocument();
});
it('links conversation items and gives unlinked items a place to act', async () => {
server.use(http.get('/api/attention', () => HttpResponse.json(attentionResponse([criticalItem, warningItem]))));
renderAttention();
expect(await screen.findByRole('link', { name: 'Open conversation' })).toHaveAttribute(
'href',
'/conversations/room-alice',
);
expect(screen.getByText('No linked conversation — check Operations.')).toBeInTheDocument();
expect(screen.getByText('Reddit is rate limiting new chat invitations.')).toBeInTheDocument();
});
it('uses the dedicated attention dot for the open-items summary', async () => {
server.use(http.get('/api/attention', () => HttpResponse.json(attentionResponse([criticalItem]))));
renderAttention();
await screen.findByText('Open items');
const strip = screen.getByRole('region', { name: 'Attention totals' });
const dot = strip.querySelector('.attention-status-dot');
expect(dot).toHaveClass('is-open');
expect(dot).not.toHaveClass('running');
expect(dot).not.toHaveClass('stopped');
});
});
+131 -6
View File
@@ -2,12 +2,14 @@
import '@testing-library/jest-dom/vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
import { MemoryRouter } from 'react-router-dom';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { ConversationsPage } from '../../apps/dashboard/src/features/conversations/conversations-page.js';
import { resetComposerDrafts } from '../../apps/dashboard/src/features/conversations/composer-drafts.js';
interface ChatFixtureOptions {
username: string;
@@ -81,9 +83,11 @@ function renderConversations() {
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
render(
<QueryClientProvider client={queryClient}>
<ConversationsPage />
</QueryClientProvider>,
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<ConversationsPage />
</QueryClientProvider>
</MemoryRouter>,
);
return queryClient;
}
@@ -92,6 +96,7 @@ beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
cleanup();
server.resetHandlers();
resetComposerDrafts();
});
afterAll(() => server.close());
@@ -152,8 +157,7 @@ describe('ConversationsPage', () => {
);
expect(screen.getByText('2h ago')).toBeInTheDocument();
expect(screen.getByRole('img', { name: 'Source post: Looking for a chat' })).toHaveClass('is-blurred');
expect(screen.getByRole('button', { name: 'View source image full screen: Looking for a chat' })).toBeDisabled();
await user.click(screen.getByRole('button', { name: 'Reveal original image' }));
await user.click(screen.getByRole('button', { name: 'Reveal source image: Looking for a chat' }));
expect(screen.getByRole('img', { name: 'Source post: Looking for a chat' })).not.toHaveClass('is-blurred');
const previewTrigger = screen.getByRole('button', { name: 'View source image full screen: Looking for a chat' });
await user.click(previewTrigger);
@@ -321,6 +325,7 @@ describe('ConversationsPage', () => {
);
const user = userEvent.setup();
renderConversations();
await user.click(screen.getByText('Processed user lookup'));
await user.type(screen.getByLabelText('Lookup Reddit username'), 'u/alice');
await user.click(screen.getByRole('button', { name: 'Look up user' }));
expect(await screen.findByRole('list', { name: 'Processed posts for u/alice' })).toHaveTextContent('A processed post');
@@ -438,4 +443,124 @@ describe('ConversationsPage', () => {
expect(message).toHaveValue('');
expect(startRequests).toBe(2);
});
it('explains how to enable the start-conversation form when no accounts are running', async () => {
server.use(
http.get('/api/conversations', () => HttpResponse.json([])),
http.get('/api/accounts', () => HttpResponse.json([])),
);
const user = userEvent.setup();
renderConversations();
await user.click(screen.getByRole('button', { name: 'New conversation' }));
expect(await screen.findByText(/No running accounts/)).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Settings' })).toHaveAttribute('href', '/settings');
expect(screen.queryByRole('button', { name: 'Start conversation' })).not.toBeInTheDocument();
});
it('shows the unread count and marks unread rows with state and time in the label', async () => {
installConversationList();
renderConversations();
const aliceButton = await screen.findByRole('button', { name: /u\/alice/ });
expect(screen.getByText('1 unread')).toBeInTheDocument();
expect(aliceButton.getAttribute('aria-label')).toMatch(/^u\/alice, Unread, .+ ago/);
expect(aliceButton.closest('li')).toHaveClass('is-unread');
expect(screen.getByRole('button', { name: /u\/bob/ }).closest('li')).not.toHaveClass('is-unread');
});
it('offers a jump-to-latest control when the operator scrolls up in the history', async () => {
installConversationList();
const user = userEvent.setup();
renderConversations();
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
const history = await screen.findByRole('list', { name: 'Message history' });
expect(screen.queryByRole('button', { name: 'Jump to latest' })).not.toBeInTheDocument();
Object.defineProperty(history, 'scrollHeight', { configurable: true, value: 1000 });
Object.defineProperty(history, 'clientHeight', { configurable: true, value: 300 });
history.scrollTop = 100;
fireEvent.scroll(history);
const jumpButton = await screen.findByRole('button', { name: 'Jump to latest' });
await user.click(jumpButton);
expect(history.scrollTop).toBe(1000);
expect(screen.queryByRole('button', { name: 'Jump to latest' })).not.toBeInTheDocument();
});
it('preserves an unsent draft when switching away from and back to a conversation', async () => {
installConversationList();
const user = userEvent.setup();
renderConversations();
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
const composer = await screen.findByRole('textbox', { name: 'Reply to u/alice' });
await user.type(composer, 'Draft in progress');
await user.click(screen.getByRole('button', { name: 'Close conversation' }));
expect(screen.queryByRole('textbox', { name: 'Reply to u/alice' })).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /u\/alice/ }));
expect(await screen.findByRole('textbox', { name: 'Reply to u/alice' })).toHaveValue('Draft in progress');
});
it('lets the operator undo a suggestion that replaced a typed draft', async () => {
installConversationList();
server.use(
http.post('/api/conversations/room-alice/suggest-reply', () =>
HttpResponse.json({ suggestedReply: 'A generated suggestion' }),
),
);
const user = userEvent.setup();
renderConversations();
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
const composer = await screen.findByRole('textbox', { name: 'Reply to u/alice' });
await user.type(composer, 'My own words');
expect(screen.getByText('12 / 10000')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Suggest reply' }));
await waitFor(() => expect(composer).toHaveValue('A generated suggestion'));
expect(screen.getByText('The suggestion replaced your draft.')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Undo' }));
expect(composer).toHaveValue('My own words');
expect(screen.queryByRole('button', { name: 'Undo' })).not.toBeInTheDocument();
});
it('keeps the confirmation image blurred when saved images were re-blurred before review', async () => {
const asset = {
id: '22222222-2222-4222-8222-222222222222', tags: ['selfie'], safety: 'sfw',
classificationStatus: 'ready', description: 'A selfie', confidence: 0.95,
fileName: 'selfie.jpg', mimeType: 'image/jpeg', sizeBytes: 1024, width: 800, height: 1000,
enabled: true, consentConfirmedAt: 1, createdAt: 1, updatedAt: 1,
sendCount: 0, lastSentAt: null, urlPath: '/library-media/selfie.jpg',
};
server.use(
http.get('/api/conversations', () => HttpResponse.json([alice])),
http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture())),
http.get('/api/conversations/room-alice/shareable-media', () => HttpResponse.json({
requestKind: 'selfie', assets: [asset], reason: null,
})),
);
const user = userEvent.setup();
renderConversations();
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
await user.click(await screen.findByText('Share a saved image'));
expect(await screen.findByText('Reveal images to select one.')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Reveal saved images' }));
expect(screen.queryByText('Reveal images to select one.')).not.toBeInTheDocument();
await user.click(screen.getByRole('radio', { name: /Saved image tagged selfie/ }));
await user.click(screen.getByRole('button', { name: 'Blur saved images' }));
await user.click(screen.getByRole('button', { name: 'Review image share' }));
const dialog = screen.getByRole('dialog', { name: 'Share this saved image?' });
const dialogImage = within(dialog).getByRole('img', { name: 'Saved image tagged selfie' });
expect(dialogImage).toHaveClass('is-blurred');
await user.click(within(dialog).getByRole('button', { name: 'Reveal image' }));
expect(dialogImage).not.toHaveClass('is-blurred');
});
});
+149 -3
View File
@@ -2,7 +2,7 @@
import '@testing-library/jest-dom/vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
@@ -17,6 +17,12 @@ const asset = {
sendCount: 0, lastSentAt: null,
urlPath: '/library-media/portrait.jpg',
};
const secondAsset = {
...asset,
id: '029fe789-1b99-7ce1-8402-b3cbc32a445b',
fileName: 'second.jpg',
urlPath: '/library-media/second.jpg',
};
const server = setupServer();
function renderPage() {
@@ -24,6 +30,13 @@ function renderPage() {
return render(<QueryClientProvider client={client}><MediaLibraryPage /></QueryClientProvider>);
}
function cardFor(fileName: string): HTMLElement {
const title = screen.getByText(fileName, { selector: 'strong' });
const card = title.closest('article');
if (!card) throw new Error(`No card for ${fileName}`);
return card;
}
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => { cleanup(); server.resetHandlers(); vi.restoreAllMocks(); });
afterAll(() => server.close());
@@ -43,14 +56,101 @@ describe('exchange library page', () => {
const image = await screen.findByRole('img', { name: /Library image tagged selfie/i });
expect(image).toHaveClass('is-blurred');
await user.click(screen.getByRole('button', { name: 'Reveal' }));
const revealToggle = screen.getByRole('button', { name: 'Reveal portrait.jpg' });
expect(revealToggle).toHaveAttribute('aria-pressed', 'false');
await user.click(revealToggle);
expect(image).not.toHaveClass('is-blurred');
expect(screen.getByRole('button', { name: 'Blur portrait.jpg' })).toHaveAttribute('aria-pressed', 'true');
await user.click(screen.getByRole('checkbox', { name: 'topless' }));
expect(screen.getByText('Unsaved changes')).toBeInTheDocument();
await user.selectOptions(screen.getByRole('combobox', { name: 'Safety' }), 'nsfw');
await user.click(screen.getByRole('button', { name: 'Save changes' }));
await waitFor(() => expect(update).toEqual({ tags: ['selfie', 'topless'], safety: 'nsfw', enabled: true }));
// Transient save feedback next to the Save button.
const saved = await screen.findByText('Saved');
expect(saved).toHaveAttribute('role', 'status');
await waitFor(() => expect(screen.queryByText('Unsaved changes')).not.toBeInTheDocument());
});
it('opens a revealed library image in the enlarging preview dialog', async () => {
server.use(http.get('/api/media-library', () => HttpResponse.json([asset])));
const user = userEvent.setup();
renderPage();
await screen.findByRole('img', { name: /Library image tagged selfie/i });
await user.click(screen.getByRole('button', { name: 'Reveal library image portrait.jpg' }));
await user.click(screen.getByRole('button', { name: 'View portrait.jpg larger' }));
const dialog = screen.getByRole('dialog', { name: 'Preview of portrait.jpg' });
expect(within(dialog).getByRole('img')).toHaveAttribute('src', '/library-media/portrait.jpg');
// Single-item mode: no prev/next controls.
expect(within(dialog).queryByRole('button', { name: 'Next media' })).not.toBeInTheDocument();
await user.click(within(dialog).getByRole('button', { name: 'Close media preview' }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('preserves a touched form when a refetch replaces the assets', async () => {
let update: unknown;
server.use(
http.get('/api/media-library', () => HttpResponse.json([asset, secondAsset])),
http.patch('/api/media-library/:id', async ({ request }) => {
update = await request.json();
return HttpResponse.json({ ...asset, ...(update as object) });
}),
);
const user = userEvent.setup();
renderPage();
await screen.findByText('portrait.jpg');
const secondCard = cardFor('second.jpg');
// Touch the second card's form without saving it.
await user.click(within(secondCard).getByRole('checkbox', { name: 'topless' }));
expect(within(secondCard).getByText('Unsaved changes')).toBeInTheDocument();
// Saving the first card invalidates and refetches the whole list.
const firstCard = cardFor('portrait.jpg');
await user.click(within(firstCard).getByRole('checkbox', { name: 'chest' }));
await user.click(within(firstCard).getByRole('button', { name: 'Save changes' }));
await waitFor(() => expect(update).toEqual({ tags: ['selfie', 'chest'], safety: 'sfw', enabled: true }));
await within(firstCard).findByText('Saved');
// The refetch must not clobber the second card's unsaved edit.
expect(within(secondCard).getByRole('checkbox', { name: 'topless' })).toBeChecked();
expect(within(secondCard).getByText('Unsaved changes')).toBeInTheDocument();
});
it('explains why saving is disabled when no tags are selected', async () => {
server.use(http.get('/api/media-library', () => HttpResponse.json([asset])));
const user = userEvent.setup();
renderPage();
await screen.findByText('portrait.jpg');
await user.click(screen.getByRole('checkbox', { name: 'selfie' }));
expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled();
expect(screen.getByText('Select at least one tag to save')).toBeInTheDocument();
});
it('arms deletion away from the Delete position and disarms it after five seconds', async () => {
server.use(http.get('/api/media-library', () => HttpResponse.json([asset])));
const user = userEvent.setup();
renderPage();
await screen.findByText('portrait.jpg');
await user.click(screen.getByRole('button', { name: 'Delete' }));
const actions = screen.getByRole('button', { name: 'Confirm delete' }).parentElement!;
const buttons = within(actions).getAllByRole('button');
// Cancel occupies the slot Delete was in; Confirm sits after it.
expect(buttons.map((button) => button.textContent)).toEqual(['Save changes', 'Cancel', 'Confirm delete']);
await waitFor(
() => expect(screen.queryByRole('button', { name: 'Confirm delete' })).not.toBeInTheDocument(),
{ timeout: 7_000 },
);
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument();
}, 15_000);
it('requires consent in the UI and uploads the selected image for classification', async () => {
let consentHeader: string | null = null;
server.use(
@@ -65,12 +165,58 @@ describe('exchange library page', () => {
await screen.findByText('No exchange images yet.');
const upload = screen.getByRole('button', { name: 'Upload and classify' });
expect(upload).toBeDisabled();
await user.upload(screen.getByLabelText(/Choose image/), new File(['image'], 'portrait.jpg', { type: 'image/jpeg' }));
const input = screen.getByLabelText(/Choose image/) as HTMLInputElement;
await user.upload(input, new File(['image'], 'portrait.jpg', { type: 'image/jpeg' }));
expect(upload).toBeDisabled();
await user.click(screen.getByRole('checkbox', { name: /I confirm every depicted adult consented/i }));
expect(upload).toBeEnabled();
await user.click(upload);
await screen.findByText('Image added. Review its tags before relying on it.');
expect(consentHeader).toBe('true');
// The input resets so the same file can be picked again.
expect(input.value).toBe('');
expect(screen.getByText('No image selected')).toBeInTheDocument();
});
it('rejects non-image and oversized files before uploading', async () => {
server.use(http.get('/api/media-library', () => HttpResponse.json([])));
const user = userEvent.setup({ applyAccept: false });
renderPage();
await screen.findByText('No exchange images yet.');
const input = screen.getByLabelText(/Choose image/) as HTMLInputElement;
await user.upload(input, new File(['nope'], 'notes.txt', { type: 'text/plain' }));
expect(screen.getByRole('alert')).toHaveTextContent('Only image files can be uploaded.');
expect(screen.getByRole('button', { name: 'Upload and classify' })).toBeDisabled();
const oversized = new File(['big'], 'huge.jpg', { type: 'image/jpeg' });
Object.defineProperty(oversized, 'size', { value: 16 * 1024 * 1024 });
await user.upload(input, oversized);
expect(screen.getByRole('alert')).toHaveTextContent('Images must be 15 MB or smaller.');
expect(screen.getByText('No image selected')).toBeInTheDocument();
});
it('shows a thumbnail preview of the picked image when object URLs are available', async () => {
const objectUrls: string[] = [];
const globalUrl = URL as unknown as Record<string, unknown>;
globalUrl.createObjectURL = vi.fn(() => {
const url = `blob:preview-${objectUrls.length}`;
objectUrls.push(url);
return url;
});
globalUrl.revokeObjectURL = vi.fn();
try {
server.use(http.get('/api/media-library', () => HttpResponse.json([])));
const user = userEvent.setup();
renderPage();
await screen.findByText('No exchange images yet.');
await user.upload(screen.getByLabelText(/Choose image/), new File(['image'], 'portrait.jpg', { type: 'image/jpeg' }));
expect(screen.getByRole('img', { name: 'Preview of portrait.jpg' })).toHaveAttribute('src', 'blob:preview-0');
} finally {
delete globalUrl.createObjectURL;
delete globalUrl.revokeObjectURL;
}
});
});
+138 -11
View File
@@ -30,6 +30,15 @@ const mediaItems = [
},
];
function altName(kind: 'Image' | 'GIF', sender: string, timestamp: number): string {
const date = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short' }).format(timestamp);
return `${kind} from u/${sender}, ${date}`;
}
const aliceAlt = altName('Image', 'alice', mediaItems[0].timestamp);
const charlieAlt = altName('GIF', 'charlie', mediaItems[1].timestamp);
const daveAlt = altName('Image', 'dave', mediaItems[2].timestamp);
const idleStatus = {
isSyncing: false,
lastSyncAt: now - 120_000,
@@ -62,6 +71,11 @@ function renderMedia() {
return { ...view, queryClient };
}
/** The card's footer toggle carries aria-pressed; the image button does not. */
function revealToggle(sender: string, pressed: boolean) {
return screen.getByRole('button', { name: `Reveal media from ${sender}`, pressed });
}
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
cleanup();
@@ -91,8 +105,8 @@ describe('media page', () => {
await waitFor(() => expect(resolveMedia).toBeTypeOf('function'));
resolveMedia?.(HttpResponse.json(mediaItems));
const aliceImage = await screen.findByRole('img', { name: 'Media from alice' });
const charlieImage = screen.getByRole('img', { name: 'Media from charlie' });
const aliceImage = await screen.findByRole('img', { name: aliceAlt });
const charlieImage = screen.getByRole('img', { name: charlieAlt });
for (const profileLink of screen.getAllByRole('link', { name: 'alice' })) {
expect(profileLink).toHaveAttribute('href', 'https://www.reddit.com/u/alice');
}
@@ -100,17 +114,44 @@ describe('media page', () => {
expect(charlieImage).toHaveClass('is-blurred');
expect(credentials.every((value) => value === 'same-origin')).toBe(true);
await user.click(screen.getByRole('button', { name: 'Reveal media from alice' }));
await user.click(revealToggle('alice', false));
expect(aliceImage).not.toHaveClass('is-blurred');
expect(charlieImage).toHaveClass('is-blurred');
await user.click(screen.getByRole('button', { name: 'Reveal all media' }));
expect(charlieImage).not.toHaveClass('is-blurred');
// Turning reveal-all back off keeps the earlier individual reveal intact.
await user.click(screen.getByRole('button', { name: 'Blur all media' }));
expect(charlieImage).toHaveClass('is-blurred');
expect(aliceImage).not.toHaveClass('is-blurred');
firstView.unmount();
installMediaApi();
renderMedia();
expect(await screen.findByRole('img', { name: 'Media from alice' })).toHaveClass('is-blurred');
expect(await screen.findByRole('img', { name: aliceAlt })).toHaveClass('is-blurred');
});
it('reveals blurred media when its preview surface is clicked', async () => {
installMediaApi();
const user = userEvent.setup();
renderMedia();
const aliceImage = await screen.findByRole('img', { name: aliceAlt });
expect(aliceImage).toHaveClass('is-blurred');
// The image surface itself acts as a reveal control while blurred.
const imageButton = screen
.getAllByRole('button', { name: 'Reveal media from alice' })
.find((button) => button.classList.contains('media-preview-button'));
expect(imageButton).toBeDefined();
expect(imageButton).toBeEnabled();
await user.click(imageButton!);
expect(aliceImage).not.toHaveClass('is-blurred');
// Once revealed, the same surface opens the full-size preview instead.
await user.click(screen.getByRole('button', { name: 'View media from alice larger' }));
expect(screen.getByRole('dialog', { name: 'Media from alice' })).toBeInTheDocument();
});
it('opens revealed media in a dismissible full-size preview', async () => {
@@ -118,16 +159,43 @@ describe('media page', () => {
const user = userEvent.setup();
renderMedia();
await user.click(await screen.findByRole('button', { name: 'Reveal media from alice' }));
await screen.findByRole('img', { name: aliceAlt });
await user.click(revealToggle('alice', false));
await user.click(screen.getByRole('button', { name: 'View media from alice larger' }));
const dialog = screen.getByRole('dialog', { name: 'Media from alice' });
expect(within(dialog).getByRole('img', { name: 'Media from alice' })).toHaveAttribute('src', '/media/one.jpg');
expect(within(dialog).getByRole('img', { name: aliceAlt })).toHaveAttribute('src', '/media/one.jpg');
await user.keyboard('{Escape}');
expect(screen.queryByRole('dialog', { name: 'Media from alice' })).not.toBeInTheDocument();
});
it('navigates the preview across the visible items with buttons and arrow keys', async () => {
installMediaApi();
const user = userEvent.setup();
renderMedia();
await screen.findByRole('img', { name: aliceAlt });
await user.click(screen.getByRole('button', { name: 'Reveal all media' }));
await user.click(screen.getByRole('button', { name: 'View media from alice larger' }));
let dialog = screen.getByRole('dialog', { name: 'Media from alice' });
expect(within(dialog).getByRole('button', { name: 'Previous media' })).toBeDisabled();
expect(within(dialog).getByText('1 of 3')).toBeInTheDocument();
await user.click(within(dialog).getByRole('button', { name: 'Next media' }));
dialog = screen.getByRole('dialog', { name: 'Media from charlie' });
expect(within(dialog).getByRole('img', { name: charlieAlt })).toHaveAttribute('src', '/media/two.gif');
expect(within(dialog).getByText('u/charlie')).toBeInTheDocument();
await user.keyboard('{ArrowRight}');
dialog = screen.getByRole('dialog', { name: 'Media from dave' });
expect(within(dialog).getByRole('button', { name: 'Next media' })).toBeDisabled();
await user.keyboard('{ArrowLeft}');
expect(screen.getByRole('dialog', { name: 'Media from charlie' })).toBeInTheDocument();
});
it('links media cards to their account-owned conversations', async () => {
installMediaApi();
renderMedia();
@@ -149,11 +217,11 @@ describe('media page', () => {
await user.selectOptions(screen.getByRole('combobox', { name: 'Conversation' }), 'alice');
expect(screen.getAllByRole('img')).toHaveLength(1);
expect(screen.getByRole('img', { name: 'Media from alice' })).toBeInTheDocument();
expect(screen.getByRole('img', { name: aliceAlt })).toBeInTheDocument();
await user.selectOptions(screen.getByRole('combobox', { name: 'Conversation' }), 'all');
await user.selectOptions(screen.getByRole('combobox', { name: 'Sender' }), 'charlie');
expect(screen.getByRole('img', { name: 'Media from charlie' })).toBeInTheDocument();
expect(screen.getByRole('img', { name: charlieAlt })).toBeInTheDocument();
await user.selectOptions(screen.getByRole('combobox', { name: 'Sender' }), 'all');
await user.selectOptions(screen.getByRole('combobox', { name: 'Media type' }), 'gif');
@@ -167,6 +235,49 @@ describe('media page', () => {
expect(screen.getByText('No media matches these filters.')).toBeInTheDocument();
});
it('shows a result count and clears filters from both the panel and the empty state', async () => {
installMediaApi();
const user = userEvent.setup();
renderMedia();
await screen.findByRole('img', { name: aliceAlt });
expect(screen.getByText('Showing 3 of 3 items')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Clear filters' })).not.toBeInTheDocument();
await user.selectOptions(screen.getByRole('combobox', { name: 'Conversation' }), 'alice');
expect(screen.getByText('Showing 1 of 3 items')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Clear filters' }));
expect(screen.getAllByRole('img')).toHaveLength(3);
expect(screen.getByRole('combobox', { name: 'Conversation' })).toHaveValue('all');
await user.selectOptions(screen.getByRole('combobox', { name: 'Sender' }), 'charlie');
await user.selectOptions(screen.getByRole('combobox', { name: 'Media type' }), 'image');
const emptyState = screen.getByText('No media matches these filters.').closest('section');
expect(emptyState).not.toBeNull();
await user.click(within(emptyState!).getByRole('button', { name: 'Clear filters' }));
expect(screen.getAllByRole('img')).toHaveLength(3);
});
it('paginates large galleries behind a Load more control', async () => {
const manyItems = Array.from({ length: 60 }, (_, index) => ({
id: `item-${index}`, chatUsername: 'alice', chatRoomId: `room-${index}`, sender: 'alice',
timestamp: now - index * 60_000, originalUrl: `https://media.example/${index}.jpg`,
fileName: `${index}.jpg`, relativePath: `${index}.jpg`, createdAt: now - index * 60_000,
urlPath: `/media/${index}.jpg`,
}));
installMediaApi(manyItems);
const user = userEvent.setup();
renderMedia();
await screen.findByText('Showing 48 of 60 items');
expect(screen.getAllByRole('img')).toHaveLength(48);
await user.click(screen.getByRole('button', { name: 'Load more (12 remaining)' }));
expect(screen.getAllByRole('img')).toHaveLength(60);
expect(screen.getByText('Showing 60 of 60 items')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Load more/ })).not.toBeInTheDocument();
});
it('orders media by received time', async () => {
server.use(
http.get('/api/media', () => HttpResponse.json([mediaItems[2], mediaItems[0], mediaItems[1]])),
@@ -175,9 +286,9 @@ describe('media page', () => {
renderMedia();
expect((await screen.findAllByRole('img')).map((item) => item.getAttribute('alt'))).toEqual([
'Media from alice',
'Media from charlie',
'Media from dave',
aliceAlt,
charlieAlt,
daveAlt,
]);
});
@@ -230,6 +341,22 @@ describe('media page', () => {
expect(await screen.findByRole('alert')).toHaveTextContent('Sync already in progress');
});
it('auto-dismisses the sync success notice after five seconds', async () => {
installMediaApi();
server.use(http.post('/api/media/sync', () => HttpResponse.json({ success: true })));
const user = userEvent.setup();
renderMedia();
await user.click(await screen.findByRole('button', { name: 'Sync new media' }));
const notice = await screen.findByText('Media sync completed.');
expect(notice).toHaveAttribute('role', 'status');
await waitFor(
() => expect(screen.queryByText('Media sync completed.')).not.toBeInTheDocument(),
{ timeout: 7_000 },
);
}, 15_000);
it('refreshes shared sync status as soon as a long-running mutation starts', async () => {
let statusRequests = 0;
let resolveSync: ((response: Response) => void) | undefined;
@@ -7,7 +7,7 @@ import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
import type { PropsWithChildren, ReactElement } from 'react';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { OperationsPage } from '../../apps/dashboard/src/features/operations/operations-page.js';
import { PerformancePage } from '../../apps/dashboard/src/features/performance/performance-page.js';
@@ -97,27 +97,102 @@ describe('PerformancePage', () => {
renderPage(<PerformancePage />);
expect(await screen.findByRole('heading', { name: 'Performance' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Outcome funnel' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'AI cost and reliability' })).toBeInTheDocument();
expect(await screen.findByRole('heading', { name: 'Outcome funnel' })).toBeInTheDocument();
expect(await screen.findByRole('heading', { name: 'AI cost and reliability' })).toBeInTheDocument();
expect(screen.getByRole('table', { name: 'AI usage by model' })).toHaveTextContent('model-a');
expect(screen.getByLabelText('AI usage totals')).toHaveTextContent('$0.0123');
expect(screen.getByText('5 awaiting maturity')).toBeInTheDocument();
const subredditTable = screen.getByRole('table', { name: 'Subreddit performance' });
const subredditTable = await screen.findByRole('table', { name: 'Subreddit performance' });
expect(within(subredditTable).getByText('n = 40')).toBeInTheDocument();
expect(within(subredditTable).getByText('n = 3')).toBeInTheDocument();
expect(within(subredditTable).getAllByText('Low confidence')).toHaveLength(2);
expect(within(subredditTable).getByRole('columnheader', { name: 'Images' })).toBeInTheDocument();
expect(within(subredditTable).getByText('15%')).toBeInTheDocument();
expect(within(subredditTable).getByText('9 pending')).toBeInTheDocument();
const strategyTable = screen.getByRole('table', { name: 'Strategy performance' });
const strategyTable = await screen.findByRole('table', { name: 'Strategy performance' });
expect(within(strategyTable).getByText('n = 25')).toBeInTheDocument();
expect(within(strategyTable).getAllByText('Configured')).toHaveLength(2);
expect(within(strategyTable).getByText('7 pending')).toBeInTheDocument();
expect(screen.getByRole('list', { name: '30 day response trend' })).toHaveTextContent('50%');
expect(screen.getByRole('article', { name: 'Successful example from askuk' })).toHaveTextContent('A thoughtful opener');
expect(await screen.findByRole('list', { name: '30 day response trend' })).toHaveTextContent('50%');
expect(await screen.findByRole('article', { name: 'Successful example from askuk' })).toHaveTextContent('A thoughtful opener');
expect(screen.getByRole('article', { name: 'Successful example from askuk' })).toHaveTextContent('That sounds lovely!');
expect(screen.getByRole('article', { name: 'Successful example from askuk' })).toHaveTextContent('Image received');
});
it('renders each panel independently and retries only the failed panel', async () => {
let funnelRequests = 0;
server.use(
http.get('/api/analytics/by-subreddit', () =>
HttpResponse.json([
{ subreddit: 'askuk', totalSent: 40, totalResponses: 12, responseRate: 30, totalImages: 6, imageRate: 15 },
]),
),
http.get('/api/analytics/by-strategy', () => HttpResponse.json([])),
http.get('/api/analytics/trend', () => HttpResponse.json([])),
http.get('/api/successful', () => HttpResponse.json([])),
http.get('/api/analytics/funnel', () => {
funnelRequests += 1;
if (funnelRequests === 1) return HttpResponse.json({ error: 'Funnel unavailable right now' }, { status: 503 });
return HttpResponse.json({
responseWindowHours: 48, processed: 100, sent: 40, pending: 5, mature: 35,
responded: 12, engaged: 4, declined: 2, boundary: 1, upsell: 3, images: 2,
});
}),
http.get('/api/analytics/ai-usage', () => HttpResponse.json({
days: 30, requests: 0, successes: 0, refusals: 0, errors: 0,
promptTokens: 0, completionTokens: 0, totalTokens: 0, costUsd: 0, averageLatencyMs: 0, models: [],
})),
);
const user = userEvent.setup();
renderPage(<PerformancePage />);
// The failed funnel query renders its own inline error…
const funnelError = await screen.findByRole('alert');
expect(funnelError).toHaveTextContent('Funnel unavailable right now');
// …while sibling panels still render their data and empty states.
expect(await screen.findByRole('table', { name: 'Subreddit performance' })).toHaveTextContent('askuk');
expect(screen.getAllByText('No sends recorded yet.').length).toBeGreaterThanOrEqual(2);
await user.click(within(funnelError).getByRole('button', { name: 'Retry outcome funnel' }));
expect(await screen.findByRole('heading', { name: 'Outcome funnel' })).toBeInTheDocument();
expect(screen.getByText('5 awaiting maturity')).toBeInTheDocument();
expect(funnelRequests).toBe(2);
});
it('switches the trend and AI usage range with the day toggle', async () => {
const trendDays: Array<string | null> = [];
const usageDays: Array<string | null> = [];
server.use(
http.get('/api/analytics/by-subreddit', () => HttpResponse.json([])),
http.get('/api/analytics/by-strategy', () => HttpResponse.json([])),
http.get('/api/successful', () => HttpResponse.json([])),
http.get('/api/analytics/funnel', () => HttpResponse.json({
responseWindowHours: 48, processed: 0, sent: 0, pending: 0, mature: 0,
responded: 0, engaged: 0, declined: 0, boundary: 0, upsell: 0, images: 0,
})),
http.get('/api/analytics/trend', ({ request }) => {
trendDays.push(new URL(request.url).searchParams.get('days'));
return HttpResponse.json([
{ date: '2026-08-07', totalSent: 10, totalResponses: 2, responseRate: 20, totalImages: 1, imageRate: 10 },
]);
}),
http.get('/api/analytics/ai-usage', ({ request }) => {
usageDays.push(new URL(request.url).searchParams.get('days'));
return HttpResponse.json({
days: 7, requests: 0, successes: 0, refusals: 0, errors: 0,
promptTokens: 0, completionTokens: 0, totalTokens: 0, costUsd: 0, averageLatencyMs: 0, models: [],
});
}),
);
const user = userEvent.setup();
renderPage(<PerformancePage />);
expect(await screen.findByRole('list', { name: '30 day response trend' })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: '7 days' }));
expect(await screen.findByRole('list', { name: '7 day response trend' })).toBeInTheDocument();
expect(trendDays).toEqual(['30', '7']);
expect(usageDays).toEqual(['30', '7']);
});
});
describe('OperationsPage', () => {
@@ -181,11 +256,14 @@ describe('OperationsPage', () => {
'href',
'https://www.reddit.com/u/operator',
);
expect(within(consoleRegion).getByText('2 retained')).toBeInTheDocument();
await user.selectOptions(within(consoleRegion).getByLabelText('Log level'), 'error');
expect(consoleRegion).not.toHaveTextContent('Sync complete');
expect(consoleRegion).toHaveTextContent('Matrix request failed: M_LIMIT_EXCEEDED');
expect(within(consoleRegion).getByText('1 shown · 2 retained')).toBeInTheDocument();
await user.type(within(consoleRegion).getByLabelText('Search logs'), 'no match');
expect(consoleRegion).toHaveTextContent('No logs match the current filters.');
expect(within(consoleRegion).getByText('0 shown · 2 retained')).toBeInTheDocument();
expect(within(consoleRegion).getByRole('button', { name: 'Pause console' })).toHaveAttribute('aria-pressed', 'false');
expect(within(consoleRegion).getByLabelText('Auto-scroll')).toBeChecked();
await user.clear(within(consoleRegion).getByLabelText('Search logs'));
@@ -204,19 +282,29 @@ describe('OperationsPage', () => {
http.post('/api/chats/sync/full', () => { fullCalls += 1; return HttpResponse.json({ success: true, kind: 'full' }); }),
http.post('/api/chats/fetch-all', () => HttpResponse.json({ success: true, count: 7 })),
);
const confirmSpy = vi.spyOn(globalThis, 'confirm').mockReturnValue(false);
const user = userEvent.setup();
renderPage(<OperationsPage realtimeEnabled={false} />);
await user.click(await screen.findByRole('button', { name: 'Sync latest chats' }));
expect(await screen.findByText(/Delta sync started/)).toBeInTheDocument();
expect(deltaCalls).toBe(1);
// Expensive operations require an in-app confirmation dialog with action-specific copy.
await user.click(screen.getByRole('button', { name: 'Full cache sync' }));
const fullDialog = screen.getByRole('dialog', { name: 'Run a full cache sync?' });
expect(fullDialog).toHaveTextContent('can take significantly longer than a delta sync');
await user.click(within(fullDialog).getByRole('button', { name: 'Cancel' }));
expect(fullCalls).toBe(0);
confirmSpy.mockReturnValue(true);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Full cache sync' }));
await user.click(within(screen.getByRole('dialog', { name: 'Run a full cache sync?' })).getByRole('button', { name: 'Run full sync' }));
expect(await screen.findByText(/Full sync started/)).toBeInTheDocument();
expect(fullCalls).toBe(1);
await user.click(screen.getByRole('button', { name: 'Fetch all chats' }));
const fetchAllDialog = screen.getByRole('dialog', { name: 'Fetch every chat now?' });
expect(fetchAllDialog).toHaveTextContent('legacy blocking operation');
await user.click(within(fetchAllDialog).getByRole('button', { name: 'Start fetching' }));
expect(await screen.findByText('Fetched 7 chats.')).toBeInTheDocument();
});
});
+154 -2
View File
@@ -2,7 +2,7 @@
import '@testing-library/jest-dom/vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import { act, cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
@@ -30,7 +30,10 @@ function renderSettings(path = '/settings') {
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const router = createMemoryRouter(
[{ path: '/settings', element: <SettingsRoute /> }],
[
{ path: '/settings', element: <SettingsRoute /> },
{ path: '/elsewhere', element: <h1>Elsewhere</h1> },
],
{ initialEntries: [path] },
);
const view = render(
@@ -112,9 +115,18 @@ describe('settings and access', () => {
await user.click(screen.getByRole('button', { name: 'Save settings' }));
expect(screen.getByText('Recent window must be greater than 0 minutes.')).toBeInTheDocument();
expect(recentWindow).toHaveAttribute('aria-invalid', 'true');
expect(screen.getByText('Resolve 1 validation error before saving.')).toBeInTheDocument();
await waitFor(() => expect(recentWindow).toHaveFocus());
expect(updateRequests).toBe(0);
// Discarding a dirty draft requires explicit confirmation.
await user.click(screen.getByRole('button', { name: 'Reset changes' }));
const resetDialog = screen.getByRole('dialog', { name: 'Discard unsaved settings?' });
await user.click(within(resetDialog).getByRole('button', { name: 'Stay' }));
expect(recentWindow).toHaveValue(0);
await user.click(screen.getByRole('button', { name: 'Reset changes' }));
await user.click(within(screen.getByRole('dialog', { name: 'Discard unsaved settings?' })).getByRole('button', { name: 'Discard changes' }));
expect(recentWindow).toHaveValue(60);
expect(screen.queryByText('You have unsaved changes.')).not.toBeInTheDocument();
});
@@ -340,6 +352,146 @@ describe('settings and access', () => {
expect(updateRequests).toBe(0);
});
it('confirms in-app navigation away from dirty settings and can discard the draft', async () => {
installConfigApi();
const user = userEvent.setup();
const { router } = renderSettings('/settings?section=monitoring');
const recentWindow = await screen.findByRole('spinbutton', { name: 'Recent window in minutes' });
await user.clear(recentWindow);
await user.type(recentWindow, '45');
await act(async () => { await router.navigate('/elsewhere'); });
const stayDialog = await screen.findByRole('dialog', { name: 'Discard unsaved settings?' });
await user.click(within(stayDialog).getByRole('button', { name: 'Stay' }));
expect(router.state.location.pathname).toBe('/settings');
expect(screen.getByRole('spinbutton', { name: 'Recent window in minutes' })).toHaveValue(45);
await act(async () => { await router.navigate('/elsewhere'); });
const discardDialog = await screen.findByRole('dialog', { name: 'Discard unsaved settings?' });
await user.click(within(discardDialog).getByRole('button', { name: 'Discard changes' }));
await waitFor(() => expect(router.state.location.pathname).toBe('/elsewhere'));
expect(screen.getByRole('heading', { name: 'Elsewhere' })).toBeInTheDocument();
});
it('requires confirmation for an autonomous mode on a renamed subreddit, matched by name not index', async () => {
const r4rConfig = {
...config,
subreddits: [{
name: 'casualconversation', type: 'chat', weight: 2, tone: 'default',
conversation: { mode: 'r4r', maxAutomatedTurns: 5, rapportTurnsBeforeMediaAsk: 3 },
}],
};
let updateRequests = 0;
server.use(
http.get('/api/config', () => HttpResponse.json(r4rConfig)),
http.put('/api/config', () => {
updateRequests += 1;
return HttpResponse.json({
success: true,
config: { rateLimit: config.rateLimit, browser: config.browser, reddit: config.reddit },
subreddits: r4rConfig.subreddits,
});
}),
);
const user = userEvent.setup();
renderSettings('/settings?section=behaviour');
// The persisted row is already r4r at index 0; giving the row a new name is
// equivalent to inserting a new r4r-enabled row, so it must be re-confirmed.
const name = await screen.findByRole('textbox', { name: 'Subreddit 1 name' });
await user.clear(name);
await user.type(name, 'brandnewplace');
await user.click(screen.getByRole('button', { name: 'Save settings' }));
const dialog = screen.getByRole('dialog', { name: 'Confirm risky settings' });
expect(dialog).toHaveTextContent('Autonomous R4R will send conversational replies without approval');
expect(updateRequests).toBe(0);
});
it('does not re-confirm an unchanged autonomous subreddit when a row before it is removed', async () => {
const twoSubredditConfig = {
...config,
subreddits: [
{ name: 'general', type: 'chat', weight: 1, tone: 'default', conversation: { mode: 'legacy', maxAutomatedTurns: 3, rapportTurnsBeforeMediaAsk: 3 } },
{ name: 'r4rlondon', type: 'chat', weight: 1, tone: 'default', conversation: { mode: 'r4r', maxAutomatedTurns: 5, rapportTurnsBeforeMediaAsk: 3 } },
],
};
let updateRequests = 0;
server.use(
http.get('/api/config', () => HttpResponse.json(twoSubredditConfig)),
http.put('/api/config', async ({ request }) => {
updateRequests += 1;
const body = await request.json() as { subreddits: typeof twoSubredditConfig.subreddits };
return HttpResponse.json({
success: true,
config: { rateLimit: config.rateLimit, browser: config.browser, reddit: config.reddit },
subreddits: body.subreddits,
});
}),
);
const user = userEvent.setup();
renderSettings('/settings?section=behaviour');
// Removing the first row shifts the r4r row to index 0; by-name matching
// must recognise it as already approved and save without a risk dialog.
await user.click(await screen.findByRole('button', { name: 'Remove subreddit 1' }));
await user.click(screen.getByRole('button', { name: 'Save settings' }));
expect(screen.queryByRole('dialog', { name: 'Confirm risky settings' })).not.toBeInTheDocument();
expect(await screen.findByText('Settings saved.')).toBeInTheDocument();
expect(updateRequests).toBe(1);
});
it('notices a server-side config change while the draft is dirty and loads it on reset', async () => {
installConfigApi();
const user = userEvent.setup();
const { queryClient } = renderSettings('/settings?section=monitoring');
const recentWindow = await screen.findByRole('spinbutton', { name: 'Recent window in minutes' });
await user.clear(recentWindow);
await user.type(recentWindow, '45');
act(() => {
queryClient.setQueryData(['dashboard', 'config'], {
...config,
reddit: { ...config.reddit, maxReadyCandidates: 99 },
subreddits: [{
name: 'casualconversation', type: 'chat', weight: 2, tone: 'default',
conversation: { mode: 'legacy', maxAutomatedTurns: 3, rapportTurnsBeforeMediaAsk: 3 },
}],
});
});
expect(await screen.findByText('Settings changed on the server — Reset to load them.')).toBeInTheDocument();
expect(recentWindow).toHaveValue(45);
await user.click(screen.getByRole('button', { name: 'Reset changes' }));
await user.click(within(screen.getByRole('dialog', { name: 'Discard unsaved settings?' })).getByRole('button', { name: 'Discard changes' }));
expect(screen.getByRole('spinbutton', { name: 'Recent window in minutes' })).toHaveValue(60);
expect(screen.getByRole('spinbutton', { name: 'Maximum ready posts' })).toHaveValue(99);
});
it('rejects a non-JSON session dump inline without sending a request', async () => {
let importRequests = 0;
server.use(
http.get('/api/config', () => HttpResponse.json(config)),
http.get('/api/login/status', () => HttpResponse.json({ ready: true, loggedIn: false, status: 'login-required' })),
http.post('/api/login/session-import', () => {
importRequests += 1;
return HttpResponse.json({ ok: true });
}),
);
const user = userEvent.setup();
renderSettings('/settings?section=access');
const brokenDump = new File(['definitely not json'], 'broken.json', { type: 'application/json' });
await user.upload(await screen.findByLabelText('Reddit session export'), brokenDump);
expect(await screen.findByText(/isn't valid JSON/)).toBeInTheDocument();
expect(importRequests).toBe(0);
});
it('waits for and imports an extension dump without exposing credentials', async () => {
let statusRequests = 0;
let importRequests = 0;