feat: persist settings and enrich conversation context

This commit is contained in:
jjbrunton committed 2026-08-08 18:18:28 +01:00
1 parent a36723a325
commit 2c100d72b6
84 files changed
+2248 -644

No files matched your search

@@ -0,0 +1,72 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
export interface OriginPost {
title: string;
url: string;
imageUrl?: string | null;
subreddit?: string;
}
export function OriginPostCard({ post }: { post: OriginPost }) {
return (
<a aria-label={post.title} className="origin-post-card" href={post.url} rel="noreferrer" target="_blank">
{post.imageUrl ? <img alt={`Source post: ${post.title}`} loading="lazy" src={post.imageUrl} /> : null}
<span>
<small>Started from {post.subreddit ? `r/${post.subreddit}` : 'Reddit'}</small>
<strong>{post.title}</strong>
<span>Open original post</span>
</span>
</a>
);
}
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">
{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"
>
<article className="origin-post-dialog">
<div className="origin-post-dialog-heading">
<span>Conversation origin</span>
<button aria-label="Close source post" onClick={close} ref={closeRef} type="button">×</button>
</div>
{post.imageUrl ? <img alt={`Source post: ${post.title}`} loading="lazy" src={post.imageUrl} /> : null}
<div className="origin-post-dialog-copy">
{post.subreddit ? <small>r/{post.subreddit}</small> : null}
<h2 id="origin-post-dialog-title">{post.title}</h2>
<a className="button primary" href={post.url} rel="noreferrer" target="_blank">Open Reddit post</a>
</div>
</article>
</div>
) : null}
</>
);
}
@@ -0,0 +1,16 @@
import { Fragment } from 'react';
import { RedditUsernameLink } from './reddit-username-link.js';
const USER_MENTION = /(u\/[A-Za-z0-9_-]+|@?t2_[A-Za-z0-9]+(?::reddit\.com)?)/gi;
export function RedditUserText({ children }: { children: string }) {
return children.split(USER_MENTION).map((part, index) => {
if (/^u\/(?!t2_)[A-Za-z0-9_-]+$/i.test(part)) {
return <RedditUsernameLink key={`${part}-${index}`} username={part} />;
}
if (/^(?:@?t2_)/i.test(part) || /^u\/t2_/i.test(part)) {
return <Fragment key={`${part}-${index}`}>Reddit user</Fragment>;
}
return <Fragment key={`${part}-${index}`}>{part}</Fragment>;
});
}
@@ -0,0 +1,42 @@
export interface RedditUsernameLinkProps {
username: string;
showPrefix?: boolean;
}
export function normalizeRedditUsername(username: string): string {
return username.trim().replace(/^\/?u\//i, '');
}
export function isDisplayableRedditUsername(username: string): boolean {
const normalized = normalizeRedditUsername(username);
return normalized.length > 0
&& !/^(?:me|you|unknown|unknown reddit user)$/i.test(normalized)
&& !/^t2_/i.test(normalized)
&& !/^@t2_/i.test(normalized);
}
export function redditUsernameLabel(username: string, showPrefix = true): string {
if (!isDisplayableRedditUsername(username)) return 'Unknown Reddit user';
const normalized = normalizeRedditUsername(username);
return showPrefix ? `u/${normalized}` : normalized;
}
export function RedditUsernameLink({ username, showPrefix = true }: RedditUsernameLinkProps) {
const normalizedUsername = normalizeRedditUsername(username);
const label = redditUsernameLabel(username, showPrefix);
if (!isDisplayableRedditUsername(username)) {
return <span className="reddit-username-link is-unresolved">{label}</span>;
}
return (
<a
className="reddit-username-link"
href={`https://www.reddit.com/u/${encodeURIComponent(normalizedUsername)}`}
rel="noreferrer"
target="_blank"
>
{label}
</a>
);
}
@@ -44,7 +44,10 @@ export function createConversationApi(fetchImplementation?: typeof fetch): Conve
return {
async list() {
return parseResponse(await request('/api/conversations'), ConversationsResponseSchema);
return parseResponse(
await request('/api/conversations?includeImageMetadata=true'),
ConversationsResponseSchema,
);
},
async detail(id) {
return parseResponse(await request(conversationPath(id)), ConversationDetailResponseSchema);
@@ -1,5 +1,7 @@
import { useCallback, useRef, useState } from 'react';
import type { ConversationDetailResponse } from '../../../../../packages/api-contracts/src/index.js';
import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
import { OriginPostCard } from '../../components/origin-post.js';
import { useConversationDetailQuery, useSendReplyMutation, useSuggestMediaAskMutation, useSuggestReplyMutation } from './conversation-queries.js';
import { SendConfirmationDialog } from './send-confirmation-dialog.js';
@@ -12,6 +14,10 @@ function formatMessageTime(timestamp: number): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp);
}
function mediaPath(localPath: string): string {
return `/media/${localPath.split('/').map(encodeURIComponent).join('/')}`;
}
function MessageHistory({ conversation }: { conversation: ConversationDetailResponse }) {
if (!conversation.messages || conversation.messages.length === 0) {
return <div className="conversation-empty-detail">No messages have been loaded for this conversation.</div>;
@@ -22,10 +28,22 @@ function MessageHistory({ conversation }: { conversation: ConversationDetailResp
{conversation.messages.map((message) => (
<li className={message.isSelf ? 'is-self' : 'is-recipient'} key={`${message.timestamp}-${message.sender}-${message.text}`}>
<div>
<strong>{message.isSelf ? 'You' : `u/${conversation.username}`}</strong>
<strong>{message.isSelf ? 'You' : <RedditUsernameLink username={conversation.username} />}</strong>
<time dateTime={new Date(message.timestamp).toISOString()}>{formatMessageTime(message.timestamp)}</time>
</div>
<p>{message.text}</p>
{message.media?.length ? (
<div className="conversation-message-images">
{message.media.map((media) => (
<img
alt={`Image ${message.isSelf ? 'sent by you' : `received from ${redditUsernameLabel(conversation.username)}`}`}
key={`${message.timestamp}-${media.localPath}`}
loading="lazy"
src={mediaPath(media.localPath)}
/>
))}
</div>
) : null}
</li>
))}
</ol>
@@ -79,7 +97,7 @@ function ConversationComposer({ conversation }: { conversation: ConversationDeta
{mediaAskMutation.isPending ? 'Generating media ask…' : 'Suggest media ask'}
</button>
</div>
<label className="sr-only" htmlFor="conversation-reply">Reply to u/{conversation.username}</label>
<label className="sr-only" htmlFor="conversation-reply">Reply to {redditUsernameLabel(conversation.username)}</label>
<textarea
id="conversation-reply"
onChange={(event) => {
@@ -94,7 +112,7 @@ function ConversationComposer({ conversation }: { conversation: ConversationDeta
{mediaAskMutation.isError ? <div className="conversation-inline-error" role="alert">{mediaAskMutation.error.message}</div> : null}
{sendMutation.isError ? <div className="conversation-inline-error" role="alert">{sendMutation.error.message}</div> : null}
{wasSent ? (
<div aria-label="Reply sent" className="conversation-inline-success" role="status">Reply sent to u/{conversation.username}</div>
<div aria-label="Reply sent" className="conversation-inline-success" role="status">Reply sent to <RedditUsernameLink username={conversation.username} /></div>
) : null}
<div className="conversation-actions">
<span>{draft.length} characters</span>
@@ -143,7 +161,7 @@ export function ConversationDetail({ conversationId, onClose }: ConversationDeta
<header>
<div>
<span className="conversation-eyebrow">Conversation</span>
<h2>u/{conversation.username}</h2>
<h2><RedditUsernameLink username={conversation.username} /></h2>
{conversation.unread ? <span className="conversation-badge">Unread</span> : null}
</div>
<button aria-label="Close conversation" onClick={onClose} type="button">Close</button>
@@ -154,16 +172,14 @@ export function ConversationDetail({ conversationId, onClose }: ConversationDeta
</div>
) : null}
{conversation.triggeringPost ? (
<a
aria-label={conversation.triggeringPost.title ?? `Open triggering post ${conversation.triggeringPost.id}`}
className="triggering-post"
href={conversation.triggeringPost.url}
rel="noreferrer"
target="_blank"
>
<span>Started from {conversation.triggeringPost.subreddit ? `r/${conversation.triggeringPost.subreddit}` : 'Reddit'}</span>
<strong>{conversation.triggeringPost.title ?? conversation.triggeringPost.id}</strong>
</a>
<div className="triggering-post">
<OriginPostCard post={{
title: conversation.triggeringPost.title ?? conversation.triggeringPost.id,
url: conversation.triggeringPost.url,
imageUrl: conversation.triggeringPost.imageUrl,
subreddit: conversation.triggeringPost.subreddit,
}} />
</div>
) : null}
<MessageHistory conversation={conversation} />
<ConversationComposer conversation={conversation} />
@@ -8,7 +8,12 @@ export function ConversationRoute() {
const filterValue = searchParams.get('filter');
const filters: ConversationFilters = {
search: searchParams.get('q') ?? '',
filter: filterValue === 'unread' || filterValue === 'awaiting-reply' ? filterValue : 'all',
filter:
filterValue === 'unread'
|| filterValue === 'awaiting-reply'
|| filterValue === 'received-images'
? filterValue
: 'all',
};
function selectConversation(nextConversationId: string | null) {
@@ -1,12 +1,13 @@
import { useMemo, 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';
import { useConversationsQuery } from './conversation-queries.js';
import { StartConversationForm } from './start-conversation-form.js';
import { ProcessedUserLookup } from './processed-user-lookup.js';
import './conversations.css';
export type ConversationFilter = 'all' | 'unread' | 'awaiting-reply';
export type ConversationFilter = 'all' | 'unread' | 'awaiting-reply' | 'received-images';
export interface ConversationFilters {
search: string;
@@ -27,6 +28,7 @@ function conversationId(conversation: ActiveChat): string {
function matchesFilter(conversation: ActiveChat, filter: ConversationFilter): boolean {
if (filter === 'unread') return conversation.unread;
if (filter === 'awaiting-reply') return conversation.isFromUs;
if (filter === 'received-images') return conversation.hasReceivedImages === true;
return true;
}
@@ -37,17 +39,17 @@ function ConversationListItem({ conversation, isSelected, onSelect }: {
}) {
const state = conversation.unread ? 'Unread' : conversation.isFromUs ? 'Awaiting reply' : 'Up to date';
return (
<li>
<li className={isSelected ? 'conversation-list-item is-selected' : 'conversation-list-item'}>
<button
aria-label={`u/${conversation.username}, ${state}`}
aria-label={`${redditUsernameLabel(conversation.username)}, ${state}${conversation.hasReceivedImages ? ', Image received' : ''}`}
aria-pressed={isSelected}
className={isSelected ? 'conversation-list-item is-selected' : 'conversation-list-item'}
className="conversation-list-target"
onClick={onSelect}
type="button"
>
<span className="conversation-list-heading"><strong>u/{conversation.username}</strong><small>{state}</small></span>
<span>{conversation.lastMessageSnippet || 'No message preview'}</span>
</button>
/>
<span className="conversation-list-heading"><strong><RedditUsernameLink username={conversation.username} /></strong><small>{state}</small></span>
{conversation.hasReceivedImages ? <span className="conversation-image-marker">Image received</span> : null}
<span>{conversation.lastMessageSnippet || 'No message preview'}</span>
</li>
);
}
@@ -121,6 +123,7 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
<option value="all">All</option>
<option value="unread">Unread</option>
<option value="awaiting-reply">Awaiting reply</option>
<option value="received-images">Received images</option>
</select>
</div>
{conversationsQuery.isPending ? (
@@ -33,12 +33,17 @@
.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-list { display: grid; gap: .35rem; max-height: 46rem; padding: .5rem; margin: 0; overflow: auto; list-style: none; }
.conversation-list-item { display: grid; gap: .4rem; width: 100%; padding: .8rem !important; text-align: left; background: transparent !important; border-color: transparent !important; }
.conversation-list-item:hover { background: var(--surface-raised) !important; }
.conversation-list-item.is-selected { background: var(--accent-dark) !important; border-color: #28593d !important; }
.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-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; }
.conversation-list-item .reddit-username-link { pointer-events: auto; }
.conversation-list-item > span:last-child { overflow: hidden; color: var(--muted); font-size: .82rem; text-overflow: ellipsis; white-space: nowrap; }
.conversation-list-heading { display: flex; align-items: center; justify-content: space-between; gap: .5rem; }
.conversation-list-heading small { color: var(--accent); }
.conversation-image-marker { justify-self: start; padding: .18rem .45rem; color: #d8e8ff !important; background: #18324d; border: 1px solid #315d85; border-radius: 999px; font-size: .68rem !important; font-weight: 750; }
.conversation-list-state, .conversation-detail-state { display: grid; place-items: center; align-content: center; gap: .5rem; min-height: 16rem; padding: 1.5rem; color: var(--muted); text-align: center; }
.conversation-list-state strong, .conversation-detail-state strong { color: #e9f1ec; }
.conversation-detail { min-height: 32rem; }
@@ -46,8 +51,7 @@
.conversation-detail h2 { display: inline; margin-right: .6rem; }
.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 { display: grid; gap: .2rem; margin: 1rem; padding: .75rem; color: inherit; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .65rem; text-decoration: none; }
.triggering-post span { color: var(--muted); font-size: .75rem; }
.triggering-post { margin: 1rem; padding: .75rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .65rem; }
.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); }
@@ -55,6 +59,8 @@
.conversation-messages li > div { display: flex; justify-content: space-between; gap: 1rem; margin-bottom: .35rem; }
.conversation-messages time { color: var(--muted); font-size: .7rem; }
.conversation-messages p { margin: 0; line-height: 1.45; white-space: pre-wrap; }
.conversation-message-images { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(12rem, 100%), 1fr)); gap: .5rem; margin-top: .65rem; }
.conversation-message-images img { display: block; width: 100%; max-width: 28rem; max-height: 32rem; object-fit: contain; background: #08100c; border: 1px solid var(--border); border-radius: .6rem; }
.conversation-empty-detail { padding: 2rem; color: var(--muted); text-align: center; }
.conversation-composer { display: grid; gap: .65rem; padding: 1rem; border-top: 1px solid var(--border); }
.conversation-section-heading, .conversation-actions { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
import { useProcessedPostsQuery } from './conversation-queries.js';
export function ProcessedUserLookup() {
@@ -17,8 +18,8 @@ export function ProcessedUserLookup() {
{postsQuery.isPending && username ? <p role="status">Looking up processed posts…</p> : null}
{postsQuery.isError ? <p role="alert">{postsQuery.error.message}</p> : null}
{postsQuery.data ? (
postsQuery.data.length === 0 ? <p>No processed posts found for u/{username}.</p> : (
<ol aria-label={`Processed posts for u/${username}`}>
postsQuery.data.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.id}><strong>{post.title}</strong><span>r/{post.subreddit} · {post.messageSent ? 'Message sent' : post.error ?? 'Processed'}</span></li>)}
</ol>
)
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
import { useStartConversationMutation } from './conversation-queries.js';
interface StartConversationFormProps {
@@ -53,7 +54,7 @@ export function StartConversationForm({ onStarted }: StartConversationFormProps)
{mutation.isError ? <div className="conversation-inline-error" role="alert">{mutation.error.message}</div> : null}
{successUsername ? (
<div aria-label="Conversation started" className="conversation-inline-success" role="status">
Conversation started with u/{successUsername}
Conversation started with <RedditUsernameLink username={successUsername} />
</div>
) : null}
<button className="conversation-primary" disabled={!canSubmit} type="submit">
@@ -1,4 +1,5 @@
import type { MediaItem } from '../../../../../packages/api-contracts/src/index.js';
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
import { mediaConversation, mediaType } from './filters.js';
interface MediaCardProps {
@@ -29,8 +30,10 @@ export function MediaCard({ item, isRevealed, onToggleReveal, onOpenPreview }: M
</div>
<div className="media-card-body">
<div>
<strong>{item.sender}</strong>
<span>{mediaConversation(item)}</span>
<strong><RedditUsernameLink showPrefix={false} username={item.sender} /></strong>
<span>{item.chatUsername
? <RedditUsernameLink showPrefix={false} username={item.chatUsername} />
: mediaConversation(item)}</span>
</div>
<dl>
<div><dt>Type</dt><dd>{mediaType(item) === 'gif' ? 'GIF' : 'Image'}</dd></div>
+3 -3
View File
@@ -25,9 +25,9 @@
.media-card-body dt { color: var(--muted); }
.media-card-body dd { margin: 0; text-align: right; }
.media-reveal { width: 100%; }
.media-preview-dialog-backdrop { position: fixed; inset: 0; z-index: 70; display: grid; place-items: center; padding: 1rem; background: rgba(0, 0, 0, .88); }
.media-preview-dialog-content { position: relative; display: grid; place-items: center; width: min(100%, 90rem); height: min(100%, 90rem); }
.media-preview-dialog-image { display: block; max-width: 100%; max-height: 100%; object-fit: contain; }
.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); }
.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-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; }
@@ -1,6 +1,9 @@
import { useEffect, useReducer, useRef, useState } from 'react';
import type { ActivityItem, ChatSyncStatus, DashboardEvent } from '../../../../../packages/api-contracts/src/index.js';
import { useDashboardRealtime } from '../../app/realtime-provider.js';
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
import { RedditUserText } from '../../components/reddit-user-text.js';
import { OriginPostButton } from '../../components/origin-post.js';
import {
createLiveConsoleState,
reduceLiveConsoleState,
@@ -27,7 +30,11 @@ function ActivityView({ items }: { items: readonly ActivityItem[] }) {
{items.map((item) => (
<li key={`${item.timestamp}-${item.message}`}>
<span className={`activity-marker ${item.type}`} aria-hidden="true" />
<div><strong>{item.message}</strong><small>{formatTimestamp(item.timestamp)}{item.recipient ? ` · u/${item.recipient}` : ''}</small></div>
<div><strong>{item.postUrl && item.postTitle ? (
<OriginPostButton post={{ title: item.postTitle, url: item.postUrl, imageUrl: item.imageUrl }}>
{item.message}
</OriginPostButton>
) : <RedditUserText>{item.message}</RedditUserText>}</strong><small>{formatTimestamp(item.timestamp)}{item.recipient && !item.message.includes(`u/${item.recipient}`) ? <> · <RedditUsernameLink username={item.recipient} /></> : null}</small></div>
</li>
))}
</ol>
@@ -167,7 +174,7 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps)
{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">
{logsQuery.isPending ? <p>Loading logs…</p> : visibleEntries.length === 0 ? <p>No logs match the current filters.</p> : visibleEntries.map((entry) => (
<article className={`console-entry ${entry.level}`} key={entry.id}><time dateTime={entry.occurredAt}>{formatTimestamp(entry.occurredAt)}</time><strong>{entry.level.toUpperCase()}</strong><span>{entry.message}</span></article>
<article className={`console-entry ${entry.level}`} key={entry.id}><time dateTime={entry.occurredAt}>{formatTimestamp(entry.occurredAt)}</time><strong>{entry.level.toUpperCase()}</strong><span><RedditUserText>{entry.message}</RedditUserText></span></article>
))}
<div ref={consoleEnd} />
</div>
@@ -124,9 +124,11 @@ export function queryKeysForDashboardEvent(event: DashboardEvent): readonly Quer
return [['conversations'], ['operations', 'activity']];
case 'sync.started':
case 'sync.progress':
return [['operations', 'sync-status']];
case 'sync.completed':
case 'sync.failed':
return [['operations', 'sync-status'], ['conversations'], ['media']];
case 'sync.failed':
return [['operations', 'sync-status']];
case 'reddit.session.changed':
return [['dashboard', 'login', 'status']];
case 'log.created':
@@ -3,6 +3,8 @@ import { useQueryClient } from '@tanstack/react-query';
import { dashboardQueryKeys, useSetRunningMutation, useStatsQuery, useTimersQuery } from '../../api/queries.js';
import type { BotStats, Timers } from '../../api/contracts.js';
import { ConfirmationDialog } from '../../components/confirmation-dialog.js';
import { RedditUserText } from '../../components/reddit-user-text.js';
import { OriginPostButton } from '../../components/origin-post.js';
import { RecentMessagesPanel } from './recent-messages.js';
function errorMessage(error: Error | null): string {
@@ -71,7 +73,11 @@ function ActivityPanel({ stats }: { stats: BotStats }) {
{stats.recentActivity.map((item) => (
<li key={`${item.timestamp}-${item.message}`}>
<span className={`activity-marker ${item.type}`} aria-hidden="true" />
<div><strong>{item.message}</strong><small>{formatTime(item.timestamp)}</small></div>
<div><strong>{item.postUrl && item.postTitle ? (
<OriginPostButton post={{ title: item.postTitle, url: item.postUrl, imageUrl: item.imageUrl }}>
{item.message}
</OriginPostButton>
) : <RedditUserText>{item.message}</RedditUserText>}</strong><small>{formatTime(item.timestamp)}</small></div>
</li>
))}
</ol>
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { MessagesResponseSchema, type SentMessage } from '../../../../../packages/api-contracts/src/index.js';
import { dashboardRequest } from '../../api/request.js';
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
async function loadRecentMessages(): Promise<SentMessage[]> {
const response = await dashboardRequest('/api/messages');
@@ -24,7 +25,7 @@ export function RecentMessagesPanel() {
{query.data && query.data.length > 0 ? (
<ol aria-label="Recent sent messages" className="activity-list">
{query.data.map((message) => (
<li key={message.id}><div><strong>u/{message.recipient}</strong><span>{message.message}</span><small>{formatSentAt(message.sentAt)} · {message.gotResponse ? 'Responded' : 'No response recorded'}</small></div></li>
<li key={message.id}><div><strong><RedditUsernameLink username={message.recipient} /></strong><span>{message.message}</span><small>{formatSentAt(message.sentAt)} · {message.gotResponse ? 'Responded' : 'No response recorded'}</small></div></li>
))}
</ol>
) : null}
@@ -1,4 +1,5 @@
import { useRef } from 'react';
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
import { useImportRedditSessionMutation, useLoginStatusQuery } from './settings-queries.js';
function errorMessage(error: Error | null): string {
@@ -52,7 +53,7 @@ export function AccessPanel() {
<section aria-labelledby="access-heading" className="panel settings-section">
<div className="panel-heading"><div><span className="eyebrow">Reddit session</span><h2 id="access-heading">Access</h2></div><span className={session.ready && 'status' in session && session.loggedIn ? 'pill success' : 'pill warning'}>{sessionLabel(session)}</span></div>
<p className="settings-copy">When Reddit is logged out, account work is paused and waiting for a fresh session dump from the extension.</p>
{username ? <p className="settings-copy">Signed in as <strong>u/{username}</strong>.</p> : null}
{username ? <p className="settings-copy">Signed in as <strong><RedditUsernameLink username={username} /></strong>.</p> : null}
<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" />
<button className="button primary" disabled={importMutation.isPending} onClick={() => importInput.current?.click()} type="button">{importMutation.isPending ? 'Importing session…' : 'Import Reddit session'}</button>
@@ -44,6 +44,7 @@ export function SettingsEditor({ config, section }: SettingsEditorProps) {
...response.config,
subreddits: response.subreddits,
tones: config.tones,
advanced: response.advanced ?? config.advanced,
};
const nextDraft = createSettingsDraft(nextConfig);
setPersistedDraft(nextDraft);
@@ -23,6 +23,7 @@ export interface SettingsDraft {
prioritizeRecentPosts: boolean;
recentWindowMinutes: string;
subreddits: SubredditDraft[];
advancedConfigJson: string;
}
export interface ValidatedSettings {
@@ -71,6 +72,7 @@ export function createSettingsDraft(config: ConfigResponse): SettingsDraft {
tone: subreddit.tone,
scripted: subreddit.scripted ?? '',
})),
advancedConfigJson: config.advanced ? JSON.stringify(config.advanced, null, 2) : '',
};
}
@@ -88,6 +90,19 @@ export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationR
});
if (Object.keys(requiredErrors).length > 0) return { errors: requiredErrors };
let advanced: Record<string, unknown> | 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.' } };
}
advanced = value as Record<string, unknown>;
} catch {
return { errors: { advancedConfigJson: 'Advanced configuration is not valid JSON.' } };
}
}
const parsed = ValidatedSettingsSchema.safeParse({
minDelayMinutes: Number(draft.minDelayMinutes),
maxMessagesPerHour: Number(draft.maxMessagesPerHour),
@@ -125,6 +140,7 @@ export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationR
recentWindowMinutes: parsed.data.recentWindowMinutes,
},
subreddits: parsed.data.subreddits,
...(advanced ? { advanced } : {}),
});
return {
@@ -20,6 +20,7 @@ export function useSaveSettingsMutation() {
...response.config,
subreddits: response.subreddits,
tones: current?.tones ?? [],
advanced: response.advanced ?? current?.advanced,
}));
},
});
@@ -204,6 +204,23 @@ export function BehaviourSection({ config, draft, errors, onChange }: SectionPro
</fieldset>
))}
</div>
{config.advanced ? (
<div className="advanced-settings">
<div><h3>Advanced application configuration</h3><p>Models, prompts, filtering, media sync, and strategy settings. Some changes take effect after a restart.</p></div>
<label className="settings-field">
<span>Advanced configuration (JSON)</span>
<textarea
aria-invalid={errors.advancedConfigJson ? 'true' : undefined}
aria-label="Advanced configuration JSON"
onChange={(event) => onChange({ ...draft, advancedConfigJson: event.target.value })}
rows={24}
spellCheck={false}
value={draft.advancedConfigJson}
/>
{errors.advancedConfigJson ? <small className="field-error">{errors.advancedConfigJson}</small> : null}
</label>
</div>
) : null}
</section>
);
}
+18
View File
@@ -20,6 +20,8 @@ button, input { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: wait; opacity: .6; }
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; }
.skip-link { position: fixed; left: 1rem; top: -5rem; z-index: 100; background: white; color: black; padding: .75rem 1rem; border-radius: .5rem; }
.skip-link:focus { top: 1rem; }
@@ -86,6 +88,22 @@ h2 { margin-bottom: 0; font-size: 1.1rem; }
.activity-list li { display: flex; align-items: flex-start; gap: .75rem; }
.activity-list li div { display: grid; gap: .15rem; }
.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; }
.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; }
.origin-post-dialog-copy h2 { margin: 0; font-size: 1.25rem; }
.origin-post-dialog-copy small { color: var(--muted); }
.origin-post-dialog-copy a { justify-self: start; text-decoration: none; }
.origin-post-card { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: .8rem; align-items: center; color: inherit; text-decoration: none; }
.origin-post-card > img { width: 5rem; height: 5rem; object-fit: cover; background: #050907; border-radius: .45rem; }
.origin-post-card > span { display: grid; gap: .2rem; min-width: 0; }
.origin-post-card small, .origin-post-card span span { color: var(--muted); font-size: .75rem; }
.origin-post-card strong { overflow-wrap: anywhere; }
.activity-marker { flex: 0 0 auto; width: .55rem; height: .55rem; margin-top: .3rem; border-radius: 50%; background: var(--muted); }
.activity-marker.sent, .activity-marker.response { background: var(--accent); }
.activity-marker.error { background: var(--danger); }