Compare commits
89
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
687abab82b | ||
|
|
bc42c55555 | ||
|
|
c028637556 | ||
|
|
a71ad56f3c | ||
|
|
e890f74478 | ||
|
|
1fd154e1eb | ||
|
|
540590f43e | ||
|
|
643c775813 | ||
|
|
7bf1aa9d24 | ||
|
|
27012399ec | ||
|
|
e2d7dc4b82 | ||
|
|
b949b6ee37 | ||
|
|
5deac87cf1 | ||
|
|
f88db1ae7d | ||
|
|
f36e93d5be | ||
|
|
1ac6ad22ad | ||
|
|
14d92e14ee | ||
|
|
3a40651930 | ||
|
|
89eb4583f8 | ||
|
|
c43e534cf1 | ||
|
|
11b6300113 | ||
|
|
8ea1cf16d4 | ||
|
|
5c57ed337b | ||
|
|
1515b3e291 | ||
|
|
d8d635e1ee | ||
|
|
d0a8bd0e2f | ||
|
|
7defff422d | ||
|
|
38d175e4a1 | ||
|
|
827eb3a1e0 | ||
|
|
0a4e0064f2 | ||
|
|
4404ae75ff | ||
|
|
ac12a3146a | ||
|
|
725b5fc4e6 | ||
|
|
c8689af04d | ||
|
|
e984bf20ac | ||
|
|
82be197b79 | ||
|
|
f34129666e | ||
|
|
b573b88b08 | ||
|
|
488145340d | ||
|
|
c0cc661a2f | ||
|
|
84a133451f | ||
|
|
bf474dfe10 | ||
|
|
b8e4a1bb88 | ||
|
|
f98c5c732d | ||
|
|
dc3fc6373b | ||
|
|
70c156e1f7 | ||
|
|
910576921b | ||
|
|
c630303b47 | ||
|
|
742c06d0c4 | ||
|
|
e9083281a8 | ||
|
|
22eb32e5bf | ||
|
|
9b86cee9be | ||
|
|
f55ae7ddf8 | ||
|
|
deaf86c364 | ||
|
|
df35648920 | ||
|
|
5abac31c28 | ||
|
|
92c54feedc | ||
|
|
d5ebdec076 | ||
|
|
c21932389e | ||
|
|
b73e305cf3 | ||
|
|
927636ec8b | ||
|
|
0f76bcf59a | ||
|
|
23d4c3a320 | ||
|
|
43f459a024 | ||
|
|
50f9f8a6aa | ||
|
|
7db60e8839 | ||
|
|
5c9839926d | ||
|
|
83b5f1f5b6 | ||
|
|
ca59ba0f1f | ||
|
|
55a6774daf | ||
|
|
a85b99ceb6 | ||
|
|
b400042244 | ||
|
|
9e1b1aeefe | ||
|
|
e7c29f91f2 | ||
|
|
5c01d7d9aa | ||
|
|
acc610c2bf | ||
|
|
e8f56b42e6 | ||
|
|
5ad7f1bf77 | ||
|
|
3beb8aada2 | ||
|
|
dcf57273e2 | ||
|
|
664aa5c098 | ||
|
|
f88d4af146 | ||
|
|
53e9a44a5c | ||
|
|
e8b730b592 | ||
|
|
720ea0652a | ||
|
|
5a5a30a1d4 | ||
|
|
345ec99348 | ||
|
|
a44768c089 | ||
|
|
5623100e93 |
No files matched your search
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "dashboard",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dashboard:dev"],
|
||||
"port": 5173
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -9,6 +9,15 @@ Configure only the application secrets required by the Compose file:
|
||||
- `OPENROUTER_API_KEY`
|
||||
- `WEB_DASHBOARD_TOKEN`
|
||||
|
||||
Optional agent credentials:
|
||||
|
||||
- `REDDIT_BOT_AGENT_READ_TOKEN` permits authenticated GET/SSE access but cannot mutate state.
|
||||
- `REDDIT_BOT_AGENT_CONTROL_TOKEN` additionally permits versioned control and sync-job operations.
|
||||
|
||||
Use separate long random values. Neither agent token can invoke legacy send,
|
||||
session-import, account-activation, or configuration mutations. The dashboard
|
||||
token remains the administrator credential.
|
||||
|
||||
Do not configure Reddit usernames, passwords, display servers, CDP endpoints, or remote-browser ports.
|
||||
|
||||
## Account activation
|
||||
|
||||
@@ -2,6 +2,10 @@ FROM mcr.microsoft.com/playwright:v1.57.0-jammy
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# Agent Guidelines & Code Standards
|
||||
|
||||
## Required orientation and documentation maintenance
|
||||
|
||||
Before changing code, read [`docs/README.md`](docs/README.md) and the documents it routes to for the area you are touching. Treat the source code, tests, generated API reference, and migrations as authoritative when a document disagrees with implementation.
|
||||
|
||||
Documentation is part of the change, not a follow-up task:
|
||||
|
||||
- Update the relevant file in `docs/` whenever behavior, architecture, configuration, data ownership, API contracts, operations, safety boundaries, or development workflow changes.
|
||||
- Update `docs/README.md` when adding, renaming, or removing a document.
|
||||
- Run `npm run docs:api` after changing `src/web/api-catalog.ts`; verify with `npm run docs:api:check`.
|
||||
- Prefer durable explanations and links to owning source files over copied implementation details, line counts, credentials, production IDs, or other facts likely to drift.
|
||||
- During final review, explicitly check whether the documentation still describes the code that will be committed.
|
||||
|
||||
Do not start, restart, deploy, or otherwise enable live outreach unless the user explicitly authorizes that operational action. A code or documentation request does not imply permission to run the bot. Never expose `.env`, imported account dumps, cookies, Matrix tokens, dashboard tokens, private media, or database contents.
|
||||
|
||||
## Refactoring Goal
|
||||
Refactor code to improve maintainability, testability, and clarity. Break down large components into smaller, single-purpose classes/modules.
|
||||
|
||||
@@ -35,11 +49,13 @@ Refactor code to improve maintainability, testability, and clarity. Break down l
|
||||
- **Logging**: Use `winston` for structured logging. Do not use `console.log` in production code.
|
||||
|
||||
## Workflow for Agents
|
||||
1. **Analyze**: Before writing code, read the file and understand the context.
|
||||
2. **Plan**: Identify what needs to change and check if the change violates SRP.
|
||||
3. **Implement**: Make the scoped change. Create new small files when SRP calls for it.
|
||||
4. **Test**: Add or update useful tests as appropriate for the change.
|
||||
5. **Refactor**: Tidy the implementation and tests while preserving behavior.
|
||||
6. **Verify**: Run the relevant tests and type checks (`npm run build` or `tsc --noEmit`) in proportion to the change.
|
||||
1. **Orient**: Read `docs/README.md`, the relevant topic documents, and the owning code/tests.
|
||||
2. **Analyze**: Before writing code, read the file and understand the context.
|
||||
3. **Plan**: Identify what needs to change and check if the change violates SRP.
|
||||
4. **Implement**: Make the scoped change. Create new small files when SRP calls for it.
|
||||
5. **Test**: Add or update useful tests as appropriate for the change.
|
||||
6. **Document**: Update the routed documentation for any changed contract or workflow.
|
||||
7. **Refactor**: Tidy the implementation and tests while preserving behavior.
|
||||
8. **Verify**: Run the relevant tests, documentation checks, and type checks (`npm run build` or `tsc --noEmit`) in proportion to the change.
|
||||
|
||||
You must use the dokploy credentials in .env for access to dokploy
|
||||
When Dokploy access is explicitly requested, use the credentials in the ignored `.env` file. Do not print, copy, commit, or blindly source that file.
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
botStatsSchema,
|
||||
controlResponseSchema,
|
||||
timersSchema,
|
||||
type BotMode,
|
||||
type BotStats,
|
||||
type ControlResponse,
|
||||
type Timers,
|
||||
@@ -36,7 +37,7 @@ async function parseResponse<T>(response: Response, schema: ZodType<T>): Promise
|
||||
export interface DashboardApi {
|
||||
getStats(): Promise<BotStats>;
|
||||
getTimers(): Promise<Timers>;
|
||||
setRunning(isRunning: boolean): Promise<ControlResponse>;
|
||||
setMode(mode: BotMode): Promise<ControlResponse>;
|
||||
}
|
||||
|
||||
export function createDashboardApi(fetchImplementation?: typeof fetch): DashboardApi {
|
||||
@@ -49,9 +50,15 @@ export function createDashboardApi(fetchImplementation?: typeof fetch): Dashboar
|
||||
async getTimers() {
|
||||
return parseResponse(await request('/api/timers'), timersSchema);
|
||||
},
|
||||
async setRunning(isRunning) {
|
||||
const endpoint = isRunning ? '/api/start' : '/api/stop';
|
||||
return parseResponse(await request(endpoint, { method: 'POST' }), controlResponseSchema);
|
||||
async setMode(mode) {
|
||||
return parseResponse(
|
||||
await request('/api/mode', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode }),
|
||||
}),
|
||||
controlResponseSchema,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export {
|
||||
} from '../../../../packages/api-contracts/src/index.js';
|
||||
|
||||
export type {
|
||||
BotMode,
|
||||
ControlResponse,
|
||||
StatsResponse as BotStats,
|
||||
TimersResponse as Timers,
|
||||
|
||||
@@ -8,20 +8,24 @@ export const dashboardQueryKeys = {
|
||||
};
|
||||
|
||||
export function useStatsQuery() {
|
||||
return useQuery({ queryKey: dashboardQueryKeys.stats, queryFn: dashboardApi.getStats });
|
||||
return useQuery({
|
||||
queryKey: dashboardQueryKeys.stats,
|
||||
queryFn: dashboardApi.getStats,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
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() {
|
||||
export function useSetModeMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: dashboardApi.setRunning,
|
||||
mutationFn: dashboardApi.setMode,
|
||||
onSuccess(response) {
|
||||
queryClient.setQueryData<BotStats>(dashboardQueryKeys.stats, (current) =>
|
||||
current ? { ...current, isRunning: response.running } : current,
|
||||
current ? { ...current, isRunning: response.running, mode: response.mode } : current,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useState } from 'react';
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import { navigationItems } from './navigation.js';
|
||||
import { navigationGroups, navigationItemsByGroup } from './navigation.js';
|
||||
import { ShellStatusPanel } from './shell-status-panel.js';
|
||||
import { NotificationBell, NotificationProvider, NotificationViewport } from './notifications.js';
|
||||
|
||||
export function AppShell() {
|
||||
const [isNavigationOpen, setIsNavigationOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<NotificationProvider><div className="app-shell">
|
||||
<a className="skip-link" href="#main-content">Skip to content</a>
|
||||
<aside className={isNavigationOpen ? 'sidebar is-open' : 'sidebar'}>
|
||||
<div className="brand">
|
||||
@@ -16,9 +17,13 @@ export function AppShell() {
|
||||
<strong>Reddit Bot</strong>
|
||||
<span>Operator console</span>
|
||||
</div>
|
||||
<NotificationBell />
|
||||
</div>
|
||||
<nav aria-label="Primary navigation" id="primary-navigation">
|
||||
{navigationItems.map((item) => (
|
||||
{navigationGroups.map((group) => (
|
||||
<div className="nav-group" key={group}>
|
||||
<span className="nav-group-label">{group}</span>
|
||||
{navigationItemsByGroup(group).map((item) => (
|
||||
<NavLink
|
||||
aria-label={item.label}
|
||||
className={({ isActive }) => (isActive ? 'nav-link is-active' : 'nav-link')}
|
||||
@@ -31,6 +36,8 @@ export function AppShell() {
|
||||
<small>{item.description}</small>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<ShellStatusPanel />
|
||||
</aside>
|
||||
@@ -45,6 +52,8 @@ export function AppShell() {
|
||||
<div className="workspace">
|
||||
<header className="mobile-header">
|
||||
<span>Reddit Bot</span>
|
||||
<div className="mobile-header-actions">
|
||||
<NotificationBell />
|
||||
<button
|
||||
aria-controls="primary-navigation"
|
||||
aria-expanded={isNavigationOpen}
|
||||
@@ -55,11 +64,13 @@ export function AppShell() {
|
||||
>
|
||||
<span aria-hidden="true">{isNavigationOpen ? '×' : '☰'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main id="main-content" tabIndex={-1}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<NotificationViewport />
|
||||
</div></NotificationProvider>
|
||||
);
|
||||
}
|
||||
@@ -2,13 +2,30 @@ export interface NavigationItem {
|
||||
label: string;
|
||||
path: string;
|
||||
description: string;
|
||||
group: NavigationGroup;
|
||||
}
|
||||
|
||||
export type NavigationGroup = 'Monitor' | 'Content' | 'Analyse' | 'Configure';
|
||||
|
||||
/** Render order for the sidebar's group headings. */
|
||||
export const navigationGroups: readonly NavigationGroup[] = ['Monitor', 'Content', 'Analyse', 'Configure'];
|
||||
|
||||
export const navigationItems: readonly NavigationItem[] = [
|
||||
{ label: 'Overview', path: '/', description: 'System health and current activity' },
|
||||
{ label: 'Conversations', path: '/conversations', description: 'Inbox and message history' },
|
||||
{ label: 'Media', path: '/media', description: 'Private media and sync controls' },
|
||||
{ label: 'Performance', path: '/performance', description: 'Response and strategy trends' },
|
||||
{ label: 'Operations', path: '/operations', description: 'Jobs, events, and diagnostics' },
|
||||
{ label: 'Settings', path: '/settings', description: 'Runtime and access configuration' },
|
||||
{ label: 'Overview', path: '/', description: 'System health and current activity', group: 'Monitor' },
|
||||
{ label: 'Needs attention', path: '/attention', description: 'Handoffs, failures, and account risks', group: 'Monitor' },
|
||||
{ label: 'Outreach funnel', path: '/outreach', description: 'Active posts moving toward delivery', group: 'Monitor' },
|
||||
{ label: 'Conversations', path: '/conversations', description: 'Inbox and message history', group: 'Monitor' },
|
||||
{ label: 'Media', path: '/media', description: 'Private media and sync controls', group: 'Content' },
|
||||
{ label: 'Exchange library', path: '/media-library', description: 'Images the bot may send when asked', group: 'Content' },
|
||||
{ label: 'Performance', path: '/performance', description: 'Response and strategy trends', group: 'Analyse' },
|
||||
{ label: 'Learning review', path: '/learning', description: 'Human-led examples and attribution', group: 'Analyse' },
|
||||
{ label: 'Operations', path: '/operations', description: 'Jobs, events, and diagnostics', group: 'Analyse' },
|
||||
{ label: 'Subreddits', path: '/subreddits', description: 'Where the bot posts and how it talks', group: 'Configure' },
|
||||
{ label: 'Accounts', path: '/accounts', description: 'Reddit sessions and access', group: 'Configure' },
|
||||
{ label: 'Account growth', path: '/growth', description: 'Build comment history on new accounts', group: 'Configure' },
|
||||
{ label: 'Settings', path: '/settings', description: 'Pacing, safety, and notifications', group: 'Configure' },
|
||||
];
|
||||
|
||||
export function navigationItemsByGroup(group: NavigationGroup): readonly NavigationItem[] {
|
||||
return navigationItems.filter((item) => item.group === group);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
.notification-bell {
|
||||
position: relative;
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
padding: 0;
|
||||
color: #dfe9e3;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: .7rem;
|
||||
}
|
||||
.notification-bell:hover { color: var(--accent); border-color: #3d5a4c; }
|
||||
.notification-bell:focus-visible, .notification-close:focus-visible, .notification-clear:focus-visible, .notification-entry:focus-visible, .notification-toast a:focus-visible, .notification-toast button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.notification-bell svg, .notification-empty svg { width: 1.2rem; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.8; }
|
||||
.notification-badge { position: absolute; top: -.35rem; right: -.35rem; display: grid; place-items: center; min-width: 1.15rem; height: 1.15rem; padding: 0 .25rem; color: #07130c; background: var(--accent); border: 2px solid #0c1511; border-radius: 999px; font-size: .62rem; font-weight: 900; line-height: 1; }
|
||||
.brand { justify-content: space-between; }
|
||||
.brand > div { margin-right: auto; }
|
||||
.notification-backdrop { position: fixed; inset: 0; z-index: 69; width: 100%; padding: 0; background: rgba(0, 0, 0, .52); border: 0; }
|
||||
.notification-centre { position: fixed; inset: 0 0 0 auto; z-index: 70; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; width: min(27rem, 100%); visibility: hidden; background: #0c1511; border-left: 1px solid var(--border); box-shadow: -1.5rem 0 4rem rgba(0, 0, 0, .38); transform: translateX(100%); transition: transform 220ms ease, visibility 220ms; }
|
||||
.notification-centre.is-open { visibility: visible; transform: translateX(0); }
|
||||
.notification-centre > header { display: flex; align-items: center; justify-content: space-between; min-height: 5.5rem; padding: 1.2rem 1.25rem; border-bottom: 1px solid var(--border); }
|
||||
.notification-centre h2 { font-size: 1.35rem; letter-spacing: -.025em; }
|
||||
.notification-centre .eyebrow { margin-bottom: .25rem; }
|
||||
.notification-close { display: grid; place-items: center; width: 2.35rem; height: 2.35rem; padding: 0; color: var(--muted); background: transparent; border: 1px solid var(--border); border-radius: .6rem; font-size: 1.5rem; line-height: 1; }
|
||||
.notification-list { min-height: 0; padding: 0; margin: 0; overflow-y: auto; list-style: none; }
|
||||
.notification-list li { border-bottom: 1px solid var(--border); }
|
||||
.notification-entry { display: grid; grid-template-columns: .65rem minmax(0, 1fr); gap: .8rem; padding: 1rem 1.25rem; color: inherit; text-decoration: none; }
|
||||
a.notification-entry:hover { background: var(--surface); }
|
||||
.notification-signal { width: .55rem; height: .55rem; margin-top: .3rem; background: #77a6d8; border-radius: 50%; box-shadow: 0 0 0 .3rem rgba(119, 166, 216, .1); }
|
||||
.notification-signal.success { background: var(--accent); box-shadow: 0 0 0 .3rem rgba(117, 214, 157, .1); }
|
||||
.notification-signal.warning { background: var(--warning); box-shadow: 0 0 0 .3rem rgba(243, 199, 107, .1); }
|
||||
.notification-signal.error { background: var(--danger); box-shadow: 0 0 0 .3rem rgba(255, 138, 132, .1); }
|
||||
.notification-copy { display: grid; gap: .28rem; min-width: 0; }
|
||||
.notification-copy strong { color: #edf5f0; font-size: .9rem; line-height: 1.3; }
|
||||
.notification-copy > span { overflow: hidden; color: var(--muted); font-size: .82rem; line-height: 1.45; text-overflow: ellipsis; }
|
||||
.notification-copy time { color: #71857b; font-size: .7rem; }
|
||||
.notification-centre > footer { display: flex; justify-content: flex-end; padding: .8rem 1.25rem; border-top: 1px solid var(--border); }
|
||||
.notification-clear { padding: .4rem 0; color: var(--muted); background: transparent; border: 0; font-size: .78rem; font-weight: 700; }
|
||||
.notification-clear:hover { color: #edf5f0; }
|
||||
.notification-empty { display: grid; place-items: center; align-content: center; padding: 2rem; text-align: center; }
|
||||
.notification-empty > span { display: grid; place-items: center; width: 3.25rem; height: 3.25rem; margin-bottom: .9rem; color: var(--accent); background: var(--accent-dark); border: 1px solid #28593d; border-radius: 1rem; }
|
||||
.notification-empty strong { margin-bottom: .35rem; font-size: 1rem; }
|
||||
.notification-empty p { max-width: 18rem; margin: 0; color: var(--muted); font-size: .82rem; line-height: 1.5; }
|
||||
.toast-stack { position: fixed; right: 1rem; bottom: 1rem; z-index: 75; display: grid; gap: .65rem; width: min(24rem, calc(100% - 2rem)); pointer-events: none; }
|
||||
.notification-toast { position: relative; overflow: hidden; background: #15221c; border: 1px solid #355044; border-radius: .85rem; box-shadow: 0 1rem 3rem rgba(0, 0, 0, .4); pointer-events: auto; animation: notification-arrive 220ms ease-out both; }
|
||||
.notification-toast.warning { background: #282112; border-color: #5b4922; }
|
||||
.notification-toast.error { background: #2d1715; border-color: #69332e; }
|
||||
.notification-toast > a, .notification-toast > div { display: grid; grid-template-columns: .65rem minmax(0, 1fr); gap: .75rem; padding: .9rem 2.75rem .9rem 1rem; color: inherit; text-decoration: none; }
|
||||
.notification-toast > a:hover { background: rgba(255, 255, 255, .035); }
|
||||
.notification-toast .notification-copy time { display: none; }
|
||||
.notification-toast > button { position: absolute; top: .5rem; right: .5rem; width: 1.8rem; height: 1.8rem; padding: 0; color: var(--muted); background: transparent; border: 0; border-radius: .35rem; font-size: 1.2rem; }
|
||||
@keyframes notification-arrive { from { opacity: 0; transform: translateY(.75rem) scale(.98); } }
|
||||
@media (prefers-reduced-motion: reduce) { .notification-centre, .notification-toast { transition: none; animation: none; } }
|
||||
@media (max-width: 47.999rem) { .brand .notification-bell { display: none; } .toast-stack { bottom: .75rem; } }
|
||||
@media (min-width: 48rem) { .mobile-header .notification-bell { display: none; } }
|
||||
@@ -0,0 +1,357 @@
|
||||
import type { DashboardEvent } from '../../../../packages/api-contracts/src/index.js';
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
} from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { z } from 'zod';
|
||||
import { useDashboardRealtime } from './realtime-provider.js';
|
||||
import './notifications.css';
|
||||
|
||||
export type NotificationTone = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
export interface NotificationItem {
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
occurredAt: string;
|
||||
tone: NotificationTone;
|
||||
href?: string;
|
||||
isRead: boolean;
|
||||
}
|
||||
|
||||
export type NotificationDraft = Omit<NotificationItem, 'isRead'>;
|
||||
|
||||
interface NotificationContextValue {
|
||||
isOpen: boolean;
|
||||
notifications: readonly NotificationItem[];
|
||||
toasts: readonly NotificationItem[];
|
||||
unreadCount: number;
|
||||
clearAll(): void;
|
||||
dismissToast(id: string): void;
|
||||
setOpen(isOpen: boolean): void;
|
||||
}
|
||||
|
||||
interface RealtimeSubscription {
|
||||
subscribe(listener: (event: DashboardEvent) => void): () => void;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'reddit-bot.notifications.v1';
|
||||
const MAX_NOTIFICATIONS = 50;
|
||||
const NotificationContext = createContext<NotificationContextValue | null>(null);
|
||||
const StoredNotificationsSchema = z.array(z.strictObject({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
message: z.string(),
|
||||
occurredAt: z.string(),
|
||||
tone: z.enum(['info', 'success', 'warning', 'error']),
|
||||
href: z.string().optional(),
|
||||
isRead: z.boolean(),
|
||||
}));
|
||||
|
||||
function notificationStorage(): Storage | undefined {
|
||||
try {
|
||||
return globalThis.localStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function titleCase(value: string): string {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1).replaceAll('-', ' ');
|
||||
}
|
||||
|
||||
export function notificationForEvent(event: DashboardEvent): NotificationDraft | null {
|
||||
switch (event.type) {
|
||||
case 'reply.received':
|
||||
return {
|
||||
id: event.id,
|
||||
occurredAt: event.occurredAt,
|
||||
title: `New reply from u/${event.payload.sender}`,
|
||||
message: event.payload.preview?.trim() || 'A new message arrived in this conversation.',
|
||||
tone: 'success',
|
||||
href: `/conversations/${encodeURIComponent(event.payload.conversationId)}`,
|
||||
};
|
||||
case 'media.received':
|
||||
return {
|
||||
id: event.id,
|
||||
occurredAt: event.occurredAt,
|
||||
title: `Image received from u/${event.payload.sender}`,
|
||||
message: event.payload.count > 1
|
||||
? `${event.payload.count} images are attached to this conversation.`
|
||||
: 'An image is attached to this conversation.',
|
||||
tone: 'success',
|
||||
href: `/conversations/${encodeURIComponent(event.payload.conversationId)}`,
|
||||
};
|
||||
case 'message.sent':
|
||||
return {
|
||||
id: event.id,
|
||||
occurredAt: event.occurredAt,
|
||||
title: `Message sent to u/${event.payload.recipient}`,
|
||||
message: 'The message was delivered successfully.',
|
||||
tone: 'success',
|
||||
href: event.payload.conversationId
|
||||
? `/conversations/${encodeURIComponent(event.payload.conversationId)}`
|
||||
: '/conversations',
|
||||
};
|
||||
case 'message.failed':
|
||||
return {
|
||||
id: event.id,
|
||||
occurredAt: event.occurredAt,
|
||||
title: `Message to u/${event.payload.recipient} failed`,
|
||||
message: event.payload.reason,
|
||||
tone: 'error',
|
||||
href: event.payload.conversationId
|
||||
? `/conversations/${encodeURIComponent(event.payload.conversationId)}`
|
||||
: '/operations',
|
||||
};
|
||||
case 'bot.status.changed':
|
||||
return {
|
||||
id: event.id,
|
||||
occurredAt: event.occurredAt,
|
||||
title: `Bot ${event.payload.status}`,
|
||||
message: event.payload.reason ?? `The bot is now ${event.payload.status}.`,
|
||||
tone: event.payload.status === 'running' ? 'success' : event.payload.status === 'stopped' ? 'warning' : 'info',
|
||||
href: '/operations',
|
||||
};
|
||||
case 'sync.completed':
|
||||
return {
|
||||
id: event.id,
|
||||
occurredAt: event.occurredAt,
|
||||
title: `${titleCase(event.payload.kind)} sync complete`,
|
||||
message: `${event.payload.processed.toLocaleString()} items processed.`,
|
||||
tone: 'success',
|
||||
href: '/operations',
|
||||
};
|
||||
case 'sync.failed':
|
||||
return {
|
||||
id: event.id,
|
||||
occurredAt: event.occurredAt,
|
||||
title: `${titleCase(event.payload.kind)} sync failed`,
|
||||
message: event.payload.reason,
|
||||
tone: 'error',
|
||||
href: '/operations',
|
||||
};
|
||||
case 'rate-limit.changed':
|
||||
return {
|
||||
id: event.id,
|
||||
occurredAt: event.occurredAt,
|
||||
title: event.payload.limited ? 'Rate limit reached' : 'Rate limit cleared',
|
||||
message: event.payload.reason ?? (event.payload.limited
|
||||
? 'Sending will resume when the limit resets.'
|
||||
: 'The bot can send messages again.'),
|
||||
tone: event.payload.limited ? 'warning' : 'success',
|
||||
href: '/operations',
|
||||
};
|
||||
case 'reddit.session.changed':
|
||||
if (!['authenticated', 'expired', 'logged-out', 'error'].includes(event.payload.status)) return null;
|
||||
return {
|
||||
id: event.id,
|
||||
occurredAt: event.occurredAt,
|
||||
title: event.payload.status === 'authenticated' ? 'Reddit account connected' : 'Reddit account needs attention',
|
||||
message: event.payload.reason ?? (event.payload.username
|
||||
? `Signed in as u/${event.payload.username}.`
|
||||
: `Session status changed to ${event.payload.status}.`),
|
||||
tone: event.payload.status === 'authenticated' ? 'success' : 'error',
|
||||
href: '/accounts',
|
||||
};
|
||||
case 'conversation.updated':
|
||||
case 'sync.started':
|
||||
case 'sync.progress':
|
||||
case 'log.created':
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function loadNotifications(): NotificationItem[] {
|
||||
const storage = notificationStorage();
|
||||
if (!storage) return [];
|
||||
try {
|
||||
const value: unknown = JSON.parse(storage.getItem(STORAGE_KEY) ?? '[]');
|
||||
const parsed = StoredNotificationsSchema.safeParse(value);
|
||||
return parsed.success ? parsed.data.slice(0, MAX_NOTIFICATIONS) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export interface NotificationProviderProps extends PropsWithChildren {
|
||||
realtime?: RealtimeSubscription;
|
||||
}
|
||||
|
||||
export function NotificationProvider({ children, realtime: realtimeOverride }: NotificationProviderProps) {
|
||||
const dashboardRealtime = useDashboardRealtime();
|
||||
const realtime = realtimeOverride ?? dashboardRealtime;
|
||||
const [isOpen, setIsOpenState] = useState(false);
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>(loadNotifications);
|
||||
const [toasts, setToasts] = useState<NotificationItem[]>([]);
|
||||
|
||||
const dismissToast = useCallback((id: string) => {
|
||||
setToasts((current) => current.filter((toast) => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
const setOpen = useCallback((nextOpen: boolean) => {
|
||||
setIsOpenState(nextOpen);
|
||||
if (nextOpen) setNotifications((current) => current.map((item) => ({ ...item, isRead: true })));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
notificationStorage()?.setItem(STORAGE_KEY, JSON.stringify(notifications));
|
||||
} catch {
|
||||
// Storage can be unavailable in private or restricted browser contexts.
|
||||
}
|
||||
}, [notifications]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && notifications.some((item) => !item.isRead)) {
|
||||
setNotifications((current) => current.map((item) => ({ ...item, isRead: true })));
|
||||
}
|
||||
}, [isOpen, notifications]);
|
||||
|
||||
useEffect(() => realtime.subscribe((event) => {
|
||||
const draft = notificationForEvent(event);
|
||||
if (!draft) return;
|
||||
const item = { ...draft, isRead: false };
|
||||
setNotifications((current) => current.some((entry) => entry.id === item.id)
|
||||
? current
|
||||
: [item, ...current].slice(0, MAX_NOTIFICATIONS));
|
||||
setToasts((current) => current.some((entry) => entry.id === item.id)
|
||||
? current
|
||||
: [...current, item].slice(-3));
|
||||
}), [realtime]);
|
||||
|
||||
useEffect(() => {
|
||||
if (toasts.length === 0) return undefined;
|
||||
const timers = toasts.map((toast) => globalThis.setTimeout(
|
||||
() => dismissToast(toast.id),
|
||||
toast.tone === 'error' ? 8_000 : 5_000,
|
||||
));
|
||||
return () => timers.forEach((timer) => globalThis.clearTimeout(timer));
|
||||
}, [dismissToast, toasts]);
|
||||
|
||||
useEffect(() => {
|
||||
function closeOnEscape(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') setOpen(false);
|
||||
}
|
||||
globalThis.addEventListener('keydown', closeOnEscape);
|
||||
return () => globalThis.removeEventListener('keydown', closeOnEscape);
|
||||
}, [setOpen]);
|
||||
|
||||
const value = useMemo<NotificationContextValue>(() => ({
|
||||
isOpen,
|
||||
notifications,
|
||||
toasts,
|
||||
unreadCount: notifications.filter((item) => !item.isRead).length,
|
||||
clearAll: () => setNotifications([]),
|
||||
dismissToast,
|
||||
setOpen,
|
||||
}), [dismissToast, isOpen, notifications, setOpen, toasts]);
|
||||
|
||||
return <NotificationContext.Provider value={value}>{children}</NotificationContext.Provider>;
|
||||
}
|
||||
|
||||
function useNotifications(): NotificationContextValue {
|
||||
const context = useContext(NotificationContext);
|
||||
if (!context) throw new Error('Notifications must be used within NotificationProvider');
|
||||
return context;
|
||||
}
|
||||
|
||||
function BellIcon() {
|
||||
return (
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24">
|
||||
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotificationBell({ className = '' }: { className?: string }) {
|
||||
const { isOpen, setOpen, unreadCount } = useNotifications();
|
||||
return (
|
||||
<button
|
||||
aria-controls="notification-centre"
|
||||
aria-expanded={isOpen}
|
||||
aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : 'Notifications'}
|
||||
className={`notification-bell ${className}`.trim()}
|
||||
onClick={() => setOpen(!isOpen)}
|
||||
type="button"
|
||||
>
|
||||
<BellIcon />
|
||||
{unreadCount > 0 ? <span className="notification-badge">{unreadCount > 99 ? '99+' : unreadCount}</span> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function formattedTime(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date);
|
||||
}
|
||||
|
||||
function NotificationContent({ item }: { item: NotificationItem }) {
|
||||
return (
|
||||
<>
|
||||
<span className={`notification-signal ${item.tone}`} aria-hidden="true" />
|
||||
<span className="notification-copy">
|
||||
<strong>{item.title}</strong>
|
||||
<span>{item.message}</span>
|
||||
<time dateTime={item.occurredAt}>{formattedTime(item.occurredAt)}</time>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotificationViewport() {
|
||||
const { clearAll, dismissToast, isOpen, notifications, setOpen, toasts } = useNotifications();
|
||||
return (
|
||||
<>
|
||||
{isOpen ? <button aria-label="Close notifications" className="notification-backdrop" onClick={() => setOpen(false)} type="button" /> : null}
|
||||
<aside aria-label="Notifications" className={isOpen ? 'notification-centre is-open' : 'notification-centre'} id="notification-centre">
|
||||
<header>
|
||||
<div>
|
||||
<span className="eyebrow">Activity inbox</span>
|
||||
<h2>Notifications</h2>
|
||||
</div>
|
||||
<button aria-label="Close notification centre" className="notification-close" onClick={() => setOpen(false)} type="button">×</button>
|
||||
</header>
|
||||
{notifications.length > 0 ? (
|
||||
<>
|
||||
<ol className="notification-list">
|
||||
{notifications.map((item) => (
|
||||
<li key={item.id}>
|
||||
{item.href ? (
|
||||
<Link className="notification-entry" onClick={() => setOpen(false)} to={item.href}>
|
||||
<NotificationContent item={item} />
|
||||
</Link>
|
||||
) : <div className="notification-entry"><NotificationContent item={item} /></div>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<footer><button className="notification-clear" onClick={clearAll} type="button">Clear all</button></footer>
|
||||
</>
|
||||
) : (
|
||||
<div className="notification-empty">
|
||||
<span aria-hidden="true"><BellIcon /></span>
|
||||
<strong>All quiet</strong>
|
||||
<p>Replies, delivery updates, syncs, and account alerts will appear here.</p>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
<div aria-atomic="false" aria-live="polite" className="toast-stack">
|
||||
{toasts.map((toast) => (
|
||||
<div className={`notification-toast ${toast.tone}`} key={toast.id} role={toast.tone === 'error' ? 'alert' : 'status'}>
|
||||
{toast.href ? (
|
||||
<Link onClick={() => dismissToast(toast.id)} to={toast.href}><NotificationContent item={toast} /></Link>
|
||||
) : <div><NotificationContent item={toast} /></div>}
|
||||
<button aria-label={`Dismiss ${toast.title}`} onClick={() => dismissToast(toast.id)} type="button">×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,13 @@ import { OperationsPage } from '../features/operations/operations-page.js';
|
||||
import { PerformancePage } from '../features/performance/performance-page.js';
|
||||
import { SettingsRoute } from '../features/settings/settings-route.js';
|
||||
import { RouteErrorPage } from './route-error-page.js';
|
||||
import { AttentionPage } from '../features/attention/attention-page.js';
|
||||
import { OutreachPage } from '../features/outreach/outreach-page.js';
|
||||
import { MediaLibraryPage } from '../features/media-library/media-library-page.js';
|
||||
import { SubredditsPage } from '../features/subreddits/subreddits-page.js';
|
||||
import { AccountsPage } from '../features/accounts/accounts-page.js';
|
||||
import { GrowthPage } from '../features/growth/growth-page.js';
|
||||
import { LearningPage } from '../features/learning/learning-page.js';
|
||||
|
||||
export function createDashboardRoutes(): RouteObject[] {
|
||||
return [
|
||||
@@ -16,11 +23,18 @@ export function createDashboardRoutes(): RouteObject[] {
|
||||
errorElement: <RouteErrorPage />,
|
||||
children: [
|
||||
{ index: true, element: <OverviewPage /> },
|
||||
{ path: 'attention', element: <AttentionPage /> },
|
||||
{ path: 'outreach', element: <OutreachPage /> },
|
||||
{ path: 'conversations', element: <ConversationRoute /> },
|
||||
{ path: 'conversations/:conversationId', element: <ConversationRoute /> },
|
||||
{ path: 'media', element: <MediaRoute /> },
|
||||
{ path: 'media-library', element: <MediaLibraryPage /> },
|
||||
{ path: 'performance', element: <PerformancePage /> },
|
||||
{ path: 'learning', element: <LearningPage /> },
|
||||
{ path: 'operations', element: <OperationsPage /> },
|
||||
{ path: 'subreddits', element: <SubredditsPage /> },
|
||||
{ path: 'accounts', element: <AccountsPage /> },
|
||||
{ path: 'growth', element: <GrowthPage /> },
|
||||
{ path: 'settings', element: <SettingsRoute /> },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useStatsQuery, useSetRunningMutation } from '../api/queries.js';
|
||||
import { useStatsQuery, useSetModeMutation } from '../api/queries.js';
|
||||
import type { BotMode } from '../api/contracts.js';
|
||||
import { ConfirmationDialog } from '../components/confirmation-dialog.js';
|
||||
import { useSyncStatusQuery } from '../features/operations/operations-queries.js';
|
||||
import { useLoginStatusQuery } from '../features/settings/settings-queries.js';
|
||||
@@ -12,15 +13,15 @@ export function ShellStatusPanel() {
|
||||
const reddit = useLoginStatusQuery();
|
||||
const sync = useSyncStatusQuery();
|
||||
const realtime = useDashboardRealtime();
|
||||
const control = useSetRunningMutation();
|
||||
const [confirming, setConfirming] = useState<'start' | 'stop' | null>(null);
|
||||
const control = useSetModeMutation();
|
||||
const [confirming, setConfirming] = useState<BotMode | null>(null);
|
||||
const status = deriveShellStatus({ stats: stats.data, reddit: reddit.data, sync: sync.data, realtime: realtime.connection });
|
||||
return <>
|
||||
<section aria-label="Platform status" className="shell-status">
|
||||
<strong>{status.bot}</strong><span>{status.reddit}</span><span>{status.realtime}</span><span>{status.sync}</span>
|
||||
{status.cooldown ? <span className="shell-warning">Cooldown: {status.cooldown}</span> : null}
|
||||
<button aria-label={`${status.controlAction === 'start' ? 'Start' : 'Stop'} bot from global status`} className={status.controlAction === 'start' ? 'button primary' : 'button danger'} disabled={status.controlDisabled || control.isPending} onClick={() => setConfirming(status.controlAction)} type="button">{status.controlAction === 'start' ? 'Start bot' : 'Stop bot'}</button>
|
||||
<button aria-label={`${status.controlAction === 'running' ? 'Start' : 'Stop'} bot from global status`} className={status.controlAction === 'running' ? 'button primary' : 'button danger'} disabled={status.controlDisabled || control.isPending} onClick={() => setConfirming(status.controlAction)} type="button">{status.controlAction === 'running' ? 'Start bot' : 'Stop bot'}</button>
|
||||
</section>
|
||||
{confirming ? <ConfirmationDialog action={confirming} isPending={control.isPending} onCancel={() => setConfirming(null)} onConfirm={() => control.mutate(confirming === 'start', { onSuccess: () => setConfirming(null) })} /> : null}
|
||||
{confirming ? <ConfirmationDialog action={confirming} isPending={control.isPending} onCancel={() => setConfirming(null)} onConfirm={() => control.mutate(confirming, { onSuccess: () => setConfirming(null) })} /> : null}
|
||||
</>;
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { RealtimeConnectionSnapshot } from '../features/operations/realtime-client.js';
|
||||
import type { BotMode } from '../api/contracts.js';
|
||||
|
||||
const MODE_LABEL: Record<BotMode, string> = {
|
||||
running: 'Running',
|
||||
scan: 'Scanning',
|
||||
stopped: 'Stopped',
|
||||
};
|
||||
|
||||
interface ShellStatusInput {
|
||||
stats?: { isRunning: boolean; dryRunMode: boolean; invitationCooldown: { reason: string } | null };
|
||||
stats?: { mode: BotMode; dryRunMode: boolean; invitationCooldown: { reason: string } | null };
|
||||
reddit?: { ready: boolean; loggedIn?: boolean; status?: string; error?: string };
|
||||
realtime: RealtimeConnectionSnapshot;
|
||||
sync?: { syncing: boolean; syncingKind?: string | null };
|
||||
@@ -13,7 +20,7 @@ export interface ShellStatus {
|
||||
realtime: string;
|
||||
cooldown: string | null;
|
||||
sync: string;
|
||||
controlAction: 'start' | 'stop';
|
||||
controlAction: BotMode;
|
||||
controlDisabled: boolean;
|
||||
}
|
||||
|
||||
@@ -28,12 +35,14 @@ export function deriveShellStatus(input: ShellStatusInput): ShellStatus {
|
||||
? input.reddit.loggedIn ? `Reddit ${input.reddit.status ?? 'authenticated'}` : 'Reddit login required'
|
||||
: 'Reddit session unavailable';
|
||||
return {
|
||||
bot: stats ? `${stats.isRunning ? 'Running' : 'Stopped'}${stats.dryRunMode ? ' (dry run)' : ''}` : 'Bot state unavailable',
|
||||
bot: stats ? `${MODE_LABEL[stats.mode]}${stats.dryRunMode ? ' (dry run)' : ''}` : 'Bot state unavailable',
|
||||
reddit,
|
||||
realtime,
|
||||
cooldown: stats?.invitationCooldown?.reason ?? null,
|
||||
sync: input.sync?.syncing ? `${sentence(input.sync.syncingKind ?? 'unknown')} sync active` : 'No active sync',
|
||||
controlAction: stats?.isRunning ? 'stop' : 'start',
|
||||
// The header's single control is the opposite of wherever we are now;
|
||||
// scan mode's natural next step is a full start.
|
||||
controlAction: stats?.mode === 'running' ? 'stopped' : 'running',
|
||||
controlDisabled: stats === undefined,
|
||||
};
|
||||
}
|
||||
@@ -1,39 +1,52 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { BotMode } from '../api/contracts.js';
|
||||
import { Modal } from './modal.js';
|
||||
|
||||
interface ConfirmationDialogProps {
|
||||
action: 'start' | 'stop';
|
||||
action: BotMode;
|
||||
errorMessage?: string | null;
|
||||
isPending: boolean;
|
||||
onCancel(): void;
|
||||
onConfirm(): void;
|
||||
}
|
||||
|
||||
export function ConfirmationDialog({ action, isPending, onCancel, onConfirm }: ConfirmationDialogProps) {
|
||||
const cancelButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const isStarting = action === 'start';
|
||||
const copy: Record<BotMode, { title: string; body: string; confirm: string; tone: string }> = {
|
||||
running: {
|
||||
title: 'Start bot?',
|
||||
body: 'The bot will resume automated processing.',
|
||||
confirm: 'Confirm start',
|
||||
tone: 'button primary',
|
||||
},
|
||||
scan: {
|
||||
title: 'Switch to scan mode?',
|
||||
body: 'Conversations stay in sync and replies are flagged for you, but nothing is sent and no new candidates are discovered.',
|
||||
confirm: 'Confirm scan mode',
|
||||
tone: 'button secondary',
|
||||
},
|
||||
stopped: {
|
||||
title: 'Stop bot?',
|
||||
body: 'The bot will pause after its current operation. Conversations stop syncing too — use scan mode to keep them up to date.',
|
||||
confirm: 'Confirm stop',
|
||||
tone: 'button danger',
|
||||
},
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
cancelButtonRef.current?.focus();
|
||||
}, []);
|
||||
export function ConfirmationDialog({ action, errorMessage = null, isPending, onCancel, onConfirm }: ConfirmationDialogProps) {
|
||||
const { title, body, confirm, tone } = copy[action];
|
||||
|
||||
return (
|
||||
<div aria-labelledby="confirmation-title" aria-modal="true" className="dialog-backdrop" role="dialog">
|
||||
<div className="dialog-card">
|
||||
<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>
|
||||
<h2 id="confirmation-title">{title}</h2>
|
||||
<p>{body}</p>
|
||||
{errorMessage ? <p className="dialog-error" role="alert">{errorMessage}</p> : null}
|
||||
<div className="dialog-actions">
|
||||
<button className="button secondary" disabled={isPending} onClick={onCancel} ref={cancelButtonRef} type="button">
|
||||
<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 className={tone} disabled={isPending} onClick={onConfirm} type="button">
|
||||
{isPending ? 'Applying…' : confirm}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface FieldShellProps {
|
||||
error?: string;
|
||||
/** Guidance shown when there is no error to show in its place. */
|
||||
hint?: ReactNode;
|
||||
label: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function fieldIds(name: string) {
|
||||
return { errorId: `${name}-error`, hintId: `${name}-hint` };
|
||||
}
|
||||
|
||||
function FieldMessage({ error, hint, name }: FieldShellProps) {
|
||||
const { errorId, hintId } = fieldIds(name);
|
||||
if (error) return <small className="field-error" id={errorId}>{error}</small>;
|
||||
if (hint) return <small className="field-hint" id={hintId}>{hint}</small>;
|
||||
return null;
|
||||
}
|
||||
|
||||
function describedBy({ error, hint, name }: FieldShellProps): string | undefined {
|
||||
const { errorId, hintId } = fieldIds(name);
|
||||
if (error) return errorId;
|
||||
return hint ? hintId : undefined;
|
||||
}
|
||||
|
||||
export interface NumberFieldProps extends FieldShellProps {
|
||||
max?: number;
|
||||
min?: number;
|
||||
step?: number | 'any';
|
||||
/** Rendered inside the input as a trailing unit, e.g. "min" or "/hr". */
|
||||
suffix?: string;
|
||||
value: string;
|
||||
onChange(value: string): void;
|
||||
}
|
||||
|
||||
export function NumberField({ error, hint, label, max, min, name, onChange, step = 1, suffix, value }: NumberFieldProps) {
|
||||
const shell = { error, hint, label, name };
|
||||
return (
|
||||
<label className="field">
|
||||
<span className="field-label">{label}</span>
|
||||
<span className={suffix ? 'field-input has-suffix' : 'field-input'}>
|
||||
<input
|
||||
aria-describedby={describedBy(shell)}
|
||||
aria-invalid={error ? 'true' : undefined}
|
||||
aria-label={label}
|
||||
max={max}
|
||||
min={min}
|
||||
name={name}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
step={step}
|
||||
type="number"
|
||||
value={value}
|
||||
/>
|
||||
{suffix ? <span aria-hidden="true" className="field-suffix">{suffix}</span> : null}
|
||||
</span>
|
||||
<FieldMessage {...shell} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TextFieldProps extends FieldShellProps {
|
||||
placeholder?: string;
|
||||
/** Rendered as a non-editable prefix inside the input, e.g. "r/". */
|
||||
prefix?: string;
|
||||
type?: 'text' | 'url';
|
||||
value: string;
|
||||
onChange(value: string): void;
|
||||
}
|
||||
|
||||
export function TextField({ error, hint, label, name, onChange, placeholder, prefix, type = 'text', value }: TextFieldProps) {
|
||||
const shell = { error, hint, label, name };
|
||||
return (
|
||||
<label className="field">
|
||||
<span className="field-label">{label}</span>
|
||||
<span className={prefix ? 'field-input has-prefix' : 'field-input'}>
|
||||
{prefix ? <span aria-hidden="true" className="field-prefix">{prefix}</span> : null}
|
||||
<input
|
||||
aria-describedby={describedBy(shell)}
|
||||
aria-invalid={error ? 'true' : undefined}
|
||||
aria-label={label}
|
||||
name={name}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
type={type}
|
||||
value={value}
|
||||
/>
|
||||
</span>
|
||||
<FieldMessage {...shell} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SelectFieldProps<Value extends string> extends FieldShellProps {
|
||||
options: ReadonlyArray<{ value: Value; label: string }>;
|
||||
value: Value;
|
||||
onChange(value: Value): void;
|
||||
}
|
||||
|
||||
export function SelectField<Value extends string>({ error, hint, label, name, onChange, options, value }: SelectFieldProps<Value>) {
|
||||
const shell = { error, hint, label, name };
|
||||
return (
|
||||
<label className="field">
|
||||
<span className="field-label">{label}</span>
|
||||
<select
|
||||
aria-describedby={describedBy(shell)}
|
||||
aria-invalid={error ? 'true' : undefined}
|
||||
aria-label={label}
|
||||
name={name}
|
||||
onChange={(event) => onChange(event.target.value as Value)}
|
||||
value={value}
|
||||
>
|
||||
{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
<FieldMessage {...shell} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TextAreaFieldProps extends FieldShellProps {
|
||||
monospace?: boolean;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
value: string;
|
||||
onChange(value: string): void;
|
||||
}
|
||||
|
||||
export function TextAreaField({ error, hint, label, monospace, name, onChange, placeholder, rows = 3, value }: TextAreaFieldProps) {
|
||||
const shell = { error, hint, label, name };
|
||||
return (
|
||||
<label className="field">
|
||||
<span className="field-label">{label}</span>
|
||||
<textarea
|
||||
aria-describedby={describedBy(shell)}
|
||||
aria-invalid={error ? 'true' : undefined}
|
||||
aria-label={label}
|
||||
className={monospace ? 'is-monospace' : undefined}
|
||||
name={name}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
spellCheck={monospace ? false : undefined}
|
||||
value={value}
|
||||
/>
|
||||
<FieldMessage {...shell} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SwitchFieldProps {
|
||||
checked: boolean;
|
||||
description?: ReactNode;
|
||||
label: string;
|
||||
/** Emphasises the field as a consequential control. */
|
||||
tone?: 'default' | 'critical';
|
||||
onChange(checked: boolean): void;
|
||||
}
|
||||
|
||||
export function SwitchField({ checked, description, label, onChange, tone = 'default' }: SwitchFieldProps) {
|
||||
return (
|
||||
<label className={tone === 'critical' ? 'switch-field is-critical' : 'switch-field'}>
|
||||
<input aria-label={label} checked={checked} onChange={(event) => onChange(event.target.checked)} type="checkbox" />
|
||||
<span className="switch-copy">
|
||||
<strong>{label}</strong>
|
||||
{description ? <small>{description}</small> : null}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export interface RadioCardOption<Value extends string> {
|
||||
value: Value;
|
||||
label: string;
|
||||
description: string;
|
||||
/** Extra controls revealed only while this option is selected. */
|
||||
detail?: ReactNode;
|
||||
/** Flags the option as operationally risky. */
|
||||
isRisky?: boolean;
|
||||
}
|
||||
|
||||
export interface RadioCardsProps<Value extends string> {
|
||||
legend: string;
|
||||
name: string;
|
||||
options: ReadonlyArray<RadioCardOption<Value>>;
|
||||
value: Value;
|
||||
onChange(value: Value): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-of choice where each option's explanation is body copy rather than a
|
||||
* footnote, and its dependent settings are nested inside the chosen card so
|
||||
* revealing them does not reflow the options above.
|
||||
*/
|
||||
export function RadioCards<Value extends string>({ legend, name, onChange, options, value }: RadioCardsProps<Value>) {
|
||||
return (
|
||||
<fieldset className="radio-cards">
|
||||
<legend>{legend}</legend>
|
||||
{options.map((option) => {
|
||||
const isSelected = option.value === value;
|
||||
return (
|
||||
<div className={isSelected ? 'radio-card is-selected' : 'radio-card'} key={option.value}>
|
||||
<label>
|
||||
<input
|
||||
checked={isSelected}
|
||||
name={name}
|
||||
onChange={() => onChange(option.value)}
|
||||
type="radio"
|
||||
value={option.value}
|
||||
/>
|
||||
<span className="radio-copy">
|
||||
<strong>
|
||||
{option.label}
|
||||
{option.isRisky ? <span className="pill warning">Autonomous</span> : null}
|
||||
</strong>
|
||||
<small>{option.description}</small>
|
||||
</span>
|
||||
</label>
|
||||
{isSelected && option.detail ? <div className="radio-detail">{option.detail}</div> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +1,124 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Modal } from './modal.js';
|
||||
|
||||
export interface OriginPost {
|
||||
title: string;
|
||||
url: string;
|
||||
imageUrl?: string | null;
|
||||
subreddit?: string;
|
||||
postedAt?: number;
|
||||
}
|
||||
|
||||
export function formatPostAge(postedAt: number, now = Date.now()): string {
|
||||
const elapsedSeconds = Math.max(0, Math.floor((now - postedAt) / 1000));
|
||||
if (elapsedSeconds < 60) return 'now';
|
||||
const elapsedMinutes = Math.floor(elapsedSeconds / 60);
|
||||
if (elapsedMinutes < 60) return `${elapsedMinutes}m ago`;
|
||||
const elapsedHours = Math.floor(elapsedMinutes / 60);
|
||||
if (elapsedHours < 24) return `${elapsedHours}h ago`;
|
||||
const elapsedDays = Math.floor(elapsedHours / 24);
|
||||
if (elapsedDays < 30) return `${elapsedDays}d ago`;
|
||||
const elapsedMonths = Math.floor(elapsedDays / 30);
|
||||
if (elapsedMonths < 12) return `${elapsedMonths}mo ago`;
|
||||
return `${Math.floor(elapsedMonths / 12)}y ago`;
|
||||
}
|
||||
|
||||
function PostOriginMeta({ post }: { post: OriginPost }) {
|
||||
return (
|
||||
<small>
|
||||
Started from {post.subreddit ? `r/${post.subreddit}` : 'Reddit'}
|
||||
{post.postedAt ? (
|
||||
<> · <time dateTime={new Date(post.postedAt).toISOString()} title={new Date(post.postedAt).toLocaleString()}>{formatPostAge(post.postedAt)}</time></>
|
||||
) : null}
|
||||
</small>
|
||||
);
|
||||
}
|
||||
|
||||
export function OriginPostCard({ post }: { post: OriginPost }) {
|
||||
const [isImageRevealed, setIsImageRevealed] = useState(false);
|
||||
const [isImagePreviewOpen, setIsImagePreviewOpen] = useState(false);
|
||||
|
||||
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>
|
||||
<>
|
||||
<div className="origin-post-card">
|
||||
{post.imageUrl ? (
|
||||
<div className="origin-post-image">
|
||||
<button
|
||||
aria-label={isImageRevealed
|
||||
? `View source image full screen: ${post.title}`
|
||||
: `Reveal source image: ${post.title}`}
|
||||
className="origin-post-image-preview-trigger"
|
||||
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} />
|
||||
</button>
|
||||
<button
|
||||
aria-pressed={isImageRevealed}
|
||||
className="origin-post-image-reveal"
|
||||
onClick={() => setIsImageRevealed((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
{isImageRevealed ? 'Blur original image' : 'Reveal original image'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<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>
|
||||
<span>Open original post <span aria-hidden="true">↗</span></span>
|
||||
</a>
|
||||
</div>
|
||||
{isImagePreviewOpen && post.imageUrl ? (
|
||||
<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" autoFocus onClick={() => setIsImagePreviewOpen(false)} type="button">×</button>
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
{post.subreddit ? <small>r/{post.subreddit}</small> : null}
|
||||
<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}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { useBlocker } from 'react-router-dom';
|
||||
import { Modal } from './modal.js';
|
||||
|
||||
interface UnsavedChangesContextValue {
|
||||
register(id: string, isDirty: boolean): void;
|
||||
release(id: string): void;
|
||||
}
|
||||
|
||||
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Page-level guard for a screen made of independently saved cards.
|
||||
*
|
||||
* Each card reports its own dirty state by id, so the guard can warn about
|
||||
* leaving while *any* card has unsaved edits without the cards needing to
|
||||
* share a draft. Section switches within a page do not trip it: only a
|
||||
* pathname change is blocked.
|
||||
*/
|
||||
export function UnsavedChangesProvider({ children }: PropsWithChildren): ReactNode {
|
||||
const [dirtyIds, setDirtyIds] = useState<readonly string[]>([]);
|
||||
|
||||
const register = useCallback((id: string, isDirty: boolean) => {
|
||||
setDirtyIds((current) => {
|
||||
const isTracked = current.includes(id);
|
||||
if (isDirty === isTracked) return current;
|
||||
return isDirty ? [...current, id] : current.filter((item) => item !== id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const release = useCallback((id: string) => {
|
||||
setDirtyIds((current) => (current.includes(id) ? current.filter((item) => item !== id) : current));
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ register, release }), [register, release]);
|
||||
const hasUnsavedChanges = dirtyIds.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasUnsavedChanges) return;
|
||||
function warnBeforeUnload(event: BeforeUnloadEvent) {
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
}
|
||||
window.addEventListener('beforeunload', warnBeforeUnload);
|
||||
return () => window.removeEventListener('beforeunload', warnBeforeUnload);
|
||||
}, [hasUnsavedChanges]);
|
||||
|
||||
const blocker = useBlocker(({ currentLocation, nextLocation }) =>
|
||||
hasUnsavedChanges && currentLocation.pathname !== nextLocation.pathname);
|
||||
|
||||
return (
|
||||
<UnsavedChangesContext.Provider value={value}>
|
||||
{children}
|
||||
{blocker.state === 'blocked' ? (
|
||||
<Modal className="dialog-card" labelledBy="unsaved-changes-title" onClose={() => blocker.reset()}>
|
||||
<span className="eyebrow">Unsaved changes</span>
|
||||
<h2 id="unsaved-changes-title">Leave without saving?</h2>
|
||||
<p>
|
||||
{dirtyIds.length === 1
|
||||
? 'One section has edits that have not been saved. Leaving will discard them.'
|
||||
: `${dirtyIds.length} sections have edits that have not been saved. Leaving will discard them.`}
|
||||
</p>
|
||||
<div className="dialog-actions">
|
||||
<button autoFocus className="button secondary" onClick={() => blocker.reset()} type="button">Stay</button>
|
||||
<button className="button danger" onClick={() => blocker.proceed()} type="button">Discard and leave</button>
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
</UnsavedChangesContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports a card's dirty state to the surrounding {@link UnsavedChangesProvider}.
|
||||
* Safe to call with no provider mounted, which keeps cards usable in isolation.
|
||||
*/
|
||||
export function useUnsavedChangesFlag(id: string, isDirty: boolean): void {
|
||||
const context = useContext(UnsavedChangesContext);
|
||||
|
||||
useEffect(() => {
|
||||
context?.register(id, isDirty);
|
||||
}, [context, id, isDirty]);
|
||||
|
||||
useEffect(() => () => context?.release(id), [context, id]);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { AccountEstablishmentSummary } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
||||
import {
|
||||
useImportRedditSessionMutation,
|
||||
useLoginStatusQuery,
|
||||
useRedditAccountsQuery,
|
||||
} from '../settings/settings-queries.js';
|
||||
import './accounts.css';
|
||||
|
||||
function errorMessage(error: Error | null): string {
|
||||
return error?.message ?? 'Something went wrong.';
|
||||
}
|
||||
|
||||
function karmaLabel(value: number | null): string {
|
||||
return value === null ? 'unknown' : value.toLocaleString();
|
||||
}
|
||||
|
||||
function ageLabel(days: number | null): string {
|
||||
if (days === null) return 'unknown';
|
||||
if (days < 1) return 'less than a day';
|
||||
return `${days.toLocaleString()} day${days === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
function retryLabel(retryAt: number | null): string {
|
||||
if (retryAt === null) return 'Will be re-tested on the next profile check.';
|
||||
const minutes = Math.round((retryAt - Date.now()) / 60_000);
|
||||
if (minutes <= 0) return 'Due for a re-test now.';
|
||||
if (minutes < 60) return `Next re-test in ${minutes} minute${minutes === 1 ? '' : 's'}.`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
return `Next re-test in about ${hours} hour${hours === 1 ? '' : 's'}.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two different situations share this panel: Reddit actively refusing
|
||||
* invitations from the account (sending is on hold), and the account merely
|
||||
* sitting below our own configured thresholds (nothing is blocked). Conflating
|
||||
* them reads as a broken account when it is only a soft signal.
|
||||
*/
|
||||
function EstablishmentDetail({ establishment }: { establishment: AccountEstablishmentSummary }) {
|
||||
const belowThresholds = establishment.state === 'unestablished' && !establishment.sendingHeld;
|
||||
return (
|
||||
<div className="account-establishment">
|
||||
{establishment.sendingHeld ? (
|
||||
<>
|
||||
<p className="account-establishment-reason">
|
||||
{establishment.lastProbeFailure
|
||||
?? 'Reddit rejected an invitation from this account as unestablished.'}
|
||||
</p>
|
||||
<p className="field-hint">Outbound sending is on hold. {retryLabel(establishment.retryAt)}</p>
|
||||
</>
|
||||
) : null}
|
||||
{belowThresholds ? (
|
||||
<>
|
||||
<p className="account-establishment-reason">
|
||||
{establishment.reasons.join('; ')}
|
||||
</p>
|
||||
<p className="field-hint">
|
||||
Still sending normally — these are our own configured thresholds, not limits Reddit
|
||||
publishes. The account is only held back if Reddit actually rejects an invitation.
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
{establishment.lastProbeFailure && !establishment.sendingHeld ? (
|
||||
<p className="field-hint">Last rejection: {establishment.lastProbeFailure}</p>
|
||||
) : null}
|
||||
<dl className="account-establishment-metrics">
|
||||
<div><dt>Age</dt><dd>{ageLabel(establishment.accountAgeDays)}</dd></div>
|
||||
<div><dt>Post karma</dt><dd>{karmaLabel(establishment.linkKarma)}</dd></div>
|
||||
<div><dt>Comment karma</dt><dd>{karmaLabel(establishment.commentKarma)}</dd></div>
|
||||
<div><dt>Total karma</dt><dd>{karmaLabel(establishment.totalKarma)}</dd></div>
|
||||
<div>
|
||||
<dt>Verified email</dt>
|
||||
<dd>{establishment.hasVerifiedEmail === null ? 'unknown' : establishment.hasVerifiedEmail ? 'Yes' : 'No'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function establishmentPill(establishment: AccountEstablishmentSummary | null | undefined) {
|
||||
if (!establishment || establishment.state === 'unknown') return null;
|
||||
if (establishment.sendingHeld) return <span className="pill warning">Sending on hold</span>;
|
||||
if (establishment.state === 'unestablished') return <span className="pill neutral">Below thresholds</span>;
|
||||
return <span className="pill success">Established</span>;
|
||||
}
|
||||
|
||||
function sessionLabel(status: ReturnType<typeof useLoginStatusQuery>['data']): string {
|
||||
if (!status) return 'Unknown';
|
||||
if (!status.ready) return 'Browser unavailable';
|
||||
if ('error' in status) return 'Check failed';
|
||||
return {
|
||||
unknown: 'Unknown',
|
||||
authenticated: 'Authenticated',
|
||||
'login-required': 'Login required',
|
||||
unreachable: 'Reddit unreachable',
|
||||
}[status.status];
|
||||
}
|
||||
|
||||
export function AccountsPage() {
|
||||
const statusQuery = useLoginStatusQuery();
|
||||
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 {
|
||||
exported = JSON.parse(await file.text());
|
||||
} catch {
|
||||
importMutation.reset();
|
||||
setImportFileError("That file isn't valid JSON. Export a fresh session dump from the extension and try again.");
|
||||
return;
|
||||
}
|
||||
importMutation.mutate(exported);
|
||||
}
|
||||
|
||||
const session = statusQuery.data;
|
||||
const verifiedUsername = session?.ready && 'loggedIn' in session && session.loggedIn
|
||||
? session.username ?? null
|
||||
: null;
|
||||
const accounts = accountsQuery.data ?? [];
|
||||
const pausedCount = accounts.filter((account) => !account.isRunning).length;
|
||||
const heldAccounts = accounts.filter((account) => account.establishment?.sendingHeld);
|
||||
|
||||
return (
|
||||
<div className="page accounts-page">
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">Reddit access</span>
|
||||
<h1>Accounts</h1>
|
||||
<p>Each Reddit account runs on its own imported browser session. Content and sync state stay owned by the account that produced them.</p>
|
||||
</div>
|
||||
{session ? (
|
||||
<span className={session.ready && 'status' in session && session.loggedIn ? 'pill success' : 'pill warning'}>
|
||||
{sessionLabel(session)}
|
||||
</span>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{pausedCount > 0 ? (
|
||||
<div className="state-banner is-warning" role="status">
|
||||
<strong>
|
||||
{pausedCount === 1 ? 'One account is paused.' : `${pausedCount} accounts are paused.`}
|
||||
</strong>
|
||||
<span>A paused account is waiting for a fresh session dump. Other accounts keep running normally.</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{heldAccounts.length > 0 ? (
|
||||
<div className="state-banner is-warning" role="status">
|
||||
<strong>
|
||||
{heldAccounts.length === 1
|
||||
? `u/${heldAccounts[0].redditUsername} cannot start chats yet.`
|
||||
: `${heldAccounts.length} accounts cannot start chats yet.`}
|
||||
</strong>
|
||||
<span>
|
||||
Reddit rejected an invitation from a new account, so outbound sending is on hold for it.
|
||||
The bot re-tests periodically and resumes on its own.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{statusQuery.isPending ? (
|
||||
<div aria-label="Loading access" className="page-state" role="status">
|
||||
<span className="spinner" />Loading Reddit access…
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{statusQuery.isError ? (
|
||||
<div className="page-state error-state" role="alert">
|
||||
<strong>Reddit access unavailable</strong>
|
||||
<span>{errorMessage(statusQuery.error)}</span>
|
||||
<button className="button secondary" onClick={() => void statusQuery.refetch()} type="button">Retry access</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{session ? (
|
||||
<>
|
||||
<section aria-labelledby="accounts-heading" className="panel settings-card">
|
||||
<div className="settings-card-heading">
|
||||
<div>
|
||||
<span className="eyebrow">Connected</span>
|
||||
<h2 id="accounts-heading">Reddit accounts</h2>
|
||||
{verifiedUsername ? (
|
||||
<p>Verified as <strong><RedditUsernameLink username={verifiedUsername} /></strong>.</p>
|
||||
) : <p>No account is currently verified as logged in.</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-card-body">
|
||||
<div aria-label="Reddit accounts" className="account-list">
|
||||
{accountsQuery.isPending ? (
|
||||
<p className="field-hint" role="status"><span className="spinner" /> Loading Reddit accounts…</p>
|
||||
) : null}
|
||||
{accountsQuery.isError ? (
|
||||
<div className="state-banner is-error" role="alert">
|
||||
<strong>Could not load Reddit accounts</strong>
|
||||
<span>{errorMessage(accountsQuery.error)}</span>
|
||||
<button className="button secondary" onClick={() => void accountsQuery.refetch()} type="button">Retry accounts</button>
|
||||
</div>
|
||||
) : null}
|
||||
{accounts.map((account) => (
|
||||
<div className={account.isRunning ? 'account-row is-active' : 'account-row'} key={account.accountId}>
|
||||
<div>
|
||||
<strong><RedditUsernameLink username={account.redditUsername} /></strong>
|
||||
<span>{account.isRunning ? 'Running independently' : 'Waiting for a refreshed session'}</span>
|
||||
</div>
|
||||
<div className="account-row-pills">
|
||||
{establishmentPill(account.establishment)}
|
||||
<span className={account.isRunning ? 'pill success' : 'pill warning'}>
|
||||
{account.isRunning ? 'Running' : 'Paused'}
|
||||
</span>
|
||||
</div>
|
||||
{account.establishment && account.establishment.state !== 'unknown' ? (
|
||||
<EstablishmentDetail establishment={account.establishment} />
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{accountsQuery.isSuccess && accounts.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<strong>No accounts yet</strong>
|
||||
<span>Import a session dump below to add the first one.</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="settings-card-footer">
|
||||
<div aria-live="polite" className="settings-card-status">
|
||||
{importFileError ? <strong className="save-error" role="alert">{importFileError}</strong> : null}
|
||||
{importMutation.isSuccess ? (
|
||||
<strong className="save-success" role="status">Session imported and verified — the account is running.</strong>
|
||||
) : null}
|
||||
{importMutation.isError ? (
|
||||
<strong className="save-error" role="alert">{errorMessage(importMutation.error)}</strong>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<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
|
||||
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>
|
||||
<button
|
||||
className="button primary"
|
||||
disabled={importMutation.isPending}
|
||||
onClick={() => importInput.current?.click()}
|
||||
type="button"
|
||||
>
|
||||
{importMutation.isPending ? 'Adding account…' : 'Add or refresh account'}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="import-help-heading" className="panel settings-card">
|
||||
<div className="settings-card-heading">
|
||||
<div>
|
||||
<span className="eyebrow">How to import</span>
|
||||
<h2 id="import-help-heading">Getting a session dump</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-card-body">
|
||||
<ol className="numbered-steps">
|
||||
<li>Open Reddit Chat in Chrome, signed in as the account you want to add.</li>
|
||||
<li>Wait for the extension badge to show <strong>OK</strong>.</li>
|
||||
<li>Export the JSON dump from the extension.</li>
|
||||
<li>Upload it with <strong>Add or refresh account</strong> above.</li>
|
||||
</ol>
|
||||
<p className="state-banner is-warning">
|
||||
<strong>A session dump contains login credentials.</strong>
|
||||
<span>Anyone holding the file can act as that Reddit account. Do not share it or commit it anywhere.</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
.accounts-page { max-width: 58rem; }
|
||||
.accounts-page .state-banner { margin-bottom: 1rem; }
|
||||
.accounts-page .settings-card + .settings-card { margin-top: 1rem; }
|
||||
|
||||
.account-list { display: grid; gap: .6rem; }
|
||||
.account-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; padding: .85rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .75rem; }
|
||||
.account-row.is-active { border-color: var(--accent); }
|
||||
.account-row > div { display: grid; gap: .2rem; min-width: 0; }
|
||||
.account-row span { color: var(--muted); font-size: .82rem; }
|
||||
.account-row .pill { font-size: .7rem; }
|
||||
.account-row > .account-row-pills { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; }
|
||||
|
||||
/* Establishment evidence sits on its own row beneath the account summary. */
|
||||
.account-establishment { flex-basis: 100%; display: grid; gap: .4rem; padding-top: .7rem; border-top: 1px solid var(--border); }
|
||||
.account-establishment .account-establishment-reason { margin: 0; font-size: .82rem; }
|
||||
.account-establishment .field-hint { margin: 0; }
|
||||
.account-establishment-metrics { display: flex; flex-wrap: wrap; gap: .35rem 1.25rem; margin: 0; }
|
||||
.account-establishment-metrics > div { display: grid; gap: .1rem; }
|
||||
.account-establishment-metrics dt { color: var(--muted); font-size: .7rem; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.account-establishment-metrics dd { margin: 0; font-size: .82rem; font-variant-numeric: tabular-nums; }
|
||||
@@ -0,0 +1,16 @@
|
||||
import { AttentionResponseSchema } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { createDashboardRequest } from '../../api/request.js';
|
||||
|
||||
export function createAttentionApi(fetchImplementation?: typeof fetch) {
|
||||
const request = createDashboardRequest(fetchImplementation);
|
||||
return {
|
||||
async getAttention() {
|
||||
const response = await request('/api/attention');
|
||||
const payload: unknown = await response.json();
|
||||
if (!response.ok) throw new Error(`Attention inbox unavailable (${response.status})`);
|
||||
return AttentionResponseSchema.parse(payload);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const attentionApi = createAttentionApi();
|
||||
@@ -0,0 +1,88 @@
|
||||
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 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 'account-unestablished':
|
||||
return 'No linked conversation — check Accounts.';
|
||||
case 'runtime-failed':
|
||||
case 'invitation-cooldown':
|
||||
default:
|
||||
return 'No linked conversation — check Operations.';
|
||||
}
|
||||
}
|
||||
|
||||
function itemAction(item: AttentionItem) {
|
||||
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>;
|
||||
}
|
||||
|
||||
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>
|
||||
<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' ? '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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
.attention-list { display: grid; gap: .8rem; padding: 0; margin: 0; list-style: none; }
|
||||
.attention-item { display: grid; gap: .8rem; padding: 1rem; background: var(--surface); border: 1px solid var(--border); border-left: .3rem solid var(--warning); border-radius: .9rem; }
|
||||
.attention-item.is-critical { border-left-color: var(--danger); }
|
||||
.attention-item-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
|
||||
.attention-item-heading > div { display: grid; gap: .55rem; }
|
||||
.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();
|
||||
}
|
||||
@@ -2,16 +2,28 @@ import type { ZodType } from 'zod';
|
||||
import {
|
||||
ConversationsResponseSchema,
|
||||
ConversationDetailResponseSchema,
|
||||
ConversationMediaShareOptionsResponseSchema,
|
||||
ErrorResponseSchema,
|
||||
ProcessedPostsResponseSchema,
|
||||
SendReplyRequestSchema,
|
||||
ShareConversationMediaRequestSchema,
|
||||
StartConversationRequestSchema,
|
||||
StartConversationResponseSchema,
|
||||
SuccessResponseSchema,
|
||||
SuggestedReplyResponseSchema,
|
||||
BulkMarkConversationsReadRequestSchema,
|
||||
BulkMarkConversationsReadResponseSchema,
|
||||
SellerScanResponseSchema,
|
||||
MarkConversationReadRequestSchema,
|
||||
MarkConversationUnreadRequestSchema,
|
||||
SetConversationStatusRequestSchema,
|
||||
type ActiveChat,
|
||||
type BulkMarkConversationsReadResponse,
|
||||
type ConversationDetailResponse,
|
||||
type ConversationMediaShareOptions,
|
||||
type ConversationTriageStatus,
|
||||
type ProcessedPost,
|
||||
type SellerScanResponse,
|
||||
type SuccessResponse,
|
||||
} from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { createDashboardRequest } from '../../api/request.js';
|
||||
@@ -34,9 +46,24 @@ export interface ConversationApi {
|
||||
detail(id: string): Promise<ConversationDetailResponse>;
|
||||
suggestReply(id: string): Promise<string>;
|
||||
suggestMediaAsk(id: string): Promise<string>;
|
||||
mediaShareOptions(id: string): Promise<ConversationMediaShareOptions>;
|
||||
processedPosts(username: string): Promise<ProcessedPost[]>;
|
||||
sendReply(id: string, message: string): Promise<SuccessResponse>;
|
||||
shareMedia(id: string, assetId: string): Promise<SuccessResponse>;
|
||||
startConversation(accountId: string, username: string, message: string): Promise<ActiveChat>;
|
||||
markRead(id: string, throughTimestamp?: number): Promise<SuccessResponse>;
|
||||
markUnread(id: string, beforeTimestamp?: number): Promise<SuccessResponse>;
|
||||
setStatus(id: string, status: ConversationTriageStatus, snoozeUntil?: number | null): Promise<SuccessResponse>;
|
||||
bulkMarkRead(ids: string[]): Promise<BulkMarkConversationsReadResponse>;
|
||||
sellerScan(): Promise<SellerScanResponse>;
|
||||
}
|
||||
|
||||
function jsonPost(body: unknown): RequestInit {
|
||||
return {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
|
||||
export function createConversationApi(fetchImplementation?: typeof fetch): ConversationApi {
|
||||
@@ -66,6 +93,12 @@ export function createConversationApi(fetchImplementation?: typeof fetch): Conve
|
||||
);
|
||||
return response.suggestedReply;
|
||||
},
|
||||
async mediaShareOptions(id) {
|
||||
return parseResponse(
|
||||
await request(`${conversationPath(id)}/shareable-media`),
|
||||
ConversationMediaShareOptionsResponseSchema,
|
||||
);
|
||||
},
|
||||
async processedPosts(username) {
|
||||
return parseResponse(
|
||||
await request(`/api/posts/by-author/${encodeURIComponent(username.trim().replace(/^u\//i, ''))}`),
|
||||
@@ -83,6 +116,17 @@ export function createConversationApi(fetchImplementation?: typeof fetch): Conve
|
||||
SuccessResponseSchema,
|
||||
);
|
||||
},
|
||||
async shareMedia(id, assetId) {
|
||||
const body = ShareConversationMediaRequestSchema.parse({ assetId });
|
||||
return parseResponse(
|
||||
await request(`${conversationPath(id)}/share-media`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
SuccessResponseSchema,
|
||||
);
|
||||
},
|
||||
async startConversation(accountId, username, message) {
|
||||
const body = StartConversationRequestSchema.parse({
|
||||
accountId,
|
||||
@@ -99,6 +143,46 @@ export function createConversationApi(fetchImplementation?: typeof fetch): Conve
|
||||
);
|
||||
return response.chat;
|
||||
},
|
||||
async markRead(id, throughTimestamp) {
|
||||
const body = MarkConversationReadRequestSchema.parse(
|
||||
throughTimestamp === undefined ? {} : { throughTimestamp },
|
||||
);
|
||||
return parseResponse(
|
||||
await request(`${conversationPath(id)}/read`, jsonPost(body)),
|
||||
SuccessResponseSchema,
|
||||
);
|
||||
},
|
||||
async markUnread(id, beforeTimestamp) {
|
||||
const body = MarkConversationUnreadRequestSchema.parse(
|
||||
beforeTimestamp === undefined ? {} : { beforeTimestamp },
|
||||
);
|
||||
return parseResponse(
|
||||
await request(`${conversationPath(id)}/unread`, jsonPost(body)),
|
||||
SuccessResponseSchema,
|
||||
);
|
||||
},
|
||||
async setStatus(id, status, snoozeUntil) {
|
||||
const body = SetConversationStatusRequestSchema.parse(
|
||||
snoozeUntil === undefined ? { status } : { status, snoozeUntil },
|
||||
);
|
||||
return parseResponse(
|
||||
await request(`${conversationPath(id)}/status`, jsonPost(body)),
|
||||
SuccessResponseSchema,
|
||||
);
|
||||
},
|
||||
async bulkMarkRead(ids) {
|
||||
const body = BulkMarkConversationsReadRequestSchema.parse({ conversationIds: ids });
|
||||
return parseResponse(
|
||||
await request('/api/conversations/bulk-read', jsonPost(body)),
|
||||
BulkMarkConversationsReadResponseSchema,
|
||||
);
|
||||
},
|
||||
async sellerScan() {
|
||||
return parseResponse(
|
||||
await request('/api/conversations/seller-scan', { method: 'POST' }),
|
||||
SellerScanResponseSchema,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,101 +1,285 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import type { ConversationDetailResponse } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type {
|
||||
ChatMessage,
|
||||
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';
|
||||
import { useConversationDetailQuery, useSendReplyMutation, useSuggestMediaAskMutation, useSuggestReplyMutation } from './conversation-queries.js';
|
||||
import {
|
||||
useConversationDetailQuery,
|
||||
useConversationMediaShareOptionsQuery,
|
||||
useMarkConversationReadMutation,
|
||||
useMarkConversationUnreadMutation,
|
||||
useSendReplyMutation,
|
||||
useSetConversationStatusMutation,
|
||||
useShareConversationMediaMutation,
|
||||
useSuggestMediaAskMutation,
|
||||
useSuggestReplyMutation,
|
||||
} from './conversation-queries.js';
|
||||
import { SendConfirmationDialog } from './send-confirmation-dialog.js';
|
||||
import { MediaShareConfirmationDialog } from './media-share-confirmation-dialog.js';
|
||||
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
||||
import { formatDayLabel, formatExactTime, formatTimeOfDay, isSameDay } from './relative-time.js';
|
||||
import { getStoredComposerDraft, storeComposerDraft } from './composer-drafts.js';
|
||||
import { SNOOZE_OPTIONS, isUnread, triageStatus } from './conversation-triage.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;
|
||||
/** Keeps a little context above the "New messages" rule when jumping to it. */
|
||||
const UNREAD_DIVIDER_HEADROOM_PX = 12;
|
||||
/** Grace period before an open conversation is considered read. */
|
||||
export const AUTO_MARK_READ_DELAY_MS = 1_000;
|
||||
|
||||
interface ConversationDetailProps {
|
||||
conversationId: string;
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
function formatMessageTime(timestamp: number): string {
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp);
|
||||
}
|
||||
|
||||
function mediaPath(localPath: string): string {
|
||||
return `/media/${localPath.split(/[\\/]/).map(encodeURIComponent).join('/')}`;
|
||||
}
|
||||
|
||||
function MessageHistory({ conversation }: { conversation: ConversationDetailResponse }) {
|
||||
if (!conversation.messages || conversation.messages.length === 0) {
|
||||
/** 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]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of the first inbound message the operator has not read.
|
||||
*
|
||||
* Returns -1 unless the boundary falls *inside* the loaded history — a
|
||||
* conversation being opened for the first time has no meaningful boundary, and
|
||||
* a "New messages" rule above the very first message is noise rather than
|
||||
* information.
|
||||
*/
|
||||
export function firstUnreadIndex(messages: ChatMessage[], lastReadMessageTs: number | undefined): number {
|
||||
if (!lastReadMessageTs) return -1;
|
||||
const index = messages.findIndex((message) => !message.isSelf && message.timestamp > lastReadMessageTs);
|
||||
return index > 0 ? index : -1;
|
||||
}
|
||||
|
||||
function MessageHistory({ conversation, now }: { conversation: ConversationDetailResponse; now: number }) {
|
||||
const [revealedMedia, setRevealedMedia] = useState<Set<string>>(() => new Set());
|
||||
const listRef = useRef<HTMLOListElement>(null);
|
||||
const dividerRef = useRef<HTMLLIElement>(null);
|
||||
const isAtBottomRef = useRef(true);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
const messages = conversation.messages ?? [];
|
||||
const messageCount = messages.length;
|
||||
// Pinned at mount: opening the conversation advances the read watermark a
|
||||
// second later, and the marker must not disappear from under the operator
|
||||
// while they are still reading. It resets when they switch conversations,
|
||||
// because the panel is keyed on the conversation id.
|
||||
const [readBoundary] = useState(() => conversation.lastReadMessageTs ?? 0);
|
||||
const unreadIndex = firstUnreadIndex(messages, readBoundary);
|
||||
const hasScrolledToBoundaryRef = useRef(false);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
list.scrollTop = list.scrollHeight;
|
||||
isAtBottomRef.current = true;
|
||||
setIsAtBottom(true);
|
||||
}, []);
|
||||
|
||||
// Land on the first unread message rather than the bottom, so the operator
|
||||
// starts reading where they left off. Only done once per mount — after that,
|
||||
// stick to the latest message unless they have scrolled up to read history.
|
||||
useEffect(() => {
|
||||
const list = listRef.current;
|
||||
const divider = dividerRef.current;
|
||||
if (unreadIndex >= 0 && !hasScrolledToBoundaryRef.current && list && divider) {
|
||||
hasScrolledToBoundaryRef.current = true;
|
||||
// Scroll the history pane only. `scrollIntoView` would also scroll every
|
||||
// ancestor scroller, yanking the whole page out of position.
|
||||
const offset = divider.getBoundingClientRect().top - list.getBoundingClientRect().top;
|
||||
list.scrollTop = Math.max(0, list.scrollTop + offset - UNREAD_DIVIDER_HEADROOM_PX);
|
||||
isAtBottomRef.current = false;
|
||||
setIsAtBottom(false);
|
||||
return;
|
||||
}
|
||||
if (isAtBottomRef.current) scrollToBottom();
|
||||
}, [messageCount, scrollToBottom, unreadIndex]);
|
||||
|
||||
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 (messageCount === 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.timestamp}-${message.sender}-${message.text}`}>
|
||||
<div className="conversation-messages-shell">
|
||||
<ol aria-label="Message history" className="conversation-messages" onScroll={handleScroll} ref={listRef}>
|
||||
{messages.map((message, index) => {
|
||||
const previous = index > 0 ? messages[index - 1] : null;
|
||||
const startsNewDay = previous === null || !isSameDay(previous.timestamp, message.timestamp);
|
||||
const key = message.eventId ?? `${message.timestamp}-${message.sender}-${message.text}`;
|
||||
|
||||
return [
|
||||
startsNewDay ? (
|
||||
<li aria-hidden="true" className="conversation-day-divider" key={`day-${key}`}>
|
||||
<span>{formatDayLabel(message.timestamp, now)}</span>
|
||||
</li>
|
||||
) : null,
|
||||
index === unreadIndex ? (
|
||||
<li className="conversation-unread-divider" key={`unread-${key}`} ref={dividerRef}>
|
||||
<span>New messages</span>
|
||||
</li>
|
||||
) : null,
|
||||
<li className={message.isSelf ? 'is-self' : 'is-recipient'} key={key}>
|
||||
<div>
|
||||
<strong>{message.isSelf
|
||||
? `You${conversation.owner ? ` (u/${conversation.owner.redditUsername})` : ''}`
|
||||
: <RedditUsernameLink username={conversation.username} />}</strong>
|
||||
<time dateTime={new Date(message.timestamp).toISOString()}>{formatMessageTime(message.timestamp)}</time>
|
||||
{message.isAi ? <span aria-label="Sent by AI" className="conversation-ai-badge">AI</span> : null}
|
||||
<time dateTime={new Date(message.timestamp).toISOString()} title={formatExactTime(message.timestamp)}>
|
||||
{formatTimeOfDay(message.timestamp)}
|
||||
</time>
|
||||
</div>
|
||||
<p>{message.text}</p>
|
||||
{message.media?.length ? (
|
||||
<div className="conversation-message-images">
|
||||
{message.media.map((media) => (
|
||||
<a
|
||||
aria-label={`View original image ${message.isSelf ? 'sent by you' : `received from ${redditUsernameLabel(conversation.username)}`}`}
|
||||
href={mediaPath(media.localPath)}
|
||||
key={`${message.timestamp}-${media.localPath}`}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{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 ${message.isSelf ? 'sent by you' : `received from ${redditUsernameLabel(conversation.username)}`}`}
|
||||
alt={`Image ${sender}`}
|
||||
className={isRevealed ? undefined : 'is-blurred'}
|
||||
loading="lazy"
|
||||
src={mediaPath(media.localPath)}
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
<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>
|
||||
))}
|
||||
</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);
|
||||
const mediaShareSummaryRef = useRef<HTMLElement>(null);
|
||||
const suggestionMutation = useSuggestReplyMutation(conversationId);
|
||||
const mediaAskMutation = useSuggestMediaAskMutation(conversationId);
|
||||
const sendMutation = useSendReplyMutation(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);
|
||||
queueMicrotask(() => sendButtonRef.current?.focus());
|
||||
}, []);
|
||||
|
||||
const closeMediaConfirmation = useCallback(() => {
|
||||
setIsConfirmingMedia(false);
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
function shareMedia() {
|
||||
if (!selectedAsset) return;
|
||||
mediaShareMutation.mutate(selectedAsset.id, {
|
||||
onSuccess() {
|
||||
setSelectedAssetId(null);
|
||||
setWasMediaShared(true);
|
||||
},
|
||||
onSettled: closeMediaConfirmation,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby="reply-composer-heading" className="conversation-composer">
|
||||
<div className="conversation-section-heading">
|
||||
@@ -103,29 +287,106 @@ function ConversationComposer({ conversation, conversationId }: { conversation:
|
||||
<button disabled={suggestionMutation.isPending} onClick={suggestReply} type="button">
|
||||
{suggestionMutation.isPending ? 'Generating…' : 'Suggest reply'}
|
||||
</button>
|
||||
<button disabled={mediaAskMutation.isPending} onClick={suggestMediaAsk} type="button">
|
||||
{mediaAskMutation.isPending ? 'Generating media ask…' : 'Suggest media ask'}
|
||||
<button disabled={mediaAskMutation.isPending || !automationActive} onClick={suggestMediaAsk} type="button">
|
||||
{mediaAskMutation.isPending ? 'Generating media ask…' : 'Suggest media ask'}
|
||||
</button>
|
||||
</div>
|
||||
<label className="sr-only" htmlFor="conversation-reply">Reply to {redditUsernameLabel(conversation.username)}</label>
|
||||
<textarea
|
||||
id="conversation-reply"
|
||||
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" open={isMediaShareOpen}>
|
||||
<summary
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
setIsMediaShareOpen((current) => !current);
|
||||
}}
|
||||
ref={mediaShareSummaryRef}
|
||||
>
|
||||
Share a saved image
|
||||
</summary>
|
||||
<p>Pick any enabled image from your exchange library to send. Images already shared in this conversation are hidden.</p>
|
||||
{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?.warning ? <div className="conversation-media-warning" role="status">{mediaOptionsQuery.data.warning}</div> : null}
|
||||
{mediaOptionsQuery.data?.reason ? <div className="conversation-media-unavailable">{mediaOptionsQuery.data.reason}</div> : null}
|
||||
{mediaOptions.length > 0 ? (
|
||||
<>
|
||||
<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}>
|
||||
<input
|
||||
checked={selectedAssetId === asset.id}
|
||||
disabled={!areSavedImagesRevealed}
|
||||
name="conversation-media-asset"
|
||||
onChange={() => {
|
||||
setSelectedAssetId(asset.id);
|
||||
setWasMediaShared(false);
|
||||
}}
|
||||
type="radio"
|
||||
value={asset.id}
|
||||
/>
|
||||
<img
|
||||
alt={`Saved image tagged ${asset.tags.join(', ')}`}
|
||||
className={areSavedImagesRevealed ? undefined : 'is-blurred'}
|
||||
src={asset.urlPath}
|
||||
/>
|
||||
<span>{asset.tags.join(', ')} · {asset.safety.toUpperCase()}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="conversation-primary"
|
||||
disabled={!selectedAsset || mediaShareMutation.isPending}
|
||||
onClick={() => setIsConfirmingMedia(true)}
|
||||
type="button"
|
||||
>
|
||||
Review image share
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{mediaShareMutation.isError ? <div className="conversation-inline-error" role="alert">{mediaShareMutation.error.message}</div> : null}
|
||||
{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"
|
||||
@@ -145,12 +406,151 @@ function ConversationComposer({ conversation, conversationId }: { conversation:
|
||||
onConfirm={sendReply}
|
||||
/>
|
||||
) : null}
|
||||
{isConfirmingMedia && selectedAsset ? (
|
||||
<MediaShareConfirmationDialog
|
||||
areImagesRevealed={areSavedImagesRevealed}
|
||||
asset={selectedAsset}
|
||||
isPending={mediaShareMutation.isPending}
|
||||
onCancel={closeMediaConfirmation}
|
||||
onConfirm={shareMedia}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function automationStatusMessage(conversation: ConversationDetailResponse): string {
|
||||
switch (conversation.automation.status) {
|
||||
case 'killed':
|
||||
return conversation.automation.handoffReason
|
||||
?? 'Automation ended this conversation as a seller or off-platform push.';
|
||||
case 'image-received':
|
||||
return 'Image received. Automation is complete.';
|
||||
case 'human-required':
|
||||
return conversation.automation.handoffReason ?? 'Automated turns are exhausted. A human should take over.';
|
||||
case 'boundary':
|
||||
return 'Automation stopped because the user set a boundary.';
|
||||
case 'user-overridden':
|
||||
return 'A manual reply was sent. AI turns are disabled for this conversation.';
|
||||
default:
|
||||
return 'The next unread reply can be handled automatically.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Triage controls for the open conversation. These live in the header rather
|
||||
* than the composer because they act on the conversation, not on the draft.
|
||||
*/
|
||||
function ConversationTriageActions({ conversation, conversationId, onResolved }: {
|
||||
conversation: ConversationDetailResponse;
|
||||
conversationId: string;
|
||||
onResolved(): void;
|
||||
}) {
|
||||
const [isSnoozeOpen, setIsSnoozeOpen] = useState(false);
|
||||
const markRead = useMarkConversationReadMutation();
|
||||
const markUnread = useMarkConversationUnreadMutation();
|
||||
const setStatus = useSetConversationStatusMutation();
|
||||
const status = triageStatus(conversation);
|
||||
const unread = isUnread(conversation);
|
||||
|
||||
return (
|
||||
<div className="conversation-triage-actions">
|
||||
<button
|
||||
disabled={markRead.isPending || markUnread.isPending}
|
||||
onClick={() => (unread
|
||||
? markRead.mutate({ id: conversationId })
|
||||
: markUnread.mutate({ id: conversationId }))}
|
||||
type="button"
|
||||
>
|
||||
{unread ? 'Mark read' : 'Mark unread'}
|
||||
</button>
|
||||
<div className="conversation-snooze">
|
||||
<button
|
||||
aria-expanded={isSnoozeOpen}
|
||||
onClick={() => setIsSnoozeOpen((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
{status === 'snoozed' ? 'Snoozed' : 'Snooze'}
|
||||
</button>
|
||||
{isSnoozeOpen ? (
|
||||
<div aria-label="Snooze options" className="conversation-snooze-menu" role="group">
|
||||
{SNOOZE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.label}
|
||||
onClick={() => {
|
||||
setStatus.mutate({ id: conversationId, status: 'snoozed', snoozeUntil: Date.now() + option.ms });
|
||||
setIsSnoozeOpen(false);
|
||||
onResolved();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
{status === 'snoozed' ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
setStatus.mutate({ id: conversationId, status: 'open' });
|
||||
setIsSnoozeOpen(false);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Unsnooze
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{status === 'done' ? (
|
||||
<button onClick={() => setStatus.mutate({ id: conversationId, status: 'open' })} type="button">
|
||||
Reopen
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
setStatus.mutate({ id: conversationId, status: 'done' });
|
||||
onResolved();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Mark done
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Advances the read watermark once the operator has actually had the
|
||||
* conversation open for a moment. Debounced so switching quickly through rows
|
||||
* does not mark everything read, and keyed on the newest message so a reply
|
||||
* arriving while the panel is open is also picked up.
|
||||
*/
|
||||
function useAutoMarkRead(conversationId: string, conversation: ConversationDetailResponse | undefined) {
|
||||
const markRead = useMarkConversationReadMutation();
|
||||
const markedRef = useRef<string | null>(null);
|
||||
const newestTimestamp = conversation?.messages?.length
|
||||
? conversation.messages[conversation.messages.length - 1].timestamp
|
||||
: conversation?.lastMessageTime ?? 0;
|
||||
const lastReadMessageTs = conversation?.lastReadMessageTs ?? 0;
|
||||
const mutate = markRead.mutate;
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversation || newestTimestamp <= lastReadMessageTs) return;
|
||||
const key = `${conversationId}:${newestTimestamp}`;
|
||||
if (markedRef.current === key) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
markedRef.current = key;
|
||||
mutate({ id: conversationId, throughTimestamp: newestTimestamp });
|
||||
}, AUTO_MARK_READ_DELAY_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [conversation, conversationId, newestTimestamp, lastReadMessageTs, mutate]);
|
||||
}
|
||||
|
||||
export function ConversationDetail({ conversationId, onClose }: ConversationDetailProps) {
|
||||
const detailQuery = useConversationDetailQuery(conversationId);
|
||||
const [now] = useState(() => Date.now());
|
||||
useAutoMarkRead(conversationId, detailQuery.data);
|
||||
|
||||
if (detailQuery.isPending) {
|
||||
return <div aria-label="Loading conversation" className="conversation-detail-state" role="status">Loading conversation…</div>;
|
||||
@@ -166,17 +566,40 @@ export function ConversationDetail({ conversationId, onClose }: ConversationDeta
|
||||
}
|
||||
|
||||
const conversation = detailQuery.data;
|
||||
const status = triageStatus(conversation);
|
||||
return (
|
||||
<article className="conversation-detail">
|
||||
<header>
|
||||
<div>
|
||||
<div className="conversation-detail-identity">
|
||||
<span className="conversation-eyebrow">Conversation</span>
|
||||
<h2><RedditUsernameLink username={conversation.username} /></h2>
|
||||
<span className="conversation-detail-badges">
|
||||
<AccountOwnerBadge owner={conversation.owner} />
|
||||
{conversation.unread ? <span className="conversation-badge">Unread</span> : null}
|
||||
{conversation.sellerDetected ? <span className="conversation-seller-marker">Seller</span> : null}
|
||||
{conversation.recipientAccountStatus === 'deleted' ? (
|
||||
<span className="conversation-badge is-done">Account deleted</span>
|
||||
) : null}
|
||||
{isUnread(conversation) ? <span className="conversation-badge">Unread</span> : null}
|
||||
{status === 'snoozed' ? <span className="conversation-badge is-snoozed">Snoozed</span> : null}
|
||||
{status === 'done' ? <span className="conversation-badge is-done">Done</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
<div className="conversation-detail-controls">
|
||||
<ConversationTriageActions
|
||||
conversation={conversation}
|
||||
conversationId={conversationId}
|
||||
onResolved={onClose}
|
||||
/>
|
||||
<button aria-label="Close conversation" onClick={onClose} type="button">Close</button>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
className={`conversation-automation-status is-${conversation.automation.status}`}
|
||||
role="status"
|
||||
>
|
||||
<strong>{conversation.automation.remainingTurns} automated {conversation.automation.remainingTurns === 1 ? 'turn' : 'turns'} remaining</strong>
|
||||
<span>{automationStatusMessage(conversation)}</span>
|
||||
</div>
|
||||
{conversation.syncInProgress ? (
|
||||
<div className="conversation-sync-notice" role="status">
|
||||
A {conversation.syncInProgress.kind ?? 'chat'} sync is in progress. Messages may be stale.
|
||||
@@ -189,10 +612,11 @@ export function ConversationDetail({ conversationId, onClose }: ConversationDeta
|
||||
url: conversation.triggeringPost.url,
|
||||
imageUrl: conversation.triggeringPost.imageUrl,
|
||||
subreddit: conversation.triggeringPost.subreddit,
|
||||
postedAt: conversation.triggeringPost.postedAt,
|
||||
}} />
|
||||
</div>
|
||||
) : null}
|
||||
<MessageHistory conversation={conversation} />
|
||||
<MessageHistory conversation={conversation} now={now} />
|
||||
<ConversationComposer conversation={conversation} conversationId={conversationId} />
|
||||
</article>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { ActiveChat } from '../../../../../packages/api-contracts/src/index.js';
|
||||
|
||||
export function createOwnedConversationId(accountId: string | undefined, conversationId: string): string {
|
||||
return accountId
|
||||
? `${encodeURIComponent(accountId)}~${encodeURIComponent(conversationId)}`
|
||||
: conversationId;
|
||||
}
|
||||
|
||||
export function ownedConversationId(conversation: ActiveChat): string {
|
||||
const conversationId = conversation.roomId ?? conversation.username;
|
||||
return conversation.owner
|
||||
? `${encodeURIComponent(conversation.owner.accountId)}~${encodeURIComponent(conversationId)}`
|
||||
: conversationId;
|
||||
return createOwnedConversationId(conversation.owner?.accountId, conversationId);
|
||||
}
|
||||
@@ -1,14 +1,23 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query';
|
||||
import type { ActiveChat, ConversationTriageStatus } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { conversationApi } from './conversation-api.js';
|
||||
import { ownedConversationId } from './conversation-identity.js';
|
||||
|
||||
export const conversationQueryKeys = {
|
||||
all: ['conversations'] as const,
|
||||
detail: (id: string) => ['conversations', 'detail', id] as const,
|
||||
processedPosts: (username: string) => ['conversations', 'processed-posts', username] as const,
|
||||
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) {
|
||||
@@ -16,6 +25,7 @@ export function useConversationDetailQuery(id: string | null) {
|
||||
queryKey: conversationQueryKeys.detail(id ?? ''),
|
||||
queryFn: () => conversationApi.detail(id ?? ''),
|
||||
enabled: id !== null,
|
||||
refetchInterval: CONVERSATION_REFRESH_INTERVAL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,6 +37,14 @@ export function useSuggestMediaAskMutation(id: string) {
|
||||
return useMutation({ mutationFn: () => conversationApi.suggestMediaAsk(id) });
|
||||
}
|
||||
|
||||
export function useConversationMediaShareOptionsQuery(id: string, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: conversationQueryKeys.mediaShareOptions(id),
|
||||
queryFn: () => conversationApi.mediaShareOptions(id),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useProcessedPostsQuery(username: string) {
|
||||
return useQuery({
|
||||
queryKey: conversationQueryKeys.processedPosts(username),
|
||||
@@ -48,6 +66,136 @@ export function useSendReplyMutation(id: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useShareConversationMediaMutation(id: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (assetId: string) => conversationApi.shareMedia(id, assetId),
|
||||
async onSuccess() {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: conversationQueryKeys.detail(id) }),
|
||||
queryClient.invalidateQueries({ queryKey: conversationQueryKeys.mediaShareOptions(id) }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a triage patch to the cached list and detail entries for one
|
||||
* conversation. Read-state writes are optimistic because the list only polls
|
||||
* every 30s — waiting for a refetch would make marking read feel broken.
|
||||
*/
|
||||
function patchCachedConversation(
|
||||
queryClient: QueryClient,
|
||||
id: string,
|
||||
patch: Partial<ActiveChat>,
|
||||
): void {
|
||||
queryClient.setQueryData<ActiveChat[]>(conversationQueryKeys.all, (current) =>
|
||||
current?.map((conversation) => (ownedConversationId(conversation) === id
|
||||
? { ...conversation, ...patch }
|
||||
: conversation)));
|
||||
queryClient.setQueryData<ActiveChat>(
|
||||
conversationQueryKeys.detail(id),
|
||||
(current) => (current ? { ...current, ...patch } : current),
|
||||
);
|
||||
}
|
||||
|
||||
function cachedConversation(queryClient: QueryClient, id: string): ActiveChat | undefined {
|
||||
return queryClient.getQueryData<ActiveChat[]>(conversationQueryKeys.all)
|
||||
?.find((conversation) => ownedConversationId(conversation) === id);
|
||||
}
|
||||
|
||||
export function useMarkConversationReadMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, throughTimestamp }: { id: string; throughTimestamp?: number }) =>
|
||||
conversationApi.markRead(id, throughTimestamp),
|
||||
onMutate({ id, throughTimestamp }) {
|
||||
const watermark = throughTimestamp ?? cachedConversation(queryClient, id)?.lastMessageTime ?? 0;
|
||||
patchCachedConversation(queryClient, id, { hasUnreadInbound: false, lastReadMessageTs: watermark });
|
||||
},
|
||||
async onSettled() {
|
||||
await queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkConversationUnreadMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, beforeTimestamp }: { id: string; beforeTimestamp?: number }) =>
|
||||
conversationApi.markUnread(id, beforeTimestamp),
|
||||
onMutate({ id, beforeTimestamp }) {
|
||||
const boundary = beforeTimestamp ?? cachedConversation(queryClient, id)?.lastMessageTime ?? 0;
|
||||
patchCachedConversation(queryClient, id, {
|
||||
hasUnreadInbound: true,
|
||||
triageStatus: 'open',
|
||||
snoozedUntil: null,
|
||||
lastReadMessageTs: Math.max(0, boundary - 1),
|
||||
});
|
||||
},
|
||||
async onSettled() {
|
||||
await queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetConversationStatusMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, status, snoozeUntil }: {
|
||||
id: string;
|
||||
status: ConversationTriageStatus;
|
||||
snoozeUntil?: number | null;
|
||||
}) => conversationApi.setStatus(id, status, snoozeUntil),
|
||||
onMutate({ id, status, snoozeUntil }) {
|
||||
const conversation = cachedConversation(queryClient, id);
|
||||
patchCachedConversation(queryClient, id, {
|
||||
triageStatus: status,
|
||||
snoozedUntil: status === 'snoozed' ? snoozeUntil ?? null : null,
|
||||
// Resolving a conversation implies you have read it.
|
||||
...(status === 'done'
|
||||
? { hasUnreadInbound: false, lastReadMessageTs: conversation?.lastMessageTime ?? 0 }
|
||||
: {}),
|
||||
needsReply: status === 'open' && conversation?.isFromUs === false,
|
||||
});
|
||||
},
|
||||
async onSettled() {
|
||||
await queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useBulkMarkConversationsReadMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (ids: string[]) => conversationApi.bulkMarkRead(ids),
|
||||
onMutate(ids) {
|
||||
for (const id of ids) {
|
||||
const watermark = cachedConversation(queryClient, id)?.lastMessageTime ?? 0;
|
||||
patchCachedConversation(queryClient, id, { hasUnreadInbound: false, lastReadMessageTs: watermark });
|
||||
}
|
||||
},
|
||||
async onSettled() {
|
||||
await queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces a seller pass over every active conversation. The list is refetched
|
||||
* on completion because a sweep can flag, kill and resolve conversations.
|
||||
*/
|
||||
export function useSellerScanMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => conversationApi.sellerScan(),
|
||||
async onSuccess() {
|
||||
await queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useStartConversationMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { ConversationsPage, type ConversationFilters } from './conversations-page.js';
|
||||
import { ConversationsPage } from './conversations-page.js';
|
||||
import {
|
||||
DEFAULT_CONVERSATION_FILTERS,
|
||||
isConversationFilter,
|
||||
isConversationSort,
|
||||
type ConversationFilters,
|
||||
} from './conversation-triage.js';
|
||||
|
||||
export function ConversationRoute() {
|
||||
const navigate = useNavigate();
|
||||
const { conversationId } = useParams<{ conversationId: string }>();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const filterValue = searchParams.get('filter');
|
||||
const sortValue = searchParams.get('sort');
|
||||
const filters: ConversationFilters = {
|
||||
search: searchParams.get('q') ?? '',
|
||||
filter:
|
||||
filterValue === 'unread'
|
||||
|| filterValue === 'awaiting-reply'
|
||||
|| filterValue === 'received-images'
|
||||
? filterValue
|
||||
: 'all',
|
||||
showSellers: searchParams.get('showSellers') === 'true',
|
||||
filter: isConversationFilter(filterValue) ? filterValue : DEFAULT_CONVERSATION_FILTERS.filter,
|
||||
sort: isConversationSort(sortValue) ? sortValue : DEFAULT_CONVERSATION_FILTERS.sort,
|
||||
account: searchParams.get('account') ?? DEFAULT_CONVERSATION_FILTERS.account,
|
||||
};
|
||||
|
||||
function selectConversation(nextConversationId: string | null) {
|
||||
@@ -29,6 +34,9 @@ export function ConversationRoute() {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (next.search) params.set('q', next.search); else params.delete('q');
|
||||
if (next.filter !== 'all') params.set('filter', next.filter); else params.delete('filter');
|
||||
if (next.showSellers) params.set('showSellers', 'true'); else params.delete('showSellers');
|
||||
if (next.sort !== DEFAULT_CONVERSATION_FILTERS.sort) params.set('sort', next.sort); else params.delete('sort');
|
||||
if (next.account !== 'all') params.set('account', next.account); else params.delete('account');
|
||||
setSearchParams(params, { replace: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import type { ActiveChat } from '../../../../../packages/api-contracts/src/index.js';
|
||||
|
||||
/**
|
||||
* Inbox buckets. These are the operator's actual work queues, derived from
|
||||
* application read state rather than Matrix's unread count.
|
||||
*
|
||||
* `all` deliberately excludes resolved and snoozed conversations — the default
|
||||
* view is "what is still on my plate", and non-actionable conversations have
|
||||
* dedicated pills so nothing becomes unreachable.
|
||||
*/
|
||||
export type ConversationFilter =
|
||||
| 'all'
|
||||
| 'needs-reply'
|
||||
| 'unread'
|
||||
| 'awaiting-reply'
|
||||
| 'manual'
|
||||
| 'received-images'
|
||||
| 'account-deleted'
|
||||
| 'snoozed'
|
||||
| 'done';
|
||||
|
||||
export type ConversationSort = 'recent' | 'oldest-unanswered';
|
||||
|
||||
export interface ConversationFilters {
|
||||
search: string;
|
||||
filter: ConversationFilter;
|
||||
showSellers: boolean;
|
||||
sort: ConversationSort;
|
||||
/** Reddit account id to narrow to, or `all`. */
|
||||
account: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_CONVERSATION_FILTERS: ConversationFilters = {
|
||||
search: '',
|
||||
filter: 'all',
|
||||
showSellers: false,
|
||||
sort: 'recent',
|
||||
account: 'all',
|
||||
};
|
||||
|
||||
export const CONVERSATION_FILTERS: ReadonlyArray<{ value: ConversationFilter; label: string }> = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'needs-reply', label: 'Needs reply' },
|
||||
{ value: 'unread', label: 'Unread' },
|
||||
{ value: 'awaiting-reply', label: 'Waiting on them' },
|
||||
{ value: 'manual', label: 'Manual' },
|
||||
{ value: 'received-images', label: 'Images' },
|
||||
{ value: 'account-deleted', label: 'Account deleted' },
|
||||
{ value: 'snoozed', label: 'Snoozed' },
|
||||
{ value: 'done', label: 'Done' },
|
||||
];
|
||||
|
||||
export const CONVERSATION_SORTS: ReadonlyArray<{ value: ConversationSort; label: string }> = [
|
||||
{ value: 'recent', label: 'Newest activity' },
|
||||
{ value: 'oldest-unanswered', label: 'Oldest unanswered' },
|
||||
];
|
||||
|
||||
export function isConversationFilter(value: string | null): value is ConversationFilter {
|
||||
return CONVERSATION_FILTERS.some((option) => option.value === value);
|
||||
}
|
||||
|
||||
export function isConversationSort(value: string | null): value is ConversationSort {
|
||||
return CONVERSATION_SORTS.some((option) => option.value === value);
|
||||
}
|
||||
|
||||
export function triageStatus(conversation: ActiveChat): 'open' | 'snoozed' | 'done' {
|
||||
return conversation.triageStatus ?? 'open';
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the newest message is inbound and newer than the read watermark.
|
||||
*
|
||||
* Falls back to Matrix's `unread` only when the server has not supplied read
|
||||
* state at all, so an older backend keeps behaving as before rather than
|
||||
* showing an inbox with nothing unread.
|
||||
*/
|
||||
export function isUnread(conversation: ActiveChat): boolean {
|
||||
if (conversation.hasUnreadInbound !== undefined) return conversation.hasUnreadInbound;
|
||||
return conversation.unread;
|
||||
}
|
||||
|
||||
export function needsReply(conversation: ActiveChat): boolean {
|
||||
if (conversation.recipientAccountStatus === 'deleted') return false;
|
||||
if (conversation.needsReply !== undefined) return conversation.needsReply;
|
||||
return !conversation.isFromUs && triageStatus(conversation) === 'open';
|
||||
}
|
||||
|
||||
/** We have replied and are waiting on them. */
|
||||
export function isWaitingOnThem(conversation: ActiveChat): boolean {
|
||||
return conversation.recipientAccountStatus !== 'deleted'
|
||||
&& conversation.isFromUs
|
||||
&& triageStatus(conversation) === 'open';
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the reply loop is no longer sending on its own, so every further
|
||||
* message has to be written by an operator: either the subreddit's policy was
|
||||
* manual from the start, or automation handed the conversation over (a manual
|
||||
* reply was sent, an image arrived, a boundary or turn limit was hit).
|
||||
*
|
||||
* Conversations with no recorded automation state are not manual — they have
|
||||
* simply never been through the reply loop.
|
||||
*/
|
||||
export function isManualFlow(conversation: ActiveChat): boolean {
|
||||
if (conversation.automationPolicyMode === 'manual') return true;
|
||||
return conversation.automationStatus !== undefined && conversation.automationStatus !== 'active';
|
||||
}
|
||||
|
||||
/** Short label for why a conversation is being handled by hand. */
|
||||
export function manualFlowLabel(conversation: ActiveChat): string | null {
|
||||
if (!isManualFlow(conversation)) return null;
|
||||
if (conversation.automationPolicyMode === 'manual' && conversation.automationStatus === 'active') {
|
||||
return 'Manual by policy';
|
||||
}
|
||||
switch (conversation.automationStatus) {
|
||||
case 'killed':
|
||||
return 'Killed — seller';
|
||||
case 'user-overridden':
|
||||
return 'Manual — you replied';
|
||||
case 'image-received':
|
||||
return 'Manual — image received';
|
||||
case 'boundary':
|
||||
return 'Manual — boundary';
|
||||
case 'human-required':
|
||||
return 'Manual — handed over';
|
||||
default:
|
||||
return 'Manual';
|
||||
}
|
||||
}
|
||||
|
||||
export function matchesFilter(conversation: ActiveChat, filter: ConversationFilter): boolean {
|
||||
if (conversation.recipientAccountStatus === 'deleted') return filter === 'account-deleted';
|
||||
const status = triageStatus(conversation);
|
||||
switch (filter) {
|
||||
case 'needs-reply':
|
||||
return needsReply(conversation);
|
||||
case 'unread':
|
||||
return status === 'open' && isUnread(conversation);
|
||||
case 'awaiting-reply':
|
||||
return isWaitingOnThem(conversation);
|
||||
case 'manual':
|
||||
return status === 'open' && isManualFlow(conversation);
|
||||
case 'received-images':
|
||||
return status === 'open' && conversation.hasReceivedImages === true;
|
||||
case 'account-deleted':
|
||||
return false;
|
||||
case 'snoozed':
|
||||
return status === 'snoozed';
|
||||
case 'done':
|
||||
return status === 'done';
|
||||
default:
|
||||
return status === 'open';
|
||||
}
|
||||
}
|
||||
|
||||
export function matchesSearch(conversation: ActiveChat, search: string): boolean {
|
||||
const normalized = search.trim().toLowerCase();
|
||||
if (normalized.length === 0) return true;
|
||||
if (conversation.username.toLowerCase().includes(normalized)) return true;
|
||||
if (conversation.lastMessageSnippet.toLowerCase().includes(normalized)) return true;
|
||||
// Message text is only present for conversations whose history has been
|
||||
// loaded, so this is a bonus rather than a guarantee.
|
||||
return (conversation.messages ?? []).some((message) => message.text.toLowerCase().includes(normalized));
|
||||
}
|
||||
|
||||
function matchesAccount(conversation: ActiveChat, account: string): boolean {
|
||||
return account === 'all' || conversation.owner?.accountId === account;
|
||||
}
|
||||
|
||||
export interface ConversationListPartition {
|
||||
/** Conversations passing every active filter, in the requested order. */
|
||||
visible: ActiveChat[];
|
||||
/** How many were removed purely by the seller toggle, so the UI can say so. */
|
||||
hiddenSellerCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* `oldest-unanswered` puts the longest-waiting conversation that needs a reply
|
||||
* first — the order you actually want when draining a queue — and keeps
|
||||
* everything else in newest-first order behind it.
|
||||
*/
|
||||
function compareConversations(sort: ConversationSort) {
|
||||
return (left: ActiveChat, right: ActiveChat): number => {
|
||||
if (sort === 'oldest-unanswered') {
|
||||
const leftNeeds = needsReply(left);
|
||||
const rightNeeds = needsReply(right);
|
||||
if (leftNeeds !== rightNeeds) return leftNeeds ? -1 : 1;
|
||||
if (leftNeeds && rightNeeds) return left.lastMessageTime - right.lastMessageTime;
|
||||
}
|
||||
return right.lastMessageTime - left.lastMessageTime;
|
||||
};
|
||||
}
|
||||
|
||||
export function partitionConversations(
|
||||
conversations: readonly ActiveChat[],
|
||||
filters: ConversationFilters,
|
||||
): ConversationListPartition {
|
||||
let hiddenSellerCount = 0;
|
||||
const visible = conversations.filter((conversation) => {
|
||||
if (!matchesSearch(conversation, filters.search)) return false;
|
||||
if (!matchesFilter(conversation, filters.filter)) return false;
|
||||
if (!matchesAccount(conversation, filters.account)) return false;
|
||||
if (!filters.showSellers && conversation.sellerDetected === true) {
|
||||
hiddenSellerCount += 1;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return { visible: visible.sort(compareConversations(filters.sort)), hiddenSellerCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-bucket counts for the filter pills. Counts respect search, account and
|
||||
* the seller toggle so a pill never promises rows the list will not show.
|
||||
*/
|
||||
export function filterCounts(
|
||||
conversations: readonly ActiveChat[],
|
||||
filters: ConversationFilters,
|
||||
): Record<ConversationFilter, number> {
|
||||
const candidates = conversations.filter((conversation) =>
|
||||
matchesSearch(conversation, filters.search)
|
||||
&& matchesAccount(conversation, filters.account)
|
||||
&& (filters.showSellers || conversation.sellerDetected !== true));
|
||||
const counts = {} as Record<ConversationFilter, number>;
|
||||
for (const { value } of CONVERSATION_FILTERS) {
|
||||
counts[value] = candidates.filter((conversation) => matchesFilter(conversation, value)).length;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
export interface ConversationAccountOption {
|
||||
accountId: string;
|
||||
redditUsername: string;
|
||||
}
|
||||
|
||||
/** Distinct owning accounts present in the list, for the account switcher. */
|
||||
export function accountOptions(conversations: readonly ActiveChat[]): ConversationAccountOption[] {
|
||||
const seen = new Map<string, string>();
|
||||
for (const conversation of conversations) {
|
||||
if (conversation.owner) seen.set(conversation.owner.accountId, conversation.owner.redditUsername);
|
||||
}
|
||||
return [...seen]
|
||||
.map(([accountId, redditUsername]) => ({ accountId, redditUsername }))
|
||||
.sort((left, right) => left.redditUsername.localeCompare(right.redditUsername));
|
||||
}
|
||||
|
||||
export const SNOOZE_OPTIONS: ReadonlyArray<{ label: string; ms: number }> = [
|
||||
{ label: '1 hour', ms: 60 * 60_000 },
|
||||
{ label: 'Tomorrow', ms: 24 * 60 * 60_000 },
|
||||
{ label: '3 days', ms: 3 * 24 * 60 * 60_000 },
|
||||
{ label: '1 week', ms: 7 * 24 * 60 * 60_000 },
|
||||
];
|
||||
@@ -1,20 +1,40 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ActiveChat } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ActiveChat, SellerScanResponse } 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 {
|
||||
useBulkMarkConversationsReadMutation,
|
||||
useConversationsQuery,
|
||||
useMarkConversationReadMutation,
|
||||
useMarkConversationUnreadMutation,
|
||||
useSellerScanMutation,
|
||||
useSetConversationStatusMutation,
|
||||
} from './conversation-queries.js';
|
||||
import { StartConversationForm } from './start-conversation-form.js';
|
||||
import { ProcessedUserLookup } from './processed-user-lookup.js';
|
||||
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
||||
import './conversations.css';
|
||||
import { ownedConversationId } from './conversation-identity.js';
|
||||
import { conversationAge, formatExactTime, formatShortRelativeTime } from './relative-time.js';
|
||||
import {
|
||||
CONVERSATION_FILTERS,
|
||||
CONVERSATION_SORTS,
|
||||
DEFAULT_CONVERSATION_FILTERS,
|
||||
accountOptions,
|
||||
filterCounts,
|
||||
isUnread,
|
||||
manualFlowLabel,
|
||||
needsReply,
|
||||
partitionConversations,
|
||||
triageStatus,
|
||||
type ConversationFilter,
|
||||
type ConversationFilters,
|
||||
type ConversationSort,
|
||||
} from './conversation-triage.js';
|
||||
|
||||
export type ConversationFilter = 'all' | 'unread' | 'awaiting-reply' | 'received-images';
|
||||
export type { ConversationFilter, ConversationFilters, ConversationSort } from './conversation-triage.js';
|
||||
|
||||
export interface ConversationFilters {
|
||||
search: string;
|
||||
filter: ConversationFilter;
|
||||
}
|
||||
const SMALL_VIEWPORT_QUERY = '(max-width: 51.9999rem)';
|
||||
|
||||
export interface ConversationsPageProps {
|
||||
selectedConversationId?: string | null;
|
||||
@@ -23,64 +43,354 @@ export interface ConversationsPageProps {
|
||||
onFiltersChange?(filters: ConversationFilters): void;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function ConversationListItem({ conversation, isSelected, onSelect }: {
|
||||
interface ConversationRowProps {
|
||||
conversation: ActiveChat;
|
||||
isSelected: boolean;
|
||||
isChecked: boolean;
|
||||
/** Only worth the row space when more than one account is in play. */
|
||||
showOwner: boolean;
|
||||
now: number;
|
||||
onSelect(): void;
|
||||
}) {
|
||||
const state = conversation.unread ? 'Unread' : conversation.isFromUs ? 'Awaiting reply' : 'Up to date';
|
||||
onToggleChecked(): void;
|
||||
registerRef(element: HTMLButtonElement | null): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A chip's visible label, plus the longer form used for screen readers and the
|
||||
* hover tooltip when the label alone does not say why the chip is there.
|
||||
*/
|
||||
interface ConversationChip {
|
||||
label: string;
|
||||
hint?: string;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain-English outcome of a forced seller sweep. The AI counts are shown
|
||||
* because the sweep is deliberately frugal with them — most conversations are
|
||||
* settled by keyword rules or skipped as unchanged, and the operator should be
|
||||
* able to see that.
|
||||
*/
|
||||
function sellerScanSummary(result: SellerScanResponse): string {
|
||||
const flagged = result.flagged.length;
|
||||
const killed = result.flagged.filter((flag) => flag.killed).length;
|
||||
const parts = [
|
||||
`Scanned ${result.scanned} ${result.scanned === 1 ? 'conversation' : 'conversations'}`,
|
||||
flagged === 0 ? 'no new sellers found' : `${flagged} flagged as sellers${killed > 0 ? ` (${killed} ended)` : ''}`,
|
||||
result.aiScreened === 0
|
||||
? 'no AI needed'
|
||||
: `${result.aiScreened} needed AI over ${result.aiRequests} ${result.aiRequests === 1 ? 'request' : 'requests'}`,
|
||||
];
|
||||
if (result.unchanged > 0) parts.push(`${result.unchanged} unchanged since the last scan`);
|
||||
if (result.budgetSkipped > 0) parts.push(`${result.budgetSkipped} left for the next scan`);
|
||||
if (result.degraded) parts.push('some conversations were skipped because screening failed');
|
||||
return `${parts.join(' · ')}.`;
|
||||
}
|
||||
|
||||
/** Independent state chips. A conversation can be several of these at once. */
|
||||
function conversationChips(conversation: ActiveChat, now: number): ConversationChip[] {
|
||||
const chips: ConversationChip[] = [];
|
||||
if (conversation.recipientAccountStatus === 'deleted') {
|
||||
chips.push({ label: 'Account deleted' });
|
||||
return chips;
|
||||
}
|
||||
if (isUnread(conversation)) chips.push({ label: 'Unread' });
|
||||
if (needsReply(conversation)) chips.push({ label: 'Needs reply' });
|
||||
else if (conversation.isFromUs && triageStatus(conversation) === 'open') chips.push({ label: 'Waiting on them' });
|
||||
const manualLabel = manualFlowLabel(conversation);
|
||||
if (manualLabel) {
|
||||
chips.push({
|
||||
label: 'Manual',
|
||||
hint: manualLabel,
|
||||
tooltip: conversation.automationHandoffReason
|
||||
? `${manualLabel} — ${conversation.automationHandoffReason}`
|
||||
: manualLabel,
|
||||
});
|
||||
}
|
||||
const status = triageStatus(conversation);
|
||||
if (status === 'snoozed') chips.push({ label: 'Snoozed' });
|
||||
if (status === 'done') chips.push({ label: 'Done' });
|
||||
if (needsReply(conversation) && conversationAge(conversation.lastMessageTime, now) !== 'fresh') {
|
||||
chips.push({ label: 'Overdue' });
|
||||
}
|
||||
return chips;
|
||||
}
|
||||
|
||||
function ConversationRow({
|
||||
conversation, isSelected, isChecked, showOwner, now, onSelect, onToggleChecked, registerRef,
|
||||
}: ConversationRowProps) {
|
||||
const relativeTime = formatShortRelativeTime(conversation.lastMessageTime, now);
|
||||
const unread = isUnread(conversation);
|
||||
const overdue = needsReply(conversation) ? conversationAge(conversation.lastMessageTime, now) : 'fresh';
|
||||
const chips = conversationChips(conversation, now);
|
||||
const itemClasses = ['conversation-list-item'];
|
||||
if (isSelected) itemClasses.push('is-selected');
|
||||
if (unread) itemClasses.push('is-unread');
|
||||
if (isChecked) itemClasses.push('is-checked');
|
||||
|
||||
const descriptors = [
|
||||
redditUsernameLabel(conversation.username),
|
||||
...chips.map((chip) => chip.hint ?? chip.label),
|
||||
relativeTime,
|
||||
...(conversation.sellerDetected ? ['Seller'] : []),
|
||||
...(conversation.hasReceivedImages ? ['Image received'] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<li className={isSelected ? 'conversation-list-item is-selected' : 'conversation-list-item'}>
|
||||
<button
|
||||
aria-label={`${redditUsernameLabel(conversation.username)}, ${state}${conversation.hasReceivedImages ? ', Image received' : ''}`}
|
||||
aria-pressed={isSelected}
|
||||
className="conversation-list-target"
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
<li className={itemClasses.join(' ')}>
|
||||
<label className="conversation-list-check">
|
||||
<input
|
||||
aria-label={`Select ${redditUsernameLabel(conversation.username)}`}
|
||||
checked={isChecked}
|
||||
onChange={onToggleChecked}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span className="conversation-list-heading"><strong><RedditUsernameLink username={conversation.username} /></strong><small>{state}</small></span>
|
||||
<AccountOwnerBadge owner={conversation.owner} />
|
||||
</label>
|
||||
<button
|
||||
aria-current={isSelected || undefined}
|
||||
aria-label={descriptors.join(', ')}
|
||||
className="conversation-list-open"
|
||||
onClick={onSelect}
|
||||
ref={registerRef}
|
||||
type="button"
|
||||
>
|
||||
<span className="conversation-list-heading">
|
||||
<strong>{redditUsernameLabel(conversation.username)}</strong>
|
||||
<time
|
||||
className={overdue === 'fresh' ? undefined : `is-${overdue}`}
|
||||
dateTime={new Date(conversation.lastMessageTime).toISOString()}
|
||||
title={formatExactTime(conversation.lastMessageTime)}
|
||||
>
|
||||
{relativeTime}
|
||||
</time>
|
||||
</span>
|
||||
<span className="conversation-list-snippet">
|
||||
{conversation.lastMessageSnippet || 'No message preview'}
|
||||
</span>
|
||||
</button>
|
||||
<span className="conversation-list-chips">
|
||||
{chips.map((chip) => (
|
||||
<span
|
||||
className={`conversation-chip is-${chip.label.toLowerCase().replaceAll(' ', '-')}`}
|
||||
key={chip.label}
|
||||
title={chip.tooltip}
|
||||
>
|
||||
{chip.label}
|
||||
</span>
|
||||
))}
|
||||
{showOwner ? <AccountOwnerBadge owner={conversation.owner} /> : null}
|
||||
{conversation.sellerDetected ? <span className="conversation-seller-marker">Seller</span> : null}
|
||||
{conversation.hasReceivedImages ? <span className="conversation-image-marker">Image received</span> : null}
|
||||
<span>{conversation.lastMessageSnippet || 'No message preview'}</span>
|
||||
<span className="conversation-list-profile"><RedditUsernameLink username={conversation.username} /></span>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConversationsPage({ selectedConversationId, onSelectedConversationChange, filters, onFiltersChange }: ConversationsPageProps) {
|
||||
export function ConversationsPage({
|
||||
selectedConversationId, onSelectedConversationChange, filters, onFiltersChange,
|
||||
}: ConversationsPageProps) {
|
||||
const conversationsQuery = useConversationsQuery();
|
||||
const [internalSelectedId, setInternalSelectedId] = useState<string | null>(null);
|
||||
const [localFilters, setLocalFilters] = useState<ConversationFilters>({ search: '', filter: 'all' });
|
||||
const [localFilters, setLocalFilters] = useState<ConversationFilters>(DEFAULT_CONVERSATION_FILTERS);
|
||||
const [isStartingConversation, setIsStartingConversation] = useState(false);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const [checkedIds, setCheckedIds] = useState<Set<string>>(() => new Set());
|
||||
const detailPanelRef = useRef<HTMLElement>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const rowRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||
const activeConversationId = selectedConversationId !== undefined ? selectedConversationId : internalSelectedId;
|
||||
const activeFilters = filters ?? localFilters;
|
||||
const markRead = useMarkConversationReadMutation();
|
||||
const markUnread = useMarkConversationUnreadMutation();
|
||||
const setStatus = useSetConversationStatusMutation();
|
||||
const bulkMarkRead = useBulkMarkConversationsReadMutation();
|
||||
const sellerScan = useSellerScanMutation();
|
||||
|
||||
function updateFilters(next: ConversationFilters) {
|
||||
useEffect(() => {
|
||||
const interval = window.setInterval(() => setNow(Date.now()), 60_000);
|
||||
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]);
|
||||
|
||||
const updateFilters = useCallback((next: ConversationFilters) => {
|
||||
if (filters === undefined) setLocalFilters(next);
|
||||
onFiltersChange?.(next);
|
||||
}, [filters, onFiltersChange]);
|
||||
|
||||
const allConversations = conversationsQuery.data ?? [];
|
||||
|
||||
const { visible: liveConversations, hiddenSellerCount } = useMemo(
|
||||
() => partitionConversations(allConversations, activeFilters),
|
||||
[allConversations, activeFilters],
|
||||
);
|
||||
const counts = useMemo(() => filterCounts(allConversations, activeFilters), [allConversations, activeFilters]);
|
||||
const accounts = useMemo(() => accountOptions(allConversations), [allConversations]);
|
||||
|
||||
// While a conversation is open the row order is frozen. A background poll
|
||||
// re-sorting the list would move rows out from under the operator mid-triage;
|
||||
// anything new is surfaced as an explicit refresh instead.
|
||||
const [frozenOrder, setFrozenOrder] = useState<string[] | null>(null);
|
||||
const liveIds = useMemo(
|
||||
() => liveConversations.map((conversation) => ownedConversationId(conversation)),
|
||||
[liveConversations],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeConversationId) {
|
||||
setFrozenOrder(null);
|
||||
return;
|
||||
}
|
||||
// Capture the order as it was when the conversation was opened. `liveIds` is
|
||||
// intentionally read once here rather than tracked, so later polls do not
|
||||
// re-freeze against a reordered list.
|
||||
setFrozenOrder((current) => current ?? liveIds);
|
||||
}, [activeConversationId, liveIds]);
|
||||
|
||||
const visibleConversations = useMemo(() => {
|
||||
const normalizedSearch = activeFilters.search.trim().toLowerCase();
|
||||
return (conversationsQuery.data ?? []).filter((conversation) => {
|
||||
const matchesSearch = normalizedSearch.length === 0
|
||||
|| conversation.username.toLowerCase().includes(normalizedSearch)
|
||||
|| conversation.lastMessageSnippet.toLowerCase().includes(normalizedSearch);
|
||||
return matchesSearch && matchesFilter(conversation, activeFilters.filter);
|
||||
});
|
||||
}, [activeFilters, conversationsQuery.data]);
|
||||
if (!frozenOrder) return liveConversations;
|
||||
const rank = new Map(frozenOrder.map((id, index) => [id, index]));
|
||||
return liveConversations
|
||||
.filter((conversation) => rank.has(ownedConversationId(conversation)))
|
||||
.sort((left, right) =>
|
||||
(rank.get(ownedConversationId(left)) ?? 0) - (rank.get(ownedConversationId(right)) ?? 0));
|
||||
}, [liveConversations, frozenOrder]);
|
||||
|
||||
const pendingCount = liveConversations.length - visibleConversations.length;
|
||||
|
||||
const visibleIds = useMemo(
|
||||
() => visibleConversations.map((conversation) => ownedConversationId(conversation)),
|
||||
[visibleConversations],
|
||||
);
|
||||
|
||||
function selectConversation(id: string | null) {
|
||||
if (selectedConversationId === undefined) setInternalSelectedId(id);
|
||||
onSelectedConversationChange?.(id);
|
||||
}
|
||||
|
||||
function releaseFrozenOrder() {
|
||||
setFrozenOrder(activeConversationId ? liveIds : null);
|
||||
}
|
||||
|
||||
function toggleChecked(id: string) {
|
||||
setCheckedIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const checkedVisibleIds = visibleIds.filter((id) => checkedIds.has(id));
|
||||
const allVisibleChecked = visibleIds.length > 0 && checkedVisibleIds.length === visibleIds.length;
|
||||
|
||||
function toggleAllVisible() {
|
||||
setCheckedIds(allVisibleChecked ? new Set() : new Set(visibleIds));
|
||||
}
|
||||
|
||||
function moveFocus(offset: number) {
|
||||
if (visibleIds.length === 0) return;
|
||||
const focused = document.activeElement;
|
||||
let index = visibleIds.findIndex((id) => rowRefs.current.get(id) === focused);
|
||||
if (index < 0) index = visibleIds.findIndex((id) => id === activeConversationId);
|
||||
if (index < 0) {
|
||||
// Entering the list for the first time: land on the near end rather than
|
||||
// stepping past it.
|
||||
rowRefs.current.get(visibleIds[offset > 0 ? 0 : visibleIds.length - 1])?.focus();
|
||||
return;
|
||||
}
|
||||
const nextIndex = Math.min(visibleIds.length - 1, Math.max(0, index + offset));
|
||||
rowRefs.current.get(visibleIds[nextIndex])?.focus();
|
||||
}
|
||||
|
||||
function focusedConversationId(): string | null {
|
||||
const focused = document.activeElement;
|
||||
const focusedId = visibleIds.find((id) => rowRefs.current.get(id) === focused);
|
||||
return focusedId ?? activeConversationId ?? null;
|
||||
}
|
||||
|
||||
function isTypingTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
return target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inbox keyboard shortcuts. Suppressed whenever focus is inside a text field
|
||||
* so typing a reply or a search term never triggers an action.
|
||||
*/
|
||||
function handleShortcut(event: KeyboardEvent) {
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
// A modal owns the keyboard while it is open — including Escape, which must
|
||||
// dismiss the dialog rather than the conversation behind it.
|
||||
if (event.defaultPrevented || document.querySelector('dialog[open]')) return;
|
||||
const typing = isTypingTarget(event.target);
|
||||
if (event.key === '/' && !typing) {
|
||||
event.preventDefault();
|
||||
searchRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
if (typing) (event.target as HTMLElement).blur();
|
||||
else if (activeConversationId) selectConversation(null);
|
||||
return;
|
||||
}
|
||||
if (typing) return;
|
||||
|
||||
switch (event.key) {
|
||||
case 'j':
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
moveFocus(1);
|
||||
break;
|
||||
case 'k':
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
moveFocus(-1);
|
||||
break;
|
||||
case 'u': {
|
||||
const id = focusedConversationId();
|
||||
const conversation = allConversations.find((item) => ownedConversationId(item) === id);
|
||||
if (!id || !conversation) return;
|
||||
event.preventDefault();
|
||||
if (isUnread(conversation)) markRead.mutate({ id });
|
||||
else markUnread.mutate({ id });
|
||||
break;
|
||||
}
|
||||
case 'e': {
|
||||
const id = focusedConversationId();
|
||||
if (!id) return;
|
||||
event.preventDefault();
|
||||
setStatus.mutate({ id, status: 'done' });
|
||||
if (id === activeConversationId) selectConversation(null);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// The handler closes over per-render state, so it is kept in a ref and the
|
||||
// listener is registered once rather than re-bound on every render.
|
||||
const shortcutRef = useRef(handleShortcut);
|
||||
shortcutRef.current = handleShortcut;
|
||||
useEffect(() => {
|
||||
const listener = (event: KeyboardEvent) => shortcutRef.current(event);
|
||||
window.addEventListener('keydown', listener);
|
||||
return () => window.removeEventListener('keydown', listener);
|
||||
}, []);
|
||||
|
||||
const lastSyncedLabel = conversationsQuery.dataUpdatedAt
|
||||
? formatShortRelativeTime(conversationsQuery.dataUpdatedAt, now)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="conversations-page">
|
||||
<header className="conversations-header">
|
||||
@@ -89,11 +399,46 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
|
||||
<h1>Conversations</h1>
|
||||
<p>Review replies, draft carefully, and send only after confirmation.</p>
|
||||
</div>
|
||||
<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">
|
||||
{lastSyncedLabel ? `Updated ${lastSyncedLabel} · auto every 30s` : 'Auto-refreshes every 30s'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
aria-busy={sellerScan.isPending || undefined}
|
||||
className="conversation-seller-scan"
|
||||
disabled={sellerScan.isPending}
|
||||
onClick={() => sellerScan.mutate()}
|
||||
title="Re-check every active conversation for content sellers"
|
||||
type="button"
|
||||
>
|
||||
{sellerScan.isPending ? 'Scanning for sellers…' : 'Scan for sellers'}
|
||||
</button>
|
||||
<button className="conversation-primary" onClick={() => setIsStartingConversation((current) => !current)} type="button">
|
||||
{isStartingConversation ? 'Close new conversation' : 'New conversation'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{sellerScan.isError ? (
|
||||
<p className="conversation-seller-scan-result is-error" role="alert">
|
||||
Seller scan failed: {sellerScan.error.message}
|
||||
</p>
|
||||
) : sellerScan.data ? (
|
||||
<p className="conversation-seller-scan-result" role="status">
|
||||
{sellerScanSummary(sellerScan.data)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{isStartingConversation ? (
|
||||
<section aria-labelledby="new-conversation-heading" className="new-conversation-panel">
|
||||
<h2 id="new-conversation-heading">Start a conversation</h2>
|
||||
@@ -109,21 +454,107 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
|
||||
<input
|
||||
id="conversation-search"
|
||||
onChange={(event) => updateFilters({ ...activeFilters, search: event.target.value })}
|
||||
placeholder="Search conversations"
|
||||
placeholder="Search conversations (/)"
|
||||
ref={searchRef}
|
||||
type="search"
|
||||
value={activeFilters.search}
|
||||
/>
|
||||
<label className="sr-only" htmlFor="conversation-filter">Filter conversations</label>
|
||||
<select
|
||||
id="conversation-filter"
|
||||
onChange={(event) => updateFilters({ ...activeFilters, filter: event.target.value as ConversationFilter })}
|
||||
value={activeFilters.filter}
|
||||
<div aria-label="Filter conversations" className="conversation-filter-pills" role="group">
|
||||
{CONVERSATION_FILTERS.map((option) => (
|
||||
<button
|
||||
aria-label={`${option.label}, ${counts[option.value]}`}
|
||||
aria-pressed={activeFilters.filter === option.value}
|
||||
className="conversation-filter-pill"
|
||||
key={option.value}
|
||||
onClick={() => updateFilters({ ...activeFilters, filter: option.value })}
|
||||
type="button"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="unread">Unread</option>
|
||||
<option value="awaiting-reply">Awaiting reply</option>
|
||||
<option value="received-images">Received images</option>
|
||||
{option.label}
|
||||
<span className="conversation-filter-count">{counts[option.value]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="conversation-toolbar-row">
|
||||
<label className="conversation-sort">
|
||||
<span>Sort</span>
|
||||
<select
|
||||
onChange={(event) => updateFilters({
|
||||
...activeFilters,
|
||||
sort: event.target.value as ConversationSort,
|
||||
})}
|
||||
value={activeFilters.sort}
|
||||
>
|
||||
{CONVERSATION_SORTS.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{accounts.length > 1 ? (
|
||||
<label className="conversation-account-filter">
|
||||
<span>Account</span>
|
||||
<select
|
||||
onChange={(event) => updateFilters({ ...activeFilters, account: event.target.value })}
|
||||
value={activeFilters.account}
|
||||
>
|
||||
<option value="all">All accounts</option>
|
||||
{accounts.map((account) => (
|
||||
<option key={account.accountId} value={account.accountId}>
|
||||
u/{account.redditUsername}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
<label className="conversation-seller-toggle">
|
||||
<input
|
||||
checked={activeFilters.showSellers}
|
||||
onChange={(event) => updateFilters({ ...activeFilters, showSellers: event.target.checked })}
|
||||
type="checkbox"
|
||||
/>
|
||||
Show sellers
|
||||
</label>
|
||||
</div>
|
||||
{visibleConversations.length > 0 ? (
|
||||
<div className="conversation-bulk-bar">
|
||||
<label className="conversation-bulk-select-all">
|
||||
<input
|
||||
aria-label="Select all visible conversations"
|
||||
checked={allVisibleChecked}
|
||||
onChange={toggleAllVisible}
|
||||
type="checkbox"
|
||||
/>
|
||||
{checkedVisibleIds.length > 0 ? `${checkedVisibleIds.length} selected` : 'Select all'}
|
||||
</label>
|
||||
{checkedVisibleIds.length > 0 ? (
|
||||
<>
|
||||
<button
|
||||
disabled={bulkMarkRead.isPending}
|
||||
onClick={() => bulkMarkRead.mutate(checkedVisibleIds, {
|
||||
onSuccess: () => setCheckedIds(new Set()),
|
||||
})}
|
||||
type="button"
|
||||
>
|
||||
{bulkMarkRead.isPending ? 'Marking read…' : 'Mark read'}
|
||||
</button>
|
||||
<button
|
||||
disabled={setStatus.isPending}
|
||||
onClick={() => {
|
||||
for (const id of checkedVisibleIds) setStatus.mutate({ id, status: 'done' });
|
||||
setCheckedIds(new Set());
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Mark done
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{pendingCount > 0 ? (
|
||||
<button className="conversation-pending-refresh" onClick={releaseFrozenOrder} type="button">
|
||||
{pendingCount} new {pendingCount === 1 ? 'conversation' : 'conversations'} — refresh list
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{conversationsQuery.isPending ? (
|
||||
<div aria-label="Loading conversations" className="conversation-list-state" role="status">Loading conversations…</div>
|
||||
@@ -136,22 +567,49 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
|
||||
) : conversationsQuery.data.length === 0 ? (
|
||||
<div className="conversation-list-state"><strong>No conversations yet.</strong><span>Start one when you are ready.</span></div>
|
||||
) : visibleConversations.length === 0 ? (
|
||||
<div className="conversation-list-state"><strong>No matching conversations.</strong><span>Try a different search or filter.</span></div>
|
||||
<div className="conversation-list-state">
|
||||
<strong>No matching conversations.</strong>
|
||||
<span>Try a different search or filter.</span>
|
||||
{hiddenSellerCount > 0 ? (
|
||||
<span>{hiddenSellerCount} hidden by the seller filter.</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ul className="conversation-list">
|
||||
{visibleConversations.map((conversation) => (
|
||||
<ConversationListItem
|
||||
{visibleConversations.map((conversation) => {
|
||||
const id = ownedConversationId(conversation);
|
||||
return (
|
||||
<ConversationRow
|
||||
conversation={conversation}
|
||||
isSelected={ownedConversationId(conversation) === activeConversationId}
|
||||
key={ownedConversationId(conversation)}
|
||||
onSelect={() => selectConversation(ownedConversationId(conversation))}
|
||||
isChecked={checkedIds.has(id)}
|
||||
isSelected={id === activeConversationId}
|
||||
key={id}
|
||||
now={now}
|
||||
onSelect={() => selectConversation(id)}
|
||||
onToggleChecked={() => toggleChecked(id)}
|
||||
showOwner={accounts.length > 1}
|
||||
registerRef={(element) => {
|
||||
if (element) rowRefs.current.set(id, element);
|
||||
else rowRefs.current.delete(id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<p className="conversation-list-footer">
|
||||
{hiddenSellerCount > 0
|
||||
? `${hiddenSellerCount} hidden by the seller filter.`
|
||||
: null}
|
||||
<span className="conversation-shortcut-hint">
|
||||
j/k move · Enter opens · u toggles unread · e marks done · / searches
|
||||
</span>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</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,14 +14,25 @@
|
||||
}
|
||||
|
||||
.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; }
|
||||
.conversation-seller-scan-result { margin: 0 0 1rem; color: var(--muted); font-size: .8rem; }
|
||||
.conversation-seller-scan-result.is-error { color: var(--danger, #ff8f8f); }
|
||||
.conversations-page button, .conversations-page input, .conversations-page select, .conversations-page textarea { font: inherit; }
|
||||
.conversations-page button { min-height: 2.5rem; padding: .5rem .8rem; border: 1px solid var(--border); border-radius: .6rem; color: #e9f1ec; background: var(--surface-raised); }
|
||||
.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; }
|
||||
@@ -31,28 +42,89 @@
|
||||
.conversation-field input:focus, .conversation-field textarea:focus, .conversation-toolbar input:focus, .conversation-toolbar select:focus, .conversation-composer textarea:focus { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.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-toolbar { display: grid; gap: .55rem; padding: .75rem; border-bottom: 1px solid var(--border); }
|
||||
.conversation-toolbar-row { display: flex; flex-wrap: wrap; align-items: center; gap: .75rem; }
|
||||
.conversation-seller-toggle { display: inline-flex; align-items: center; gap: .45rem; color: var(--muted); font-size: .78rem; font-weight: 700; white-space: nowrap; }
|
||||
.conversation-seller-toggle input { width: auto; accent-color: var(--accent); }
|
||||
.conversation-sort, .conversation-account-filter { display: inline-flex; align-items: center; gap: .4rem; color: var(--muted); font-size: .78rem; font-weight: 700; }
|
||||
.conversation-sort select, .conversation-account-filter select { padding: .35rem .45rem; color: #e9f1ec; background: #0b1410; border: 1px solid var(--border); border-radius: .45rem; font-size: .78rem; }
|
||||
|
||||
/* Filter pills replace a select so every bucket's count is visible at a glance. */
|
||||
.conversation-filter-pills { display: flex; flex-wrap: wrap; gap: .35rem; }
|
||||
.conversation-filter-pill { display: inline-flex; align-items: center; gap: .4rem; min-height: 2rem !important; padding: .3rem .6rem !important; border-radius: 999px !important; font-size: .78rem; font-weight: 700; }
|
||||
.conversation-filter-pill[aria-pressed='true'] { color: #06140b !important; background: var(--accent) !important; border-color: transparent !important; }
|
||||
.conversation-filter-count { padding: .05rem .35rem; border-radius: 999px; background: rgba(255, 255, 255, .12); font-size: .7rem; font-weight: 800; font-variant-numeric: tabular-nums; }
|
||||
.conversation-filter-pill[aria-pressed='true'] .conversation-filter-count { background: rgba(0, 0, 0, .22); }
|
||||
|
||||
.conversation-bulk-bar { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; }
|
||||
.conversation-bulk-select-all { display: inline-flex; align-items: center; gap: .45rem; color: var(--muted); font-size: .78rem; font-weight: 700; white-space: nowrap; }
|
||||
.conversation-bulk-select-all input { accent-color: var(--accent); }
|
||||
.conversation-bulk-bar button { min-height: 2rem !important; padding: .3rem .6rem !important; font-size: .78rem; }
|
||||
.conversation-pending-refresh { justify-self: start; min-height: 2rem !important; padding: .3rem .65rem !important; color: var(--warning-text) !important; background: var(--warning-bg) !important; border-color: var(--warning-border) !important; font-size: .78rem; font-weight: 700; }
|
||||
.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; }
|
||||
/*
|
||||
* The row is a grid of real siblings: a selection checkbox, a primary open
|
||||
* button, and a chip strip. The previous full-bleed absolute overlay button made
|
||||
* every per-row control need a pointer-events escape hatch.
|
||||
*/
|
||||
.conversation-list-item { position: relative; display: grid; grid-template-columns: auto minmax(0, 1fr); gap: .15rem .5rem; width: 100%; padding: .6rem .7rem; 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-item.is-checked { border-color: var(--accent); }
|
||||
.conversation-list-item.is-unread .conversation-list-heading strong { font-weight: 900; }
|
||||
.conversation-list-item.is-unread .conversation-list-heading strong::before { content: ''; display: inline-block; width: .45rem; height: .45rem; margin-right: .45rem; vertical-align: middle; background: var(--accent); border-radius: 50%; }
|
||||
.conversation-list-check { display: flex; align-items: start; padding-top: .3rem; }
|
||||
.conversation-list-check input { accent-color: var(--accent); }
|
||||
.conversation-list-open { display: grid; gap: .25rem; width: 100%; min-height: 0 !important; padding: 0 !important; text-align: left; background: transparent !important; border: 0 !important; }
|
||||
.conversation-list-open:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.conversation-list-snippet { 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-list-heading time { color: var(--muted); font-size: .7rem; white-space: nowrap; }
|
||||
.conversation-list-heading time.is-stale { color: var(--warning-text); font-weight: 700; }
|
||||
.conversation-list-heading time.is-critical { color: var(--danger-text); font-weight: 800; }
|
||||
.conversation-list-chips { grid-column: 2; display: flex; flex-wrap: wrap; align-items: center; gap: .3rem; }
|
||||
.conversation-list-profile { font-size: .7rem; }
|
||||
|
||||
.conversation-chip { padding: .1rem .4rem; border: 1px solid var(--border); border-radius: 999px; background: var(--surface-raised); color: var(--muted); font-size: .68rem; font-weight: 750; }
|
||||
.conversation-chip.is-unread { color: var(--accent); border-color: #28593d; background: var(--accent-dark); }
|
||||
.conversation-chip.is-needs-reply { color: #cfe8ff; border-color: #315d85; background: #18324d; }
|
||||
.conversation-chip.is-manual { color: #e7d8ff; border-color: #5a4285; background: #2c1f47; }
|
||||
.conversation-chip.is-overdue { color: var(--warning-text); border-color: var(--warning-border); background: var(--warning-bg); }
|
||||
.conversation-chip.is-snoozed, .conversation-chip.is-done, .conversation-chip.is-account-deleted { opacity: .85; }
|
||||
|
||||
.conversation-list-footer { display: flex; flex-wrap: wrap; justify-content: space-between; gap: .5rem; padding: .5rem .75rem .75rem; margin: 0; color: var(--muted); font-size: .72rem; }
|
||||
.conversation-shortcut-hint { margin-left: auto; opacity: .8; }
|
||||
.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-seller-marker { display: inline-flex; justify-self: start; padding: .18rem .45rem; margin-right: .35rem; color: #ffd6d0 !important; background: #4b1e1a; border: 1px solid #874139; border-radius: 999px; font-size: .68rem !important; font-weight: 800; }
|
||||
.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; }
|
||||
.conversation-detail > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; padding: 1rem; border-bottom: 1px solid var(--border); }
|
||||
/* Column layout: history takes the slack, the composer stays reachable. */
|
||||
.conversation-detail { display: flex; flex-direction: column; min-height: 32rem; max-height: min(48rem, calc(100vh - 6rem)); }
|
||||
.conversation-detail > header { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: .75rem; padding: 1rem; border-bottom: 1px solid var(--border); }
|
||||
.conversation-detail h2 { display: inline; margin-right: .6rem; }
|
||||
.conversation-detail-identity { min-width: 0; }
|
||||
.conversation-detail-badges { display: inline-flex; flex-wrap: wrap; align-items: center; gap: .35rem; }
|
||||
.conversation-detail-controls { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; }
|
||||
.conversation-triage-actions { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; }
|
||||
.conversation-triage-actions button, .conversation-detail-controls > button { min-height: 2.1rem !important; padding: .35rem .6rem !important; font-size: .8rem; }
|
||||
.conversation-snooze { position: relative; }
|
||||
.conversation-snooze-menu { position: absolute; right: 0; z-index: 3; display: grid; gap: .2rem; min-width: 9rem; padding: .35rem; margin-top: .25rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .55rem; box-shadow: 0 .5rem 1.5rem rgba(0, 0, 0, .45); }
|
||||
.conversation-snooze-menu button { width: 100%; text-align: left; }
|
||||
.conversation-badge { display: inline-flex; padding: .2rem .45rem; color: #aaf0c4; background: #163a26; border-radius: 999px; font-size: .7rem; font-weight: 800; }
|
||||
.conversation-badge.is-snoozed { color: var(--warning-text); background: var(--warning-bg); }
|
||||
.conversation-badge.is-done { color: var(--muted); background: var(--surface-raised); }
|
||||
.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 { display: grid; gap: .75rem; max-height: 30rem; padding: 1rem; margin: 0; overflow: auto; list-style: none; }
|
||||
.conversation-messages-shell { position: relative; display: flex; flex: 1 1 auto; min-height: 0; overflow: hidden; }
|
||||
.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; flex: 1 1 auto; align-content: start; gap: .75rem; min-height: 0; padding: 1rem; margin: 0; overflow-y: auto; list-style: none; }
|
||||
|
||||
/* Day dividers let the reader see silences; the unread rule marks where to resume. */
|
||||
.conversation-day-divider, .conversation-unread-divider { display: flex; align-items: center; gap: .6rem; justify-self: stretch; max-width: none !important; padding: 0 !important; background: none !important; }
|
||||
.conversation-day-divider::before, .conversation-day-divider::after, .conversation-unread-divider::before, .conversation-unread-divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||||
.conversation-day-divider span { color: var(--muted); font-size: .7rem; font-weight: 750; text-transform: uppercase; letter-spacing: .06em; }
|
||||
.conversation-unread-divider::before, .conversation-unread-divider::after { background: var(--accent); opacity: .6; }
|
||||
.conversation-unread-divider span { padding: .1rem .5rem; border-radius: 999px; background: var(--accent); color: #06140b; font-size: .68rem; font-weight: 800; text-transform: uppercase; letter-spacing: .06em; }
|
||||
.conversation-messages li { max-width: 85%; padding: .7rem .8rem; border-radius: .8rem; }
|
||||
.conversation-messages li.is-self { justify-self: end; background: var(--accent-dark); }
|
||||
.conversation-messages li.is-recipient { justify-self: start; background: var(--surface-raised); }
|
||||
@@ -60,19 +132,42 @@
|
||||
.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 a { display: block; border-radius: .6rem; }
|
||||
.conversation-message-images a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.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-message-image { position: relative; overflow: hidden; border-radius: .6rem; }
|
||||
.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; transition: filter 160ms ease, transform 160ms ease; }
|
||||
.conversation-message-images img.is-blurred { filter: blur(24px) brightness(.55); transform: scale(1.12); }
|
||||
.conversation-image-reveal { position: absolute; bottom: .5rem; left: .5rem; min-height: 2rem !important; padding: .35rem .6rem !important; color: white !important; background: rgba(0, 0, 0, .76) !important; border-color: rgba(255, 255, 255, .45) !important; font-size: .75rem; font-weight: 800; }
|
||||
.conversation-image-reveal:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.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-composer { flex: 0 0 auto; display: grid; gap: .65rem; padding: 1rem; overflow-y: auto; border-top: 1px solid var(--border); }
|
||||
.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-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-share { display: grid; gap: .7rem; padding: .75rem; background: #0b1410; border: 1px solid var(--border); border-radius: .65rem; }
|
||||
.conversation-media-share[open] > * + * { margin-top: .7rem; }
|
||||
.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-warning { margin: 0; padding: .5rem .65rem; border-radius: .5rem; border: 1px solid var(--warning-text); background: color-mix(in srgb, var(--warning-text) 12%, transparent); color: var(--warning-text); font-size: .78rem; 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); }
|
||||
.conversation-media-options label:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.conversation-media-options input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.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; 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; }
|
||||
@@ -81,5 +176,57 @@
|
||||
@media (min-width: 52rem) {
|
||||
.conversations-page { padding: 3rem 2rem 5rem; }
|
||||
.start-conversation-form { grid-template-columns: minmax(12rem, .5fr) minmax(18rem, 1fr) auto; align-items: end; }
|
||||
.conversation-workspace { grid-template-columns: minmax(17rem, .75fr) minmax(28rem, 1.5fr); }
|
||||
.conversation-workspace { grid-template-columns: minmax(21rem, .85fr) minmax(28rem, 1.5fr); }
|
||||
}
|
||||
.conversation-automation-status {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 0.875rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.625rem;
|
||||
background: rgba(54, 116, 181, 0.08);
|
||||
}
|
||||
|
||||
.conversation-automation-status span {
|
||||
color: var(--muted);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.conversation-automation-status.is-human-required,
|
||||
.conversation-automation-status.is-boundary,
|
||||
.conversation-automation-status.is-user-overridden {
|
||||
border-color: #d8943a;
|
||||
background: rgba(216, 148, 58, 0.12);
|
||||
}
|
||||
|
||||
.conversation-automation-status.is-image-received {
|
||||
border-color: #4d9b68;
|
||||
background: rgba(77, 155, 104, 0.12);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.conversation-automation-status {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.conversation-automation-status span {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
.conversation-ai-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 1.25rem;
|
||||
padding: 0.125rem 0.4rem;
|
||||
border: 1px solid rgba(99, 102, 241, 0.35);
|
||||
border-radius: 999px;
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
color: #a5b4fc;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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, areImagesRevealed, isPending, onCancel, onConfirm }: MediaShareConfirmationDialogProps) {
|
||||
const [isRevealed, setIsRevealed] = useState(areImagesRevealed);
|
||||
|
||||
return (
|
||||
<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 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">
|
||||
<details className="new-conversation-panel processed-user-lookup">
|
||||
<summary>
|
||||
<h2 id="processed-user-heading">Processed user lookup</h2>
|
||||
<form onSubmit={(event) => { event.preventDefault(); setUsername(input.trim().replace(/^u\//i, '')); }}>
|
||||
</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 id="processed-user" onChange={(event) => setInput(event.target.value)} value={input} />
|
||||
<button disabled={input.trim().length === 0} type="submit">Look up user</button>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
const MINUTE_MS = 60_000;
|
||||
const HOUR_MS = 60 * MINUTE_MS;
|
||||
const DAY_MS = 24 * HOUR_MS;
|
||||
const MONTH_MS = 30 * DAY_MS;
|
||||
const YEAR_MS = 365 * DAY_MS;
|
||||
|
||||
export function formatShortRelativeTime(timestamp: number, now: number): string {
|
||||
const elapsed = Math.max(0, now - timestamp);
|
||||
|
||||
if (elapsed < MINUTE_MS) return 'just now';
|
||||
if (elapsed < HOUR_MS) return `${Math.floor(elapsed / MINUTE_MS)}m ago`;
|
||||
if (elapsed < DAY_MS) return `${Math.floor(elapsed / HOUR_MS)}h ago`;
|
||||
if (elapsed < MONTH_MS) return `${Math.floor(elapsed / DAY_MS)}d ago`;
|
||||
if (elapsed < YEAR_MS) return `${Math.floor(elapsed / MONTH_MS)}mo ago`;
|
||||
return `${Math.floor(elapsed / YEAR_MS)}y ago`;
|
||||
}
|
||||
|
||||
export function formatExactTime(timestamp: number): string {
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp);
|
||||
}
|
||||
|
||||
/** Time of day only. Paired with a day divider so history is not full of dates. */
|
||||
export function formatTimeOfDay(timestamp: number): string {
|
||||
return new Intl.DateTimeFormat(undefined, { timeStyle: 'short' }).format(timestamp);
|
||||
}
|
||||
|
||||
export function isSameDay(left: number, right: number): boolean {
|
||||
const a = new Date(left);
|
||||
const b = new Date(right);
|
||||
return a.getFullYear() === b.getFullYear()
|
||||
&& a.getMonth() === b.getMonth()
|
||||
&& a.getDate() === b.getDate();
|
||||
}
|
||||
|
||||
/** "Today" / "Yesterday" / an explicit date, for message-history day dividers. */
|
||||
export function formatDayLabel(timestamp: number, now: number): string {
|
||||
if (isSameDay(timestamp, now)) return 'Today';
|
||||
if (isSameDay(timestamp, now - DAY_MS)) return 'Yesterday';
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'full' }).format(timestamp);
|
||||
}
|
||||
|
||||
export const STALE_REPLY_THRESHOLD_MS = 12 * HOUR_MS;
|
||||
export const CRITICAL_REPLY_THRESHOLD_MS = 48 * HOUR_MS;
|
||||
|
||||
export type ConversationAge = 'fresh' | 'stale' | 'critical';
|
||||
|
||||
/**
|
||||
* How overdue an unanswered conversation is. Only meaningful for conversations
|
||||
* waiting on us — "3d ago" on a conversation we already replied to is fine, but
|
||||
* on one awaiting a reply it is the thing the operator most needs to notice.
|
||||
*/
|
||||
export function conversationAge(timestamp: number, now: number): ConversationAge {
|
||||
const elapsed = Math.max(0, now - timestamp);
|
||||
if (elapsed >= CRITICAL_REPLY_THRESHOLD_MS) return 'critical';
|
||||
if (elapsed >= STALE_REPLY_THRESHOLD_MS) return 'stale';
|
||||
return 'fresh';
|
||||
}
|
||||
@@ -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">
|
||||
<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 disabled={isPending} onClick={onCancel} ref={cancelButtonRef} type="button">Cancel</button>
|
||||
<button className="conversation-primary" disabled={isPending} onClick={onConfirm} type="button">
|
||||
<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>
|
||||
</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="/accounts">Accounts</Link>.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { ZodType } from 'zod';
|
||||
import {
|
||||
ErrorResponseSchema,
|
||||
GrowthAccountResponseSchema,
|
||||
GrowthOverviewResponseSchema,
|
||||
type GrowthAccountDetail,
|
||||
type GrowthAccountSummary,
|
||||
type BrowsingSettingsUpdateRequest,
|
||||
type GrowthSettingsUpdateRequest,
|
||||
type GrowthSubredditCreateRequest,
|
||||
type GrowthSubredditUpdateRequest,
|
||||
} from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { dashboardRequest } from '../../api/request.js';
|
||||
|
||||
async function parseResponse<T>(response: Response, schema: ZodType<T>): Promise<T> {
|
||||
const payload: unknown = await response.json();
|
||||
if (!response.ok) {
|
||||
const parsed = ErrorResponseSchema.safeParse(payload);
|
||||
throw new Error(parsed.success ? parsed.data.error : `Request failed (${response.status})`);
|
||||
}
|
||||
return schema.parse(payload);
|
||||
}
|
||||
|
||||
/** Account IDs are Matrix-shaped (`@t2_x:reddit.com`), so they must be encoded. */
|
||||
function accountPath(accountId: string): string {
|
||||
return `/api/growth/${encodeURIComponent(accountId)}`;
|
||||
}
|
||||
|
||||
export function fetchGrowthOverview(): Promise<GrowthAccountSummary[]> {
|
||||
return dashboardRequest('/api/growth')
|
||||
.then((response) => parseResponse(response, GrowthOverviewResponseSchema))
|
||||
.then((payload) => payload.accounts);
|
||||
}
|
||||
|
||||
export function fetchGrowthAccount(accountId: string): Promise<GrowthAccountDetail> {
|
||||
return dashboardRequest(accountPath(accountId))
|
||||
.then((response) => parseResponse(response, GrowthAccountResponseSchema))
|
||||
.then((payload) => payload.account);
|
||||
}
|
||||
|
||||
export function updateGrowthSettings(
|
||||
accountId: string,
|
||||
update: GrowthSettingsUpdateRequest,
|
||||
): Promise<GrowthAccountDetail> {
|
||||
return dashboardRequest(accountPath(accountId), {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(update),
|
||||
})
|
||||
.then((response) => parseResponse(response, GrowthAccountResponseSchema))
|
||||
.then((payload) => payload.account);
|
||||
}
|
||||
|
||||
export function updateBrowsingSettings(
|
||||
accountId: string,
|
||||
update: BrowsingSettingsUpdateRequest,
|
||||
): Promise<GrowthAccountDetail> {
|
||||
return dashboardRequest(`${accountPath(accountId)}/browsing`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(update),
|
||||
})
|
||||
.then((response) => parseResponse(response, GrowthAccountResponseSchema))
|
||||
.then((payload) => payload.account);
|
||||
}
|
||||
|
||||
export function runBrowsingSimulation(accountId: string): Promise<GrowthAccountDetail> {
|
||||
return dashboardRequest(`${accountPath(accountId)}/browsing/run`, { method: 'POST' })
|
||||
.then((response) => parseResponse(response, GrowthAccountResponseSchema))
|
||||
.then((payload) => payload.account);
|
||||
}
|
||||
|
||||
export function pauseGrowth(accountId: string, reason?: string): Promise<GrowthAccountDetail> {
|
||||
return dashboardRequest(`${accountPath(accountId)}/pause`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reason ? { reason } : {}),
|
||||
})
|
||||
.then((response) => parseResponse(response, GrowthAccountResponseSchema))
|
||||
.then((payload) => payload.account);
|
||||
}
|
||||
|
||||
export function resumeGrowth(accountId: string): Promise<GrowthAccountDetail> {
|
||||
return dashboardRequest(`${accountPath(accountId)}/resume`, { method: 'POST' })
|
||||
.then((response) => parseResponse(response, GrowthAccountResponseSchema))
|
||||
.then((payload) => payload.account);
|
||||
}
|
||||
|
||||
export function addGrowthSubreddit(
|
||||
accountId: string,
|
||||
request: GrowthSubredditCreateRequest,
|
||||
): Promise<GrowthAccountDetail> {
|
||||
return dashboardRequest(`${accountPath(accountId)}/subreddits`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
.then((response) => parseResponse(response, GrowthAccountResponseSchema))
|
||||
.then((payload) => payload.account);
|
||||
}
|
||||
|
||||
export function updateGrowthSubreddit(
|
||||
accountId: string,
|
||||
subreddit: string,
|
||||
update: GrowthSubredditUpdateRequest,
|
||||
): Promise<GrowthAccountDetail> {
|
||||
return dashboardRequest(
|
||||
`${accountPath(accountId)}/subreddits/${encodeURIComponent(subreddit)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(update),
|
||||
},
|
||||
)
|
||||
.then((response) => parseResponse(response, GrowthAccountResponseSchema))
|
||||
.then((payload) => payload.account);
|
||||
}
|
||||
|
||||
export function removeGrowthSubreddit(
|
||||
accountId: string,
|
||||
subreddit: string,
|
||||
): Promise<GrowthAccountDetail> {
|
||||
return dashboardRequest(
|
||||
`${accountPath(accountId)}/subreddits/${encodeURIComponent(subreddit)}`,
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
.then((response) => parseResponse(response, GrowthAccountResponseSchema))
|
||||
.then((payload) => payload.account);
|
||||
}
|
||||
@@ -0,0 +1,770 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type {
|
||||
GrowthAccountDetail,
|
||||
GrowthAccountSummary,
|
||||
GrowthComment,
|
||||
GrowthSubreddit,
|
||||
BrowsingDecision,
|
||||
} from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { NumberField, SwitchField, TextField } from '../../components/form-fields.js';
|
||||
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
||||
import {
|
||||
useAddGrowthSubredditMutation,
|
||||
useGrowthAccountQuery,
|
||||
useGrowthOverviewQuery,
|
||||
usePauseGrowthMutation,
|
||||
useRemoveGrowthSubredditMutation,
|
||||
useResumeGrowthMutation,
|
||||
useRunBrowsingSimulationMutation,
|
||||
useUpdateGrowthSettingsMutation,
|
||||
useUpdateBrowsingSettingsMutation,
|
||||
useUpdateGrowthSubredditMutation,
|
||||
} from './growth-queries.js';
|
||||
import './growth.css';
|
||||
|
||||
function errorMessage(error: Error | null): string {
|
||||
return error?.message ?? 'Something went wrong.';
|
||||
}
|
||||
|
||||
function minutes(ms: number): string {
|
||||
if (ms < 60_000) return `${Math.round(ms / 1_000)}s`;
|
||||
const mins = Math.round(ms / 60_000);
|
||||
if (mins < 60) return `${mins} min`;
|
||||
const hours = (mins / 60).toFixed(mins % 60 === 0 ? 0 : 1);
|
||||
return `${hours}h`;
|
||||
}
|
||||
|
||||
function statusPill(comment: GrowthComment) {
|
||||
if (comment.removed) return <span className="pill critical">Removed</span>;
|
||||
if (comment.shadowBanned) return <span className="pill critical">Not public</span>;
|
||||
switch (comment.status) {
|
||||
case 'posted':
|
||||
case 'verified':
|
||||
return <span className="pill success">Live</span>;
|
||||
case 'rejected':
|
||||
return <span className="pill neutral">Discarded</span>;
|
||||
case 'retryable':
|
||||
return <span className="pill warning">Retrying</span>;
|
||||
default:
|
||||
return <span className="pill neutral">Queued</span>;
|
||||
}
|
||||
}
|
||||
|
||||
function AccountRow({
|
||||
account,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
account: GrowthAccountSummary;
|
||||
isSelected: boolean;
|
||||
onSelect(): void;
|
||||
}) {
|
||||
const { settings, stats } = account;
|
||||
return (
|
||||
<button
|
||||
className={isSelected ? 'growth-account-row is-selected' : 'growth-account-row'}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
<div>
|
||||
<strong>u/{account.redditUsername}</strong>
|
||||
<span>
|
||||
{settings.enabled
|
||||
? `${stats.postedLast24h}/${settings.dailyAllowance} comments today · ${account.activeSubredditCount} active communities`
|
||||
: 'Growth is off'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="growth-account-pills">
|
||||
{settings.pausedReason ? <span className="pill critical">Paused</span> : null}
|
||||
{settings.enabled && !settings.pausedReason
|
||||
? <span className="pill success">Growing</span>
|
||||
: null}
|
||||
{!settings.enabled ? <span className="pill neutral">Off</span> : null}
|
||||
<span className="pill neutral">Level {settings.trustLevel}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SubredditRow({
|
||||
accountId,
|
||||
subreddit,
|
||||
dailyAllowance,
|
||||
}: {
|
||||
accountId: string;
|
||||
subreddit: GrowthSubreddit;
|
||||
/** The account-wide ceiling, which binds before any per-community cap. */
|
||||
dailyAllowance: number;
|
||||
}) {
|
||||
const update = useUpdateGrowthSubredditMutation(accountId);
|
||||
const remove = useRemoveGrowthSubredditMutation(accountId);
|
||||
const [cap, setCap] = useState(String(subreddit.dailyCap));
|
||||
|
||||
useEffect(() => setCap(String(subreddit.dailyCap)), [subreddit.dailyCap]);
|
||||
|
||||
const coolingDown = subreddit.cooldownUntil !== null && subreddit.cooldownUntil > Date.now();
|
||||
|
||||
return (
|
||||
<div className="growth-subreddit-row">
|
||||
<div className="growth-subreddit-name">
|
||||
<strong>r/{subreddit.subreddit}</strong>
|
||||
<span>{subreddit.postedLast24h} of {subreddit.dailyCap} in the last 24h</span>
|
||||
</div>
|
||||
|
||||
{subreddit.dailyCap > dailyAllowance ? (
|
||||
<p className="growth-subreddit-note">
|
||||
Only {dailyAllowance} of these can actually be used today — the account allowance is the
|
||||
overall limit, and this cap only stops one community taking all of it.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{subreddit.disabledReason ? (
|
||||
<p className="growth-subreddit-note">
|
||||
Reddit refused this account here: {subreddit.disabledReason}
|
||||
</p>
|
||||
) : null}
|
||||
{coolingDown && !subreddit.disabledReason ? (
|
||||
<p className="growth-subreddit-note">
|
||||
Cooling down until {new Date(subreddit.cooldownUntil as number).toLocaleTimeString()}.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="growth-subreddit-controls">
|
||||
<label className="growth-inline-field">
|
||||
<span>Daily cap</span>
|
||||
<input
|
||||
aria-label={`Daily cap for r/${subreddit.subreddit}`}
|
||||
max={50}
|
||||
min={1}
|
||||
onBlur={() => {
|
||||
const parsed = Number.parseInt(cap, 10);
|
||||
if (Number.isInteger(parsed) && parsed !== subreddit.dailyCap) {
|
||||
update.mutate({ subreddit: subreddit.subreddit, update: { dailyCap: parsed } });
|
||||
}
|
||||
}}
|
||||
onChange={(event) => setCap(event.target.value)}
|
||||
type="number"
|
||||
value={cap}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="button secondary"
|
||||
disabled={update.isPending}
|
||||
onClick={() => update.mutate({
|
||||
subreddit: subreddit.subreddit,
|
||||
update: { enabled: !subreddit.enabled },
|
||||
})}
|
||||
type="button"
|
||||
>
|
||||
{subreddit.enabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<button
|
||||
className="button secondary"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => remove.mutate(subreddit.subreddit)}
|
||||
type="button"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
{update.isError ? <p className="save-error" role="alert">{errorMessage(update.error)}</p> : null}
|
||||
{remove.isError ? <p className="save-error" role="alert">{errorMessage(remove.error)}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BrowsingSimulationSection({ account }: { account: GrowthAccountDetail }) {
|
||||
const update = useUpdateBrowsingSettingsMutation(account.accountId);
|
||||
const runNow = useRunBrowsingSimulationMutation(account.accountId);
|
||||
const { settings, stats, todayTarget, recentDecisions } = account.browsing;
|
||||
const selected = recentDecisions.filter((decision) => decision.decision === 'simulated-upvote');
|
||||
const [dailyMinimum, setDailyMinimum] = useState(String(settings.dailyTargetMin));
|
||||
const [dailyMaximum, setDailyMaximum] = useState(String(settings.dailyTargetMax));
|
||||
const [gapMinutes, setGapMinutes] = useState(String(settings.minimumGapMs / 60_000));
|
||||
const [selectionsPerSession, setSelectionsPerSession] = useState(String(settings.selectionsPerSession));
|
||||
|
||||
useEffect(() => setDailyMinimum(String(settings.dailyTargetMin)), [settings.dailyTargetMin]);
|
||||
useEffect(() => setDailyMaximum(String(settings.dailyTargetMax)), [settings.dailyTargetMax]);
|
||||
useEffect(() => setGapMinutes(String(settings.minimumGapMs / 60_000)), [settings.minimumGapMs]);
|
||||
useEffect(() => setSelectionsPerSession(String(settings.selectionsPerSession)), [settings.selectionsPerSession]);
|
||||
|
||||
const parsedDailyMinimum = Number.parseInt(dailyMinimum, 10);
|
||||
const parsedDailyMaximum = Number.parseInt(dailyMaximum, 10);
|
||||
const dailyRangeValid = Number.isInteger(parsedDailyMinimum)
|
||||
&& Number.isInteger(parsedDailyMaximum)
|
||||
&& parsedDailyMinimum >= 1
|
||||
&& parsedDailyMaximum >= 1
|
||||
&& parsedDailyMinimum <= parsedDailyMaximum;
|
||||
const dailyRangeError = dailyMinimum !== '' && dailyMaximum !== '' && !dailyRangeValid
|
||||
? 'Use positive whole numbers, with the minimum no greater than the maximum.'
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<section aria-labelledby="browsing-simulation-heading" className="panel settings-card">
|
||||
<div className="settings-card-heading">
|
||||
<div>
|
||||
<span className="eyebrow">Conservative account activity</span>
|
||||
<h2 id="browsing-simulation-heading">Browsing sessions</h2>
|
||||
<p>Selects conservatively; automatic upvotes require their own explicit switch.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-card-body">
|
||||
<div className="state-banner is-warning" role="status">
|
||||
<strong>{account.browsing.noVotesSubmitted ? 'No Reddit votes will be submitted.' : 'Automatic upvotes are enabled.'}</strong>
|
||||
<span>{account.browsing.noVotesSubmitted
|
||||
? 'Selections are recorded only in RedditBot.'
|
||||
: 'Votes run only in running mode with browser dry-run disabled.'}</span>
|
||||
</div>
|
||||
<SwitchField
|
||||
checked={settings.enabled}
|
||||
description={`Selects ${settings.dailyTargetMin}–${settings.dailyTargetMax} eligible posts or comments during waking hours.`}
|
||||
label="Run browsing sessions"
|
||||
onChange={(enabled) => update.mutate({ enabled })}
|
||||
/>
|
||||
<SwitchField
|
||||
checked={settings.autoVoteEnabled}
|
||||
description="Submit the selected upvote automatically. Scan mode and browser dry-run always remain simulation-only."
|
||||
label="Submit selected upvotes"
|
||||
onChange={(autoVoteEnabled) => update.mutate({ autoVoteEnabled })}
|
||||
/>
|
||||
<div className="growth-pacing">
|
||||
<NumberField
|
||||
error={dailyRangeError}
|
||||
hint="The system chooses one stable target inside this range for each account and local day."
|
||||
label="Daily minimum"
|
||||
min={1}
|
||||
name="browsing-daily-minimum"
|
||||
onChange={setDailyMinimum}
|
||||
suffix="/day"
|
||||
value={dailyMinimum}
|
||||
/>
|
||||
<NumberField
|
||||
error={dailyRangeError}
|
||||
label="Daily maximum"
|
||||
min={1}
|
||||
name="browsing-daily-maximum"
|
||||
onChange={setDailyMaximum}
|
||||
suffix="/day"
|
||||
value={dailyMaximum}
|
||||
/>
|
||||
<button
|
||||
className="button secondary"
|
||||
disabled={update.isPending || !dailyRangeValid}
|
||||
onClick={() => update.mutate({
|
||||
dailyTargetMin: parsedDailyMinimum,
|
||||
dailyTargetMax: parsedDailyMaximum,
|
||||
})}
|
||||
type="button"
|
||||
>
|
||||
Save daily range
|
||||
</button>
|
||||
</div>
|
||||
<div className="growth-pacing">
|
||||
<NumberField
|
||||
hint={`Shortest gap between sessions. The system adds a random 0–${minutes(settings.jitterMs)} on top.`}
|
||||
label="Minimum session gap"
|
||||
min={45}
|
||||
max={1_440}
|
||||
name="browsing-minimum-gap"
|
||||
onChange={setGapMinutes}
|
||||
suffix="min"
|
||||
value={gapMinutes}
|
||||
/>
|
||||
<button
|
||||
className="button secondary"
|
||||
disabled={update.isPending}
|
||||
onClick={() => {
|
||||
const parsed = Number.parseInt(gapMinutes, 10);
|
||||
if (Number.isInteger(parsed) && parsed >= 45 && parsed <= 1_440) {
|
||||
update.mutate({ minimumGapMs: parsed * 60_000 });
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Save session gap
|
||||
</button>
|
||||
</div>
|
||||
<div className="growth-pacing">
|
||||
<NumberField
|
||||
hint="Maximum eligible posts or comments selected by each scheduled or manual session."
|
||||
label="Selections per session"
|
||||
min={1}
|
||||
name="browsing-selections-per-session"
|
||||
onChange={setSelectionsPerSession}
|
||||
suffix="/session"
|
||||
value={selectionsPerSession}
|
||||
/>
|
||||
<button
|
||||
className="button secondary"
|
||||
disabled={update.isPending}
|
||||
onClick={() => {
|
||||
const parsed = Number.parseInt(selectionsPerSession, 10);
|
||||
if (Number.isSafeInteger(parsed) && parsed >= 1) {
|
||||
update.mutate({ selectionsPerSession: parsed });
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Save session size
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="button secondary"
|
||||
disabled={!settings.enabled || runNow.isPending}
|
||||
onClick={() => runNow.mutate()}
|
||||
type="button"
|
||||
>
|
||||
{runNow.isPending ? 'Running session…' : 'Run session now'}
|
||||
</button>
|
||||
{!settings.enabled ? <p className="field-hint">Enable browsing sessions to run one now.</p> : null}
|
||||
<dl className="growth-metrics">
|
||||
<div><dt>Selected today</dt><dd>{stats.selected} / {todayTarget}</dd></div>
|
||||
<div><dt>Posts</dt><dd>{stats.posts}</dd></div>
|
||||
<div><dt>Comments</dt><dd>{stats.comments}</dd></div>
|
||||
<div><dt>Rejected</dt><dd>{stats.rejected}</dd></div>
|
||||
<div><dt>Skipped</dt><dd>{stats.skipped}</dd></div>
|
||||
<div>
|
||||
<dt>Next session</dt>
|
||||
<dd>{settings.nextDueAt === null ? 'Not scheduled' : new Date(settings.nextDueAt).toLocaleString()}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{update.isError ? <p className="save-error" role="alert">{errorMessage(update.error)}</p> : null}
|
||||
{runNow.isError ? <p className="save-error" role="alert">{errorMessage(runNow.error)}</p> : null}
|
||||
{selected.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<strong>No browsing selections yet</strong>
|
||||
<span>The first session is durably scheduled after this feature is enabled.</span>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="growth-comment-list">
|
||||
{selected.map((decision: BrowsingDecision) => (
|
||||
<li className="growth-comment" key={`${decision.thingFullname}-${decision.decidedAt}`}>
|
||||
<div className="growth-comment-head">
|
||||
<a href={`https://www.reddit.com${decision.permalink}`} rel="noreferrer noopener" target="_blank">
|
||||
r/{decision.subreddit} — {decision.thingKind} by u/{decision.author}
|
||||
</a>
|
||||
<span className={`pill ${decision.voteStatus === 'submitted' ? 'success' : decision.voteStatus === 'failed' ? 'danger' : 'neutral'}`}>
|
||||
{decision.voteStatus === 'pending' ? 'Pending upvote'
|
||||
: decision.voteStatus === 'submitted' ? 'Upvote submitted'
|
||||
: decision.voteStatus === 'failed' ? 'Upvote failed'
|
||||
: 'Simulated selection'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="field-hint">{new Date(decision.decidedAt).toLocaleString()}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountDetail({ account }: { account: GrowthAccountDetail }) {
|
||||
const { accountId, settings, stats } = account;
|
||||
const updateSettings = useUpdateGrowthSettingsMutation(accountId);
|
||||
const pause = usePauseGrowthMutation(accountId);
|
||||
const resume = useResumeGrowthMutation(accountId);
|
||||
const addSubreddit = useAddGrowthSubredditMutation(accountId);
|
||||
const [newSubreddit, setNewSubreddit] = useState('');
|
||||
const [delayMinutes, setDelayMinutes] = useState(String(Math.round(settings.minDelayMs / 60_000)));
|
||||
const [dailyLimit, setDailyLimit] = useState(settings.dailyLimit === null ? '' : String(settings.dailyLimit));
|
||||
|
||||
useEffect(
|
||||
() => setDelayMinutes(String(Math.round(settings.minDelayMs / 60_000))),
|
||||
[settings.minDelayMs],
|
||||
);
|
||||
useEffect(
|
||||
() => setDailyLimit(settings.dailyLimit === null ? '' : String(settings.dailyLimit)),
|
||||
[settings.dailyLimit],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="growth-detail">
|
||||
<section aria-labelledby="growth-state-heading" className="panel settings-card">
|
||||
<div className="settings-card-heading">
|
||||
<div>
|
||||
<span className="eyebrow">Commenting as</span>
|
||||
<h2 id="growth-state-heading"><RedditUsernameLink username={account.redditUsername} /></h2>
|
||||
<p>
|
||||
Comments on active threads in the communities below to build this account's history,
|
||||
so Reddit stops treating it as new.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{settings.pausedReason ? (
|
||||
<div className="state-banner is-error" role="alert">
|
||||
<strong>Growth is paused for this account.</strong>
|
||||
<span>{settings.pausedReason}</span>
|
||||
<span>
|
||||
Nothing will be posted until you resume it. Check the comment history below before
|
||||
you do — a removal or a filtered comment usually means this account needs a rest,
|
||||
not another attempt.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="settings-card-body">
|
||||
<SwitchField
|
||||
checked={settings.enabled}
|
||||
description="When on, this account posts a small number of AI-written comments per day, without waiting for your approval."
|
||||
label="Grow this account"
|
||||
onChange={(enabled) => updateSettings.mutate({ enabled })}
|
||||
tone="critical"
|
||||
/>
|
||||
|
||||
<dl className="growth-metrics">
|
||||
<div>
|
||||
<dt>Used in last 24h</dt>
|
||||
<dd>{stats.postedLast24h} / {settings.dailyAllowance}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Daily limit</dt>
|
||||
<dd>
|
||||
{settings.dailyLimit === null
|
||||
? `Automatic — ${settings.adaptiveAllowance}/day`
|
||||
: `Set to ${settings.dailyLimit}/day`}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Trust level</dt>
|
||||
<dd>{settings.trustLevel} of 4 → {settings.adaptiveAllowance}/day earned</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Comments live</dt>
|
||||
<dd>{stats.postedTotal - stats.removedTotal}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Removed</dt>
|
||||
<dd>{stats.removedTotal}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Median score</dt>
|
||||
<dd>{stats.medianScore === null ? '—' : stats.medianScore}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Queued</dt>
|
||||
<dd>{stats.queued}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p className="field-hint">
|
||||
Left automatic, the allowance follows the trust level: level 0 permits 2 comments a day,
|
||||
then 3, 5, 8 and 12, rising while comments survive and earn upvotes and falling back the
|
||||
moment one is removed or heavily downvoted. Set a daily limit to override that. Either
|
||||
way it is a rolling 24-hour window rather than a midnight reset, so capacity frees up 24
|
||||
hours after each comment, and two removals in a row or a comment the public cannot see
|
||||
pauses the account regardless.
|
||||
</p>
|
||||
|
||||
<div className="growth-pacing">
|
||||
<NumberField
|
||||
hint={settings.dailyLimit === null
|
||||
? `Leave blank to let the allowance follow the trust level, currently ${settings.adaptiveAllowance}/day.`
|
||||
: `Overriding the earned allowance of ${settings.adaptiveAllowance}/day. Clear the field to go back to automatic.`}
|
||||
label="Daily limit"
|
||||
min={1}
|
||||
max={50}
|
||||
name="growth-daily-limit"
|
||||
onChange={setDailyLimit}
|
||||
suffix="/day"
|
||||
value={dailyLimit}
|
||||
/>
|
||||
<button
|
||||
className="button secondary"
|
||||
disabled={updateSettings.isPending}
|
||||
onClick={() => {
|
||||
const raw = dailyLimit.trim();
|
||||
if (raw === '') {
|
||||
updateSettings.mutate({ dailyLimit: null });
|
||||
return;
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (Number.isInteger(parsed) && parsed >= 1 && parsed <= 50) {
|
||||
updateSettings.mutate({ dailyLimit: parsed });
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Save limit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{settings.dailyLimit !== null && settings.dailyLimit > settings.adaptiveAllowance ? (
|
||||
<p className="field-hint">
|
||||
This is above the {settings.adaptiveAllowance}/day this account has earned, so the
|
||||
adaptive backoff can no longer slow it down after a bad comment. The pause on two
|
||||
removals in a row, or a comment the public cannot see, still applies.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="growth-pacing">
|
||||
<NumberField
|
||||
hint="Shortest gap between two comments. A random extra delay is always added on top."
|
||||
label="Minimum gap"
|
||||
min={0}
|
||||
name="growth-min-delay"
|
||||
onChange={setDelayMinutes}
|
||||
suffix="min"
|
||||
value={delayMinutes}
|
||||
/>
|
||||
<button
|
||||
className="button secondary"
|
||||
disabled={updateSettings.isPending}
|
||||
onClick={() => {
|
||||
const parsed = Number.parseInt(delayMinutes, 10);
|
||||
if (Number.isInteger(parsed) && parsed >= 0) {
|
||||
updateSettings.mutate({ minDelayMs: parsed * 60_000 });
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Save gap
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SwitchField
|
||||
checked={settings.quietHours.enabled}
|
||||
description={`No comments between ${settings.quietHours.startHour}:00 and ${settings.quietHours.endHour}:00 local time.`}
|
||||
label="Quiet hours"
|
||||
onChange={(enabled) => updateSettings.mutate({
|
||||
quietHours: { ...settings.quietHours, enabled },
|
||||
})}
|
||||
/>
|
||||
<SwitchField
|
||||
checked={settings.allowNsfw}
|
||||
description="Off by default. NSFW threads are skipped entirely."
|
||||
label="Comment on NSFW threads"
|
||||
onChange={(allowNsfw) => updateSettings.mutate({ allowNsfw })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<footer className="settings-card-footer">
|
||||
<div aria-live="polite" className="settings-card-status">
|
||||
{updateSettings.isError
|
||||
? <strong className="save-error" role="alert">{errorMessage(updateSettings.error)}</strong>
|
||||
: null}
|
||||
<span className="field-hint">Minimum gap now {minutes(settings.minDelayMs)}.</span>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
{settings.pausedReason ? (
|
||||
<button
|
||||
className="button primary"
|
||||
disabled={resume.isPending}
|
||||
onClick={() => resume.mutate()}
|
||||
type="button"
|
||||
>
|
||||
{resume.isPending ? 'Resuming…' : 'Resume growth'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="button secondary"
|
||||
disabled={pause.isPending}
|
||||
onClick={() => pause.mutate(undefined)}
|
||||
type="button"
|
||||
>
|
||||
{pause.isPending ? 'Pausing…' : 'Pause growth'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="growth-communities-heading" className="panel settings-card">
|
||||
<div className="settings-card-heading">
|
||||
<div>
|
||||
<span className="eyebrow">Where it comments</span>
|
||||
<h2 id="growth-communities-heading">Communities</h2>
|
||||
<p>Each community has its own daily cap so this account never floods one place.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-card-body">
|
||||
{account.subreddits.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<strong>No communities yet</strong>
|
||||
<span>Add one below. Nothing is posted until at least one is on the list.</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="growth-subreddit-list">
|
||||
{account.subreddits.map((subreddit) => (
|
||||
<SubredditRow
|
||||
accountId={accountId}
|
||||
dailyAllowance={settings.dailyAllowance}
|
||||
key={subreddit.subreddit}
|
||||
subreddit={subreddit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
className="growth-add-subreddit"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const name = newSubreddit.trim();
|
||||
if (!name) return;
|
||||
addSubreddit.mutate({ subreddit: name }, { onSuccess: () => setNewSubreddit('') });
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label="Add a community"
|
||||
name="growth-new-subreddit"
|
||||
onChange={setNewSubreddit}
|
||||
placeholder="AskUK"
|
||||
prefix="r/"
|
||||
value={newSubreddit}
|
||||
/>
|
||||
<button className="button primary" disabled={addSubreddit.isPending} type="submit">
|
||||
{addSubreddit.isPending ? 'Adding…' : 'Add community'}
|
||||
</button>
|
||||
</form>
|
||||
{addSubreddit.isError
|
||||
? <p className="save-error" role="alert">{errorMessage(addSubreddit.error)}</p>
|
||||
: null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<BrowsingSimulationSection account={account} />
|
||||
|
||||
<section aria-labelledby="growth-history-heading" className="panel settings-card">
|
||||
<div className="settings-card-heading">
|
||||
<div>
|
||||
<span className="eyebrow">Audit trail</span>
|
||||
<h2 id="growth-history-heading">Recent comments</h2>
|
||||
<p>
|
||||
Everything this account posted, and everything it decided not to post. Comments go
|
||||
live without review, so this is the record of what was actually said.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-card-body">
|
||||
{account.recentComments.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<strong>Nothing yet</strong>
|
||||
<span>Comments will appear here as they are prepared and posted.</span>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="growth-comment-list">
|
||||
{account.recentComments.map((comment) => (
|
||||
<li className="growth-comment" key={`${comment.postId}-${comment.discoveredAt}`}>
|
||||
<div className="growth-comment-head">
|
||||
<a href={comment.threadUrl} rel="noreferrer noopener" target="_blank">
|
||||
r/{comment.subreddit} — {comment.threadTitle}
|
||||
</a>
|
||||
<div className="growth-comment-pills">
|
||||
{typeof comment.score === 'number'
|
||||
? <span className="pill neutral">{comment.score} points</span>
|
||||
: null}
|
||||
{statusPill(comment)}
|
||||
</div>
|
||||
</div>
|
||||
{comment.comment ? <p className="growth-comment-body">{comment.comment}</p> : null}
|
||||
{comment.lastError ? <p className="field-hint">{comment.lastError}</p> : null}
|
||||
{comment.commentPermalink ? (
|
||||
<a
|
||||
className="growth-comment-link"
|
||||
href={`https://www.reddit.com${comment.commentPermalink}`}
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
View on Reddit
|
||||
</a>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GrowthPage() {
|
||||
const overview = useGrowthOverviewQuery();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const accounts = overview.data ?? [];
|
||||
const activeId = selectedId ?? accounts[0]?.accountId ?? null;
|
||||
const detail = useGrowthAccountQuery(activeId);
|
||||
|
||||
const pausedAccounts = accounts.filter((account) => account.settings.pausedReason);
|
||||
|
||||
return (
|
||||
<div className="page growth-page">
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">Account health</span>
|
||||
<h1>Account growth</h1>
|
||||
<p>
|
||||
Builds a comment history for a new account so Reddit lets it start chats. Comments are
|
||||
written by AI and posted without review, so keep the volume low and check the audit
|
||||
trail.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{pausedAccounts.length > 0 ? (
|
||||
<div className="state-banner is-warning" role="status">
|
||||
<strong>
|
||||
{pausedAccounts.length === 1
|
||||
? `Growth is paused for u/${pausedAccounts[0].redditUsername}.`
|
||||
: `Growth is paused for ${pausedAccounts.length} accounts.`}
|
||||
</strong>
|
||||
<span>
|
||||
Reddit removed or filtered recent comments. Review the history before resuming.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{overview.isPending ? (
|
||||
<div aria-label="Loading growth" className="page-state" role="status">
|
||||
<span className="spinner" />Loading account growth…
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{overview.isError ? (
|
||||
<div className="page-state error-state" role="alert">
|
||||
<strong>Could not load account growth</strong>
|
||||
<span>{errorMessage(overview.error)}</span>
|
||||
<button className="button secondary" onClick={() => void overview.refetch()} type="button">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{overview.isSuccess && accounts.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<strong>No Reddit accounts yet</strong>
|
||||
<span>Add an account on the Accounts page before setting up growth.</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{accounts.length > 0 ? (
|
||||
<div className="growth-layout">
|
||||
<nav aria-label="Accounts" className="growth-account-list">
|
||||
{accounts.map((account) => (
|
||||
<AccountRow
|
||||
account={account}
|
||||
isSelected={account.accountId === activeId}
|
||||
key={account.accountId}
|
||||
onSelect={() => setSelectedId(account.accountId)}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{detail.isPending ? (
|
||||
<div className="page-state" role="status"><span className="spinner" />Loading account…</div>
|
||||
) : null}
|
||||
{detail.isError ? (
|
||||
<div className="page-state error-state" role="alert">
|
||||
<strong>Could not load this account</strong>
|
||||
<span>{errorMessage(detail.error)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{detail.data ? <AccountDetail account={detail.data} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type {
|
||||
GrowthAccountDetail,
|
||||
BrowsingSettingsUpdateRequest,
|
||||
GrowthSettingsUpdateRequest,
|
||||
GrowthSubredditCreateRequest,
|
||||
GrowthSubredditUpdateRequest,
|
||||
} from '../../../../../packages/api-contracts/src/index.js';
|
||||
import {
|
||||
addGrowthSubreddit,
|
||||
fetchGrowthAccount,
|
||||
fetchGrowthOverview,
|
||||
pauseGrowth,
|
||||
removeGrowthSubreddit,
|
||||
resumeGrowth,
|
||||
runBrowsingSimulation,
|
||||
updateGrowthSettings,
|
||||
updateBrowsingSettings,
|
||||
updateGrowthSubreddit,
|
||||
} from './growth-api.js';
|
||||
|
||||
export const growthOverviewQueryKey = ['dashboard', 'growth'] as const;
|
||||
export const growthAccountQueryKey = (accountId: string) =>
|
||||
['dashboard', 'growth', accountId] as const;
|
||||
|
||||
export function useGrowthOverviewQuery() {
|
||||
return useQuery({
|
||||
queryKey: growthOverviewQueryKey,
|
||||
queryFn: fetchGrowthOverview,
|
||||
// Growth moves on the order of hours, so a slow refresh is plenty.
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGrowthAccountQuery(accountId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: growthAccountQueryKey(accountId ?? ''),
|
||||
queryFn: () => fetchGrowthAccount(accountId as string),
|
||||
enabled: accountId !== null,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Every mutation returns the whole updated account, so the detail cache is
|
||||
* written directly and the overview is invalidated. HTTP stays authoritative.
|
||||
*/
|
||||
function useGrowthMutation<TVariables>(
|
||||
accountId: string,
|
||||
mutationFn: (variables: TVariables) => Promise<GrowthAccountDetail>,
|
||||
) {
|
||||
const client = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn,
|
||||
onSuccess: (account) => {
|
||||
client.setQueryData(growthAccountQueryKey(accountId), account);
|
||||
void client.invalidateQueries({ queryKey: growthOverviewQueryKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateGrowthSettingsMutation(accountId: string) {
|
||||
return useGrowthMutation(
|
||||
accountId,
|
||||
(update: GrowthSettingsUpdateRequest) => updateGrowthSettings(accountId, update),
|
||||
);
|
||||
}
|
||||
|
||||
export function useUpdateBrowsingSettingsMutation(accountId: string) {
|
||||
return useGrowthMutation(
|
||||
accountId,
|
||||
(update: BrowsingSettingsUpdateRequest) => updateBrowsingSettings(accountId, update),
|
||||
);
|
||||
}
|
||||
|
||||
export function useRunBrowsingSimulationMutation(accountId: string) {
|
||||
return useGrowthMutation<void>(accountId, () => runBrowsingSimulation(accountId));
|
||||
}
|
||||
|
||||
export function usePauseGrowthMutation(accountId: string) {
|
||||
return useGrowthMutation(accountId, (reason?: string) => pauseGrowth(accountId, reason));
|
||||
}
|
||||
|
||||
export function useResumeGrowthMutation(accountId: string) {
|
||||
return useGrowthMutation<void>(accountId, () => resumeGrowth(accountId));
|
||||
}
|
||||
|
||||
export function useAddGrowthSubredditMutation(accountId: string) {
|
||||
return useGrowthMutation(
|
||||
accountId,
|
||||
(request: GrowthSubredditCreateRequest) => addGrowthSubreddit(accountId, request),
|
||||
);
|
||||
}
|
||||
|
||||
export function useUpdateGrowthSubredditMutation(accountId: string) {
|
||||
return useGrowthMutation(
|
||||
accountId,
|
||||
({ subreddit, update }: { subreddit: string; update: GrowthSubredditUpdateRequest }) =>
|
||||
updateGrowthSubreddit(accountId, subreddit, update),
|
||||
);
|
||||
}
|
||||
|
||||
export function useRemoveGrowthSubredditMutation(accountId: string) {
|
||||
return useGrowthMutation(
|
||||
accountId,
|
||||
(subreddit: string) => removeGrowthSubreddit(accountId, subreddit),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
.growth-page { max-width: 64rem; }
|
||||
.growth-page .state-banner { margin-bottom: 1rem; }
|
||||
|
||||
.growth-layout { display: grid; gap: 1rem; }
|
||||
|
||||
/* Account switcher: a short list, so it stays inline rather than in a sidebar. */
|
||||
.growth-account-list { display: grid; gap: .5rem; }
|
||||
@media (min-width: 45rem) {
|
||||
.growth-account-list { grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); }
|
||||
}
|
||||
.growth-account-row {
|
||||
display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .75rem;
|
||||
padding: .8rem; text-align: left; cursor: pointer;
|
||||
background: var(--surface-raised); border: 1px solid var(--border); border-radius: .75rem;
|
||||
color: inherit; font: inherit;
|
||||
}
|
||||
.growth-account-row:hover { border-color: var(--accent); }
|
||||
.growth-account-row.is-selected { border-color: var(--accent); background: var(--surface-selected, var(--surface-raised)); }
|
||||
.growth-account-row > div { display: grid; gap: .2rem; min-width: 0; }
|
||||
.growth-account-row span { color: var(--muted); font-size: .82rem; }
|
||||
.growth-account-row .pill { font-size: .7rem; }
|
||||
.growth-account-pills { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; }
|
||||
|
||||
.growth-detail { display: grid; gap: 1rem; }
|
||||
|
||||
.growth-metrics { display: flex; flex-wrap: wrap; gap: .35rem 1.5rem; margin: 0; }
|
||||
.growth-metrics > div { display: grid; gap: .1rem; }
|
||||
.growth-metrics dt { color: var(--muted); font-size: .7rem; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.growth-metrics dd { margin: 0; font-size: .95rem; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.growth-pacing { display: flex; flex-wrap: wrap; align-items: flex-end; gap: .75rem; }
|
||||
.growth-pacing > :first-child { flex: 1 1 12rem; }
|
||||
|
||||
.growth-subreddit-list { display: grid; gap: .6rem; }
|
||||
.growth-subreddit-row {
|
||||
display: grid; gap: .5rem; padding: .75rem;
|
||||
background: var(--surface-raised); border: 1px solid var(--border); border-radius: .75rem;
|
||||
}
|
||||
.growth-subreddit-name { display: flex; flex-wrap: wrap; align-items: baseline; gap: .6rem; }
|
||||
.growth-subreddit-name span { color: var(--muted); font-size: .8rem; font-variant-numeric: tabular-nums; }
|
||||
.growth-subreddit-note { margin: 0; color: var(--muted); font-size: .8rem; }
|
||||
.growth-subreddit-controls { display: flex; flex-wrap: wrap; align-items: flex-end; gap: .5rem; }
|
||||
.growth-inline-field { display: grid; gap: .2rem; }
|
||||
.growth-inline-field span { color: var(--muted); font-size: .7rem; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.growth-inline-field input { width: 5rem; }
|
||||
|
||||
.growth-add-subreddit { display: flex; flex-wrap: wrap; align-items: flex-end; gap: .75rem; }
|
||||
.growth-add-subreddit > :first-child { flex: 1 1 14rem; }
|
||||
|
||||
.growth-comment-list { display: grid; gap: .6rem; margin: 0; padding: 0; list-style: none; }
|
||||
.growth-comment {
|
||||
display: grid; gap: .4rem; padding: .75rem;
|
||||
background: var(--surface-raised); border: 1px solid var(--border); border-radius: .75rem;
|
||||
}
|
||||
.growth-comment-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: .6rem; }
|
||||
.growth-comment-head a { font-size: .85rem; font-weight: 600; }
|
||||
.growth-comment-pills { display: flex; flex-wrap: wrap; gap: .35rem; }
|
||||
.growth-comment-pills .pill { font-size: .7rem; }
|
||||
/* The posted text is the record of what was actually said, so it reads as a quote. */
|
||||
.growth-comment-body { margin: 0; padding-left: .6rem; border-left: 2px solid var(--border); font-size: .88rem; }
|
||||
.growth-comment-link { font-size: .78rem; }
|
||||
.growth-comment .field-hint { margin: 0; }
|
||||
@@ -0,0 +1,20 @@
|
||||
import { LearningOverviewResponseSchema, LearningReviewResponseSchema, type LearningOverviewResponse } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { createDashboardRequest } from '../../api/request.js';
|
||||
|
||||
async function parse<T>(response: Response, schema: { parse(value: unknown): T }): Promise<T> {
|
||||
const body: unknown = await response.json();
|
||||
if (!response.ok) throw new Error(typeof body === 'object' && body && 'error' in body ? String(body.error) : `Request failed (${response.status})`);
|
||||
return schema.parse(body);
|
||||
}
|
||||
|
||||
const request = createDashboardRequest();
|
||||
export const learningApi = {
|
||||
getOverview(): Promise<LearningOverviewResponse> {
|
||||
return request('/api/learning').then((response) => parse(response, LearningOverviewResponseSchema));
|
||||
},
|
||||
review(id: string, status: 'eligible' | 'excluded'): Promise<{ success: true }> {
|
||||
return request(`/api/learning/trajectories/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }),
|
||||
}).then((response) => parse(response, LearningReviewResponseSchema));
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { learningApi } from './learning-api.js';
|
||||
import './learning.css';
|
||||
|
||||
const humanCredits = new Set(['human-created-success', 'human-improved-success', 'human-completed-ai-trajectory']);
|
||||
|
||||
export function LearningPage() {
|
||||
const client = useQueryClient();
|
||||
const overview = useQuery({ queryKey: ['learning'], queryFn: learningApi.getOverview });
|
||||
const review = useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: 'eligible' | 'excluded' }) => learningApi.review(id, status),
|
||||
onSuccess: () => client.invalidateQueries({ queryKey: ['learning'] }),
|
||||
});
|
||||
if (overview.isPending) return <main className="page-shell"><div className="panel-loading" role="status"><span className="spinner" />Loading learning evidence…</div></main>;
|
||||
if (overview.isError) return <main className="page-shell"><div className="error-panel" role="alert">{overview.error.message}</div></main>;
|
||||
const { summary, trajectories } = overview.data;
|
||||
const humanTotal = summary.humanCreated + summary.humanImproved + summary.humanCompletedAi;
|
||||
return (
|
||||
<main className="page-shell learning-page">
|
||||
<header className="page-heading"><div><span className="eyebrow">Human-in-the-loop evidence</span><h1>Learning review</h1><p>Audit who made the successful decision before examples can influence generation.</p></div><span className={`learning-mode ${overview.data.generationEnabled ? 'live' : ''}`}>{overview.data.generationEnabled ? (overview.data.shadowMode ? 'Shadow only' : 'Injected') : 'Capture only'}</span></header>
|
||||
<section className="metrics-grid learning-metrics" aria-label="Learning totals">
|
||||
<article className="metric-card"><span>Human-led outcomes</span><strong>{humanTotal}</strong><small>{summary.humanCreated} created · {summary.humanImproved} improved · {summary.humanCompletedAi} completed</small></article>
|
||||
<article className="metric-card"><span>Eligible examples</span><strong>{summary.eligible}</strong><small>{summary.provisional} still maturing</small></article>
|
||||
<article className="metric-card"><span>Needs review</span><strong>{summary.needsReview}</strong><small>{summary.inferred} inferred attributions</small></article>
|
||||
<article className="metric-card"><span>Revoked or excluded</span><strong>{summary.invalidated + summary.excluded}</strong><small>Never supplied to generation</small></article>
|
||||
</section>
|
||||
<section className="panel learning-panel" aria-labelledby="trajectory-heading">
|
||||
<div className="panel-heading"><div><span className="eyebrow">Policy {overview.data.policyVersion}</span><h2 id="trajectory-heading">Attributed trajectories</h2></div><span className="sample-size">n = {summary.total}</span></div>
|
||||
{trajectories.length === 0 ? <p className="feature-empty">No received-image trajectories have been extracted yet. A full chat sync will backfill authoritative histories.</p> : (
|
||||
<div className="table-scroll"><table className="performance-table learning-table"><thead><tr><th>Credit</th><th>Community</th><th>Confidence</th><th>Status</th><th>Updated</th><th>Review</th></tr></thead><tbody>{trajectories.map((item) => (
|
||||
<tr key={item.id} className={humanCredits.has(item.creditClass) ? 'human-led' : ''}>
|
||||
<th scope="row">{item.creditClass.replaceAll('-', ' ')}</th><td>{item.subreddit ? `r/${item.subreddit}` : 'Unknown'}</td><td>{item.attributionConfidence}</td><td><span className={`trajectory-status status-${item.status}`}>{item.status}</span></td><td>{new Date(item.updatedAt).toLocaleString()}</td><td><div className="review-actions"><button className="button secondary" disabled={review.isPending || item.status === 'invalidated'} onClick={() => review.mutate({ id: item.id, status: 'eligible' })}>Include</button><button className="button secondary" disabled={review.isPending || item.status === 'invalidated'} onClick={() => review.mutate({ id: item.id, status: 'excluded' })}>Exclude</button></div></td>
|
||||
</tr>
|
||||
))}</tbody></table></div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
.learning-page { display: grid; gap: 1rem; }
|
||||
.learning-metrics { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.learning-mode, .trajectory-status { border-radius: 999px; background: var(--surface-strong); color: var(--muted); font-size: .78rem; padding: .35rem .65rem; text-transform: capitalize; }
|
||||
.learning-mode.live, .status-eligible { color: var(--success); }
|
||||
.status-needs-review { color: var(--warning); }
|
||||
.status-invalidated, .status-excluded { color: var(--danger); }
|
||||
.learning-panel { padding: 1rem; }
|
||||
.learning-table tr.human-led th:first-child { color: var(--accent); }
|
||||
.review-actions { display: flex; gap: .4rem; }
|
||||
.review-actions .button { padding: .4rem .6rem; }
|
||||
@media (max-width: 900px) { .learning-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 600px) { .learning-metrics { grid-template-columns: 1fr; } }
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ZodType } from 'zod';
|
||||
import {
|
||||
ErrorResponseSchema,
|
||||
MediaLibraryAssetSchema,
|
||||
MediaLibraryResponseSchema,
|
||||
type MediaLibraryAsset,
|
||||
type MediaLibraryUpdate,
|
||||
} from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { dashboardRequest } from '../../api/request.js';
|
||||
|
||||
async function parseResponse<T>(response: Response, schema: ZodType<T>): Promise<T> {
|
||||
const payload: unknown = await response.json();
|
||||
if (!response.ok) {
|
||||
const parsed = ErrorResponseSchema.safeParse(payload);
|
||||
throw new Error(parsed.success ? parsed.data.error : `Request failed (${response.status})`);
|
||||
}
|
||||
return schema.parse(payload);
|
||||
}
|
||||
|
||||
export function fetchMediaLibrary(): Promise<MediaLibraryAsset[]> {
|
||||
return dashboardRequest('/api/media-library').then((response) => parseResponse(response, MediaLibraryResponseSchema));
|
||||
}
|
||||
|
||||
export function uploadMediaLibraryImage(file: File): Promise<MediaLibraryAsset> {
|
||||
return dashboardRequest('/api/media-library', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': file.type,
|
||||
'X-File-Name': encodeURIComponent(file.name),
|
||||
'X-Consent-Confirmed': 'true',
|
||||
},
|
||||
body: file,
|
||||
}).then((response) => parseResponse(response, MediaLibraryAssetSchema));
|
||||
}
|
||||
|
||||
export function updateMediaLibraryImage(id: string, update: MediaLibraryUpdate): Promise<MediaLibraryAsset> {
|
||||
return dashboardRequest(`/api/media-library/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(update),
|
||||
}).then((response) => parseResponse(response, MediaLibraryAssetSchema));
|
||||
}
|
||||
|
||||
export async function deleteMediaLibraryImage(id: string): Promise<void> {
|
||||
const response = await dashboardRequest(`/api/media-library/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
const payload: unknown = await response.json();
|
||||
if (!response.ok) {
|
||||
const parsed = ErrorResponseSchema.safeParse(payload);
|
||||
throw new Error(parsed.success ? parsed.data.error : `Request failed (${response.status})`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
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
|
||||
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 (
|
||||
<div className="page exchange-library-page">
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">Operator-owned media</span>
|
||||
<h1>Exchange library</h1>
|
||||
<p>Add consented images, review the automatic anatomy classification, and control what the bot may send after a request.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section aria-label="Nonspecific request progression" className="exchange-ladder">
|
||||
<div><span>First ask</span><strong>Random SFW</strong><small>Selfie, chest, or feet</small></div>
|
||||
<div><span>Asked again</span><strong>Broader reveal</strong><small>SFW or topless</small></div>
|
||||
<div><span>Further ask</span><strong>Full library</strong><small>Still exact-match when named</small></div>
|
||||
</section>
|
||||
|
||||
<section className="panel library-upload-panel">
|
||||
<div>
|
||||
<span className="eyebrow">Add an image</span>
|
||||
<h2>Strip metadata and classify</h2>
|
||||
<p>JPEG, PNG, or WebP up to 15 MB. Location and camera metadata are removed before the cleaned image is sent to the configured OpenRouter model for classification.</p>
|
||||
</div>
|
||||
<label className="library-file-field">
|
||||
<span>Choose image</span>
|
||||
<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);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
},
|
||||
})}
|
||||
type="button"
|
||||
>
|
||||
{upload.isPending ? 'Classifying…' : 'Upload and classify'}
|
||||
</button>
|
||||
{upload.isError ? <div className="media-notice error" role="alert">{upload.error.message}</div> : null}
|
||||
{upload.isSuccess ? <div className="media-notice success" role="status">Image added. Review its tags before relying on it.</div> : null}
|
||||
</section>
|
||||
|
||||
<div className="library-section-heading">
|
||||
<div><span className="eyebrow">Available inventory</span><h2>{assets.length} {assets.length === 1 ? 'image' : 'images'}</h2></div>
|
||||
<small>Images remain private and blurred until revealed.</small>
|
||||
</div>
|
||||
{assets.length === 0 ? (
|
||||
<section className="panel empty-state"><strong>No exchange images yet.</strong><span>Add the first consented image above.</span></section>
|
||||
) : (
|
||||
<section aria-label="Exchange library images" className="library-grid">
|
||||
{assets.map((asset) => <LibraryAssetCard asset={asset} key={asset.id} />)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LibraryAssetCard({ asset }: { asset: MediaLibraryAsset }) {
|
||||
const updateMutation = useUpdateMediaLibraryMutation();
|
||||
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, 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) {
|
||||
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">
|
||||
<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="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>
|
||||
{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>
|
||||
<fieldset>
|
||||
<legend>Anatomy tags</legend>
|
||||
<div className="library-tag-grid">
|
||||
{TAGS.map((tag) => <label key={tag}><input checked={form.tags.includes(tag)} onChange={() => toggleTag(tag)} type="checkbox" /><span>{tag}</span></label>)}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="library-controls">
|
||||
<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
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
return value < 1024 * 1024 ? `${Math.round(value / 1024)} KB` : `${(value / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatDate(value: number): string {
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(value);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
.exchange-library-page { --library-cyan: #7dd8d0; --library-violet: #b7a0ff; }
|
||||
.exchange-ladder { display: grid; gap: 1px; margin-bottom: 1rem; overflow: hidden; border: 1px solid var(--border); border-radius: .9rem; background: var(--border); }
|
||||
.exchange-ladder > div { position: relative; display: grid; gap: .25rem; min-height: 8rem; padding: 1.15rem; background: linear-gradient(145deg, #111c18, #14211d); }
|
||||
.exchange-ladder > div::after { content: ''; position: absolute; inset: auto 1.15rem 0; height: .2rem; background: var(--accent); }
|
||||
.exchange-ladder > div:nth-child(2)::after { background: var(--library-cyan); }
|
||||
.exchange-ladder > div:nth-child(3)::after { background: var(--library-violet); }
|
||||
.exchange-ladder span { color: var(--muted); font-size: .72rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.exchange-ladder strong { align-self: end; font-size: 1.2rem; }
|
||||
.exchange-ladder small { color: var(--muted); }
|
||||
.library-upload-panel { display: grid; gap: 1rem; margin-bottom: 2rem; }
|
||||
.library-upload-panel p { margin: .35rem 0 0; color: var(--muted); }
|
||||
.library-file-field { display: grid; gap: .4rem; padding: 1rem; border: 1px dashed #4d685d; border-radius: .75rem; background: #0c1511; }
|
||||
.library-file-field span { font-weight: 750; }
|
||||
.library-file-field small { color: var(--muted); }
|
||||
.library-file-field input { color: var(--muted); }
|
||||
.library-consent-field, .library-enable { display: flex; align-items: flex-start; gap: .65rem; color: var(--muted); }
|
||||
.library-consent-field input, .library-enable input { flex: 0 0 auto; width: 1.1rem; height: 1.1rem; margin-top: .1rem; accent-color: var(--accent); }
|
||||
.library-upload-panel > .button { justify-self: start; }
|
||||
.library-section-heading { display: flex; align-items: end; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; }
|
||||
.library-section-heading small { color: var(--muted); text-align: right; }
|
||||
.library-grid { display: grid; gap: 1rem; }
|
||||
.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: 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; }
|
||||
.library-tag-grid label { position: relative; }
|
||||
.library-tag-grid input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.library-tag-grid span { display: block; padding: .35rem .6rem; border: 1px solid var(--border); border-radius: 999px; color: var(--muted); background: #0c1511; text-transform: capitalize; cursor: pointer; }
|
||||
.library-tag-grid input:checked + span { color: #07130c; border-color: var(--accent); background: var(--accent); font-weight: 800; }
|
||||
.library-tag-grid input:focus-visible + span { outline: 2px solid white; outline-offset: 2px; }
|
||||
.library-controls { display: grid; gap: .8rem; }
|
||||
.library-controls > label:first-child { display: grid; gap: .35rem; color: var(--muted); font-size: .8rem; font-weight: 750; }
|
||||
.library-controls select { min-height: 2.5rem; padding: .45rem; color: white; border: 1px solid var(--border); border-radius: .55rem; background: var(--surface-raised); }
|
||||
.library-card-actions { display: flex; flex-wrap: wrap; gap: .55rem; padding-top: .25rem; }
|
||||
@media (min-width: 48rem) {
|
||||
.exchange-ladder { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.library-upload-panel { grid-template-columns: minmax(16rem, 1fr) minmax(18rem, 1.2fr); align-items: center; }
|
||||
.library-upload-panel .library-consent-field, .library-upload-panel .media-notice { grid-column: 1 / -1; }
|
||||
.library-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.library-controls { grid-template-columns: 1fr 1fr; align-items: end; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .library-image-frame img { transition: none; } }
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { MediaLibraryUpdate } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { deleteMediaLibraryImage, fetchMediaLibrary, updateMediaLibraryImage, uploadMediaLibraryImage } from './api.js';
|
||||
|
||||
export const mediaLibraryQueryKey = ['dashboard', 'media-library'] as const;
|
||||
|
||||
export function useMediaLibraryQuery() {
|
||||
return useQuery({ queryKey: mediaLibraryQueryKey, queryFn: fetchMediaLibrary });
|
||||
}
|
||||
|
||||
export function useUploadMediaLibraryMutation() {
|
||||
const client = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: uploadMediaLibraryImage,
|
||||
onSuccess: () => client.invalidateQueries({ queryKey: mediaLibraryQueryKey }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateMediaLibraryMutation() {
|
||||
const client = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, update }: { id: string; update: MediaLibraryUpdate }) => updateMediaLibraryImage(id, update),
|
||||
onSuccess: () => client.invalidateQueries({ queryKey: mediaLibraryQueryKey }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteMediaLibraryMutation() {
|
||||
const client = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: deleteMediaLibraryImage,
|
||||
onSuccess: () => client.invalidateQueries({ queryKey: mediaLibraryQueryKey }),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
@@ -23,11 +29,13 @@ export function filterMedia(items: MediaItem[], filters: MediaFilters, now: numb
|
||||
'30d': 30 * 86_400_000,
|
||||
}[filters.dateRange];
|
||||
|
||||
return items.filter((item) => {
|
||||
return items
|
||||
.filter((item) => {
|
||||
if (filters.conversation !== 'all' && mediaConversation(item) !== filters.conversation) return false;
|
||||
if (filters.sender !== 'all' && item.sender !== filters.sender) return false;
|
||||
if (filters.type !== 'all' && mediaType(item) !== filters.type) return false;
|
||||
if (rangeMs !== null && item.timestamp < now - rangeMs) return false;
|
||||
return true;
|
||||
});
|
||||
})
|
||||
.sort((left, right) => right.timestamp - left.timestamp);
|
||||
}
|
||||
@@ -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">
|
||||
<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 className="button secondary" disabled={isPending} onClick={onCancel} ref={cancelButtonRef} type="button">
|
||||
<button autoFocus className="button secondary" disabled={isPending} onClick={onCancel} type="button">
|
||||
Cancel
|
||||
</button>
|
||||
<button className="button danger" disabled={isPending} onClick={onConfirm} type="button">
|
||||
<button aria-busy={isPending || undefined} className="button danger" disabled={isPending} onClick={onConfirm} type="button">
|
||||
{isPending ? 'Syncing…' : 'Run full sync'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { MediaItem } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
||||
import { mediaConversation, mediaType } from './filters.js';
|
||||
import { createOwnedConversationId } from '../conversations/conversation-identity.js';
|
||||
import { mediaAlt, mediaConversation, mediaType } from './filters.js';
|
||||
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
||||
|
||||
interface MediaCardProps {
|
||||
@@ -15,25 +17,32 @@ function formatMediaDate(timestamp: number): string {
|
||||
}
|
||||
|
||||
export function MediaCard({ item, isRevealed, onToggleReveal, onOpenPreview }: MediaCardProps) {
|
||||
const conversationId = item.chatRoomId ?? item.chatUsername;
|
||||
const conversationPath = conversationId
|
||||
? `/conversations/${encodeURIComponent(createOwnedConversationId(item.owner?.accountId, conversationId))}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<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>
|
||||
@@ -41,7 +50,13 @@ export function MediaCard({ item, isRevealed, onToggleReveal, onOpenPreview }: M
|
||||
<div><dt>Type</dt><dd>{mediaType(item) === 'gif' ? 'GIF' : 'Image'}</dd></div>
|
||||
<div><dt>Received</dt><dd>{formatMediaDate(item.timestamp)}</dd></div>
|
||||
</dl>
|
||||
{conversationPath ? (
|
||||
<Link className="button secondary media-conversation-link" to={conversationPath}>
|
||||
Open conversation
|
||||
</Link>
|
||||
) : null}
|
||||
<button
|
||||
aria-pressed={isRevealed}
|
||||
className="button secondary media-reveal"
|
||||
onClick={onToggleReveal}
|
||||
type="button"
|
||||
|
||||
@@ -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,13 +232,28 @@ 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 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">
|
||||
{visibleItems.map((item) => (
|
||||
{displayedItems.map((item) => (
|
||||
<MediaCard
|
||||
isRevealed={isRevealAll || revealedIds.has(item.id)}
|
||||
item={item}
|
||||
@@ -204,6 +263,14 @@ export function MediaPage({ filters: controlledFilters, onFiltersChange }: Media
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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 { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.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,16 +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 } from '../../../../../packages/api-contracts/src/index.js';
|
||||
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 ? (
|
||||
@@ -72,10 +73,54 @@ function connectionLabel(snapshot: RealtimeConnectionSnapshot): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function accountOwnerFromLogData(data: unknown): RedditAccountOwner | undefined {
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) return undefined;
|
||||
const accountId = Reflect.get(data, 'accountId');
|
||||
const redditUsername = Reflect.get(data, 'redditUsername');
|
||||
return typeof accountId === 'string' && accountId.length > 0
|
||||
&& typeof redditUsername === 'string' && redditUsername.length > 0
|
||||
? { accountId, redditUsername }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -84,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) => {
|
||||
@@ -126,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">
|
||||
@@ -157,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}
|
||||
@@ -165,22 +246,52 @@ 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">
|
||||
{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><RedditUserText>{entry.message}</RedditUserText></span></article>
|
||||
))}
|
||||
<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 (
|
||||
<article className={`console-entry ${entry.level}`} key={entry.id}>
|
||||
<time dateTime={entry.occurredAt}>{formatTimestamp(entry.occurredAt)}</time>
|
||||
<strong>{entry.level.toUpperCase()}</strong>
|
||||
<span className="console-account">{owner ? <RedditUsernameLink username={owner.redditUsername} /> : 'global'}</span>
|
||||
<span><RedditUserText>{entry.message}</RedditUserText></span>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
<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; }
|
||||
@@ -21,12 +21,16 @@
|
||||
.console-controls .checkbox-control { display: flex; flex-direction: row; align-items: center; }
|
||||
.console-controls .checkbox-control input { min-height: auto; }
|
||||
.live-console { height: min(30rem, 55vh); overflow: auto; padding: .65rem; color: #cbd8d1; background: #050906; border: 1px solid var(--border); border-radius: .6rem; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .78rem; }
|
||||
.console-entry { display: grid; grid-template-columns: 11rem 3.5rem minmax(0, 1fr); gap: .65rem; padding: .35rem; border-bottom: 1px solid #142019; }
|
||||
.console-entry { display: grid; grid-template-columns: 11rem 3.5rem minmax(6rem, auto) minmax(0, 1fr); gap: .65rem; padding: .35rem; border-bottom: 1px solid #142019; }
|
||||
.console-entry time { color: #708178; }
|
||||
.console-entry strong { color: #8da49a; }
|
||||
.console-account { color: #708178; }
|
||||
.console-account a { color: var(--accent); font-weight: 700; }
|
||||
.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; }
|
||||
|
||||
@@ -10,6 +10,7 @@ export const DASHBOARD_EVENT_TYPES = [
|
||||
'message.failed',
|
||||
'conversation.updated',
|
||||
'reply.received',
|
||||
'media.received',
|
||||
'sync.started',
|
||||
'sync.progress',
|
||||
'sync.completed',
|
||||
@@ -122,6 +123,8 @@ export function queryKeysForDashboardEvent(event: DashboardEvent): readonly Quer
|
||||
case 'conversation.updated':
|
||||
case 'reply.received':
|
||||
return [['conversations'], ['operations', 'activity']];
|
||||
case 'media.received':
|
||||
return [['conversations'], ['media'], ['operations', 'activity']];
|
||||
case 'sync.started':
|
||||
case 'sync.progress':
|
||||
return [['operations', 'sync-status']];
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { OutreachFunnelResponseSchema } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { createDashboardRequest } from '../../api/request.js';
|
||||
|
||||
export function createOutreachApi(fetchImplementation?: typeof fetch) {
|
||||
const request = createDashboardRequest(fetchImplementation);
|
||||
return {
|
||||
async getFunnel() {
|
||||
const response = await request('/api/outreach');
|
||||
const payload: unknown = await response.json();
|
||||
if (!response.ok) {
|
||||
const message = typeof payload === 'object' && payload !== null && 'error' in payload
|
||||
&& typeof payload.error === 'string'
|
||||
? payload.error
|
||||
: `Outreach funnel unavailable (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return OutreachFunnelResponseSchema.parse(payload);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const outreachApi = createOutreachApi();
|
||||
@@ -0,0 +1,209 @@
|
||||
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';
|
||||
import { OriginPostCard } from '../../components/origin-post.js';
|
||||
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
||||
import { formatExactTime, formatShortRelativeTime } from '../conversations/relative-time.js';
|
||||
import { outreachApi } from './outreach-api.js';
|
||||
import './outreach.css';
|
||||
|
||||
const STAGES: ReadonlyArray<{
|
||||
status: OutreachCandidateStatus;
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{ status: 'discovered', label: 'Waiting', description: 'Found and queued' },
|
||||
{ status: 'screening', label: 'Screening', description: 'Checking eligibility' },
|
||||
{ status: 'ready', label: 'Ready', description: 'Message prepared' },
|
||||
{ status: 'sending', label: 'Sending', description: 'Assigned to account' },
|
||||
{ status: 'retryable', label: 'Retrying', description: 'Cooling down' },
|
||||
];
|
||||
|
||||
const KNOWN_STATUSES = new Set<string>(STAGES.map((stage) => stage.status));
|
||||
|
||||
function statusLabel(status: OutreachCandidateStatus): string {
|
||||
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 {
|
||||
const remaining = Math.max(0, timestamp - now);
|
||||
if (remaining < 60_000) return 'in under a minute';
|
||||
if (remaining < 3_600_000) return `in ${Math.ceil(remaining / 60_000)}m`;
|
||||
if (remaining < 86_400_000) return `in ${Math.ceil(remaining / 3_600_000)}h`;
|
||||
return `in ${Math.ceil(remaining / 86_400_000)}d`;
|
||||
}
|
||||
|
||||
function PreparedCandidateDetails({ candidate }: { candidate: OutreachCandidate }) {
|
||||
const preparation = candidate.preparation;
|
||||
if (!preparation) return null;
|
||||
const targeting = preparation.physiqueTargeting;
|
||||
const strategy = preparation.scripted
|
||||
? 'Scripted opener'
|
||||
: preparation.strategyId ?? 'Default strategy';
|
||||
|
||||
return (
|
||||
<section aria-label={`Prepared outreach for u/${candidate.author}`} className="candidate-preparation">
|
||||
<div className="candidate-preparation-grid">
|
||||
{preparation.sourceImageUrl ? (
|
||||
<OriginPostCard post={{
|
||||
title: candidate.title,
|
||||
url: candidate.postUrl,
|
||||
imageUrl: preparation.sourceImageUrl,
|
||||
subreddit: candidate.subreddit,
|
||||
postedAt: candidate.postCreatedAt,
|
||||
}} />
|
||||
) : null}
|
||||
<div className="candidate-preparation-copy">
|
||||
<div className="candidate-score-grid" aria-label="Preparation scoring">
|
||||
<div><span>Attractiveness</span><strong>{preparation.attractivenessScore === null ? 'Not scored' : `${preparation.attractivenessScore}/100`}</strong><small>Used in the opener prompt</small></div>
|
||||
<div><span>Physique</span><strong>{preparation.physiqueScore === null ? 'Not scorable' : `${preparation.physiqueScore}/10`}</strong><small>{targeting?.enabled ? `Current target ${targeting.minScore}–${targeting.maxScore}` : 'Current filter off'}</small></div>
|
||||
<div><span>Detected gender</span><strong>{preparation.detectedGender}</strong><small>Image analysis</small></div>
|
||||
</div>
|
||||
<div className="candidate-preparation-metadata">
|
||||
<span><strong>Strategy</strong> {strategy}</span>
|
||||
{preparation.selectionReason ? <span><strong>Selection</strong> {preparation.selectionReason}</span> : null}
|
||||
<span><strong>Tone</strong> {preparation.toneId}</span>
|
||||
<span><strong>Model</strong> {preparation.modelId}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="candidate-prepared-message">
|
||||
<span>Prepared message</span>
|
||||
<blockquote>{preparation.message}</blockquote>
|
||||
</div>
|
||||
<details className="candidate-analysis">
|
||||
<summary>Show image analysis</summary>
|
||||
<pre>{preparation.imageAnalysis}</pre>
|
||||
</details>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 ${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 ${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>
|
||||
<time dateTime={new Date(candidate.discoveredAt).toISOString()} title={formatExactTime(candidate.discoveredAt)}>
|
||||
queued {formatShortRelativeTime(candidate.discoveredAt, now)}
|
||||
</time>
|
||||
</div>
|
||||
<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>
|
||||
{candidate.physiqueScore !== null ? <span>Physique {candidate.physiqueScore}/10</span> : null}
|
||||
<span>{candidate.attemptCount} {candidate.attemptCount === 1 ? 'attempt' : 'attempts'}</span>
|
||||
{waitingUntil ? <span title={formatExactTime(candidate.availableAt)}>Retry {formatWaitTime(candidate.availableAt, now)}</span> : null}
|
||||
</div>
|
||||
{candidate.note ? <p className="candidate-note"><strong>Latest note</strong>{candidate.note}</p> : null}
|
||||
{candidate.owner ? <AccountOwnerBadge owner={candidate.owner} /> : null}
|
||||
<PreparedCandidateDetails candidate={candidate} />
|
||||
</article>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function OutreachPage() {
|
||||
const query = useQuery({
|
||||
queryKey: ['outreach', 'funnel'],
|
||||
queryFn: outreachApi.getFunnel,
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
|
||||
// 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;
|
||||
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
|
||||
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">
|
||||
<div className="runway-heading">
|
||||
<div><span className="eyebrow">Current burn-down</span><h2 id="active-funnel-heading">{data.activeCount} active {data.activeCount === 1 ? 'post' : 'posts'}</h2></div>
|
||||
<div className="runway-outcomes"><span><strong>{data.outcomes.sent}</strong> sent</span><span><strong>{data.outcomes.rejected}</strong> screened out</span></div>
|
||||
</div>
|
||||
<ol className="outreach-stages">
|
||||
{STAGES.map((stage) => (
|
||||
<li className={`stage-${stage.status}`} key={stage.status}>
|
||||
<span className="stage-marker" aria-hidden="true" />
|
||||
<div><span>{stage.label}</span><small>{stage.description}</small></div>
|
||||
<strong>{data.stages[stage.status]}</strong>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="runway-caption">Retrying posts loop back to Ready when their cooldown expires. Sent and screened-out totals cover the lifetime of this queue.</p>
|
||||
</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, 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>
|
||||
) : (
|
||||
<ol aria-label="Active outreach candidates" className="outreach-candidates">
|
||||
{data.candidates.map((candidate) => <CandidateCard candidate={candidate} key={candidate.postId} now={now} />)}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
.outreach-page { display: grid; gap: 1rem; }
|
||||
.outreach-runway { position: relative; overflow: hidden; padding: 1.2rem; background: #101a16; border: 1px solid #355044; border-radius: 1rem; }
|
||||
.outreach-runway::before { position: absolute; inset: 0; content: ""; pointer-events: none; background: radial-gradient(circle at 12% -20%, rgba(117, 214, 157, .16), transparent 34%); }
|
||||
.runway-heading { position: relative; display: flex; flex-wrap: wrap; justify-content: space-between; align-items: flex-start; gap: 1rem; margin-bottom: 1.2rem; }
|
||||
.runway-heading h2 { font-size: clamp(1.55rem, 3vw, 2.25rem); letter-spacing: -.045em; }
|
||||
.runway-outcomes { display: flex; gap: .5rem; }
|
||||
.runway-outcomes span { display: grid; min-width: 5.7rem; padding: .55rem .7rem; color: var(--muted); background: rgba(9, 17, 14, .7); border: 1px solid var(--border); border-radius: .65rem; font-size: .72rem; }
|
||||
.runway-outcomes strong { color: #e9f1ec; font-size: 1.15rem; }
|
||||
.outreach-stages { position: relative; display: grid; gap: .55rem; padding: 0; margin: 0; list-style: none; }
|
||||
.outreach-stages li { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: .75rem; padding: .75rem; background: rgba(23, 35, 30, .86); border: 1px solid var(--border); border-radius: .75rem; }
|
||||
.outreach-stages li > div { display: grid; gap: .05rem; }
|
||||
.outreach-stages li span { font-weight: 750; }
|
||||
.outreach-stages li small { color: var(--muted); }
|
||||
.outreach-stages li > strong { font-size: 1.4rem; }
|
||||
.stage-marker { width: .65rem; height: .65rem; border-radius: 50%; background: #668077; box-shadow: 0 0 0 .25rem rgba(102, 128, 119, .12); }
|
||||
.stage-screening .stage-marker { background: #86b7d7; }
|
||||
.stage-ready .stage-marker { background: var(--accent); }
|
||||
.stage-sending .stage-marker { background: #c1ef91; box-shadow: 0 0 0 .25rem rgba(193, 239, 145, .12); }
|
||||
.stage-retryable .stage-marker { background: var(--warning); }
|
||||
.runway-caption { position: relative; margin: .9rem 0 0; color: var(--muted); font-size: .78rem; }
|
||||
.candidate-section { margin-top: .5rem; }
|
||||
.candidate-updated { color: var(--muted); font-size: .8rem; }
|
||||
.outreach-candidates { display: grid; gap: .7rem; padding: 0; margin: 0; list-style: none; }
|
||||
.outreach-candidate { display: grid; grid-template-columns: .32rem minmax(0, 1fr); overflow: hidden; background: var(--surface); border: 1px solid var(--border); border-radius: .85rem; }
|
||||
.candidate-status-rail { background: #668077; }
|
||||
.candidate-status-rail span { display: block; width: 100%; height: 3.1rem; background: #8ca49c; }
|
||||
.outreach-candidate.is-screening .candidate-status-rail span { background: #86b7d7; }
|
||||
.outreach-candidate.is-ready .candidate-status-rail span { background: var(--accent); }
|
||||
.outreach-candidate.is-sending .candidate-status-rail span { background: #c1ef91; }
|
||||
.outreach-candidate.is-retryable .candidate-status-rail span { background: var(--warning); }
|
||||
.outreach-candidate article { display: grid; gap: .7rem; min-width: 0; padding: .9rem 1rem; }
|
||||
.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: 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: var(--warning-text); font-size: .68rem; letter-spacing: .06em; text-transform: uppercase; }
|
||||
.outreach-candidate .account-owner-badge { margin-top: -.1rem; }
|
||||
.candidate-preparation { display: grid; gap: .8rem; padding-top: .8rem; border-top: 1px solid var(--border); }
|
||||
.candidate-preparation-grid { display: grid; gap: .8rem; }
|
||||
.candidate-preparation .origin-post-card { grid-template-columns: 1fr; align-items: stretch; gap: .55rem; margin: 0; padding: .6rem; background: #111915; border: 1px solid var(--border); border-radius: .6rem; }
|
||||
.candidate-preparation .origin-post-image { width: 100%; height: 12rem; }
|
||||
.candidate-preparation-copy { display: grid; gap: .7rem; min-width: 0; }
|
||||
.candidate-score-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .55rem; }
|
||||
.candidate-score-grid > div { display: grid; gap: .08rem; min-width: 0; padding: .6rem .7rem; background: #111c17; border: 1px solid var(--border); border-radius: .55rem; }
|
||||
.candidate-score-grid span, .candidate-prepared-message > span { color: var(--muted); font-size: .66rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; }
|
||||
.candidate-score-grid strong { color: #eef6f1; font-size: 1rem; text-transform: capitalize; overflow-wrap: anywhere; }
|
||||
.candidate-score-grid small { color: var(--muted); font-size: .67rem; }
|
||||
.candidate-preparation-metadata { display: flex; flex-wrap: wrap; gap: .4rem .8rem; color: var(--muted); font-size: .72rem; overflow-wrap: anywhere; }
|
||||
.candidate-preparation-metadata strong { color: #dce8e2; }
|
||||
.candidate-prepared-message { display: grid; gap: .35rem; }
|
||||
.candidate-prepared-message blockquote { margin: 0; padding: .7rem .8rem; color: #eef6f1; background: rgba(117, 214, 157, .07); border-left: 3px solid var(--accent); border-radius: .35rem; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.candidate-analysis { padding: .55rem .7rem; background: #111915; border: 1px solid var(--border); border-radius: .5rem; }
|
||||
.candidate-analysis summary { width: fit-content; color: var(--accent); cursor: pointer; font-size: .75rem; font-weight: 750; }
|
||||
.candidate-analysis pre { margin: .65rem 0 0; color: #dce8e2; font: inherit; font-size: .76rem; line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
|
||||
@media (min-width: 48rem) {
|
||||
.candidate-preparation-grid:has(.origin-post-card) { grid-template-columns: minmax(15rem, .8fr) minmax(0, 1.2fr); align-items: stretch; }
|
||||
}
|
||||
|
||||
@media (max-width: 34rem) {
|
||||
.candidate-score-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (min-width: 64rem) {
|
||||
.outreach-stages { grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 0; }
|
||||
.outreach-stages::before { position: absolute; top: 50%; left: 8%; right: 8%; height: 1px; content: ""; background: #3f5a4e; }
|
||||
.outreach-stages li { position: relative; grid-template-columns: auto minmax(0, 1fr); margin-right: .55rem; }
|
||||
.outreach-stages li:last-child { margin-right: 0; }
|
||||
.outreach-stages li > strong { grid-column: 2; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.outreach-candidate.is-screening .candidate-status-rail span,
|
||||
.outreach-candidate.is-sending .candidate-status-rail span { animation: queue-pulse 2.4s ease-in-out infinite; }
|
||||
@keyframes queue-pulse { 50% { opacity: .45; } }
|
||||
}
|
||||
@@ -1,13 +1,22 @@
|
||||
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';
|
||||
import { dashboardQueryKeys, useSetModeMutation, useStatsQuery, useTimersQuery } from '../../api/queries.js';
|
||||
import type { BotMode, BotStats, Timers } from '../../api/contracts.js';
|
||||
import type { AccountStatus } from '../../../../../packages/api-contracts/src/index.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';
|
||||
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
||||
|
||||
const MODE_ORDER: readonly BotMode[] = ['running', 'scan', 'stopped'];
|
||||
|
||||
const modeCopy: Record<BotMode, { label: string; action: string; dot: string; buttonTone: string }> = {
|
||||
running: { label: 'Running', action: 'Start bot', dot: 'running', buttonTone: 'button primary' },
|
||||
scan: { label: 'Scanning', action: 'Scan mode', dot: 'scanning', buttonTone: 'button secondary' },
|
||||
stopped: { label: 'Stopped', action: 'Stop bot', dot: 'stopped', buttonTone: 'button danger' },
|
||||
};
|
||||
|
||||
function errorMessage(error: Error | null): string {
|
||||
return error?.message ?? 'Something went wrong.';
|
||||
}
|
||||
@@ -16,6 +25,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">
|
||||
@@ -27,7 +57,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">
|
||||
@@ -40,7 +71,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>
|
||||
@@ -55,6 +88,65 @@ function TimersPanel({ timers }: { timers: Timers }) {
|
||||
);
|
||||
}
|
||||
|
||||
const sendingStateCopy: Record<AccountStatus['sendingState'], { label: string; tone: 'success' | 'warning' | 'neutral'; detail: string }> = {
|
||||
ready: { label: 'Ready', tone: 'success', detail: 'Available for the next outbound candidate.' },
|
||||
'bot-stopped': { label: 'Bot stopped', tone: 'neutral', detail: 'Global sending is stopped.' },
|
||||
'scan-only': { label: 'Scan only', tone: 'neutral', detail: 'Conversations stay in sync, but nothing is sent.' },
|
||||
'runtime-stopped': { label: 'Runtime stopped', tone: 'warning', detail: 'This account runtime is not running.' },
|
||||
'runtime-failed': { label: 'Runtime failed', tone: 'warning', detail: 'This account runtime needs attention.' },
|
||||
'login-required': { label: 'Login required', tone: 'warning', detail: 'Refresh this account session before it can send.' },
|
||||
'matrix-cooldown': { label: 'Matrix cooldown', tone: 'warning', detail: 'Only this account is paused by Reddit chat throttling.' },
|
||||
'replies-prioritized': { label: 'Replies first', tone: 'neutral', detail: 'Inbound replies are being handled before new outreach.' },
|
||||
pacing: { label: 'Pacing', tone: 'neutral', detail: 'Waiting for the account sending window.' },
|
||||
unestablished: { label: 'Not established', tone: 'warning', detail: 'Reddit will not accept chat invitations from this account yet.' },
|
||||
};
|
||||
|
||||
function AccountStatusPanel({ accounts }: { accounts: readonly AccountStatus[] }) {
|
||||
return (
|
||||
<section aria-labelledby="account-status-heading" className="panel account-status-panel">
|
||||
<div className="panel-heading">
|
||||
<div><span className="eyebrow">Multi-account delivery</span><h2 id="account-status-heading">Account status</h2></div>
|
||||
<span className="pill neutral">{accounts.length} configured</span>
|
||||
</div>
|
||||
{accounts.length === 0 ? <div className="empty-state"><strong>No accounts configured.</strong><span>Add an account under Settings → Access.</span></div> : (
|
||||
<div className="account-status-grid">
|
||||
{accounts.map((account) => {
|
||||
const state = sendingStateCopy[account.sendingState];
|
||||
return (
|
||||
<article aria-label={`Status for u/${account.redditUsername}`} className="account-status-card" key={account.accountId}>
|
||||
<div className="account-status-heading">
|
||||
<AccountOwnerBadge owner={account} />
|
||||
<span className={`pill ${state.tone}`}>{state.label}</span>
|
||||
</div>
|
||||
<p>{state.detail}</p>
|
||||
<dl className="account-status-details">
|
||||
<div><dt>Runtime</dt><dd>{account.runtimeState}</dd></div>
|
||||
<div><dt>Authentication</dt><dd>{account.authenticated ? 'Authenticated' : 'Unavailable'}</dd></div>
|
||||
<div><dt>Hourly usage</dt><dd>{account.messagesThisHour} of {account.maxMessagesPerHour}</dd></div>
|
||||
<div><dt>Remaining</dt><dd>{account.messagesRemaining}</dd></div>
|
||||
<div><dt>Allowance resets</dt><dd>{formatTime(account.hourlyResetAt)}</dd></div>
|
||||
<div><dt>Pending replies</dt><dd>{account.actionableReplies}</dd></div>
|
||||
</dl>
|
||||
{account.nextMessageAvailableAt && account.nextMessageAvailableAt > Date.now() && !account.invitationCooldown
|
||||
? <p className="account-status-note">Next send: {formatTime(account.nextMessageAvailableAt)}</p>
|
||||
: null}
|
||||
{account.invitationCooldown ? (
|
||||
<div className="account-status-warning">
|
||||
<strong>Cooldown until {formatTime(account.invitationCooldown.until)}</strong>
|
||||
<span>{account.invitationCooldown.reason}</span>
|
||||
{account.invitationCooldown.redditCode ? <code>{account.invitationCooldown.redditCode}</code> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{account.failure ? <div className="account-status-warning is-error" role="alert"><strong>Runtime failure</strong><span>{account.failure}</span></div> : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityPanel({ stats }: { stats: BotStats }) {
|
||||
return (
|
||||
<section aria-labelledby="activity-heading" className="panel activity-panel">
|
||||
@@ -71,8 +163,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 }}>
|
||||
@@ -91,8 +183,8 @@ export function OverviewPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const statsQuery = useStatsQuery();
|
||||
const timersQuery = useTimersQuery();
|
||||
const controlMutation = useSetRunningMutation();
|
||||
const [confirmationAction, setConfirmationAction] = useState<'start' | 'stop' | null>(null);
|
||||
const controlMutation = useSetModeMutation();
|
||||
const [confirmationAction, setConfirmationAction] = useState<BotMode | null>(null);
|
||||
const isRefreshing = (statsQuery.isFetching || timersQuery.isFetching) && !statsQuery.isPending;
|
||||
|
||||
async function refreshOverview() {
|
||||
@@ -104,12 +196,21 @@ export function OverviewPage() {
|
||||
|
||||
function confirmControl() {
|
||||
if (!confirmationAction) return;
|
||||
controlMutation.mutate(confirmationAction === 'start', {
|
||||
controlMutation.mutate(confirmationAction, {
|
||||
onSuccess: () => setConfirmationAction(null),
|
||||
});
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -124,7 +225,8 @@ export function OverviewPage() {
|
||||
}
|
||||
|
||||
const stats = statsQuery.data;
|
||||
const action = stats.isRunning ? 'stop' : 'start';
|
||||
// Offer the two modes the bot is not currently in, in a stable order.
|
||||
const offeredModes = MODE_ORDER.filter((mode) => mode !== stats.mode);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
@@ -136,6 +238,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}
|
||||
@@ -144,21 +247,23 @@ export function OverviewPage() {
|
||||
>
|
||||
{isRefreshing ? 'Refreshing…' : 'Refresh'}
|
||||
</button>
|
||||
{offeredModes.map((mode) => (
|
||||
<button
|
||||
aria-label={`${action === 'start' ? 'Start' : 'Stop'} bot`}
|
||||
className={action === 'start' ? 'button primary' : 'button danger'}
|
||||
onClick={() => setConfirmationAction(action)}
|
||||
className={modeCopy[mode].buttonTone}
|
||||
key={mode}
|
||||
onClick={() => setConfirmationAction(mode)}
|
||||
type="button"
|
||||
>
|
||||
{action === 'start' ? 'Start bot' : 'Stop bot'}
|
||||
{modeCopy[mode].action}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section aria-label="Bot state" className="status-strip">
|
||||
<div>
|
||||
<span className={`status-dot ${stats.isRunning ? 'running' : 'stopped'}`} aria-hidden="true" />
|
||||
<div><small>Bot status</small><strong data-bot-status>{stats.isRunning ? 'Running' : 'Stopped'}</strong></div>
|
||||
<span className={`status-dot ${modeCopy[stats.mode].dot}`} aria-hidden="true" />
|
||||
<div><small>Bot status</small><strong data-bot-status>{modeCopy[stats.mode].label}</strong></div>
|
||||
</div>
|
||||
{stats.dryRunMode ? <span className="pill neutral">Dry run</span> : null}
|
||||
<span className="status-note">{stats.messagesThisHour} of {stats.maxMessagesPerHour} messages used this hour</span>
|
||||
@@ -166,18 +271,22 @@ export function OverviewPage() {
|
||||
|
||||
{stats.invitationCooldown ? (
|
||||
<div className="cooldown-banner" role="status">
|
||||
<strong>Reddit invitation cooldown</strong>
|
||||
<strong>{stats.invitationCooldown.redditUsername
|
||||
? `u/${stats.invitationCooldown.redditUsername} invitation cooldown`
|
||||
: 'Reddit invitation cooldown'}</strong>
|
||||
<span>{stats.invitationCooldown.reason}</span>
|
||||
</div>
|
||||
) : 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} />
|
||||
|
||||
<div className="overview-grid">
|
||||
{timersQuery.isPending ? (
|
||||
<section aria-label="Loading timers" className="panel panel-loading" role="status"><span className="spinner" />Loading sending window…</section>
|
||||
@@ -194,12 +303,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) => (
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
StrategyPerformanceResponseSchema,
|
||||
SubredditPerformanceResponseSchema,
|
||||
SuccessfulExamplesResponseSchema,
|
||||
FunnelResponseSchema,
|
||||
AiUsageSummaryResponseSchema,
|
||||
} from '../../../../../packages/api-contracts/src/index.js';
|
||||
import type { z } from 'zod';
|
||||
import { createDashboardRequest } from '../../api/request.js';
|
||||
@@ -11,6 +13,8 @@ type SubredditPerformanceResponse = z.infer<typeof SubredditPerformanceResponseS
|
||||
type StrategyPerformanceResponse = z.infer<typeof StrategyPerformanceResponseSchema>;
|
||||
type DailyTrendResponse = z.infer<typeof DailyTrendResponseSchema>;
|
||||
type SuccessfulExamplesResponse = z.infer<typeof SuccessfulExamplesResponseSchema>;
|
||||
type FunnelResponse = z.infer<typeof FunnelResponseSchema>;
|
||||
type AiUsageSummaryResponse = z.infer<typeof AiUsageSummaryResponseSchema>;
|
||||
|
||||
async function parseResponse<T>(
|
||||
response: Response,
|
||||
@@ -35,6 +39,8 @@ export interface PerformanceApi {
|
||||
getStrategyPerformance(): Promise<StrategyPerformanceResponse>;
|
||||
getDailyTrend(days: number): Promise<DailyTrendResponse>;
|
||||
getSuccessfulExamples(): Promise<SuccessfulExamplesResponse>;
|
||||
getFunnel(): Promise<FunnelResponse>;
|
||||
getAiUsage(days: number): Promise<AiUsageSummaryResponse>;
|
||||
}
|
||||
|
||||
export function createPerformanceApi(
|
||||
@@ -66,6 +72,18 @@ export function createPerformanceApi(
|
||||
SuccessfulExamplesResponseSchema,
|
||||
);
|
||||
},
|
||||
async getFunnel() {
|
||||
return parseResponse(
|
||||
await request('/api/analytics/funnel'),
|
||||
FunnelResponseSchema,
|
||||
);
|
||||
},
|
||||
async getAiUsage(days: number) {
|
||||
return parseResponse(
|
||||
await request(`/api/analytics/ai-usage?days=${days}`),
|
||||
AiUsageSummaryResponseSchema,
|
||||
);
|
||||
},
|
||||
} satisfies PerformanceApi;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
import type { z } from 'zod';
|
||||
import type {
|
||||
DailyTrendResponseSchema,
|
||||
StrategyPerformanceResponseSchema,
|
||||
SubredditPerformanceResponseSchema,
|
||||
SuccessfulExample,
|
||||
FunnelResponseSchema,
|
||||
AiUsageSummaryResponseSchema,
|
||||
} from '../../../../../packages/api-contracts/src/index.js';
|
||||
import {
|
||||
useDailyTrendQuery,
|
||||
useStrategyPerformanceQuery,
|
||||
useSubredditPerformanceQuery,
|
||||
useSuccessfulExamplesQuery,
|
||||
useFunnelQuery,
|
||||
useAiUsageQuery,
|
||||
} from './performance-queries.js';
|
||||
import './performance.css';
|
||||
|
||||
@@ -17,6 +23,61 @@ const LOW_CONFIDENCE_SAMPLE_SIZE = 20;
|
||||
type SubredditPerformance = z.infer<typeof SubredditPerformanceResponseSchema>[number];
|
||||
type StrategyPerformance = z.infer<typeof StrategyPerformanceResponseSchema>[number];
|
||||
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 AiUsagePanelBody({ usage }: { usage: AiUsageSummary }) {
|
||||
const successRate = usage.requests > 0 ? Math.round((usage.successes / usage.requests) * 100) : 0;
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
<article className="metric-card"><span>Tokens</span><strong>{usage.totalTokens.toLocaleString()}</strong><small>{usage.promptTokens.toLocaleString()} prompt · {usage.completionTokens.toLocaleString()} completion</small></article>
|
||||
<article className="metric-card"><span>Success rate</span><strong>{successRate}%</strong><small>{usage.refusals} refusals · {usage.errors} errors/empty</small></article>
|
||||
<article className="metric-card"><span>Average latency</span><strong>{usage.averageLatencyMs.toLocaleString()} ms</strong><small>All recorded attempts</small></article>
|
||||
</div>
|
||||
{usage.models.length === 0 ? <p className="feature-empty">No AI usage has been recorded yet. New requests will appear here.</p> : (
|
||||
<div className="table-scroll"><table aria-label="AI usage by model" className="performance-table"><thead><tr><th scope="col">Model</th><th scope="col">Requests</th><th scope="col">Success</th><th scope="col">Refusals</th><th scope="col">Errors</th><th scope="col">Tokens</th><th scope="col">Cost</th><th scope="col">Latency</th></tr></thead><tbody>{usage.models.map((model) => (
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SampleSize({ totalSent }: { totalSent: number }) {
|
||||
const isLowConfidence = totalSent < LOW_CONFIDENCE_SAMPLE_SIZE;
|
||||
@@ -28,6 +89,41 @@ function SampleSize({ totalSent }: { totalSent: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
function FunnelPanelBody({ funnel }: { funnel: Funnel }) {
|
||||
const stages = [
|
||||
['Processed', funnel.processed],
|
||||
['Sent', funnel.sent],
|
||||
[`Mature (${funnel.responseWindowHours}h)`, funnel.mature],
|
||||
['Responded', funnel.responded],
|
||||
['Engaged', funnel.engaged],
|
||||
['Images', funnel.images],
|
||||
] as const;
|
||||
return (
|
||||
<>
|
||||
<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], 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PerformanceTable({
|
||||
label,
|
||||
rows,
|
||||
@@ -37,10 +133,18 @@ 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">
|
||||
<thead><tr><th scope="col">Name</th><th scope="col">Responses</th><th scope="col">Response rate</th><th scope="col">Images</th><th scope="col">Image rate</th><th scope="col">Sample</th></tr></thead>
|
||||
<thead><tr><th scope="col">Name</th><th scope="col">Responses</th><th scope="col">Mature response rate</th><th scope="col">Images</th><th scope="col">Image rate</th><th scope="col">Sample</th></tr></thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={rowLabel(row)}>
|
||||
@@ -49,10 +153,10 @@ function PerformanceTable({
|
||||
{'configured' in row && row.configured ? <span className="configured-label">Configured</span> : null}
|
||||
</th>
|
||||
<td>{row.totalResponses}</td>
|
||||
<td>{row.responseRate}%</td>
|
||||
<td>{row.responseRate}%{row.responseRateLow !== undefined && row.responseRateHigh !== undefined ? <small className="interval">95% CI {row.responseRateLow}–{row.responseRateHigh}%</small> : null}</td>
|
||||
<td>{row.totalImages}</td>
|
||||
<td>{row.imageRate}%</td>
|
||||
<td><SampleSize totalSent={row.totalSent} /></td>
|
||||
<td><SampleSize totalSent={row.totalSent} />{row.pendingSent ? <small className="interval">{row.pendingSent} pending</small> : null}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -61,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 (
|
||||
<ol aria-label="30 day response trend" className="trend-list">
|
||||
<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={`${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>
|
||||
@@ -96,29 +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 queries = [subreddits, strategies, trend, examples];
|
||||
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 funnel = useFunnelQuery();
|
||||
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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
<PanelBody label="successful examples" query={examples}>{(data) => <SuccessfulExamples examples={data} />}</PanelBody>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,8 @@ export const performanceQueryKeys = {
|
||||
strategies: ['performance', 'strategies'] as const,
|
||||
trend: (days: number) => ['performance', 'trend', days] as const,
|
||||
successfulExamples: ['performance', 'successful-examples'] as const,
|
||||
funnel: ['performance', 'funnel'] as const,
|
||||
aiUsage: (days: number) => ['performance', 'ai-usage', days] as const,
|
||||
};
|
||||
|
||||
export function useSubredditPerformanceQuery() {
|
||||
@@ -35,3 +37,17 @@ export function useSuccessfulExamplesQuery() {
|
||||
queryFn: performanceApi.getSuccessfulExamples,
|
||||
});
|
||||
}
|
||||
|
||||
export function useFunnelQuery() {
|
||||
return useQuery({
|
||||
queryKey: performanceQueryKeys.funnel,
|
||||
queryFn: performanceApi.getFunnel,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAiUsageQuery(days: number) {
|
||||
return useQuery({
|
||||
queryKey: performanceQueryKeys.aiUsage(days),
|
||||
queryFn: () => performanceApi.getAiUsage(days),
|
||||
});
|
||||
}
|
||||
@@ -9,8 +9,20 @@
|
||||
.configured-label, .confidence-warning { display: block; margin-top: .2rem; color: var(--accent); font-size: .68rem; font-weight: 750; }
|
||||
.confidence-warning { color: var(--warning); }
|
||||
.sample-size { white-space: nowrap; color: var(--muted); }
|
||||
.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; }
|
||||
@@ -21,4 +33,4 @@
|
||||
.successful-example blockquote { margin: 1rem 0; padding-left: 1rem; border-left: 2px solid var(--accent); }
|
||||
.successful-example p { margin-bottom: 0; color: var(--muted); }
|
||||
.feature-empty { color: var(--muted); }
|
||||
@media (min-width: 64rem) { .performance-grid, .example-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (min-width: 64rem) { .performance-grid, .example-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .funnel-list { grid-template-columns: repeat(6, minmax(0, 1fr)); } }
|
||||
@@ -1,81 +0,0 @@
|
||||
import { useRef } from 'react';
|
||||
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
||||
import {
|
||||
useImportRedditSessionMutation,
|
||||
useLoginStatusQuery,
|
||||
useRedditAccountsQuery,
|
||||
} from './settings-queries.js';
|
||||
|
||||
function errorMessage(error: Error | null): string {
|
||||
return error?.message ?? 'Something went wrong.';
|
||||
}
|
||||
|
||||
function sessionLabel(status: ReturnType<typeof useLoginStatusQuery>['data']): string {
|
||||
if (!status) return 'Unknown';
|
||||
if (!status.ready) return 'Browser unavailable';
|
||||
if ('error' in status) return 'Check failed';
|
||||
return {
|
||||
unknown: 'Unknown',
|
||||
authenticated: 'Authenticated',
|
||||
'login-required': 'Login required',
|
||||
unreachable: 'Reddit unreachable',
|
||||
}[status.status];
|
||||
}
|
||||
|
||||
export function AccessPanel() {
|
||||
const statusQuery = useLoginStatusQuery();
|
||||
const accountsQuery = useRedditAccountsQuery();
|
||||
const importMutation = useImportRedditSessionMutation();
|
||||
const importInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function importSession(file: File | undefined) {
|
||||
if (!file) return;
|
||||
try {
|
||||
const exported: unknown = JSON.parse(await file.text());
|
||||
importMutation.mutate(exported);
|
||||
} catch {
|
||||
importMutation.reset();
|
||||
importMutation.mutate({});
|
||||
}
|
||||
}
|
||||
|
||||
if (statusQuery.isPending) {
|
||||
return <section aria-label="Loading access" className="panel panel-loading" role="status"><span className="spinner" />Loading Reddit access...</section>;
|
||||
}
|
||||
|
||||
if (statusQuery.isError) {
|
||||
return (
|
||||
<section className="panel error-panel" role="alert">
|
||||
<strong>Reddit access unavailable</strong><span>{errorMessage(statusQuery.error)}</span>
|
||||
<button className="button secondary" onClick={() => void statusQuery.refetch()} type="button">Retry access</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const session = statusQuery.data;
|
||||
const username = session.ready && 'loggedIn' in session && session.loggedIn ? session.username ?? null : null;
|
||||
return (
|
||||
<section aria-labelledby="access-heading" className="panel settings-section">
|
||||
<div className="panel-heading"><div><span className="eyebrow">Reddit accounts</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">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.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>
|
||||
<span className={account.isRunning ? 'pill success' : 'pill warning'}>{account.isRunning ? 'Running' : 'Paused'}</span>
|
||||
</div>
|
||||
))}
|
||||
{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" />
|
||||
<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>
|
||||
</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>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useState } from 'react';
|
||||
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { TextAreaField } from '../../components/form-fields.js';
|
||||
import { SettingsCard } from './settings-card.js';
|
||||
import { createAdvancedDraft, validateAdvancedDraft, type AdvancedDraft } from './settings-model.js';
|
||||
import { useConfigCard } from './use-config-card.js';
|
||||
|
||||
/** Top-level keys of the advanced blob, so the card says what it holds while locked. */
|
||||
function summariseKeys(advanced: Record<string, unknown>): string[] {
|
||||
return Object.keys(advanced).sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
export function AdvancedCard({ config }: { config: ConfigResponse }) {
|
||||
const [isUnlocked, setIsUnlocked] = useState(false);
|
||||
const card = useConfigCard<AdvancedDraft>(config, {
|
||||
id: 'advanced',
|
||||
toDraft: createAdvancedDraft,
|
||||
validate: validateAdvancedDraft,
|
||||
});
|
||||
|
||||
const keys = config.advanced ? summariseKeys(config.advanced) : [];
|
||||
|
||||
// Locked state is a plain section: no draft, so no save footer to show.
|
||||
if (!isUnlocked) {
|
||||
return (
|
||||
<section aria-labelledby="advanced-heading" className="panel settings-card" id="advanced">
|
||||
<div className="settings-card-heading">
|
||||
<div>
|
||||
<span className="eyebrow">Raw configuration</span>
|
||||
<h2 id="advanced-heading">Advanced</h2>
|
||||
<p>Models, prompts, filtering, media sync and strategy settings, edited as raw JSON. Some changes take effect only after a restart.</p>
|
||||
</div>
|
||||
<span className="pill neutral">Locked</span>
|
||||
</div>
|
||||
<div className="settings-card-body">
|
||||
{keys.length > 0 ? (
|
||||
<div className="advanced-keys">
|
||||
<span className="field-label">Currently configured</span>
|
||||
<ul>{keys.map((key) => <li key={key}><code>{key}</code></li>)}</ul>
|
||||
</div>
|
||||
) : null}
|
||||
<p className="state-banner is-warning">
|
||||
<strong>There is no validation beyond JSON shape here.</strong>
|
||||
<span>A wrong value can stop the bot from running or change what it says to people. Edit this only if you know the schema.</span>
|
||||
</p>
|
||||
<button className="button secondary" onClick={() => setIsUnlocked(true)} type="button">
|
||||
Edit raw configuration
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
anchor="advanced"
|
||||
card={card}
|
||||
description="Edited as raw JSON and saved on its own, so a syntax error here cannot block any other section."
|
||||
eyebrow="Raw configuration"
|
||||
saveLabel="Save advanced"
|
||||
status={
|
||||
<button
|
||||
className="button secondary"
|
||||
disabled={card.isSaving}
|
||||
onClick={() => { card.reset(); setIsUnlocked(false); }}
|
||||
type="button"
|
||||
>
|
||||
Done editing
|
||||
</button>
|
||||
}
|
||||
title="Advanced"
|
||||
>
|
||||
<TextAreaField
|
||||
error={card.errors.advancedConfigJson}
|
||||
hint="A single JSON object. Unknown keys are preserved as-is."
|
||||
label="Advanced configuration (JSON)"
|
||||
monospace
|
||||
name="advanced-config"
|
||||
onChange={(advancedConfigJson) => card.update({ advancedConfigJson })}
|
||||
rows={22}
|
||||
value={card.draft.advancedConfigJson}
|
||||
/>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { NumberField, SwitchField } from '../../components/form-fields.js';
|
||||
import { SettingsCard } from './settings-card.js';
|
||||
import {
|
||||
createPacingDraft,
|
||||
estimateThroughput,
|
||||
pacingRisks,
|
||||
validatePacingDraft,
|
||||
type PacingDraft,
|
||||
} from './settings-model.js';
|
||||
import { useConfigCard } from './use-config-card.js';
|
||||
|
||||
function ThroughputReadout({ draft }: { draft: PacingDraft }) {
|
||||
const estimate = estimateThroughput(draft);
|
||||
if (!estimate) {
|
||||
return <p className="throughput-readout is-unknown">Enter valid numbers to see the effective sending rate.</p>;
|
||||
}
|
||||
return (
|
||||
<p aria-live="polite" className="throughput-readout">
|
||||
<strong>{estimate.effectivePerHour} messages per hour</strong>
|
||||
<span>
|
||||
{estimate.spacingLabel}
|
||||
{estimate.isDelayBound
|
||||
? ' — the minimum delay is the binding limit, so raising the hourly limit alone changes nothing.'
|
||||
: ' — the hourly limit is the binding limit.'}
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function PacingCard({ config }: { config: ConfigResponse }) {
|
||||
const card = useConfigCard<PacingDraft>(config, {
|
||||
id: 'pacing',
|
||||
toDraft: createPacingDraft,
|
||||
validate: validatePacingDraft,
|
||||
risks: pacingRisks,
|
||||
});
|
||||
const { draft, errors } = card;
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
anchor="pacing"
|
||||
card={card}
|
||||
description="How fast the bot sends, and how it picks which posts to act on. Both limits apply at once."
|
||||
eyebrow="Throughput"
|
||||
saveLabel="Save pacing"
|
||||
title="Pacing"
|
||||
>
|
||||
<fieldset className="field-group">
|
||||
<legend>Sending pace</legend>
|
||||
<div className="field-row">
|
||||
<NumberField
|
||||
error={errors.minDelayMinutes}
|
||||
hint="Shortest gap between two messages."
|
||||
label="Minimum delay"
|
||||
min={0}
|
||||
name="minimum-delay"
|
||||
onChange={(minDelayMinutes) => card.update({ ...draft, minDelayMinutes })}
|
||||
suffix="min"
|
||||
value={draft.minDelayMinutes}
|
||||
/>
|
||||
<NumberField
|
||||
error={errors.maxMessagesPerHour}
|
||||
hint="Hard ceiling per rolling hour."
|
||||
label="Hourly limit"
|
||||
min={1}
|
||||
name="hourly-limit"
|
||||
onChange={(maxMessagesPerHour) => card.update({ ...draft, maxMessagesPerHour })}
|
||||
suffix="msg/hr"
|
||||
value={draft.maxMessagesPerHour}
|
||||
/>
|
||||
<NumberField
|
||||
error={errors.jitterSeconds}
|
||||
hint="Random padding so timing looks human."
|
||||
label="Random jitter"
|
||||
min={0}
|
||||
name="jitter"
|
||||
onChange={(jitterSeconds) => card.update({ ...draft, jitterSeconds })}
|
||||
suffix="sec"
|
||||
value={draft.jitterSeconds}
|
||||
/>
|
||||
</div>
|
||||
<ThroughputReadout draft={draft} />
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="field-group">
|
||||
<legend>Post discovery</legend>
|
||||
<SwitchField
|
||||
checked={draft.prioritizeRecentPosts}
|
||||
description="Act on posts inside the recent window before older ones."
|
||||
label="Prioritise recent posts"
|
||||
onChange={(prioritizeRecentPosts) => card.update({ ...draft, prioritizeRecentPosts })}
|
||||
/>
|
||||
<div className="field-row">
|
||||
<NumberField
|
||||
error={errors.recentWindowMinutes}
|
||||
hint="How new a post must be to count as recent."
|
||||
label="Recent window"
|
||||
min={1}
|
||||
name="recent-window"
|
||||
onChange={(recentWindowMinutes) => card.update({ ...draft, recentWindowMinutes })}
|
||||
suffix="min"
|
||||
value={draft.recentWindowMinutes}
|
||||
/>
|
||||
<NumberField
|
||||
error={errors.maxReadyCandidates}
|
||||
hint="Queue depth of posts waiting to be actioned (1–500)."
|
||||
label="Maximum ready posts"
|
||||
max={500}
|
||||
min={1}
|
||||
name="maximum-ready-posts"
|
||||
onChange={(maxReadyCandidates) => card.update({ ...draft, maxReadyCandidates })}
|
||||
suffix="posts"
|
||||
value={draft.maxReadyCandidates}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ZodType } from 'zod';
|
||||
import {
|
||||
ErrorResponseSchema,
|
||||
PushNotificationSettingsResponseSchema,
|
||||
PushNotificationSettingsUpdateSchema,
|
||||
PushNotificationTestResponseSchema,
|
||||
type PushNotificationSettingsResponse,
|
||||
type PushNotificationSettingsUpdate,
|
||||
type PushNotificationTestResponse,
|
||||
} from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { dashboardRequest } from '../../api/request.js';
|
||||
|
||||
async function parseResponse<T>(response: Response, schema: ZodType<T>): Promise<T> {
|
||||
const payload: unknown = await response.json();
|
||||
if (!response.ok) {
|
||||
const parsedError = ErrorResponseSchema.safeParse(payload);
|
||||
throw new Error(parsedError.success ? parsedError.data.error : `Request failed (${response.status})`);
|
||||
}
|
||||
return schema.parse(payload);
|
||||
}
|
||||
|
||||
export function fetchPushNotifications(): Promise<PushNotificationSettingsResponse> {
|
||||
return dashboardRequest('/api/push-notifications')
|
||||
.then((response) => parseResponse(response, PushNotificationSettingsResponseSchema));
|
||||
}
|
||||
|
||||
export async function savePushNotifications(
|
||||
updates: PushNotificationSettingsUpdate,
|
||||
): Promise<PushNotificationSettingsResponse> {
|
||||
const body = PushNotificationSettingsUpdateSchema.parse(updates);
|
||||
return parseResponse(
|
||||
await dashboardRequest('/api/push-notifications', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
PushNotificationSettingsResponseSchema,
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendTestPushNotification(): Promise<PushNotificationTestResponse> {
|
||||
return parseResponse(
|
||||
await dashboardRequest('/api/push-notifications/test', { method: 'POST' }),
|
||||
PushNotificationTestResponseSchema,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
NTFY_TOPIC_PATTERN,
|
||||
PUSH_NOTIFICATION_EVENT_KEYS,
|
||||
type PushNotificationEventKey,
|
||||
type PushNotificationSettingsResponse,
|
||||
} from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { SelectField, SwitchField, TextField } from '../../components/form-fields.js';
|
||||
import { useUnsavedChangesFlag } from '../../components/unsaved-changes.js';
|
||||
import {
|
||||
usePushNotificationsQuery,
|
||||
useSavePushNotificationsMutation,
|
||||
useTestPushNotificationMutation,
|
||||
} from './settings-queries.js';
|
||||
|
||||
type Priority = PushNotificationSettingsResponse['priority'];
|
||||
|
||||
interface PushDraft {
|
||||
enabled: boolean;
|
||||
serverUrl: string;
|
||||
topic: string;
|
||||
priority: Priority;
|
||||
events: Record<PushNotificationEventKey, boolean>;
|
||||
}
|
||||
|
||||
const eventLabels: Record<PushNotificationEventKey, { label: string; description: string }> = {
|
||||
imageReceived: { label: 'Image received', description: 'Someone sends an image into a conversation.' },
|
||||
replyReceived: { label: 'Reply received', description: 'A conversation gets its first reply.' },
|
||||
messageFailed: { label: 'Message failed', description: 'An outbound message could not be delivered.' },
|
||||
botStopped: { label: 'Bot stopped', description: 'Processing stops, whether requested or not.' },
|
||||
rateLimited: { label: 'Rate limit reached', description: 'Sending pauses until the limit resets.' },
|
||||
sessionExpired: { label: 'Account needs attention', description: 'A Reddit session expired or failed.' },
|
||||
syncFailed: { label: 'Sync failed', description: 'A conversation or media sync did not complete.' },
|
||||
};
|
||||
|
||||
const priorities: ReadonlyArray<{ value: Priority; label: string }> = [
|
||||
{ value: 'min', label: 'Min' },
|
||||
{ value: 'low', label: 'Low' },
|
||||
{ value: 'default', label: 'Default' },
|
||||
{ value: 'high', label: 'High' },
|
||||
{ value: 'max', label: 'Max' },
|
||||
];
|
||||
|
||||
function toDraft(settings: PushNotificationSettingsResponse): PushDraft {
|
||||
return {
|
||||
enabled: settings.enabled,
|
||||
serverUrl: settings.serverUrl,
|
||||
topic: settings.topic,
|
||||
priority: settings.priority,
|
||||
events: { ...settings.events },
|
||||
};
|
||||
}
|
||||
|
||||
function errorMessage(error: Error | null): string {
|
||||
return error?.message ?? 'Something went wrong.';
|
||||
}
|
||||
|
||||
function validate(draft: PushDraft): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
const serverUrl = draft.serverUrl.trim();
|
||||
if (serverUrl === '') {
|
||||
errors.serverUrl = 'Server URL is required.';
|
||||
} else {
|
||||
try {
|
||||
const url = new URL(serverUrl);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
errors.serverUrl = 'Server URL must be an http:// or https:// address.';
|
||||
}
|
||||
} catch {
|
||||
errors.serverUrl = 'Server URL must be an http:// or https:// address.';
|
||||
}
|
||||
}
|
||||
const topic = draft.topic.trim();
|
||||
if (topic === '') {
|
||||
if (draft.enabled) errors.topic = 'A topic is required before notifications can be enabled.';
|
||||
} else if (!NTFY_TOPIC_PATTERN.test(topic)) {
|
||||
errors.topic = 'Topic may only contain letters, numbers, hyphens and underscores.';
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function isDirty(draft: PushDraft, persisted: PushDraft): boolean {
|
||||
return JSON.stringify(draft) !== JSON.stringify(persisted);
|
||||
}
|
||||
|
||||
export function PushNotificationsCard() {
|
||||
const settingsQuery = usePushNotificationsQuery();
|
||||
const saveMutation = useSavePushNotificationsMutation();
|
||||
const testMutation = useTestPushNotificationMutation();
|
||||
const settings = settingsQuery.data;
|
||||
const [state, setState] = useState<{ persisted: PushDraft; draft: PushDraft } | null>(null);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const dirty = state ? isDirty(state.draft, state.persisted) : false;
|
||||
useUnsavedChangesFlag('notifications', dirty);
|
||||
|
||||
// Seed on first load, and reconcile server refetches: adopt them into a
|
||||
// clean editor, keep the operator's edits in a dirty one.
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
const serverDraft = toDraft(settings);
|
||||
setState((current) => {
|
||||
if (!current) return { persisted: serverDraft, draft: serverDraft };
|
||||
if (!isDirty(serverDraft, current.persisted)) return current;
|
||||
return isDirty(current.draft, current.persisted)
|
||||
? { persisted: serverDraft, draft: current.draft }
|
||||
: { persisted: serverDraft, draft: serverDraft };
|
||||
});
|
||||
}, [settings]);
|
||||
|
||||
if (settingsQuery.isError) {
|
||||
return (
|
||||
<section aria-labelledby="notifications-heading" className="panel settings-card" id="notifications">
|
||||
<div className="settings-card-heading">
|
||||
<div>
|
||||
<span className="eyebrow">Phone push</span>
|
||||
<h2 id="notifications-heading">Notifications</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="error-panel" role="alert">
|
||||
<strong>Notification settings unavailable</strong>
|
||||
<span>{errorMessage(settingsQuery.error)}</span>
|
||||
<button className="button secondary" onClick={() => void settingsQuery.refetch()} type="button">Retry notifications</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!state) {
|
||||
return (
|
||||
<section aria-label="Loading notifications" className="panel settings-card panel-loading" id="notifications" role="status">
|
||||
<span className="spinner" />Loading notification settings…
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { draft } = state;
|
||||
|
||||
function update(next: Partial<PushDraft>) {
|
||||
saveMutation.reset();
|
||||
testMutation.reset();
|
||||
setErrors({});
|
||||
setState((current) => (current ? { ...current, draft: { ...current.draft, ...next } } : current));
|
||||
}
|
||||
|
||||
function reset() {
|
||||
saveMutation.reset();
|
||||
testMutation.reset();
|
||||
setErrors({});
|
||||
setState((current) => (current ? { ...current, draft: current.persisted } : current));
|
||||
}
|
||||
|
||||
function save() {
|
||||
const validation = validate(draft);
|
||||
setErrors(validation);
|
||||
if (Object.keys(validation).length > 0) return;
|
||||
saveMutation.mutate({
|
||||
enabled: draft.enabled,
|
||||
serverUrl: draft.serverUrl.trim(),
|
||||
topic: draft.topic.trim(),
|
||||
priority: draft.priority,
|
||||
events: draft.events,
|
||||
}, {
|
||||
onSuccess(response) {
|
||||
const next = toDraft(response);
|
||||
setState({ persisted: next, draft: next });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const selectedCount = PUSH_NOTIFICATION_EVENT_KEYS.filter((key) => draft.events[key]).length;
|
||||
const errorCount = Object.keys(errors).length;
|
||||
|
||||
return (
|
||||
<section aria-labelledby="notifications-heading" className="panel settings-card" id="notifications">
|
||||
<div className="settings-card-heading">
|
||||
<div>
|
||||
<span className="eyebrow">Phone push</span>
|
||||
<h2 id="notifications-heading">Notifications</h2>
|
||||
<p>
|
||||
Selected events are published to one ntfy topic. Subscribe to the same topic in the ntfy app to receive
|
||||
them. Anyone who knows the topic name can read it, so treat it as a secret.
|
||||
</p>
|
||||
</div>
|
||||
<span className={draft.enabled ? 'pill success' : 'pill neutral'}>{draft.enabled ? 'Enabled' : 'Disabled'}</span>
|
||||
</div>
|
||||
|
||||
<div className="settings-card-body">
|
||||
<SwitchField
|
||||
checked={draft.enabled}
|
||||
description="Turn off to silence every event without losing your selection."
|
||||
label="Send push notifications"
|
||||
onChange={(enabled) => update({ enabled })}
|
||||
/>
|
||||
|
||||
<fieldset className="field-group">
|
||||
<legend>Delivery</legend>
|
||||
<div className="field-row">
|
||||
<TextField
|
||||
error={errors.serverUrl}
|
||||
hint="Your self-hosted ntfy instance."
|
||||
label="Server URL"
|
||||
name="ntfy-server-url"
|
||||
onChange={(serverUrl) => update({ serverUrl })}
|
||||
placeholder="https://ntfy.example.com"
|
||||
type="url"
|
||||
value={draft.serverUrl}
|
||||
/>
|
||||
<TextField
|
||||
error={errors.topic}
|
||||
hint="Letters, numbers, hyphens and underscores."
|
||||
label="Topic"
|
||||
name="ntfy-topic"
|
||||
onChange={(topic) => update({ topic })}
|
||||
placeholder="redditbot-7f3a9c21"
|
||||
value={draft.topic}
|
||||
/>
|
||||
<SelectField
|
||||
hint="How insistently the ntfy app alerts you."
|
||||
label="Priority"
|
||||
name="ntfy-priority"
|
||||
onChange={(priority) => update({ priority })}
|
||||
options={priorities}
|
||||
value={draft.priority}
|
||||
/>
|
||||
</div>
|
||||
<p className="field-hint">
|
||||
{settings?.hasAuthToken
|
||||
? 'An access token is configured, so protected topics will accept these notifications.'
|
||||
: 'No access token is configured. Set NTFY_AUTH_TOKEN in the environment if your topic requires one.'}
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="field-group">
|
||||
<legend>Events to notify me about ({selectedCount} of {PUSH_NOTIFICATION_EVENT_KEYS.length})</legend>
|
||||
<div className="switch-list">
|
||||
{PUSH_NOTIFICATION_EVENT_KEYS.map((key) => (
|
||||
<SwitchField
|
||||
checked={draft.events[key]}
|
||||
description={eventLabels[key].description}
|
||||
key={key}
|
||||
label={eventLabels[key].label}
|
||||
onChange={(checked) => update({ events: { ...draft.events, [key]: checked } })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<footer className="settings-card-footer">
|
||||
<div aria-live="polite" className="settings-card-status">
|
||||
{dirty ? <span role="status">Unsaved changes in this section.</span> : <span>Saved.</span>}
|
||||
{errorCount > 0 ? (
|
||||
<strong className="save-error" role="alert">
|
||||
{errorCount === 1 ? 'Resolve 1 problem before saving.' : `Resolve ${errorCount} problems before saving.`}
|
||||
</strong>
|
||||
) : null}
|
||||
{saveMutation.isSuccess && !dirty ? <strong className="save-success">Notifications saved.</strong> : null}
|
||||
{saveMutation.isError ? <strong className="save-error" role="alert">{errorMessage(saveMutation.error)}</strong> : null}
|
||||
{testMutation.isSuccess ? (
|
||||
<strong className={testMutation.data.delivered ? 'save-success' : 'save-error'} role="status">
|
||||
{testMutation.data.delivered
|
||||
? 'Test notification sent — check your phone.'
|
||||
: `Test notification not sent: ${testMutation.data.reason ?? 'unknown reason'}`}
|
||||
</strong>
|
||||
) : null}
|
||||
{testMutation.isError ? <strong className="save-error" role="alert">{errorMessage(testMutation.error)}</strong> : null}
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button
|
||||
className="button secondary"
|
||||
disabled={dirty || !draft.enabled || testMutation.isPending}
|
||||
onClick={() => testMutation.mutate()}
|
||||
title={dirty ? 'Save your changes before sending a test.' : undefined}
|
||||
type="button"
|
||||
>
|
||||
{testMutation.isPending ? 'Sending test…' : 'Send test'}
|
||||
</button>
|
||||
<button className="button secondary" disabled={!dirty || saveMutation.isPending} onClick={reset} type="button">
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
aria-busy={saveMutation.isPending ? 'true' : undefined}
|
||||
className="button primary"
|
||||
disabled={!dirty || saveMutation.isPending}
|
||||
onClick={save}
|
||||
type="button"
|
||||
>
|
||||
{saveMutation.isPending ? 'Saving…' : 'Save notifications'}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</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">
|
||||
<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 className="button secondary" disabled={isPending} onClick={onCancel} ref={cancelButtonRef} type="button">Cancel</button>
|
||||
<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>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { SwitchField } from '../../components/form-fields.js';
|
||||
import { SettingsCard } from './settings-card.js';
|
||||
import { createSafetyDraft, safetyRisks, validateSafetyDraft, type SafetyDraft } from './settings-model.js';
|
||||
import { useConfigCard } from './use-config-card.js';
|
||||
|
||||
export function SafetyCard({ config }: { config: ConfigResponse }) {
|
||||
const card = useConfigCard<SafetyDraft>(config, {
|
||||
id: 'safety',
|
||||
toDraft: createSafetyDraft,
|
||||
validate: validateSafetyDraft,
|
||||
risks: safetyRisks,
|
||||
});
|
||||
const isLive = !card.persisted.dryRun;
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
anchor="safety"
|
||||
card={card}
|
||||
description="The single switch that decides whether anything actually reaches Reddit."
|
||||
eyebrow="Send guard"
|
||||
saveLabel="Save safety"
|
||||
status={<span className={isLive ? 'pill danger' : 'pill success'}>{isLive ? 'Sending live' : 'Dry run'}</span>}
|
||||
title="Safety"
|
||||
>
|
||||
{/* State first: the operator should read what is happening now before
|
||||
being shown the control that changes it. */}
|
||||
<p className={isLive ? 'state-banner is-live' : 'state-banner is-safe'} role="status">
|
||||
<strong>{isLive ? 'Messages are being sent to real people.' : 'No messages are being sent.'}</strong>
|
||||
<span>
|
||||
{isLive
|
||||
? 'Every evaluated post can produce a real outbound message, subject to the pacing limits.'
|
||||
: 'Posts are evaluated and replies are drafted, but nothing is delivered to Reddit.'}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<SwitchField
|
||||
checked={card.draft.dryRun}
|
||||
description="Evaluate posts and draft messages without delivering anything. Turn this off only when you intend to send."
|
||||
label="Dry run mode"
|
||||
onChange={(dryRun) => card.update({ ...card.draft, dryRun })}
|
||||
tone="critical"
|
||||
/>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { RiskDialog } from './risk-dialog.js';
|
||||
import type { ConfigCard } from './use-config-card.js';
|
||||
|
||||
interface SettingsCardProps<Draft> {
|
||||
/** Anchor target and heading id stem, e.g. "pacing". */
|
||||
anchor: string;
|
||||
card: ConfigCard<Draft>;
|
||||
children: ReactNode;
|
||||
description: string;
|
||||
eyebrow: string;
|
||||
/** Rendered in the heading, opposite the title — usually a status pill. */
|
||||
status?: ReactNode;
|
||||
saveLabel: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
function errorMessage(error: Error | null): string {
|
||||
return error?.message ?? 'Something went wrong.';
|
||||
}
|
||||
|
||||
/**
|
||||
* A self-contained settings section: its own draft, its own validation, and its
|
||||
* own save. Nothing in this card can block or be blocked by another card.
|
||||
*/
|
||||
export function SettingsCard<Draft>({
|
||||
anchor,
|
||||
card,
|
||||
children,
|
||||
description,
|
||||
eyebrow,
|
||||
saveLabel,
|
||||
status,
|
||||
title,
|
||||
}: SettingsCardProps<Draft>) {
|
||||
const headingId = `${anchor}-heading`;
|
||||
return (
|
||||
<section aria-labelledby={headingId} className="panel settings-card" id={anchor}>
|
||||
<div className="settings-card-heading">
|
||||
<div>
|
||||
<span className="eyebrow">{eyebrow}</span>
|
||||
<h2 id={headingId}>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
{status}
|
||||
</div>
|
||||
|
||||
<div className="settings-card-body">{children}</div>
|
||||
|
||||
<footer className="settings-card-footer">
|
||||
<div aria-live="polite" className="settings-card-status">
|
||||
{card.isDirty ? <span role="status">Unsaved changes in this section.</span> : <span>Saved.</span>}
|
||||
{card.hasServerChanged ? (
|
||||
<strong className="save-warning" role="status">Changed on the server — reset to load the new values.</strong>
|
||||
) : null}
|
||||
{card.errorCount > 0 ? (
|
||||
<strong className="save-error" role="alert">
|
||||
{card.errorCount === 1
|
||||
? 'Resolve 1 problem before saving.'
|
||||
: `Resolve ${card.errorCount} problems before saving.`}
|
||||
</strong>
|
||||
) : null}
|
||||
{card.isSaved ? <strong className="save-success" role="status">{title} saved.</strong> : null}
|
||||
{card.saveError ? <strong className="save-error" role="alert">{errorMessage(card.saveError)}</strong> : null}
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button
|
||||
className="button secondary"
|
||||
disabled={(!card.isDirty && !card.hasServerChanged) || card.isSaving}
|
||||
onClick={card.reset}
|
||||
type="button"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
aria-busy={card.isSaving ? 'true' : undefined}
|
||||
className="button primary"
|
||||
disabled={!card.isDirty || card.isSaving}
|
||||
onClick={card.save}
|
||||
type="button"
|
||||
>
|
||||
{card.isSaving ? 'Saving…' : saveLabel}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{card.pendingRisks ? (
|
||||
<RiskDialog
|
||||
isPending={card.isSaving}
|
||||
onCancel={card.cancelRisks}
|
||||
onConfirm={card.confirmRisks}
|
||||
reasons={card.pendingRisks}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ConfigResponse, ConfigUpdateRequest } from '../../../../../packages/api-contracts/src/index.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 { BehaviourSection, MonitoringSection, RateLimitsSection } from './settings-sections.js';
|
||||
import type { SettingsSection } from './settings-page.js';
|
||||
|
||||
interface SettingsEditorProps {
|
||||
config: ConfigResponse;
|
||||
section: SettingsSection;
|
||||
}
|
||||
|
||||
interface PendingRiskySave {
|
||||
payload: ConfigUpdateRequest;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
function errorMessage(error: Error | null): string {
|
||||
return error?.message ?? 'Something went wrong.';
|
||||
}
|
||||
|
||||
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 saveMutation = useSaveSettingsMutation();
|
||||
const isDirty = isDraftDirty(draft, persistedDraft);
|
||||
|
||||
useEffect(() => {
|
||||
function warnBeforeUnload(event: BeforeUnloadEvent) {
|
||||
if (!isDirty) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
}
|
||||
window.addEventListener('beforeunload', warnBeforeUnload);
|
||||
return () => window.removeEventListener('beforeunload', warnBeforeUnload);
|
||||
}, [isDirty]);
|
||||
|
||||
function applySavedResponse(response: Awaited<ReturnType<typeof saveMutation.mutateAsync>>) {
|
||||
const nextConfig: ConfigResponse = {
|
||||
...response.config,
|
||||
subreddits: response.subreddits,
|
||||
tones: config.tones,
|
||||
advanced: response.advanced ?? config.advanced,
|
||||
};
|
||||
const nextDraft = createSettingsDraft(nextConfig);
|
||||
setPersistedDraft(nextDraft);
|
||||
setDraft(nextDraft);
|
||||
setPendingRiskySave(null);
|
||||
}
|
||||
|
||||
function submit(payload: ConfigUpdateRequest) {
|
||||
saveMutation.mutate(payload, { onSuccess: applySavedResponse });
|
||||
}
|
||||
|
||||
function updateDraft(nextDraft: SettingsDraft) {
|
||||
saveMutation.reset();
|
||||
setErrors({});
|
||||
setDraft(nextDraft);
|
||||
}
|
||||
|
||||
function prepareSave() {
|
||||
saveMutation.reset();
|
||||
const validation = validateSettingsDraft(draft);
|
||||
setErrors(validation.errors);
|
||||
if (!validation.data) return;
|
||||
|
||||
const reasons: string[] = [];
|
||||
if (persistedDraft.dryRun && !validation.data.dryRun) {
|
||||
reasons.push('Automated messages will be sent to Reddit because dry run mode is being disabled.');
|
||||
}
|
||||
const persistedLimit = Number(persistedDraft.maxMessagesPerHour);
|
||||
if (validation.data.maxMessagesPerHour > 20 && validation.data.maxMessagesPerHour > persistedLimit) {
|
||||
reasons.push(`The limit will allow ${validation.data.maxMessagesPerHour} messages per hour.`);
|
||||
}
|
||||
|
||||
if (reasons.length > 0) {
|
||||
setPendingRiskySave({ payload: validation.data.payload, reasons });
|
||||
return;
|
||||
}
|
||||
submit(validation.data.payload);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{section === 'monitoring' ? <MonitoringSection draft={draft} errors={errors} onChange={updateDraft} /> : null}
|
||||
{section === 'rate-limits' ? <RateLimitsSection draft={draft} errors={errors} onChange={updateDraft} /> : null}
|
||||
{section === 'behaviour' ? <BehaviourSection config={config} draft={draft} errors={errors} onChange={updateDraft} /> : null}
|
||||
{section === 'access' ? <AccessPanel /> : null}
|
||||
|
||||
{section !== 'access' ? (
|
||||
<div className="settings-save-bar">
|
||||
<div aria-live="polite">
|
||||
{isDirty ? <span role="status">You have unsaved changes.</span> : <span>All changes saved.</span>}
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{pendingRiskySave ? (
|
||||
<RiskDialog
|
||||
isPending={saveMutation.isPending}
|
||||
onCancel={() => setPendingRiskySave(null)}
|
||||
onConfirm={() => submit(pendingRiskySave.payload)}
|
||||
reasons={pendingRiskySave.reasons}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,158 +1,263 @@
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
ConfigUpdateRequestSchema,
|
||||
SubredditConfigSchema,
|
||||
ToneKeySchema,
|
||||
type ConfigResponse,
|
||||
type ConfigUpdateRequest,
|
||||
} from '../../../../../packages/api-contracts/src/index.js';
|
||||
import type { ConfigCardValidation } from './use-config-card.js';
|
||||
|
||||
export interface SubredditDraft {
|
||||
name: string;
|
||||
type: 'image' | 'chat';
|
||||
weight: string;
|
||||
tone: z.infer<typeof ToneKeySchema>;
|
||||
scripted: string;
|
||||
}
|
||||
/** Above this hourly ceiling a save is treated as operationally risky. */
|
||||
export const RISKY_MESSAGES_PER_HOUR = 20;
|
||||
|
||||
export interface SettingsDraft {
|
||||
export interface PacingDraft {
|
||||
minDelayMinutes: string;
|
||||
maxMessagesPerHour: string;
|
||||
jitterSeconds: string;
|
||||
dryRun: boolean;
|
||||
prioritizeRecentPosts: boolean;
|
||||
recentWindowMinutes: string;
|
||||
subreddits: SubredditDraft[];
|
||||
maxReadyCandidates: string;
|
||||
}
|
||||
|
||||
export interface SafetyDraft {
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
export interface TargetingDraft {
|
||||
physiqueTargetingEnabled: boolean;
|
||||
physiqueMinScore: string;
|
||||
physiqueMaxScore: string;
|
||||
}
|
||||
|
||||
export interface AdvancedDraft {
|
||||
advancedConfigJson: string;
|
||||
}
|
||||
|
||||
export interface ValidatedSettings {
|
||||
payload: ConfigUpdateRequest;
|
||||
dryRun: boolean;
|
||||
maxMessagesPerHour: number;
|
||||
}
|
||||
|
||||
export interface SettingsValidationResult {
|
||||
data?: ValidatedSettings;
|
||||
errors: Record<string, string>;
|
||||
}
|
||||
|
||||
const EditableSubredditSchema = SubredditConfigSchema.extend({
|
||||
name: z.string().trim().min(1, 'Subreddit name is required.'),
|
||||
weight: z.number().positive('Subreddit weight must be greater than 0.').optional(),
|
||||
});
|
||||
|
||||
const ValidatedSettingsSchema = z.strictObject({
|
||||
minDelayMinutes: z.number().finite().min(0, 'Minimum delay cannot be negative.'),
|
||||
maxMessagesPerHour: z.number().int().min(1, 'Maximum messages per hour must be at least 1.'),
|
||||
jitterSeconds: z.number().finite().min(0, 'Jitter cannot be negative.'),
|
||||
dryRun: z.boolean(),
|
||||
prioritizeRecentPosts: z.boolean(),
|
||||
recentWindowMinutes: z.number().finite().positive('Recent window must be greater than 0 minutes.'),
|
||||
subreddits: z.array(EditableSubredditSchema).min(1, 'At least one subreddit is required.'),
|
||||
}).superRefine((value, context) => {
|
||||
const names = value.subreddits.map((subreddit) => subreddit.name.trim().toLowerCase());
|
||||
if (new Set(names).size !== names.length) {
|
||||
context.addIssue({ code: 'custom', path: ['subreddits'], message: 'Subreddit names must be unique.' });
|
||||
}
|
||||
});
|
||||
|
||||
export function createSettingsDraft(config: ConfigResponse): SettingsDraft {
|
||||
export function createPacingDraft(config: ConfigResponse): PacingDraft {
|
||||
return {
|
||||
minDelayMinutes: String(config.rateLimit.minDelayBetweenMessagesMs / 60_000),
|
||||
maxMessagesPerHour: String(config.rateLimit.maxMessagesPerHour),
|
||||
jitterSeconds: String(config.rateLimit.jitterMs / 1_000),
|
||||
dryRun: config.browser.dryRun,
|
||||
prioritizeRecentPosts: config.reddit.prioritizeRecentPosts,
|
||||
recentWindowMinutes: String(config.reddit.recentWindowMinutes),
|
||||
subreddits: config.subreddits.map((subreddit) => ({
|
||||
name: subreddit.name,
|
||||
type: subreddit.type,
|
||||
weight: subreddit.weight === undefined ? '' : String(subreddit.weight),
|
||||
tone: subreddit.tone,
|
||||
scripted: subreddit.scripted ?? '',
|
||||
})),
|
||||
advancedConfigJson: config.advanced ? JSON.stringify(config.advanced, null, 2) : '',
|
||||
maxReadyCandidates: String(config.reddit.maxReadyCandidates),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationResult {
|
||||
const requiredErrors: Record<string, string> = {};
|
||||
if (draft.minDelayMinutes.trim() === '') requiredErrors.minDelayMinutes = 'Minimum delay is required.';
|
||||
if (draft.maxMessagesPerHour.trim() === '') requiredErrors.maxMessagesPerHour = 'Maximum messages per hour is required.';
|
||||
if (draft.jitterSeconds.trim() === '') requiredErrors.jitterSeconds = 'Jitter is required.';
|
||||
if (draft.recentWindowMinutes.trim() === '') requiredErrors.recentWindowMinutes = 'Recent window is required.';
|
||||
draft.subreddits.forEach((subreddit, index) => {
|
||||
if (subreddit.name.trim() === '') requiredErrors[`subreddits.${index}.name`] = 'Subreddit name is required.';
|
||||
if (subreddit.weight.trim() !== '' && Number(subreddit.weight) <= 0) {
|
||||
requiredErrors[`subreddits.${index}.weight`] = 'Subreddit weight must be greater than 0.';
|
||||
}
|
||||
});
|
||||
if (Object.keys(requiredErrors).length > 0) return { errors: requiredErrors };
|
||||
export function createSafetyDraft(config: ConfigResponse): SafetyDraft {
|
||||
return { dryRun: config.browser.dryRun };
|
||||
}
|
||||
|
||||
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.' } };
|
||||
}
|
||||
export function createTargetingDraft(config: ConfigResponse): TargetingDraft {
|
||||
return {
|
||||
physiqueTargetingEnabled: config.reddit.physiqueTargeting.enabled,
|
||||
physiqueMinScore: String(config.reddit.physiqueTargeting.minScore),
|
||||
physiqueMaxScore: String(config.reddit.physiqueTargeting.maxScore),
|
||||
};
|
||||
}
|
||||
|
||||
export function createAdvancedDraft(config: ConfigResponse): AdvancedDraft {
|
||||
return { advancedConfigJson: config.advanced ? JSON.stringify(config.advanced, null, 2) : '' };
|
||||
}
|
||||
|
||||
const PacingSchema = z.strictObject({
|
||||
minDelayMinutes: z.number().finite().min(0, 'Minimum delay cannot be negative.'),
|
||||
maxMessagesPerHour: z.number().int('Hourly limit must be a whole number.').min(1, 'Hourly limit must be at least 1.'),
|
||||
jitterSeconds: z.number().finite().min(0, 'Jitter cannot be negative.'),
|
||||
prioritizeRecentPosts: z.boolean(),
|
||||
recentWindowMinutes: z.number().finite().positive('Recent window must be greater than 0 minutes.'),
|
||||
maxReadyCandidates: z.number()
|
||||
.int('Ready-post limit must be a whole number.')
|
||||
.min(1, 'Ready-post limit must be at least 1.')
|
||||
.max(500, 'Ready-post limit cannot exceed 500.'),
|
||||
});
|
||||
|
||||
const requiredPacingFields: ReadonlyArray<{ key: keyof PacingDraft; message: string }> = [
|
||||
{ key: 'minDelayMinutes', message: 'Minimum delay is required.' },
|
||||
{ key: 'maxMessagesPerHour', message: 'Hourly limit is required.' },
|
||||
{ key: 'jitterSeconds', message: 'Jitter is required.' },
|
||||
{ key: 'recentWindowMinutes', message: 'Recent window is required.' },
|
||||
{ key: 'maxReadyCandidates', message: 'Ready-post limit is required.' },
|
||||
];
|
||||
|
||||
export function validatePacingDraft(draft: PacingDraft): ConfigCardValidation {
|
||||
const errors: Record<string, string> = {};
|
||||
for (const field of requiredPacingFields) {
|
||||
if (String(draft[field.key]).trim() === '') errors[field.key] = field.message;
|
||||
}
|
||||
|
||||
const parsed = ValidatedSettingsSchema.safeParse({
|
||||
const parsed = PacingSchema.safeParse({
|
||||
minDelayMinutes: Number(draft.minDelayMinutes),
|
||||
maxMessagesPerHour: Number(draft.maxMessagesPerHour),
|
||||
jitterSeconds: Number(draft.jitterSeconds),
|
||||
dryRun: draft.dryRun,
|
||||
prioritizeRecentPosts: draft.prioritizeRecentPosts,
|
||||
recentWindowMinutes: Number(draft.recentWindowMinutes),
|
||||
subreddits: draft.subreddits.map((subreddit) => ({
|
||||
name: subreddit.name.trim(),
|
||||
type: subreddit.type,
|
||||
weight: subreddit.weight.trim() === '' ? undefined : Number(subreddit.weight),
|
||||
tone: subreddit.tone,
|
||||
scripted: subreddit.scripted.trim() === '' ? undefined : subreddit.scripted,
|
||||
})),
|
||||
maxReadyCandidates: Number(draft.maxReadyCandidates),
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
const errors: Record<string, string> = {};
|
||||
for (const issue of parsed.error.issues) {
|
||||
const path = issue.path.join('.');
|
||||
if (!(path in errors)) errors[path] = issue.message;
|
||||
}
|
||||
return { errors };
|
||||
}
|
||||
if (!parsed.success || Object.keys(errors).length > 0) return { errors };
|
||||
|
||||
const payload = ConfigUpdateRequestSchema.parse({
|
||||
return {
|
||||
payload: ConfigUpdateRequestSchema.parse({
|
||||
rateLimit: {
|
||||
minDelayBetweenMessagesMs: parsed.data.minDelayMinutes * 60_000,
|
||||
maxMessagesPerHour: parsed.data.maxMessagesPerHour,
|
||||
jitterMs: parsed.data.jitterSeconds * 1_000,
|
||||
},
|
||||
browser: { dryRun: parsed.data.dryRun },
|
||||
reddit: {
|
||||
prioritizeRecentPosts: parsed.data.prioritizeRecentPosts,
|
||||
recentWindowMinutes: parsed.data.recentWindowMinutes,
|
||||
maxReadyCandidates: parsed.data.maxReadyCandidates,
|
||||
},
|
||||
subreddits: parsed.data.subreddits,
|
||||
...(advanced ? { advanced } : {}),
|
||||
});
|
||||
|
||||
return {
|
||||
data: {
|
||||
payload,
|
||||
dryRun: parsed.data.dryRun,
|
||||
maxMessagesPerHour: parsed.data.maxMessagesPerHour,
|
||||
},
|
||||
}),
|
||||
errors: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function isDraftDirty(draft: SettingsDraft, persisted: SettingsDraft): boolean {
|
||||
return JSON.stringify(draft) !== JSON.stringify(persisted);
|
||||
export function pacingRisks(draft: PacingDraft, persisted: PacingDraft): string[] {
|
||||
const next = Number(draft.maxMessagesPerHour);
|
||||
const current = Number(persisted.maxMessagesPerHour);
|
||||
if (next > RISKY_MESSAGES_PER_HOUR && next > current) {
|
||||
return [`The limit will allow ${next} messages per hour, which raises the risk of Reddit rate limiting or banning the account.`];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function validateSafetyDraft(draft: SafetyDraft): ConfigCardValidation {
|
||||
return {
|
||||
payload: ConfigUpdateRequestSchema.parse({ browser: { dryRun: draft.dryRun } }),
|
||||
errors: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function safetyRisks(draft: SafetyDraft, persisted: SafetyDraft): string[] {
|
||||
if (persisted.dryRun && !draft.dryRun) {
|
||||
return ['Dry run is being turned off. Automated messages will be sent to real Reddit users as soon as this is saved.'];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const PhysiqueScoreSchema = z.number()
|
||||
.int('Physique scores must be whole numbers.')
|
||||
.min(1, 'Physique scores must be between 1 and 10.')
|
||||
.max(10, 'Physique scores must be between 1 and 10.');
|
||||
|
||||
const TargetingSchema = z.strictObject({
|
||||
physiqueTargetingEnabled: z.boolean(),
|
||||
physiqueMinScore: PhysiqueScoreSchema,
|
||||
physiqueMaxScore: PhysiqueScoreSchema,
|
||||
}).superRefine((value, context) => {
|
||||
if (value.physiqueMinScore > value.physiqueMaxScore) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['physiqueMinScore'],
|
||||
message: 'Minimum physique score cannot exceed maximum physique score.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export function validateTargetingDraft(draft: TargetingDraft): ConfigCardValidation {
|
||||
const errors: Record<string, string> = {};
|
||||
if (draft.physiqueMinScore.trim() === '') errors.physiqueMinScore = 'Minimum physique score is required.';
|
||||
if (draft.physiqueMaxScore.trim() === '') errors.physiqueMaxScore = 'Maximum physique score is required.';
|
||||
|
||||
const parsed = TargetingSchema.safeParse({
|
||||
physiqueTargetingEnabled: draft.physiqueTargetingEnabled,
|
||||
physiqueMinScore: Number(draft.physiqueMinScore),
|
||||
physiqueMaxScore: Number(draft.physiqueMaxScore),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
for (const issue of parsed.error.issues) {
|
||||
const path = issue.path.join('.');
|
||||
if (!(path in errors)) errors[path] = issue.message;
|
||||
}
|
||||
}
|
||||
if (!parsed.success || Object.keys(errors).length > 0) return { errors };
|
||||
|
||||
// Only `physiqueTargeting` is sent: the route applies each `reddit` field
|
||||
// independently, so the pacing card's values are left untouched.
|
||||
return {
|
||||
payload: ConfigUpdateRequestSchema.parse({
|
||||
reddit: {
|
||||
physiqueTargeting: {
|
||||
enabled: parsed.data.physiqueTargetingEnabled,
|
||||
minScore: parsed.data.physiqueMinScore,
|
||||
maxScore: parsed.data.physiqueMaxScore,
|
||||
},
|
||||
},
|
||||
}),
|
||||
errors: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function targetingRisks(draft: TargetingDraft, persisted: TargetingDraft): string[] {
|
||||
if (draft.physiqueTargetingEnabled && !persisted.physiqueTargetingEnabled) {
|
||||
return [
|
||||
`Image posts will be screened to a physique score of ${draft.physiqueMinScore}–${draft.physiqueMaxScore}, and posts without enough visible body evidence will be skipped entirely. This can sharply reduce how many posts qualify.`,
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function validateAdvancedDraft(draft: AdvancedDraft): ConfigCardValidation {
|
||||
const raw = draft.advancedConfigJson.trim();
|
||||
if (raw === '') return { errors: { advancedConfigJson: 'Advanced configuration cannot be emptied here.' } };
|
||||
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : 'Unparseable JSON.';
|
||||
return { errors: { advancedConfigJson: `Not valid JSON — ${detail}` } };
|
||||
}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return { errors: { advancedConfigJson: 'Advanced configuration must be a JSON object, not an array or a bare value.' } };
|
||||
}
|
||||
|
||||
return {
|
||||
payload: ConfigUpdateRequestSchema.parse({ advanced: value as Record<string, unknown> }),
|
||||
errors: {},
|
||||
};
|
||||
}
|
||||
|
||||
export interface ThroughputEstimate {
|
||||
/** Messages per hour actually achievable, given both the cap and the delay. */
|
||||
effectivePerHour: number;
|
||||
/** True when the minimum delay, not the hourly cap, is the binding limit. */
|
||||
isDelayBound: boolean;
|
||||
/** Human-readable spacing between messages including average jitter. */
|
||||
spacingLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The hourly cap and the minimum delay both throttle sending, so the real
|
||||
* ceiling is whichever binds first. Surfacing it stops operators from raising
|
||||
* one number and seeing no change because the other one governs.
|
||||
*/
|
||||
export function estimateThroughput(draft: PacingDraft): ThroughputEstimate | null {
|
||||
// A blank field coerces to 0, which would produce a confident but wrong
|
||||
// readout, so treat anything unfilled as no estimate at all.
|
||||
const inputs = [draft.maxMessagesPerHour, draft.minDelayMinutes, draft.jitterSeconds];
|
||||
if (inputs.some((value) => value.trim() === '')) return null;
|
||||
|
||||
const perHourCap = Number(draft.maxMessagesPerHour);
|
||||
const minDelayMinutes = Number(draft.minDelayMinutes);
|
||||
const jitterSeconds = Number(draft.jitterSeconds);
|
||||
if (!Number.isFinite(perHourCap) || !Number.isFinite(minDelayMinutes) || !Number.isFinite(jitterSeconds)) return null;
|
||||
if (perHourCap < 1 || minDelayMinutes < 0 || jitterSeconds < 0) return null;
|
||||
|
||||
const averageSpacingMinutes = minDelayMinutes + jitterSeconds / 120;
|
||||
const delayCap = averageSpacingMinutes > 0 ? 60 / averageSpacingMinutes : Number.POSITIVE_INFINITY;
|
||||
const effectivePerHour = Math.min(perHourCap, delayCap);
|
||||
|
||||
return {
|
||||
effectivePerHour: Math.round(effectivePerHour * 10) / 10,
|
||||
isDelayBound: delayCap < perHourCap,
|
||||
spacingLabel: averageSpacingMinutes === 0
|
||||
? 'back to back'
|
||||
: `about one every ${Math.round(averageSpacingMinutes * 10) / 10} min`,
|
||||
};
|
||||
}
|
||||
@@ -1,44 +1,87 @@
|
||||
import { SettingsEditor } from './settings-editor.js';
|
||||
import { useEffect } from 'react';
|
||||
import { UnsavedChangesProvider } from '../../components/unsaved-changes.js';
|
||||
import { AdvancedCard } from './advanced-card.js';
|
||||
import { PacingCard } from './pacing-card.js';
|
||||
import { PushNotificationsCard } from './push-notifications-panel.js';
|
||||
import { SafetyCard } from './safety-card.js';
|
||||
import { TargetingCard } from './targeting-card.js';
|
||||
import { useSettingsQuery } from './settings-queries.js';
|
||||
import './settings.css';
|
||||
|
||||
export type SettingsSection = 'monitoring' | 'rate-limits' | 'behaviour' | 'access';
|
||||
|
||||
interface SettingsPageProps {
|
||||
section: SettingsSection;
|
||||
onSectionChange(section: SettingsSection): void;
|
||||
}
|
||||
|
||||
const sections: ReadonlyArray<{ id: SettingsSection; label: string; description: string }> = [
|
||||
{ id: 'monitoring', label: 'Monitoring', description: 'Post discovery and recency' },
|
||||
{ id: 'rate-limits', label: 'Rate limits', description: 'Sending pace and safeguards' },
|
||||
{ id: 'behaviour', label: 'Behaviour', description: 'Dry run and subreddits' },
|
||||
{ id: 'access', label: 'Access', description: 'Extension session dump' },
|
||||
/** In-page section anchors. Every section is on one page; these only jump. */
|
||||
const sections: ReadonlyArray<{ anchor: string; label: string }> = [
|
||||
{ anchor: 'safety', label: 'Safety' },
|
||||
{ anchor: 'pacing', label: 'Pacing' },
|
||||
{ anchor: 'targeting', label: 'Targeting' },
|
||||
{ anchor: 'notifications', label: 'Notifications' },
|
||||
{ anchor: 'advanced', label: 'Advanced' },
|
||||
];
|
||||
|
||||
function errorMessage(error: Error | null): string {
|
||||
return error?.message ?? 'Something went wrong.';
|
||||
}
|
||||
|
||||
export function SettingsPage({ section, onSectionChange }: SettingsPageProps) {
|
||||
interface SettingsPageProps {
|
||||
/** Section to scroll to on mount, from the legacy `?section=` link format. */
|
||||
initialAnchor?: string;
|
||||
}
|
||||
|
||||
export function SettingsPage({ initialAnchor }: SettingsPageProps) {
|
||||
const settingsQuery = useSettingsQuery();
|
||||
const hasConfig = Boolean(settingsQuery.data);
|
||||
|
||||
// Deep links land before the cards exist, so wait for the config to render.
|
||||
// Scrolling is a nicety: environments without scrollIntoView still land on
|
||||
// the right page, just at the top.
|
||||
useEffect(() => {
|
||||
if (!initialAnchor || !hasConfig) return;
|
||||
const target = document.getElementById(initialAnchor);
|
||||
if (typeof target?.scrollIntoView === 'function') target.scrollIntoView({ block: 'start' });
|
||||
}, [initialAnchor, hasConfig]);
|
||||
|
||||
return (
|
||||
<UnsavedChangesProvider>
|
||||
<div className="page settings-page">
|
||||
<header className="page-header">
|
||||
<div><span className="eyebrow">Platform configuration</span><h1>Settings</h1><p>Manage supported runtime behaviour and Reddit account access.</p></div>
|
||||
<div>
|
||||
<span className="eyebrow">Platform configuration</span>
|
||||
<h1>Settings</h1>
|
||||
<p>Runtime behaviour for every account. Each section saves on its own, so one change never waits on another.</p>
|
||||
</div>
|
||||
</header>
|
||||
<nav aria-label="Settings sections" className="settings-navigation">
|
||||
{sections.map((item) => (
|
||||
<button aria-current={section === item.id ? 'page' : undefined} aria-label={item.label} className={section === item.id ? 'is-active' : ''} key={item.id} onClick={() => onSectionChange(item.id)} type="button"><strong>{item.label}</strong><small>{item.description}</small></button>
|
||||
|
||||
{hasConfig ? (
|
||||
<nav aria-label="Settings sections" className="section-rail">
|
||||
{sections.map((section) => (
|
||||
<a href={`#${section.anchor}`} key={section.anchor}>{section.label}</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{settingsQuery.isPending ? <div aria-label="Loading settings" className="page-state settings-loading" role="status"><span className="spinner" />Loading settings…</div> : null}
|
||||
{settingsQuery.isError ? (
|
||||
<div className="page-state error-state settings-loading" role="alert"><strong>Settings unavailable</strong><span>{errorMessage(settingsQuery.error)}</span><button className="button secondary" onClick={() => void settingsQuery.refetch()} type="button">Retry settings</button></div>
|
||||
) : null}
|
||||
{settingsQuery.data ? <SettingsEditor config={settingsQuery.data} section={section} /> : null}
|
||||
|
||||
{settingsQuery.isPending ? (
|
||||
<div aria-label="Loading settings" className="page-state" role="status">
|
||||
<span className="spinner" />Loading settings…
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{settingsQuery.isError ? (
|
||||
<div className="page-state error-state" role="alert">
|
||||
<strong>Settings unavailable</strong>
|
||||
<span>{errorMessage(settingsQuery.error)}</span>
|
||||
<button className="button secondary" onClick={() => void settingsQuery.refetch()} type="button">Retry settings</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{settingsQuery.data ? (
|
||||
<div className="settings-stack">
|
||||
<SafetyCard config={settingsQuery.data} />
|
||||
<PacingCard config={settingsQuery.data} />
|
||||
<TargetingCard config={settingsQuery.data} />
|
||||
<PushNotificationsCard />
|
||||
<AdvancedCard config={settingsQuery.data} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</UnsavedChangesProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,37 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { fetchLoginStatus, fetchRedditAccounts, fetchSettings, importRedditSession, saveSettings } from './settings-api.js';
|
||||
import {
|
||||
fetchPushNotifications,
|
||||
savePushNotifications,
|
||||
sendTestPushNotification,
|
||||
} from './push-notifications-api.js';
|
||||
|
||||
export const settingsQueryKeys = {
|
||||
config: ['dashboard', 'config'] as const,
|
||||
loginStatus: ['dashboard', 'login', 'status'] as const,
|
||||
accounts: ['dashboard', 'reddit-accounts'] as const,
|
||||
pushNotifications: ['dashboard', 'push-notifications'] as const,
|
||||
};
|
||||
|
||||
export function usePushNotificationsQuery() {
|
||||
return useQuery({ queryKey: settingsQueryKeys.pushNotifications, queryFn: fetchPushNotifications });
|
||||
}
|
||||
|
||||
export function useSavePushNotificationsMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: savePushNotifications,
|
||||
onSuccess(response) {
|
||||
queryClient.setQueryData(settingsQueryKeys.pushNotifications, response);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTestPushNotificationMutation() {
|
||||
return useMutation({ mutationFn: sendTestPushNotification });
|
||||
}
|
||||
|
||||
export function useSettingsQuery() {
|
||||
return useQuery({ queryKey: settingsQueryKeys.config, queryFn: fetchSettings });
|
||||
}
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { SettingsPage, type SettingsSection } from './settings-page.js';
|
||||
import { Navigate, useSearchParams } from 'react-router-dom';
|
||||
import { SettingsPage } from './settings-page.js';
|
||||
|
||||
const validSections: readonly SettingsSection[] = ['monitoring', 'rate-limits', 'behaviour', 'access'];
|
||||
/**
|
||||
* Sections that used to be settings tabs and are now their own pages. Old
|
||||
* bookmarks and in-app links keep working by redirecting.
|
||||
*/
|
||||
const relocatedSections: Record<string, string> = {
|
||||
behaviour: '/subreddits',
|
||||
access: '/accounts',
|
||||
};
|
||||
|
||||
function parseSection(value: string | null): SettingsSection {
|
||||
return validSections.find((section) => section === value) ?? 'monitoring';
|
||||
}
|
||||
/** Former tab names that are now in-page anchors on the settings page. */
|
||||
const anchorSections: Record<string, string> = {
|
||||
monitoring: 'pacing',
|
||||
'rate-limits': 'pacing',
|
||||
notifications: 'notifications',
|
||||
};
|
||||
|
||||
export function SettingsRoute() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const section = parseSection(searchParams.get('section'));
|
||||
const [searchParams] = useSearchParams();
|
||||
const section = searchParams.get('section');
|
||||
|
||||
function selectSection(nextSection: SettingsSection) {
|
||||
setSearchParams({ section: nextSection }, { replace: true });
|
||||
}
|
||||
const relocated = section ? relocatedSections[section] : undefined;
|
||||
if (relocated) return <Navigate replace to={relocated} />;
|
||||
|
||||
return <SettingsPage onSectionChange={selectSection} section={section} />;
|
||||
return <SettingsPage initialAnchor={section ? anchorSections[section] : undefined} />;
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import type { SettingsDraft, SubredditDraft } from './settings-model.js';
|
||||
|
||||
interface SectionProps {
|
||||
draft: SettingsDraft;
|
||||
errors: Record<string, string>;
|
||||
onChange(next: SettingsDraft): void;
|
||||
}
|
||||
|
||||
interface NumberFieldProps {
|
||||
error?: string;
|
||||
label: string;
|
||||
name: string;
|
||||
min?: number;
|
||||
value: string;
|
||||
onChange(value: string): void;
|
||||
}
|
||||
|
||||
function NumberField({ error, label, name, min, value, onChange }: NumberFieldProps) {
|
||||
const errorId = `${name}-error`;
|
||||
return (
|
||||
<label className="settings-field">
|
||||
<span>{label}</span>
|
||||
<input
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
aria-invalid={error ? 'true' : undefined}
|
||||
aria-label={label}
|
||||
min={min}
|
||||
name={name}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
step="1"
|
||||
type="number"
|
||||
value={value}
|
||||
/>
|
||||
{error ? <small className="field-error" id={errorId}>{error}</small> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function MonitoringSection({ draft, errors, onChange }: SectionProps) {
|
||||
return (
|
||||
<section aria-labelledby="monitoring-heading" className="panel settings-section">
|
||||
<div className="panel-heading"><div><span className="eyebrow">Discovery</span><h2 id="monitoring-heading">Monitoring</h2></div></div>
|
||||
<label className="toggle-field">
|
||||
<input
|
||||
aria-label="Prioritise recent posts"
|
||||
checked={draft.prioritizeRecentPosts}
|
||||
onChange={(event) => onChange({ ...draft, prioritizeRecentPosts: event.target.checked })}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span><strong>Prioritise recent posts</strong><small>Prefer posts inside the configured recent window.</small></span>
|
||||
</label>
|
||||
<NumberField
|
||||
error={errors.recentWindowMinutes}
|
||||
label="Recent window in minutes"
|
||||
min={1}
|
||||
name="recent-window"
|
||||
onChange={(recentWindowMinutes) => onChange({ ...draft, recentWindowMinutes })}
|
||||
value={draft.recentWindowMinutes}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function RateLimitsSection({ draft, errors, onChange }: SectionProps) {
|
||||
return (
|
||||
<section aria-labelledby="rate-limits-heading" className="panel settings-section">
|
||||
<div className="panel-heading"><div><span className="eyebrow">Sending safeguards</span><h2 id="rate-limits-heading">Rate limits</h2></div></div>
|
||||
<div className="settings-grid">
|
||||
<NumberField
|
||||
error={errors.minDelayMinutes}
|
||||
label="Minimum delay in minutes"
|
||||
min={0}
|
||||
name="minimum-delay"
|
||||
onChange={(minDelayMinutes) => onChange({ ...draft, minDelayMinutes })}
|
||||
value={draft.minDelayMinutes}
|
||||
/>
|
||||
<NumberField
|
||||
error={errors.maxMessagesPerHour}
|
||||
label="Maximum messages per hour"
|
||||
min={1}
|
||||
name="hourly-limit"
|
||||
onChange={(maxMessagesPerHour) => onChange({ ...draft, maxMessagesPerHour })}
|
||||
value={draft.maxMessagesPerHour}
|
||||
/>
|
||||
<NumberField
|
||||
error={errors.jitterSeconds}
|
||||
label="Random jitter in seconds"
|
||||
min={0}
|
||||
name="jitter"
|
||||
onChange={(jitterSeconds) => onChange({ ...draft, jitterSeconds })}
|
||||
value={draft.jitterSeconds}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function updateSubreddit(draft: SettingsDraft, index: number, next: SubredditDraft): SettingsDraft {
|
||||
return { ...draft, subreddits: draft.subreddits.map((item, itemIndex) => itemIndex === index ? next : item) };
|
||||
}
|
||||
|
||||
export function BehaviourSection({ config, draft, errors, onChange }: SectionProps & { config: ConfigResponse }) {
|
||||
return (
|
||||
<section aria-labelledby="behaviour-heading" className="panel settings-section">
|
||||
<div className="panel-heading"><div><span className="eyebrow">Runtime behaviour</span><h2 id="behaviour-heading">Behaviour</h2></div></div>
|
||||
<label className="toggle-field">
|
||||
<input
|
||||
aria-label="Dry run mode"
|
||||
checked={draft.dryRun}
|
||||
onChange={(event) => onChange({ ...draft, dryRun: event.target.checked })}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span><strong>Dry run mode</strong><small>Evaluate posts without sending messages.</small></span>
|
||||
</label>
|
||||
|
||||
<div className="subreddit-heading">
|
||||
<div><h3>Subreddits</h3><p>Configure monitored communities and their supported message style.</p></div>
|
||||
<button
|
||||
className="button secondary"
|
||||
onClick={() => onChange({
|
||||
...draft,
|
||||
subreddits: [
|
||||
...draft.subreddits,
|
||||
{ name: '', type: 'image', weight: '1', tone: 'default', scripted: '' },
|
||||
],
|
||||
})}
|
||||
type="button"
|
||||
>
|
||||
Add subreddit
|
||||
</button>
|
||||
</div>
|
||||
{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}>
|
||||
<legend>Subreddit {index + 1}</legend>
|
||||
<label className="settings-field">
|
||||
<span>Name</span>
|
||||
<input
|
||||
aria-describedby={errors[`subreddits.${index}.name`] ? `subreddit-${index}-name-error` : undefined}
|
||||
aria-invalid={errors[`subreddits.${index}.name`] ? 'true' : undefined}
|
||||
aria-label={`Subreddit ${index + 1} name`}
|
||||
onChange={(event) => onChange(updateSubreddit(draft, index, { ...subreddit, name: event.target.value }))}
|
||||
type="text"
|
||||
value={subreddit.name}
|
||||
/>
|
||||
{errors[`subreddits.${index}.name`] ? <small className="field-error" id={`subreddit-${index}-name-error`}>{errors[`subreddits.${index}.name`]}</small> : null}
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
<span>Type</span>
|
||||
<select
|
||||
aria-label={`Subreddit ${index + 1} type`}
|
||||
onChange={(event) => onChange(updateSubreddit(draft, index, { ...subreddit, type: event.target.value as SubredditDraft['type'] }))}
|
||||
value={subreddit.type}
|
||||
>
|
||||
<option value="image">Image</option><option value="chat">Chat</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
<span>Weight</span>
|
||||
<input
|
||||
aria-describedby={errors[`subreddits.${index}.weight`] ? `subreddit-${index}-weight-error` : undefined}
|
||||
aria-invalid={errors[`subreddits.${index}.weight`] ? 'true' : undefined}
|
||||
aria-label={`Subreddit ${index + 1} weight`}
|
||||
min="0.01"
|
||||
onChange={(event) => onChange(updateSubreddit(draft, index, { ...subreddit, weight: event.target.value }))}
|
||||
step="0.01"
|
||||
type="number"
|
||||
value={subreddit.weight}
|
||||
/>
|
||||
{errors[`subreddits.${index}.weight`] ? <small className="field-error" id={`subreddit-${index}-weight-error`}>{errors[`subreddits.${index}.weight`]}</small> : null}
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
<span>Tone</span>
|
||||
<select
|
||||
aria-label={`Subreddit ${index + 1} tone`}
|
||||
onChange={(event) => onChange(updateSubreddit(draft, index, { ...subreddit, tone: event.target.value as SubredditDraft['tone'] }))}
|
||||
value={subreddit.tone}
|
||||
>
|
||||
{config.tones.map((tone) => <option key={tone} value={tone}>{tone}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="settings-field scripted-field">
|
||||
<span>Scripted message (optional)</span>
|
||||
<textarea
|
||||
aria-label={`Subreddit ${index + 1} scripted message`}
|
||||
onChange={(event) => onChange(updateSubreddit(draft, index, { ...subreddit, scripted: event.target.value }))}
|
||||
rows={2}
|
||||
value={subreddit.scripted}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="button secondary remove-subreddit"
|
||||
disabled={draft.subreddits.length === 1}
|
||||
onClick={() => onChange({
|
||||
...draft,
|
||||
subreddits: draft.subreddits.filter((_, itemIndex) => itemIndex !== index),
|
||||
})}
|
||||
type="button"
|
||||
>
|
||||
Remove subreddit {index + 1}
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +1,12 @@
|
||||
.settings-page { max-width: 72rem; }
|
||||
.settings-navigation { display: grid; grid-template-columns: 1fr; gap: .5rem; margin-bottom: 1rem; }
|
||||
.settings-navigation button { display: grid; gap: .15rem; padding: .8rem; color: var(--muted); text-align: left; background: var(--surface); border: 1px solid var(--border); border-radius: .75rem; }
|
||||
.settings-navigation button strong { color: #e9f1ec; }
|
||||
.settings-navigation button small { font-size: .72rem; }
|
||||
.settings-navigation button.is-active { background: var(--accent-dark); border-color: #28593d; }
|
||||
.settings-navigation button:focus-visible, .settings-field input:focus-visible, .settings-field select:focus-visible, .settings-field textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.settings-loading { min-height: 24rem; }
|
||||
.settings-section { display: grid; gap: 1rem; }
|
||||
.settings-copy { color: var(--muted); }
|
||||
.settings-grid { display: grid; gap: 1rem; }
|
||||
.settings-field { display: grid; gap: .4rem; color: var(--muted); font-size: .8rem; font-weight: 750; }
|
||||
.settings-field input, .settings-field select, .settings-field textarea { width: 100%; min-height: 2.7rem; padding: .55rem .7rem; color: #e9f1ec; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .6rem; }
|
||||
.settings-field textarea { resize: vertical; }
|
||||
.settings-field [aria-invalid="true"] { border-color: var(--danger); }
|
||||
.field-error, .save-error { color: var(--danger); }
|
||||
.toggle-field { display: flex; align-items: flex-start; gap: .8rem; padding: .9rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .75rem; }
|
||||
.toggle-field input { width: 1.15rem; height: 1.15rem; margin-top: .15rem; accent-color: var(--accent); }
|
||||
.toggle-field span { display: grid; gap: .2rem; }
|
||||
.toggle-field small { color: var(--muted); }
|
||||
.subreddit-heading { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 1rem; padding-top: .5rem; }
|
||||
.subreddit-heading h3 { margin: 0 0 .25rem; }
|
||||
.subreddit-heading p { margin: 0; color: var(--muted); }
|
||||
.subreddit-list { display: grid; gap: .75rem; }
|
||||
.subreddit-row { display: grid; gap: .75rem; min-width: 0; padding: 1rem; border: 1px solid var(--border); border-radius: .75rem; }
|
||||
.subreddit-row legend { padding: 0 .4rem; color: var(--accent); font-weight: 800; }
|
||||
.remove-subreddit { align-self: end; }
|
||||
.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); }
|
||||
.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-page { max-width: 62rem; }
|
||||
.settings-stack { display: grid; gap: 1rem; }
|
||||
|
||||
@media (min-width: 42rem) {
|
||||
.settings-navigation { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.settings-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.subreddit-row { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.scripted-field { grid-column: 1 / -1; }
|
||||
}
|
||||
/* Effective-throughput readout under the pacing fields. */
|
||||
.throughput-readout { display: grid; gap: .2rem; padding: .8rem 1rem; margin: 0; color: var(--success-text); background: var(--success-bg); border: 1px solid var(--success-border); border-radius: .7rem; }
|
||||
.throughput-readout strong { font-size: 1.05rem; }
|
||||
.throughput-readout span { color: inherit; font-size: .82rem; opacity: .85; }
|
||||
.throughput-readout.is-unknown { color: var(--muted); background: var(--surface-raised); border-color: var(--border); font-size: .85rem; }
|
||||
|
||||
@media (min-width: 70rem) {
|
||||
.settings-navigation { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
}
|
||||
.reddit-account-list {
|
||||
display: grid;
|
||||
gap: .65rem;
|
||||
}
|
||||
|
||||
.reddit-account-row {
|
||||
align-items: center;
|
||||
border: 1px solid var(--border-color, #d9d9d9);
|
||||
border-radius: .75rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
padding: .75rem;
|
||||
}
|
||||
|
||||
.reddit-account-row.is-active {
|
||||
border-color: var(--accent, #ff6534);
|
||||
}
|
||||
|
||||
.reddit-account-row > div {
|
||||
display: grid;
|
||||
gap: .2rem;
|
||||
}
|
||||
.advanced-keys { display: grid; gap: .45rem; }
|
||||
.advanced-keys ul { display: flex; flex-wrap: wrap; gap: .4rem; padding: 0; margin: 0; list-style: none; }
|
||||
.advanced-keys code { display: inline-block; padding: .2rem .45rem; color: var(--accent); background: var(--surface-raised); border: 1px solid var(--border); border-radius: .4rem; font-size: .78rem; }
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { NumberField, SwitchField } from '../../components/form-fields.js';
|
||||
import { SettingsCard } from './settings-card.js';
|
||||
import {
|
||||
createTargetingDraft,
|
||||
targetingRisks,
|
||||
validateTargetingDraft,
|
||||
type TargetingDraft,
|
||||
} from './settings-model.js';
|
||||
import { useConfigCard } from './use-config-card.js';
|
||||
|
||||
export function TargetingCard({ config }: { config: ConfigResponse }) {
|
||||
const card = useConfigCard<TargetingDraft>(config, {
|
||||
id: 'targeting',
|
||||
toDraft: createTargetingDraft,
|
||||
validate: validateTargetingDraft,
|
||||
risks: targetingRisks,
|
||||
});
|
||||
const { draft, errors } = card;
|
||||
const isActive = card.persisted.physiqueTargetingEnabled;
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
anchor="targeting"
|
||||
card={card}
|
||||
description="An optional screen on image posts. Chat and text posts are never affected by it."
|
||||
eyebrow="Post filtering"
|
||||
saveLabel="Save targeting"
|
||||
status={<span className={isActive ? 'pill warning' : 'pill neutral'}>{isActive ? 'Screening on' : 'Off'}</span>}
|
||||
title="Targeting"
|
||||
>
|
||||
<SwitchField
|
||||
checked={draft.physiqueTargetingEnabled}
|
||||
description="Applies a subjective 1–10 visible-physique range to image posts. While this is on, images without enough visible body evidence are screened out."
|
||||
label="Enable physique targeting"
|
||||
onChange={(physiqueTargetingEnabled) => card.update({ ...draft, physiqueTargetingEnabled })}
|
||||
/>
|
||||
|
||||
{/* The range still saves while disabled, so it is ready when turned on. */}
|
||||
<fieldset className="field-group">
|
||||
<legend>Accepted score range</legend>
|
||||
<div className="field-row">
|
||||
<NumberField
|
||||
error={errors.physiqueMinScore}
|
||||
hint="Lowest score that still qualifies."
|
||||
label="Minimum score"
|
||||
max={10}
|
||||
min={1}
|
||||
name="minimum-physique-score"
|
||||
onChange={(physiqueMinScore) => card.update({ ...draft, physiqueMinScore })}
|
||||
value={draft.physiqueMinScore}
|
||||
/>
|
||||
<NumberField
|
||||
error={errors.physiqueMaxScore}
|
||||
hint="Highest score that still qualifies."
|
||||
label="Maximum score"
|
||||
max={10}
|
||||
min={1}
|
||||
name="maximum-physique-score"
|
||||
onChange={(physiqueMaxScore) => card.update({ ...draft, physiqueMaxScore })}
|
||||
value={draft.physiqueMaxScore}
|
||||
/>
|
||||
</div>
|
||||
{!draft.physiqueTargetingEnabled ? (
|
||||
<p className="field-hint">This range is saved but not applied while physique targeting is off.</p>
|
||||
) : null}
|
||||
</fieldset>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { ConfigResponse, ConfigUpdateRequest } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { useUnsavedChangesFlag } from '../../components/unsaved-changes.js';
|
||||
import { useSaveSettingsMutation } from './settings-queries.js';
|
||||
|
||||
export interface ConfigCardValidation {
|
||||
/** Absent when the draft is invalid; `errors` explains why. */
|
||||
payload?: ConfigUpdateRequest;
|
||||
errors: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ConfigCardSpec<Draft> {
|
||||
/** Stable id used for the page-level unsaved-changes guard. */
|
||||
id: string;
|
||||
toDraft(config: ConfigResponse): Draft;
|
||||
validate(draft: Draft): ConfigCardValidation;
|
||||
/**
|
||||
* Consequences the operator must confirm before this save is applied.
|
||||
* Compared against the last persisted draft so re-saving an already-live
|
||||
* risky value does not prompt again.
|
||||
*/
|
||||
risks?(draft: Draft, persisted: Draft): string[];
|
||||
}
|
||||
|
||||
export interface ConfigCard<Draft> {
|
||||
draft: Draft;
|
||||
persisted: Draft;
|
||||
errors: Record<string, string>;
|
||||
errorCount: number;
|
||||
isDirty: boolean;
|
||||
/** The server changed this card's values while the operator had edits open. */
|
||||
hasServerChanged: boolean;
|
||||
isSaving: boolean;
|
||||
isSaved: boolean;
|
||||
saveError: Error | null;
|
||||
/** Non-null while a risky save waits on confirmation. */
|
||||
pendingRisks: string[] | null;
|
||||
update(next: Draft): void;
|
||||
reset(): void;
|
||||
save(): void;
|
||||
confirmRisks(): void;
|
||||
cancelRisks(): void;
|
||||
}
|
||||
|
||||
function isSame(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
interface CardState<Draft> {
|
||||
persisted: Draft;
|
||||
draft: Draft;
|
||||
hasServerChanged: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns one card's draft lifecycle: seed from the server config, track dirty
|
||||
* state, validate, confirm risk, and save a *partial* config update.
|
||||
*
|
||||
* Cards save independently, so a card only ever submits the keys it owns. An
|
||||
* invalid value in one card can never block another card's save.
|
||||
*/
|
||||
export function useConfigCard<Draft>(config: ConfigResponse, spec: ConfigCardSpec<Draft>): ConfigCard<Draft> {
|
||||
const specRef = useRef(spec);
|
||||
specRef.current = spec;
|
||||
|
||||
const [state, setState] = useState<CardState<Draft>>(() => {
|
||||
const draft = spec.toDraft(config);
|
||||
return { persisted: draft, draft, hasServerChanged: false };
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [pendingRisks, setPendingRisks] = useState<{ payload: ConfigUpdateRequest; reasons: string[] } | null>(null);
|
||||
const saveMutation = useSaveSettingsMutation();
|
||||
const seededConfig = useRef(config);
|
||||
|
||||
const isDirty = !isSame(state.draft, state.persisted);
|
||||
useUnsavedChangesFlag(spec.id, isDirty);
|
||||
|
||||
// Reconcile server refetches. A sibling card's save also produces a new
|
||||
// config object, so adopt silently whenever the server's values for *this*
|
||||
// card match what is on screen; only warn when they genuinely diverge.
|
||||
useEffect(() => {
|
||||
if (seededConfig.current === config) return;
|
||||
seededConfig.current = config;
|
||||
const serverDraft = specRef.current.toDraft(config);
|
||||
setState((current) => {
|
||||
// Nothing this card owns moved — a sibling card saved, or an unrelated
|
||||
// refetch landed. Leave the operator's edits completely alone.
|
||||
if (isSame(serverDraft, current.persisted)) return current;
|
||||
// The server now agrees with what is on screen, so there is no conflict.
|
||||
if (isSame(serverDraft, current.draft)) {
|
||||
return { persisted: serverDraft, draft: current.draft, hasServerChanged: false };
|
||||
}
|
||||
// No local edits to protect: adopt the server's values silently.
|
||||
if (isSame(current.draft, current.persisted)) {
|
||||
return { persisted: serverDraft, draft: serverDraft, hasServerChanged: false };
|
||||
}
|
||||
return { ...current, persisted: serverDraft, hasServerChanged: true };
|
||||
});
|
||||
}, [config]);
|
||||
|
||||
function submit(payload: ConfigUpdateRequest) {
|
||||
saveMutation.mutate(payload, {
|
||||
onSuccess() {
|
||||
setPendingRisks(null);
|
||||
setState((current) => ({ persisted: current.draft, draft: current.draft, hasServerChanged: false }));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
draft: state.draft,
|
||||
persisted: state.persisted,
|
||||
errors,
|
||||
errorCount: Object.keys(errors).length,
|
||||
isDirty,
|
||||
hasServerChanged: state.hasServerChanged,
|
||||
isSaving: saveMutation.isPending,
|
||||
isSaved: saveMutation.isSuccess && !isDirty,
|
||||
saveError: saveMutation.isError ? saveMutation.error : null,
|
||||
pendingRisks: pendingRisks?.reasons ?? null,
|
||||
|
||||
update(next: Draft) {
|
||||
saveMutation.reset();
|
||||
setErrors({});
|
||||
setState((current) => ({ ...current, draft: next }));
|
||||
},
|
||||
|
||||
reset() {
|
||||
saveMutation.reset();
|
||||
setErrors({});
|
||||
setState((current) => ({ ...current, draft: current.persisted, hasServerChanged: false }));
|
||||
},
|
||||
|
||||
save() {
|
||||
saveMutation.reset();
|
||||
const validation = specRef.current.validate(state.draft);
|
||||
setErrors(validation.errors);
|
||||
if (!validation.payload) return;
|
||||
const reasons = specRef.current.risks?.(state.draft, state.persisted) ?? [];
|
||||
if (reasons.length > 0) {
|
||||
setPendingRisks({ payload: validation.payload, reasons });
|
||||
return;
|
||||
}
|
||||
submit(validation.payload);
|
||||
},
|
||||
|
||||
confirmRisks() {
|
||||
if (pendingRisks) submit(pendingRisks.payload);
|
||||
},
|
||||
|
||||
cancelRisks() {
|
||||
setPendingRisks(null);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { conversationModes, type ConversationMode, type SubredditConfig } from './subreddit-model.js';
|
||||
|
||||
interface SubredditBulkBarProps {
|
||||
selected: readonly SubredditConfig[];
|
||||
/** True when a bulk write is in flight. */
|
||||
isBusy: boolean;
|
||||
/** Set when removing the selection is not allowed, explaining why. */
|
||||
removeBlockedReason?: string;
|
||||
tones: ConfigResponse['tones'];
|
||||
onClear(): void;
|
||||
onRemove(): void;
|
||||
onSetEnabled(enabled: boolean): void;
|
||||
onSetPolicy(mode: ConversationMode): void;
|
||||
onSetTone(tone: ConfigResponse['tones'][number]): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Acts on many subreddits at once. With dozens configured, opening each row to
|
||||
* change one shared field is the slowest possible path.
|
||||
*/
|
||||
export function SubredditBulkBar({
|
||||
isBusy,
|
||||
onClear,
|
||||
onRemove,
|
||||
onSetEnabled,
|
||||
onSetPolicy,
|
||||
onSetTone,
|
||||
removeBlockedReason,
|
||||
selected,
|
||||
tones,
|
||||
}: SubredditBulkBarProps) {
|
||||
const pausedCount = selected.filter((subreddit) => !subreddit.enabled).length;
|
||||
const pollingCount = selected.length - pausedCount;
|
||||
|
||||
return (
|
||||
<div aria-label="Bulk actions" className="bulk-bar" role="region">
|
||||
<div className="bulk-bar-count" aria-live="polite">
|
||||
<strong>{selected.length} selected</strong>
|
||||
<small>{pollingCount} polling, {pausedCount} paused</small>
|
||||
</div>
|
||||
|
||||
<div className="bulk-bar-actions">
|
||||
<button
|
||||
className="button secondary"
|
||||
disabled={isBusy || pausedCount === 0}
|
||||
onClick={() => onSetEnabled(true)}
|
||||
type="button"
|
||||
>
|
||||
Start polling
|
||||
</button>
|
||||
<button
|
||||
className="button secondary"
|
||||
disabled={isBusy || pollingCount === 0}
|
||||
onClick={() => onSetEnabled(false)}
|
||||
type="button"
|
||||
>
|
||||
Pause polling
|
||||
</button>
|
||||
|
||||
{/* Value resets to the placeholder each render: these are actions, not
|
||||
a field showing the current shared value (there may not be one). */}
|
||||
<label className="bulk-select">
|
||||
<span className="sr-only">Set tone for the selected subreddits</span>
|
||||
<select
|
||||
aria-label="Set tone for the selected subreddits"
|
||||
disabled={isBusy}
|
||||
onChange={(event) => {
|
||||
if (event.target.value === '') return;
|
||||
onSetTone(event.target.value as ConfigResponse['tones'][number]);
|
||||
event.target.value = '';
|
||||
}}
|
||||
value=""
|
||||
>
|
||||
<option value="">Set tone…</option>
|
||||
{tones.map((tone) => <option key={tone} value={tone}>{tone}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="bulk-select">
|
||||
<span className="sr-only">Set conversation policy for the selected subreddits</span>
|
||||
<select
|
||||
aria-label="Set conversation policy for the selected subreddits"
|
||||
disabled={isBusy}
|
||||
onChange={(event) => {
|
||||
if (event.target.value === '') return;
|
||||
onSetPolicy(event.target.value as ConversationMode);
|
||||
event.target.value = '';
|
||||
}}
|
||||
value=""
|
||||
>
|
||||
<option value="">Set policy…</option>
|
||||
{conversationModes.map((mode) => (
|
||||
<option key={mode.value} value={mode.value}>{mode.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button
|
||||
className="button danger"
|
||||
disabled={isBusy || Boolean(removeBlockedReason)}
|
||||
onClick={onRemove}
|
||||
title={removeBlockedReason}
|
||||
type="button"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
<button className="button secondary" disabled={isBusy} onClick={onClear} type="button">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { useState } from 'react';
|
||||
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import {
|
||||
NumberField,
|
||||
RadioCards,
|
||||
SelectField,
|
||||
SwitchField,
|
||||
TextAreaField,
|
||||
TextField,
|
||||
type RadioCardOption,
|
||||
} from '../../components/form-fields.js';
|
||||
import { Modal } from '../../components/modal.js';
|
||||
import { RiskDialog } from '../settings/risk-dialog.js';
|
||||
import {
|
||||
conversationModes,
|
||||
createEmptySubredditDraft,
|
||||
formatShare,
|
||||
MAX_AUTOMATED_TURNS,
|
||||
previewShare,
|
||||
toSubredditDraft,
|
||||
usesAutomatedTurns,
|
||||
validateSubredditDraft,
|
||||
type ConversationMode,
|
||||
type SubredditConfig,
|
||||
type SubredditDraft,
|
||||
} from './subreddit-model.js';
|
||||
|
||||
interface SubredditEditorProps {
|
||||
/** The subreddit being edited, or null to create a new one. */
|
||||
subreddit: SubredditConfig | null;
|
||||
allSubreddits: readonly SubredditConfig[];
|
||||
tones: ConfigResponse['tones'];
|
||||
isSaving: boolean;
|
||||
saveError: Error | null;
|
||||
onCancel(): void;
|
||||
onSave(config: SubredditConfig): void;
|
||||
}
|
||||
|
||||
function errorMessage(error: Error | null): string {
|
||||
return error?.message ?? 'Something went wrong.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits exactly one subreddit. Because the draft is a single row, validation
|
||||
* errors are keyed by plain field name — there are no array indices to shift
|
||||
* when rows are added or removed elsewhere.
|
||||
*/
|
||||
export function SubredditEditor({
|
||||
allSubreddits,
|
||||
isSaving,
|
||||
onCancel,
|
||||
onSave,
|
||||
saveError,
|
||||
subreddit,
|
||||
tones,
|
||||
}: SubredditEditorProps) {
|
||||
const isNew = subreddit === null;
|
||||
const [draft, setDraft] = useState<SubredditDraft>(() =>
|
||||
subreddit ? toSubredditDraft(subreddit) : createEmptySubredditDraft());
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [pendingRisks, setPendingRisks] = useState<{ config: SubredditConfig; reasons: string[] } | null>(null);
|
||||
|
||||
const takenNames = allSubreddits
|
||||
.filter((item) => item.name !== subreddit?.name)
|
||||
.map((item) => item.name);
|
||||
|
||||
function update(next: Partial<SubredditDraft>) {
|
||||
setErrors({});
|
||||
setDraft((current) => ({ ...current, ...next }));
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const validation = validateSubredditDraft(draft, takenNames);
|
||||
setErrors(validation.errors);
|
||||
if (!validation.config) return;
|
||||
|
||||
const reasons: string[] = [];
|
||||
const previousMode = subreddit?.conversation.mode;
|
||||
const nextMeta = conversationModes.find((mode) => mode.value === validation.config!.conversation.mode);
|
||||
if (nextMeta?.isAutonomous && previousMode !== validation.config.conversation.mode) {
|
||||
reasons.push(
|
||||
`r/${validation.config.name} will reply autonomously, without approval for each message.`
|
||||
+ (validation.config.conversation.mode === 'r4r-media'
|
||||
? ' After rapport it can make one optional, non-explicit image request.'
|
||||
: ' It will not request sexual media.'),
|
||||
);
|
||||
reasons.push('Conversations already in progress keep the policy they started with.');
|
||||
}
|
||||
|
||||
if (reasons.length > 0) {
|
||||
setPendingRisks({ config: validation.config, reasons });
|
||||
return;
|
||||
}
|
||||
onSave(validation.config);
|
||||
}
|
||||
|
||||
const share = previewShare(draft, allSubreddits, subreddit?.name ?? null);
|
||||
const turnOptions: RadioCardOption<ConversationMode>[] = conversationModes.map((mode) => ({
|
||||
value: mode.value,
|
||||
label: mode.label,
|
||||
description: mode.description,
|
||||
isRisky: mode.isAutonomous,
|
||||
detail: usesAutomatedTurns(mode.value) ? (
|
||||
<div className="field-row">
|
||||
<NumberField
|
||||
error={errors.maxAutomatedTurns}
|
||||
hint={`Replies it may send before handing over (1–${MAX_AUTOMATED_TURNS}).`}
|
||||
label="Maximum automated turns"
|
||||
max={MAX_AUTOMATED_TURNS}
|
||||
min={1}
|
||||
name="max-automated-turns"
|
||||
onChange={(maxAutomatedTurns) => update({ maxAutomatedTurns })}
|
||||
suffix="turns"
|
||||
value={draft.maxAutomatedTurns}
|
||||
/>
|
||||
{mode.value === 'r4r-media' ? (
|
||||
<NumberField
|
||||
error={errors.rapportTurnsBeforeMediaAsk}
|
||||
hint="Must be lower than the maximum automated turns."
|
||||
label="Rapport turns before the ask"
|
||||
max={MAX_AUTOMATED_TURNS - 1}
|
||||
min={1}
|
||||
name="rapport-turns"
|
||||
onChange={(rapportTurnsBeforeMediaAsk) => update({ rapportTurnsBeforeMediaAsk })}
|
||||
suffix="turns"
|
||||
value={draft.rapportTurnsBeforeMediaAsk}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : undefined,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal className="drawer-card" isDismissLocked={isSaving} labelledBy="subreddit-editor-title" onClose={onCancel}>
|
||||
<header className="drawer-heading">
|
||||
<div>
|
||||
<span className="eyebrow">{isNew ? 'New subreddit' : 'Edit subreddit'}</span>
|
||||
<h2 id="subreddit-editor-title">{isNew ? 'Add a subreddit' : `r/${subreddit.name}`}</h2>
|
||||
</div>
|
||||
<button aria-label="Close editor" className="icon-button" disabled={isSaving} onClick={onCancel} type="button">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="drawer-body">
|
||||
<fieldset className="field-group">
|
||||
<legend>Community</legend>
|
||||
<div className="field-row">
|
||||
<TextField
|
||||
error={errors.name}
|
||||
hint="Without the r/ prefix."
|
||||
label="Subreddit name"
|
||||
name="subreddit-name"
|
||||
onChange={(name) => update({ name })}
|
||||
placeholder="casualconversation"
|
||||
prefix="r/"
|
||||
value={draft.name}
|
||||
/>
|
||||
<SelectField
|
||||
label="Post type"
|
||||
name="subreddit-type"
|
||||
onChange={(type) => update({ type })}
|
||||
options={[{ value: 'image', label: 'Image posts' }, { value: 'chat', label: 'Chat and text posts' }]}
|
||||
value={draft.type}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="field-group">
|
||||
<legend>Targeting</legend>
|
||||
<SwitchField
|
||||
checked={draft.enabled}
|
||||
description="Disabled subreddits are not polled at all and take no share of attention."
|
||||
label="Poll this subreddit"
|
||||
onChange={(enabled) => update({ enabled })}
|
||||
/>
|
||||
<div className="field-row">
|
||||
<NumberField
|
||||
error={errors.weight}
|
||||
hint="Relative to other enabled subreddits. Leave blank for the default of 1."
|
||||
label="Weight"
|
||||
min={0.01}
|
||||
name="subreddit-weight"
|
||||
onChange={(weight) => update({ weight })}
|
||||
step="any"
|
||||
value={draft.weight}
|
||||
/>
|
||||
{/* Weight only means something as a proportion, so show the
|
||||
proportion it will actually produce. */}
|
||||
<div className="share-preview">
|
||||
<span className="field-label">Share of polling</span>
|
||||
<strong aria-live="polite">{formatShare(share)}</strong>
|
||||
<small>{draft.enabled ? 'Of all enabled subreddits, once saved.' : 'Nothing while polling is off.'}</small>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="field-group">
|
||||
<legend>Voice</legend>
|
||||
<div className="field-row">
|
||||
<SelectField
|
||||
hint="Which tone profile writes the messages."
|
||||
label="Tone"
|
||||
name="subreddit-tone"
|
||||
onChange={(tone) => update({ tone })}
|
||||
options={tones.map((tone) => ({ value: tone, label: tone }))}
|
||||
value={draft.tone}
|
||||
/>
|
||||
</div>
|
||||
<TextAreaField
|
||||
hint="Sent verbatim instead of a generated opener. Leave blank to let the model write it."
|
||||
label="Scripted opening message (optional)"
|
||||
name="subreddit-scripted"
|
||||
onChange={(scripted) => update({ scripted })}
|
||||
rows={3}
|
||||
value={draft.scripted}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<RadioCards
|
||||
legend="Conversation policy"
|
||||
name="conversation-mode"
|
||||
onChange={(conversationMode) => update({ conversationMode })}
|
||||
options={turnOptions}
|
||||
value={draft.conversationMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<footer className="drawer-footer">
|
||||
<div aria-live="polite" className="settings-card-status">
|
||||
{Object.keys(errors).length > 0 ? (
|
||||
<strong className="save-error" role="alert">
|
||||
{Object.keys(errors).length === 1
|
||||
? 'Resolve 1 problem before saving.'
|
||||
: `Resolve ${Object.keys(errors).length} problems before saving.`}
|
||||
</strong>
|
||||
) : null}
|
||||
{saveError ? <strong className="save-error" role="alert">{errorMessage(saveError)}</strong> : null}
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button className="button secondary" disabled={isSaving} onClick={onCancel} type="button">Cancel</button>
|
||||
<button
|
||||
aria-busy={isSaving ? 'true' : undefined}
|
||||
className="button primary"
|
||||
disabled={isSaving}
|
||||
onClick={submit}
|
||||
type="button"
|
||||
>
|
||||
{isSaving ? 'Saving…' : isNew ? 'Add subreddit' : 'Save subreddit'}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</Modal>
|
||||
|
||||
{pendingRisks ? (
|
||||
<RiskDialog
|
||||
isPending={isSaving}
|
||||
onCancel={() => setPendingRisks(null)}
|
||||
onConfirm={() => onSave(pendingRisks.config)}
|
||||
reasons={pendingRisks.reasons}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import {
|
||||
MAX_CONFIGURED_AUTOMATED_CONVERSATION_TURNS,
|
||||
SubredditConfigSchema,
|
||||
type ConfigResponse,
|
||||
type ToneKey,
|
||||
} from '../../../../../packages/api-contracts/src/index.js';
|
||||
|
||||
export type SubredditConfig = ConfigResponse['subreddits'][number];
|
||||
export type ConversationMode = SubredditConfig['conversation']['mode'];
|
||||
|
||||
/** Weight the runtime assumes when a subreddit does not set one. */
|
||||
export const DEFAULT_SUBREDDIT_WEIGHT = 1;
|
||||
|
||||
export interface SubredditDraft {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
type: 'image' | 'chat';
|
||||
weight: string;
|
||||
tone: ToneKey;
|
||||
scripted: string;
|
||||
conversationMode: ConversationMode;
|
||||
maxAutomatedTurns: string;
|
||||
rapportTurnsBeforeMediaAsk: string;
|
||||
}
|
||||
|
||||
export interface SubredditValidation {
|
||||
/** Absent when the draft is invalid; `errors` is keyed by draft field name. */
|
||||
config?: SubredditConfig;
|
||||
errors: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ConversationModeMeta {
|
||||
value: ConversationMode;
|
||||
label: string;
|
||||
/** Short form for list badges. */
|
||||
badge: string;
|
||||
description: string;
|
||||
/** Sends without per-message approval. */
|
||||
isAutonomous: boolean;
|
||||
}
|
||||
|
||||
export const conversationModes: ReadonlyArray<ConversationModeMeta> = [
|
||||
{
|
||||
value: 'legacy',
|
||||
label: 'Media asks',
|
||||
badge: 'Media asks',
|
||||
description: 'The existing three-turn flow that works toward a media request.',
|
||||
isAutonomous: false,
|
||||
},
|
||||
{
|
||||
value: 'r4r',
|
||||
label: 'Autonomous conversation',
|
||||
badge: 'Autonomous',
|
||||
description: 'Holds a general conversation on its own, without approval per message. It will never request sexual media.',
|
||||
isAutonomous: true,
|
||||
},
|
||||
{
|
||||
value: 'r4r-media',
|
||||
label: 'Autonomous, then one media ask',
|
||||
badge: 'Autonomous + ask',
|
||||
description: 'Builds rapport on its own, then makes a single optional, non-explicit image request.',
|
||||
isAutonomous: true,
|
||||
},
|
||||
{
|
||||
value: 'manual',
|
||||
label: 'Manual only',
|
||||
badge: 'Manual',
|
||||
description: 'Incoming replies are flagged for you to handle. Nothing is ever answered automatically.',
|
||||
isAutonomous: false,
|
||||
},
|
||||
];
|
||||
|
||||
export function conversationModeMeta(mode: ConversationMode): ConversationModeMeta {
|
||||
return conversationModes.find((item) => item.value === mode) ?? conversationModes[0]!;
|
||||
}
|
||||
|
||||
/** True for modes whose turn budget the operator can set. */
|
||||
export function usesAutomatedTurns(mode: ConversationMode): boolean {
|
||||
return mode === 'r4r' || mode === 'r4r-media';
|
||||
}
|
||||
|
||||
export function toSubredditDraft(subreddit: SubredditConfig): SubredditDraft {
|
||||
return {
|
||||
name: subreddit.name,
|
||||
enabled: subreddit.enabled,
|
||||
type: subreddit.type,
|
||||
weight: subreddit.weight === undefined ? '' : String(subreddit.weight),
|
||||
tone: subreddit.tone,
|
||||
scripted: subreddit.scripted ?? '',
|
||||
conversationMode: subreddit.conversation.mode,
|
||||
maxAutomatedTurns: String(subreddit.conversation.maxAutomatedTurns),
|
||||
rapportTurnsBeforeMediaAsk: String(subreddit.conversation.rapportTurnsBeforeMediaAsk),
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptySubredditDraft(): SubredditDraft {
|
||||
return {
|
||||
name: '',
|
||||
enabled: true,
|
||||
type: 'image',
|
||||
weight: '',
|
||||
tone: 'default',
|
||||
scripted: '',
|
||||
// New subreddits default to the safest policy: nothing sends without review.
|
||||
conversationMode: 'manual',
|
||||
maxAutomatedTurns: '3',
|
||||
rapportTurnsBeforeMediaAsk: '3',
|
||||
};
|
||||
}
|
||||
|
||||
/** Reddit's own rule set for community names, minus the optional `r/` prefix. */
|
||||
const namePattern = /^[A-Za-z0-9][A-Za-z0-9_]{1,20}$/;
|
||||
|
||||
export function normaliseSubredditName(value: string): string {
|
||||
return value.trim().replace(/^\/?r\//i, '').trim();
|
||||
}
|
||||
|
||||
export function validateSubredditDraft(draft: SubredditDraft, takenNames: readonly string[]): SubredditValidation {
|
||||
const errors: Record<string, string> = {};
|
||||
const name = normaliseSubredditName(draft.name);
|
||||
|
||||
if (name === '') {
|
||||
errors.name = 'Subreddit name is required.';
|
||||
} else if (!namePattern.test(name)) {
|
||||
errors.name = 'Use 2–21 letters, numbers or underscores, starting with a letter or number.';
|
||||
} else if (takenNames.some((taken) => taken.toLowerCase() === name.toLowerCase())) {
|
||||
errors.name = `r/${name} is already configured.`;
|
||||
}
|
||||
|
||||
if (draft.weight.trim() !== '') {
|
||||
const weight = Number(draft.weight);
|
||||
if (!Number.isFinite(weight) || weight <= 0) errors.weight = 'Weight must be a number greater than 0.';
|
||||
}
|
||||
|
||||
if (usesAutomatedTurns(draft.conversationMode)) {
|
||||
if (draft.maxAutomatedTurns.trim() === '') {
|
||||
errors.maxAutomatedTurns = 'Maximum automated turns is required.';
|
||||
}
|
||||
if (draft.conversationMode === 'r4r-media' && draft.rapportTurnsBeforeMediaAsk.trim() === '') {
|
||||
errors.rapportTurnsBeforeMediaAsk = 'Rapport turns is required.';
|
||||
}
|
||||
}
|
||||
|
||||
// Optional fields are omitted rather than set to undefined, so the saved
|
||||
// payload contains exactly the keys the operator actually configured.
|
||||
const parsed = SubredditConfigSchema.safeParse({
|
||||
name,
|
||||
enabled: draft.enabled,
|
||||
type: draft.type,
|
||||
...(draft.weight.trim() === '' ? {} : { weight: Number(draft.weight) }),
|
||||
tone: draft.tone,
|
||||
...(draft.scripted.trim() === '' ? {} : { scripted: draft.scripted }),
|
||||
conversation: {
|
||||
mode: draft.conversationMode,
|
||||
maxAutomatedTurns: usesAutomatedTurns(draft.conversationMode) ? Number(draft.maxAutomatedTurns) : 3,
|
||||
rapportTurnsBeforeMediaAsk: draft.conversationMode === 'r4r-media'
|
||||
? Number(draft.rapportTurnsBeforeMediaAsk)
|
||||
: 3,
|
||||
},
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
for (const issue of parsed.error.issues) {
|
||||
// Conversation fields are flat in the draft, so drop the nesting to keep
|
||||
// error keys aligned with the controls that produced them.
|
||||
const path = issue.path.filter((part) => part !== 'conversation').join('.');
|
||||
const key = path === '' ? 'name' : path;
|
||||
if (!(key in errors)) errors[key] = issue.message;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) return { errors };
|
||||
return { config: parsed.data as SubredditConfig, errors: {} };
|
||||
}
|
||||
|
||||
export const MAX_AUTOMATED_TURNS = MAX_CONFIGURED_AUTOMATED_CONVERSATION_TURNS;
|
||||
|
||||
/**
|
||||
* Switches one subreddit to a conversation mode, keeping its existing turn
|
||||
* budget where that is still valid. Used by bulk edits, where there is no
|
||||
* per-row form to catch the hybrid mode's rapport-below-total rule.
|
||||
*/
|
||||
export function withConversationMode(subreddit: SubredditConfig, mode: ConversationMode): SubredditConfig {
|
||||
const current = subreddit.conversation;
|
||||
if (mode !== 'r4r-media') {
|
||||
return { ...subreddit, conversation: { ...current, mode } };
|
||||
}
|
||||
const maxAutomatedTurns = Math.min(Math.max(current.maxAutomatedTurns, 2), MAX_AUTOMATED_TURNS);
|
||||
return {
|
||||
...subreddit,
|
||||
conversation: {
|
||||
mode,
|
||||
maxAutomatedTurns,
|
||||
rapportTurnsBeforeMediaAsk: Math.min(current.rapportTurnsBeforeMediaAsk, maxAutomatedTurns - 1),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface SubredditShare {
|
||||
/** Fraction of polling attention this subreddit receives, 0–1. */
|
||||
share: number;
|
||||
/** Its weight, with the runtime default filled in. */
|
||||
effectiveWeight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Weight is meaningless in isolation — it only sets a subreddit's share
|
||||
* relative to the others that are enabled. This turns the raw numbers into the
|
||||
* proportion the operator is actually choosing.
|
||||
*/
|
||||
export function computeShares(subreddits: readonly SubredditConfig[]): Map<string, SubredditShare> {
|
||||
const enabled = subreddits.filter((subreddit) => subreddit.enabled);
|
||||
const total = enabled.reduce((sum, subreddit) => sum + (subreddit.weight ?? DEFAULT_SUBREDDIT_WEIGHT), 0);
|
||||
const shares = new Map<string, SubredditShare>();
|
||||
for (const subreddit of subreddits) {
|
||||
const effectiveWeight = subreddit.weight ?? DEFAULT_SUBREDDIT_WEIGHT;
|
||||
shares.set(subreddit.name, {
|
||||
effectiveWeight,
|
||||
share: subreddit.enabled && total > 0 ? effectiveWeight / total : 0,
|
||||
});
|
||||
}
|
||||
return shares;
|
||||
}
|
||||
|
||||
export function formatShare(share: number): string {
|
||||
if (share <= 0) return '—';
|
||||
const percent = share * 100;
|
||||
return percent < 1 ? '<1%' : `${Math.round(percent)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Share this draft would take if saved, used for the live preview in the
|
||||
* editor. Excludes the row being edited from the baseline so its own old
|
||||
* weight does not count twice.
|
||||
*/
|
||||
export function previewShare(
|
||||
draft: SubredditDraft,
|
||||
allSubreddits: readonly SubredditConfig[],
|
||||
editingName: string | null,
|
||||
): number {
|
||||
const weight = draft.weight.trim() === '' ? DEFAULT_SUBREDDIT_WEIGHT : Number(draft.weight);
|
||||
if (!Number.isFinite(weight) || weight <= 0 || !draft.enabled) return 0;
|
||||
const others = allSubreddits
|
||||
.filter((subreddit) => subreddit.name !== editingName && subreddit.enabled)
|
||||
.reduce((sum, subreddit) => sum + (subreddit.weight ?? DEFAULT_SUBREDDIT_WEIGHT), 0);
|
||||
const total = others + weight;
|
||||
return total > 0 ? weight / total : 0;
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { Modal } from '../../components/modal.js';
|
||||
import { useSubredditPerformanceQuery } from '../performance/performance-queries.js';
|
||||
import { RiskDialog } from '../settings/risk-dialog.js';
|
||||
import { useSaveSettingsMutation, useSettingsQuery } from '../settings/settings-queries.js';
|
||||
import { SubredditBulkBar } from './subreddit-bulk-bar.js';
|
||||
import { SubredditEditor } from './subreddit-editor.js';
|
||||
import {
|
||||
computeShares,
|
||||
conversationModeMeta,
|
||||
formatShare,
|
||||
withConversationMode,
|
||||
type ConversationMode,
|
||||
type SubredditConfig,
|
||||
} from './subreddit-model.js';
|
||||
import './subreddits.css';
|
||||
|
||||
type SortKey = 'share' | 'name' | 'sent';
|
||||
|
||||
const sortOptions: ReadonlyArray<{ value: SortKey; label: string }> = [
|
||||
{ value: 'share', label: 'Share of polling' },
|
||||
{ value: 'name', label: 'Name' },
|
||||
{ value: 'sent', label: 'Messages sent' },
|
||||
];
|
||||
|
||||
interface SubredditStats {
|
||||
totalSent: number;
|
||||
totalResponses: number;
|
||||
responseRate: number;
|
||||
}
|
||||
|
||||
function errorMessage(error: Error | null): string {
|
||||
return error?.message ?? 'Something went wrong.';
|
||||
}
|
||||
|
||||
function formatRate(rate: number): string {
|
||||
return `${Math.round(rate * 100)}%`;
|
||||
}
|
||||
|
||||
/** Which row a pending mutation belongs to, so only that row shows as busy. */
|
||||
type PendingAction =
|
||||
| { kind: 'toggle' | 'delete'; name: string }
|
||||
| { kind: 'editor' }
|
||||
| { kind: 'bulk' }
|
||||
| null;
|
||||
|
||||
/** A bulk change held back until the operator confirms its consequences. */
|
||||
interface PendingBulk {
|
||||
subreddits: SubredditConfig[];
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export function SubredditsPage() {
|
||||
const settingsQuery = useSettingsQuery();
|
||||
const performanceQuery = useSubredditPerformanceQuery();
|
||||
const saveMutation = useSaveSettingsMutation();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortKey, setSortKey] = useState<SortKey>('share');
|
||||
const [editorTarget, setEditorTarget] = useState<{ subreddit: SubredditConfig | null } | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<SubredditConfig | null>(null);
|
||||
const [pendingAction, setPendingAction] = useState<PendingAction>(null);
|
||||
const [selectedNames, setSelectedNames] = useState<readonly string[]>([]);
|
||||
const [pendingBulk, setPendingBulk] = useState<PendingBulk | null>(null);
|
||||
const [isBulkRemoveOpen, setIsBulkRemoveOpen] = useState(false);
|
||||
|
||||
const subreddits = settingsQuery.data?.subreddits ?? [];
|
||||
const shares = useMemo(() => computeShares(subreddits), [subreddits]);
|
||||
|
||||
const statsByName = useMemo(() => {
|
||||
const map = new Map<string, SubredditStats>();
|
||||
for (const row of performanceQuery.data ?? []) {
|
||||
map.set(row.subreddit.toLowerCase(), {
|
||||
totalSent: row.totalSent,
|
||||
totalResponses: row.totalResponses,
|
||||
responseRate: row.responseRate,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}, [performanceQuery.data]);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const term = search.trim().toLowerCase();
|
||||
const filtered = term === ''
|
||||
? [...subreddits]
|
||||
: subreddits.filter((subreddit) => subreddit.name.toLowerCase().includes(term));
|
||||
return filtered.sort((left, right) => {
|
||||
if (sortKey === 'name') return left.name.localeCompare(right.name);
|
||||
if (sortKey === 'sent') {
|
||||
const leftSent = statsByName.get(left.name.toLowerCase())?.totalSent ?? 0;
|
||||
const rightSent = statsByName.get(right.name.toLowerCase())?.totalSent ?? 0;
|
||||
return rightSent - leftSent || left.name.localeCompare(right.name);
|
||||
}
|
||||
const leftShare = shares.get(left.name)?.share ?? 0;
|
||||
const rightShare = shares.get(right.name)?.share ?? 0;
|
||||
return rightShare - leftShare || left.name.localeCompare(right.name);
|
||||
});
|
||||
}, [search, sortKey, shares, statsByName, subreddits]);
|
||||
|
||||
const enabledCount = subreddits.filter((subreddit) => subreddit.enabled).length;
|
||||
const autonomousCount = subreddits.filter((subreddit) =>
|
||||
conversationModeMeta(subreddit.conversation.mode).isAutonomous && subreddit.enabled).length;
|
||||
|
||||
/** Every write replaces the whole array, rebuilt from the persisted config. */
|
||||
function commit(nextSubreddits: SubredditConfig[], action: PendingAction, onDone?: () => void) {
|
||||
setPendingAction(action);
|
||||
saveMutation.mutate({ subreddits: nextSubreddits }, {
|
||||
onSuccess() {
|
||||
setPendingAction(null);
|
||||
onDone?.();
|
||||
},
|
||||
onError() {
|
||||
setPendingAction(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function toggleEnabled(target: SubredditConfig, enabled: boolean) {
|
||||
commit(
|
||||
subreddits.map((subreddit) => (subreddit.name === target.name ? { ...subreddit, enabled } : subreddit)),
|
||||
{ kind: 'toggle', name: target.name },
|
||||
);
|
||||
}
|
||||
|
||||
function saveFromEditor(config: SubredditConfig) {
|
||||
const editing = editorTarget?.subreddit ?? null;
|
||||
const next = editing
|
||||
? subreddits.map((subreddit) => (subreddit.name === editing.name ? config : subreddit))
|
||||
: [...subreddits, config];
|
||||
commit(next, { kind: 'editor' }, () => setEditorTarget(null));
|
||||
}
|
||||
|
||||
function confirmDelete(target: SubredditConfig) {
|
||||
commit(
|
||||
subreddits.filter((subreddit) => subreddit.name !== target.name),
|
||||
{ kind: 'delete', name: target.name },
|
||||
() => setDeleteTarget(null),
|
||||
);
|
||||
}
|
||||
|
||||
const isLastSubreddit = subreddits.length <= 1;
|
||||
|
||||
// --- selection and bulk actions ------------------------------------------
|
||||
|
||||
const selected = useMemo(
|
||||
() => subreddits.filter((subreddit) => selectedNames.includes(subreddit.name)),
|
||||
[selectedNames, subreddits],
|
||||
);
|
||||
|
||||
// Drop names that no longer exist, so a removal cannot leave the bulk bar
|
||||
// acting on ghosts.
|
||||
useEffect(() => {
|
||||
setSelectedNames((current) => {
|
||||
const live = current.filter((name) => subreddits.some((subreddit) => subreddit.name === name));
|
||||
return live.length === current.length ? current : live;
|
||||
});
|
||||
}, [subreddits]);
|
||||
|
||||
const visibleNames = visible.map((subreddit) => subreddit.name);
|
||||
const areAllVisibleSelected = visibleNames.length > 0
|
||||
&& visibleNames.every((name) => selectedNames.includes(name));
|
||||
|
||||
function toggleSelected(name: string, isSelected: boolean) {
|
||||
setSelectedNames((current) => (isSelected
|
||||
? current.includes(name) ? current : [...current, name]
|
||||
: current.filter((item) => item !== name)));
|
||||
}
|
||||
|
||||
/** Selects or clears every row the current filter shows, not the whole list. */
|
||||
function toggleAllVisible(isSelected: boolean) {
|
||||
setSelectedNames((current) => (isSelected
|
||||
? [...new Set([...current, ...visibleNames])]
|
||||
: current.filter((name) => !visibleNames.includes(name))));
|
||||
}
|
||||
|
||||
/** Applies `change` to the selection, confirming first when `reasons` apply. */
|
||||
function applyBulk(change: (subreddit: SubredditConfig) => SubredditConfig, reasons: string[] = []) {
|
||||
const next = subreddits.map((subreddit) =>
|
||||
(selectedNames.includes(subreddit.name) ? change(subreddit) : subreddit));
|
||||
if (reasons.length > 0) {
|
||||
setPendingBulk({ subreddits: next, reasons });
|
||||
return;
|
||||
}
|
||||
commit(next, { kind: 'bulk' });
|
||||
}
|
||||
|
||||
function bulkSetPolicy(mode: ConversationMode) {
|
||||
const meta = conversationModeMeta(mode);
|
||||
// Only rows actually changing into an autonomous mode need approval.
|
||||
const newlyAutonomous = selected.filter((subreddit) =>
|
||||
meta.isAutonomous && subreddit.conversation.mode !== mode);
|
||||
const reasons = newlyAutonomous.length === 0 ? [] : [
|
||||
`${newlyAutonomous.length === 1 ? '1 subreddit' : `${newlyAutonomous.length} subreddits`} will reply autonomously, without approval for each message: ${newlyAutonomous.map((subreddit) => `r/${subreddit.name}`).join(', ')}.`,
|
||||
'Conversations already in progress keep the policy they started with.',
|
||||
];
|
||||
applyBulk((subreddit) => withConversationMode(subreddit, mode), reasons);
|
||||
}
|
||||
|
||||
function bulkRemove() {
|
||||
const next = subreddits.filter((subreddit) => !selectedNames.includes(subreddit.name));
|
||||
commit(next, { kind: 'bulk' }, () => {
|
||||
setIsBulkRemoveOpen(false);
|
||||
setSelectedNames([]);
|
||||
});
|
||||
}
|
||||
|
||||
const isBulkBusy = pendingAction?.kind === 'bulk' && saveMutation.isPending;
|
||||
const removeBlockedReason = selected.length >= subreddits.length
|
||||
? 'At least one subreddit must stay configured.'
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="page subreddits-page">
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">Targeting</span>
|
||||
<h1>Subreddits</h1>
|
||||
<p>The communities the bot polls, how much attention each one gets, and how it is allowed to talk there.</p>
|
||||
</div>
|
||||
{settingsQuery.data ? (
|
||||
<div className="header-actions">
|
||||
<button className="button primary" onClick={() => setEditorTarget({ subreddit: null })} type="button">
|
||||
Add subreddit
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{settingsQuery.isPending ? (
|
||||
<div aria-label="Loading subreddits" className="page-state" role="status">
|
||||
<span className="spinner" />Loading subreddits…
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{settingsQuery.isError ? (
|
||||
<div className="page-state error-state" role="alert">
|
||||
<strong>Subreddits unavailable</strong>
|
||||
<span>{errorMessage(settingsQuery.error)}</span>
|
||||
<button className="button secondary" onClick={() => void settingsQuery.refetch()} type="button">Retry</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{settingsQuery.data ? (
|
||||
<>
|
||||
<div className="subreddit-summary">
|
||||
<div><strong>{enabledCount}</strong><span>of {subreddits.length} being polled</span></div>
|
||||
<div><strong>{autonomousCount}</strong><span>replying autonomously</span></div>
|
||||
{saveMutation.isError ? (
|
||||
<p className="save-error" role="alert">{errorMessage(saveMutation.error)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="subreddit-toolbar">
|
||||
<label className="field inline-field">
|
||||
<span className="field-label">Filter</span>
|
||||
<span className="field-input has-prefix">
|
||||
<span aria-hidden="true" className="field-prefix">r/</span>
|
||||
<input
|
||||
aria-label="Filter subreddits by name"
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search"
|
||||
type="search"
|
||||
value={search}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
<label className="field inline-field">
|
||||
<span className="field-label">Sort by</span>
|
||||
<select aria-label="Sort subreddits" onChange={(event) => setSortKey(event.target.value as SortKey)} value={sortKey}>
|
||||
{sortOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{visible.length > 0 ? (
|
||||
<label className="inline-switch select-all">
|
||||
<input
|
||||
aria-label={search.trim() === '' ? 'Select all subreddits' : `Select all ${visible.length} matching subreddits`}
|
||||
checked={areAllVisibleSelected}
|
||||
onChange={(event) => toggleAllVisible(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>{search.trim() === '' ? 'Select all' : `Select all ${visible.length} matching`}</span>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<div className="panel empty-state">
|
||||
<strong>No subreddits match “{search}”</strong>
|
||||
<span>Clear the filter to see all {subreddits.length}.</span>
|
||||
</div>
|
||||
) : (
|
||||
<ul aria-label="Configured subreddits" className="subreddit-cards">
|
||||
{visible.map((subreddit) => {
|
||||
const mode = conversationModeMeta(subreddit.conversation.mode);
|
||||
const share = shares.get(subreddit.name);
|
||||
const stats = statsByName.get(subreddit.name.toLowerCase());
|
||||
const isToggling = pendingAction?.kind === 'toggle' && pendingAction.name === subreddit.name;
|
||||
return (
|
||||
<li
|
||||
className={[
|
||||
'subreddit-card',
|
||||
subreddit.enabled ? '' : 'is-disabled',
|
||||
selectedNames.includes(subreddit.name) ? 'is-selected' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
key={subreddit.name}
|
||||
>
|
||||
<label className="subreddit-select">
|
||||
<input
|
||||
aria-label={`Select r/${subreddit.name}`}
|
||||
checked={selectedNames.includes(subreddit.name)}
|
||||
onChange={(event) => toggleSelected(subreddit.name, event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
</label>
|
||||
<div className="subreddit-card-main">
|
||||
<div className="subreddit-identity">
|
||||
<h3>r/{subreddit.name}</h3>
|
||||
<span className="subreddit-tags">
|
||||
<span className={mode.isAutonomous ? 'pill warning' : 'pill neutral'}>{mode.badge}</span>
|
||||
<span className="pill neutral">{subreddit.type === 'image' ? 'Image' : 'Chat'}</span>
|
||||
<span className="pill neutral">{subreddit.tone}</span>
|
||||
{subreddit.scripted ? <span className="pill neutral">Scripted</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<dl className="subreddit-metrics">
|
||||
<div>
|
||||
<dt>Share</dt>
|
||||
<dd>
|
||||
{subreddit.enabled ? formatShare(share?.share ?? 0) : 'Off'}
|
||||
{subreddit.enabled ? <small>weight {share?.effectiveWeight ?? 1}</small> : null}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Sent</dt>
|
||||
<dd>{stats ? stats.totalSent : '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Replies</dt>
|
||||
<dd>
|
||||
{stats ? stats.totalResponses : '—'}
|
||||
{stats && stats.totalSent > 0 ? <small>{formatRate(stats.responseRate)} rate</small> : null}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="subreddit-card-actions">
|
||||
{/* A single reversible field, so it commits on change
|
||||
rather than pretending to need a save step. */}
|
||||
<label className="inline-switch">
|
||||
<input
|
||||
aria-label={`Poll r/${subreddit.name}`}
|
||||
checked={subreddit.enabled}
|
||||
disabled={isToggling}
|
||||
onChange={(event) => toggleEnabled(subreddit, event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>{isToggling ? 'Saving…' : subreddit.enabled ? 'Polling' : 'Paused'}</span>
|
||||
</label>
|
||||
<button
|
||||
className="button secondary"
|
||||
onClick={() => setEditorTarget({ subreddit })}
|
||||
type="button"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="button secondary"
|
||||
disabled={isLastSubreddit}
|
||||
onClick={() => setDeleteTarget(subreddit)}
|
||||
title={isLastSubreddit ? 'At least one subreddit must stay configured.' : undefined}
|
||||
type="button"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{performanceQuery.isError ? (
|
||||
<p className="field-hint" role="status">
|
||||
Per-subreddit performance could not be loaded, so Sent and Replies are unavailable.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{selected.length > 0 ? (
|
||||
<SubredditBulkBar
|
||||
isBusy={isBulkBusy}
|
||||
onClear={() => setSelectedNames([])}
|
||||
onRemove={() => setIsBulkRemoveOpen(true)}
|
||||
onSetEnabled={(enabled) => applyBulk((subreddit) => ({ ...subreddit, enabled }))}
|
||||
onSetPolicy={bulkSetPolicy}
|
||||
onSetTone={(tone) => applyBulk((subreddit) => ({ ...subreddit, tone }))}
|
||||
removeBlockedReason={removeBlockedReason}
|
||||
selected={selected}
|
||||
tones={settingsQuery.data.tones}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{editorTarget && settingsQuery.data ? (
|
||||
<SubredditEditor
|
||||
allSubreddits={subreddits}
|
||||
isSaving={pendingAction?.kind === 'editor' && saveMutation.isPending}
|
||||
onCancel={() => setEditorTarget(null)}
|
||||
onSave={saveFromEditor}
|
||||
saveError={saveMutation.isError ? saveMutation.error : null}
|
||||
subreddit={editorTarget.subreddit}
|
||||
tones={settingsQuery.data.tones}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{pendingBulk ? (
|
||||
<RiskDialog
|
||||
isPending={isBulkBusy}
|
||||
onCancel={() => setPendingBulk(null)}
|
||||
onConfirm={() => commit(pendingBulk.subreddits, { kind: 'bulk' }, () => setPendingBulk(null))}
|
||||
reasons={pendingBulk.reasons}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isBulkRemoveOpen ? (
|
||||
<Modal
|
||||
className="dialog-card"
|
||||
isDismissLocked={isBulkBusy}
|
||||
labelledBy="bulk-remove-title"
|
||||
onClose={() => setIsBulkRemoveOpen(false)}
|
||||
>
|
||||
<span className="eyebrow">Remove subreddits</span>
|
||||
<h2 id="bulk-remove-title">
|
||||
{selected.length === 1 ? 'Stop using 1 subreddit?' : `Stop using ${selected.length} subreddits?`}
|
||||
</h2>
|
||||
<p>
|
||||
They will no longer be polled and their configuration is discarded. Conversations already started there
|
||||
are not affected, and their history stays in reporting.
|
||||
</p>
|
||||
<ul className="risk-list">
|
||||
{selected.map((subreddit) => <li key={subreddit.name}>r/{subreddit.name}</li>)}
|
||||
</ul>
|
||||
<div className="dialog-actions">
|
||||
<button
|
||||
autoFocus
|
||||
className="button secondary"
|
||||
disabled={isBulkBusy}
|
||||
onClick={() => setIsBulkRemoveOpen(false)}
|
||||
type="button"
|
||||
>
|
||||
Keep them
|
||||
</button>
|
||||
<button className="button danger" disabled={isBulkBusy} onClick={bulkRemove} type="button">
|
||||
{isBulkBusy ? 'Removing…' : 'Remove subreddits'}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
{deleteTarget ? (
|
||||
<Modal
|
||||
className="dialog-card"
|
||||
isDismissLocked={saveMutation.isPending}
|
||||
labelledBy="remove-subreddit-title"
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
>
|
||||
<span className="eyebrow">Remove subreddit</span>
|
||||
<h2 id="remove-subreddit-title">Stop using r/{deleteTarget.name}?</h2>
|
||||
<p>
|
||||
It will no longer be polled and its configuration is discarded. Conversations already started there are
|
||||
not affected, and its history stays in reporting.
|
||||
</p>
|
||||
<div className="dialog-actions">
|
||||
<button
|
||||
autoFocus
|
||||
className="button secondary"
|
||||
disabled={saveMutation.isPending}
|
||||
onClick={() => setDeleteTarget(null)}
|
||||
type="button"
|
||||
>
|
||||
Keep it
|
||||
</button>
|
||||
<button
|
||||
className="button danger"
|
||||
disabled={saveMutation.isPending}
|
||||
onClick={() => confirmDelete(deleteTarget)}
|
||||
type="button"
|
||||
>
|
||||
{saveMutation.isPending ? 'Removing…' : 'Remove subreddit'}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
.subreddits-page { max-width: 68rem; }
|
||||
|
||||
.subreddit-summary { display: flex; flex-wrap: wrap; align-items: center; gap: 2rem; padding: 1rem 1.1rem; margin-bottom: 1rem; background: linear-gradient(110deg, #13221b, #0f1915); border: 1px solid var(--border); border-radius: .9rem; }
|
||||
.subreddit-summary > div { display: flex; align-items: baseline; gap: .5rem; }
|
||||
.subreddit-summary strong { font-size: 1.6rem; letter-spacing: -.04em; }
|
||||
.subreddit-summary span { color: var(--muted); font-size: .85rem; }
|
||||
.subreddit-summary .save-error { flex: 1 0 100%; margin: 0; font-size: .85rem; font-weight: 700; }
|
||||
|
||||
.subreddit-toolbar { display: flex; flex-wrap: wrap; align-items: flex-end; gap: .75rem; margin-bottom: 1rem; }
|
||||
.inline-field { flex: 1 1 12rem; max-width: 18rem; }
|
||||
|
||||
/* Live share readout beside the weight input in the editor. */
|
||||
.share-preview { display: grid; align-content: start; gap: .15rem; padding: .5rem .7rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .6rem; }
|
||||
.share-preview strong { color: var(--accent); font-size: 1.35rem; line-height: 1.1; letter-spacing: -.03em; }
|
||||
.share-preview small { color: var(--muted); font-size: .72rem; }
|
||||
|
||||
.subreddit-cards { display: grid; gap: .65rem; padding: 0; margin: 0; list-style: none; }
|
||||
.subreddit-card { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: .85rem; padding: 1rem; background: var(--surface); border: 1px solid var(--border); border-radius: .85rem; }
|
||||
.subreddit-card.is-disabled { background: #0d1512; border-style: dashed; }
|
||||
.subreddit-card.is-disabled .subreddit-identity h3 { color: var(--muted); }
|
||||
|
||||
.subreddit-card.is-selected { border-color: var(--accent); background: var(--accent-dark); }
|
||||
.subreddit-select { display: flex; align-self: start; align-items: center; padding-top: .15rem; cursor: pointer; }
|
||||
.subreddit-select input { width: 1.15rem; height: 1.15rem; accent-color: var(--accent); }
|
||||
.subreddit-select:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: .3rem; }
|
||||
.select-all { align-self: center; }
|
||||
|
||||
/* Sticks to the bottom so the selection stays actionable while scrolling a
|
||||
long list. */
|
||||
.bulk-bar { position: sticky; bottom: 1rem; z-index: 5; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; padding: .8rem 1rem; margin-top: 1rem; background: rgba(23, 35, 30, .97); border: 1px solid var(--accent); border-radius: .8rem; box-shadow: 0 1rem 3rem rgba(0, 0, 0, .45); backdrop-filter: blur(8px); }
|
||||
.bulk-bar-count { display: grid; gap: .1rem; }
|
||||
.bulk-bar-count small { color: var(--muted); font-size: .75rem; }
|
||||
/* Two columns while narrow keeps the bar to three rows instead of five, so it
|
||||
does not swallow the list it acts on. */
|
||||
.bulk-bar-actions { display: grid; grid-template-columns: 1fr 1fr; align-items: center; gap: .5rem; width: 100%; }
|
||||
.bulk-bar-actions .button { min-height: 2.3rem; padding: .4rem .75rem; font-size: .82rem; }
|
||||
.bulk-select select { width: 100%; min-height: 2.3rem; padding: .35rem .6rem; color: #e9f1ec; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .65rem; font-size: .82rem; font-weight: 700; }
|
||||
.bulk-select select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
.subreddit-card-main { display: grid; gap: .85rem; }
|
||||
.subreddit-identity { display: grid; gap: .45rem; min-width: 0; }
|
||||
.subreddit-identity h3 { margin: 0; font-size: 1.05rem; overflow-wrap: anywhere; }
|
||||
.subreddit-tags { display: flex; flex-wrap: wrap; gap: .35rem; }
|
||||
.subreddit-tags .pill { font-size: .68rem; }
|
||||
|
||||
.subreddit-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .5rem 1rem; margin: 0; }
|
||||
.subreddit-metrics dt { color: var(--muted); font-size: .68rem; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; }
|
||||
.subreddit-metrics dd { display: grid; gap: .05rem; margin: 0; font-weight: 750; }
|
||||
.subreddit-metrics dd small { color: var(--muted); font-size: .7rem; font-weight: 650; }
|
||||
|
||||
.subreddit-card-actions { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; padding-top: .85rem; border-top: 1px solid var(--border); }
|
||||
.subreddit-card-actions .button { min-height: 2.3rem; padding: .4rem .75rem; font-size: .82rem; }
|
||||
.subreddit-card-actions .inline-switch { margin-right: auto; }
|
||||
|
||||
@media (min-width: 52rem) {
|
||||
.bulk-bar-actions { display: flex; flex-wrap: wrap; width: auto; }
|
||||
.bulk-select select { width: auto; }
|
||||
.subreddit-card { grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; }
|
||||
.subreddit-card-main { grid-template-columns: minmax(11rem, 1fr) minmax(0, 1.1fr); align-items: center; gap: 1.5rem; }
|
||||
.subreddit-card-actions { grid-column: 3; padding-top: 0; padding-left: 1.25rem; border-top: 0; border-left: 1px solid var(--border); }
|
||||
.subreddit-card-actions .inline-switch { margin-right: 0; }
|
||||
}
|
||||
+161
-16
@@ -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; }
|
||||
@@ -26,13 +37,16 @@ a { color: inherit; }
|
||||
.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; }
|
||||
.app-shell { min-height: 100vh; }
|
||||
.sidebar { position: fixed; inset: 0 auto 0 0; z-index: 30; width: 17.5rem; padding: 1.5rem 1rem; visibility: hidden; background: #0c1511; border-right: 1px solid var(--border); transform: translateX(-100%); transition: transform 180ms ease, visibility 180ms; }
|
||||
.sidebar { position: fixed; inset: 0 auto 0 0; z-index: 30; width: 17.5rem; padding: 1.5rem 1rem; overflow-x: hidden; overflow-y: auto; overscroll-behavior-y: contain; scrollbar-gutter: stable; visibility: hidden; background: #0c1511; border-right: 1px solid var(--border); transform: translateX(-100%); transition: transform 180ms ease, visibility 180ms; }
|
||||
.sidebar.is-open { visibility: visible; transform: translateX(0); }
|
||||
.brand { display: flex; align-items: center; gap: .75rem; padding: 0 .5rem 1.75rem; }
|
||||
.brand-mark { display: grid; place-items: center; width: 2.4rem; height: 2.4rem; border-radius: .75rem; color: #07130c; background: var(--accent); font-weight: 900; }
|
||||
.brand div { display: grid; gap: .1rem; }
|
||||
.brand span:last-child, .brand div span { color: var(--muted); font-size: .75rem; }
|
||||
nav { display: grid; gap: .35rem; }
|
||||
.nav-group { display: grid; gap: .2rem; }
|
||||
.nav-group + .nav-group { margin-top: 1rem; }
|
||||
.nav-group-label { padding: 0 .75rem .3rem; color: var(--muted); font-size: .65rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; opacity: .75; }
|
||||
.nav-link { display: grid; gap: .2rem; padding: .8rem .75rem; color: var(--muted); text-decoration: none; border-radius: .75rem; border: 1px solid transparent; }
|
||||
.nav-link span { color: #dfe9e3; font-weight: 650; }
|
||||
.nav-link small { font-size: .72rem; line-height: 1.3; }
|
||||
@@ -42,6 +56,7 @@ nav { display: grid; gap: .35rem; }
|
||||
.navigation-backdrop { position: fixed; inset: 0; z-index: 20; width: 100%; border: 0; background: rgba(0, 0, 0, .6); }
|
||||
.workspace { min-height: 100vh; }
|
||||
.mobile-header { position: sticky; top: 0; z-index: 15; display: flex; align-items: center; justify-content: space-between; height: 4rem; padding: 0 1rem; background: rgba(9, 17, 14, .92); border-bottom: 1px solid var(--border); backdrop-filter: blur(12px); font-weight: 700; }
|
||||
.mobile-header-actions { display: flex; gap: .55rem; }
|
||||
.icon-button { width: 2.5rem; height: 2.5rem; border: 1px solid var(--border); border-radius: .6rem; color: white; background: var(--surface); font-size: 1.25rem; }
|
||||
main { min-height: calc(100vh - 4rem); }
|
||||
.page { width: min(78rem, 100%); margin: 0 auto; padding: 1.5rem 1rem 4rem; }
|
||||
@@ -62,19 +77,35 @@ h2 { margin-bottom: 0; font-size: 1.1rem; }
|
||||
.status-strip small { color: var(--muted); }
|
||||
.status-dot { width: .7rem; height: .7rem; border-radius: 50%; }
|
||||
.status-dot.running { background: var(--accent); box-shadow: 0 0 0 .35rem rgba(117, 214, 157, .12); }
|
||||
.status-dot.scanning { background: var(--warning-text); box-shadow: 0 0 0 .35rem rgba(255, 200, 87, .12); }
|
||||
.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; }
|
||||
.metric-card { display: grid; gap: .3rem; padding: 1rem; }
|
||||
.metric-card > span, .metric-card small { color: var(--muted); }
|
||||
.metric-card strong { font-size: 1.75rem; letter-spacing: -.04em; }
|
||||
.account-status-panel { margin-bottom: .75rem; }
|
||||
.account-status-grid { display: grid; gap: .75rem; }
|
||||
.account-status-card { display: grid; gap: .75rem; padding: .9rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .75rem; }
|
||||
.account-status-heading { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .75rem; }
|
||||
.account-status-card > p { margin: 0; color: var(--muted); font-size: .85rem; }
|
||||
.account-status-details { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .45rem .9rem; margin: 0; }
|
||||
.account-status-details div { display: grid; gap: .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: 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: 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; }
|
||||
@@ -90,19 +121,32 @@ 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; }
|
||||
.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 { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: .8rem; align-items: center; }
|
||||
.origin-post-image { position: relative; width: 5rem; height: 5rem; overflow: hidden; background: #050907; border-radius: .45rem; }
|
||||
.origin-post-image-preview-trigger { display: block; width: 100%; height: 100%; min-height: 0; padding: 0; overflow: hidden; background: transparent; border: 0; border-radius: inherit; cursor: zoom-in; }
|
||||
.origin-post-image-preview-trigger:disabled { cursor: default; }
|
||||
.origin-post-image-preview-trigger:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
.origin-post-image img { display: block; width: 100%; height: 100%; object-fit: cover; transition: filter 160ms ease, transform 160ms ease; }
|
||||
.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; }
|
||||
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; }
|
||||
.origin-image-preview-content button:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
||||
.origin-post-copy { display: grid; gap: .2rem; min-width: 0; color: inherit; text-decoration: none; }
|
||||
.origin-post-card small, .origin-post-card 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); }
|
||||
@@ -112,14 +156,19 @@ 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;
|
||||
align-items: center;
|
||||
background: color-mix(in srgb, var(--accent, #ff6534) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent, #ff6534) 35%, transparent);
|
||||
@@ -136,6 +185,101 @@ h2 { margin-bottom: 0; font-size: 1.1rem; }
|
||||
border-color: color-mix(in srgb, #d99a24 40%, transparent);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Forms
|
||||
Shared field primitives (components/form-fields.tsx). Every editable surface
|
||||
uses these so labels, hints and error placement stay identical everywhere.
|
||||
--------------------------------------------------------------------------- */
|
||||
.field { display: grid; gap: .35rem; min-width: 0; }
|
||||
.field-label { color: var(--muted); font-size: .72rem; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; }
|
||||
.field-hint { color: var(--muted); font-size: .78rem; }
|
||||
.field-error { color: var(--danger); font-size: .78rem; font-weight: 700; }
|
||||
.field-input { position: relative; display: flex; align-items: center; }
|
||||
.field input, .field select, .field textarea, .field-input input {
|
||||
width: 100%; min-height: 2.7rem; padding: .55rem .7rem; color: #e9f1ec;
|
||||
background: var(--surface-raised); border: 1px solid var(--border); border-radius: .6rem;
|
||||
}
|
||||
.field textarea { resize: vertical; line-height: 1.5; }
|
||||
.field textarea.is-monospace { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8rem; }
|
||||
.field input:focus-visible, .field select:focus-visible, .field textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.field [aria-invalid="true"] { border-color: var(--danger); }
|
||||
.field-prefix, .field-suffix { position: absolute; color: var(--muted); font-size: .82rem; font-weight: 700; pointer-events: none; }
|
||||
.field-prefix { left: .7rem; }
|
||||
.field-suffix { right: .7rem; }
|
||||
.field-input.has-prefix input { padding-left: 2rem; }
|
||||
.field-input.has-suffix input { padding-right: 3.6rem; }
|
||||
.field-row { display: grid; gap: .9rem; }
|
||||
.field-group { display: grid; gap: .9rem; padding: 1rem; margin: 0; border: 1px solid var(--border); border-radius: .75rem; }
|
||||
.field-group legend { padding: 0 .4rem; color: var(--accent); font-size: .75rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
||||
|
||||
.switch-field { display: flex; align-items: flex-start; gap: .8rem; padding: .9rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .75rem; cursor: pointer; }
|
||||
.switch-field input { flex: 0 0 auto; width: 1.15rem; height: 1.15rem; margin-top: .15rem; accent-color: var(--accent); }
|
||||
.switch-field:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.switch-field.is-critical { border-color: var(--warning-border); background: var(--warning-bg); }
|
||||
.switch-field.is-critical .switch-copy strong { color: var(--warning-text); }
|
||||
.switch-copy { display: grid; gap: .2rem; }
|
||||
.switch-copy small { color: var(--muted); }
|
||||
.switch-list { display: grid; gap: .5rem; }
|
||||
|
||||
.radio-cards { display: grid; gap: .5rem; padding: 1rem; margin: 0; border: 1px solid var(--border); border-radius: .75rem; }
|
||||
.radio-cards legend { padding: 0 .4rem; color: var(--accent); font-size: .75rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.radio-card { background: var(--surface-raised); border: 1px solid var(--border); border-radius: .7rem; }
|
||||
.radio-card.is-selected { border-color: var(--accent); background: var(--accent-dark); }
|
||||
.radio-card:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.radio-card > label { display: flex; align-items: flex-start; gap: .8rem; padding: .85rem; cursor: pointer; }
|
||||
/* Scoped to the option's own radio: a bare `.radio-card input` also matches
|
||||
the number inputs nested in .radio-detail and shrinks them to 1.1rem. */
|
||||
.radio-card > label > input[type="radio"] { flex: 0 0 auto; width: 1.1rem; height: 1.1rem; margin-top: .15rem; accent-color: var(--accent); }
|
||||
.radio-copy { display: grid; gap: .25rem; }
|
||||
.radio-copy strong { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; }
|
||||
.radio-copy small { color: var(--muted); line-height: 1.45; }
|
||||
.radio-detail { padding: 0 .85rem .85rem 3.05rem; }
|
||||
|
||||
.inline-switch { display: inline-flex; align-items: center; gap: .5rem; padding: .45rem .7rem; color: var(--muted); background: var(--surface-raised); border: 1px solid var(--border); border-radius: 999px; font-size: .78rem; font-weight: 750; cursor: pointer; }
|
||||
.inline-switch input { width: 1.05rem; height: 1.05rem; accent-color: var(--accent); }
|
||||
.inline-switch:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Cards that own their own save
|
||||
--------------------------------------------------------------------------- */
|
||||
.settings-card { display: grid; gap: 1rem; padding: 0; overflow: hidden; }
|
||||
.settings-card-heading { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 1rem; padding: 1.1rem 1.1rem 0; }
|
||||
.settings-card-heading h2 { margin-bottom: .35rem; }
|
||||
.settings-card-heading p { max-width: 44rem; margin: 0; color: var(--muted); font-size: .88rem; line-height: 1.5; }
|
||||
.settings-card-body { display: grid; gap: .9rem; padding: 0 1.1rem; }
|
||||
.settings-card-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; padding: .85rem 1.1rem; background: var(--surface-raised); border-top: 1px solid var(--border); }
|
||||
.settings-card-status { display: grid; gap: .15rem; color: var(--muted); font-size: .82rem; }
|
||||
.save-success { color: var(--accent); }
|
||||
.save-warning { color: var(--warning); }
|
||||
.save-error { color: var(--danger); }
|
||||
|
||||
.state-banner { display: grid; gap: .25rem; padding: .85rem 1rem; margin: 0; border: 1px solid var(--border); border-radius: .7rem; }
|
||||
.state-banner span { font-size: .85rem; opacity: .9; }
|
||||
.state-banner.is-safe { color: var(--success-text); background: var(--success-bg); border-color: var(--success-border); }
|
||||
.state-banner.is-live, .state-banner.is-error { color: var(--danger-text); background: var(--danger-bg); border-color: var(--danger-border); }
|
||||
.state-banner.is-warning { color: var(--warning-text); background: var(--warning-bg); border-color: var(--warning-border); }
|
||||
.state-banner .button { justify-self: start; margin-top: .35rem; }
|
||||
|
||||
.numbered-steps { display: grid; gap: .5rem; padding-left: 1.3rem; margin: 0; color: var(--muted); line-height: 1.5; }
|
||||
.numbered-steps strong { color: #e9f1ec; }
|
||||
|
||||
/* In-page section jump list, for pages built from stacked cards. */
|
||||
.section-rail { display: flex; flex-wrap: wrap; gap: .4rem; margin-bottom: 1.25rem; }
|
||||
.section-rail a { padding: .4rem .8rem; color: var(--muted); background: var(--surface); border: 1px solid var(--border); border-radius: 999px; font-size: .8rem; font-weight: 750; text-decoration: none; }
|
||||
.section-rail a:hover { color: #e9f1ec; border-color: #3b5147; }
|
||||
.section-rail a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
/* Wide modal used as an editing surface rather than a confirmation. */
|
||||
dialog.drawer-card { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; width: min(46rem, calc(100% - 2rem)); max-height: calc(100vh - 3rem); padding: 0; color: inherit; background: var(--surface); border: 1px solid var(--border); border-radius: 1rem; box-shadow: 0 2rem 5rem rgba(0, 0, 0, .5); }
|
||||
.drawer-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; padding: 1.1rem 1.1rem .25rem; }
|
||||
.drawer-heading h2 { margin: 0; font-size: 1.35rem; }
|
||||
.drawer-body { display: grid; gap: .9rem; padding: .85rem 1.1rem; overflow-y: auto; }
|
||||
.drawer-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; padding: .85rem 1.1rem; background: var(--surface-raised); border-top: 1px solid var(--border); }
|
||||
|
||||
@media (min-width: 42rem) {
|
||||
.field-row { grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); }
|
||||
}
|
||||
|
||||
@media (min-width: 48rem) {
|
||||
.sidebar { visibility: visible; transform: none; }
|
||||
.workspace { margin-left: 17.5rem; }
|
||||
@@ -144,5 +288,6 @@ h2 { margin-bottom: 0; font-size: 1.1rem; }
|
||||
.page { padding: 3rem 2rem 5rem; }
|
||||
.navigation-backdrop { display: none; }
|
||||
.metrics-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.account-status-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.overview-grid { grid-template-columns: minmax(18rem, .8fr) minmax(22rem, 1.2fr); }
|
||||
}
|
||||
+156
-50
@@ -48,8 +48,49 @@ reddit:
|
||||
filterSellers: true # Skip profiles that appear to be OnlyFans/content sellers
|
||||
prioritizeRecentPosts: true # Prefer posts within recentWindowMinutes; older posts only as fallback
|
||||
recentWindowMinutes: 30
|
||||
maxReadyCandidates: 25 # Pause screening when this many posts are ready to send
|
||||
physiqueTargeting:
|
||||
enabled: false # When enabled, only scored image posts inside this range continue
|
||||
minScore: 1 # 1 = visibly very large-bodied physique
|
||||
maxScore: 10 # 10 = exceptionally lean/toned, model-like physique
|
||||
# sellerKeywords: [...] # Uncomment to override default keyword list
|
||||
|
||||
conversations:
|
||||
offPlatformKill: true # End the conversation when they push Telegram/Snapchat/payment/etc.
|
||||
killOnSellerKeyword: true # Set false to hand seller-keyword hits to an operator instead
|
||||
aiReplyScreening:
|
||||
enabled: false # Second-stage classifier on inbound replies; fails open
|
||||
killConfidence: 0.8
|
||||
flagConfidence: 0.6
|
||||
|
||||
learning:
|
||||
captureEnabled: true # Build an auditable dataset without changing messages
|
||||
generationEnabled: false # Enable only after the candidate policy passes offline evaluation
|
||||
shadowMode: true # Record which examples would be used without injecting them
|
||||
maturityHours: 12
|
||||
maxContextExamples: 3
|
||||
maxTurnsPerExample: 8
|
||||
humanExampleSlots: 2
|
||||
autonomousExampleSlots: 1
|
||||
minimumAttributionConfidence: exact
|
||||
policyVersion: baseline-v1
|
||||
|
||||
pushNotifications:
|
||||
# Phone push via ntfy. The topic is world-readable to anyone who guesses it,
|
||||
# so use an unguessable name. Set NTFY_AUTH_TOKEN for a protected topic.
|
||||
enabled: false
|
||||
serverUrl: "https://ntfy.sh" # Point at your self-hosted instance
|
||||
topic: "" # e.g. redditbot-7f3a9c21
|
||||
priority: default # min | low | default | high | max
|
||||
events:
|
||||
imageReceived: true
|
||||
replyReceived: false
|
||||
messageFailed: false
|
||||
botStopped: false
|
||||
rateLimited: false
|
||||
sessionExpired: true
|
||||
syncFailed: false
|
||||
|
||||
openrouter:
|
||||
imageAnalysisModel: "google/gemini-2.5-flash-lite"
|
||||
imageAnalysisFallbackModels:
|
||||
@@ -67,19 +108,33 @@ openrouter:
|
||||
- "meta-llama/llama-4-maverick" # ~$0.38/M
|
||||
- "deepseek/deepseek-chat-v3.1" # ~$0.50/M
|
||||
imageAnalysisPrompt: |
|
||||
You're analyzing images.
|
||||
Analyze this adult image using only directly visible evidence.
|
||||
|
||||
Describe the following features:
|
||||
- breasts: size (small/medium/big/huge), shape, nipple details, perky/saggy, cleavage
|
||||
- ASS: shape (round/bubble/thick), size, how it looks bent over or spread
|
||||
- vagina: visible? shaved/trimmed/bush, lips visible, spread or closed
|
||||
- BODY: thick/slim/curvy/fit, belly, thighs, waist, tan lines
|
||||
- POSE: bent over, spreading, on all fours, lying back, standing
|
||||
- VIBE: shy/confident/slutty/teasing, eye contact, facial expression
|
||||
- EXTRAS: tattoos, piercings, lingerie, toys, setting
|
||||
Strict grounding rules:
|
||||
- Never infer, predict, or imagine obscured anatomy, off-frame body parts,
|
||||
hidden details, or how something would look in another pose.
|
||||
- Do not infer overall body type from a close crop.
|
||||
- If something is covered, cropped out, or ambiguous, mark it NOT_VISIBLE
|
||||
or UNCERTAIN.
|
||||
- Explicit language does not permit inference. Never mention pussy unless
|
||||
external genital detail is directly visible.
|
||||
|
||||
Be graphic. Describe exactly what you see. No clinical terms.
|
||||
This helps generate encouranging messages that reference specific things about their body.
|
||||
Use exactly this structure:
|
||||
VISIBLE_FACTS:
|
||||
BREASTS: VISIBLE|NOT_VISIBLE|UNCERTAIN - directly visible details only
|
||||
ASS: VISIBLE|NOT_VISIBLE|UNCERTAIN - directly visible details only
|
||||
VAGINA: VISIBLE|NOT_VISIBLE|UNCERTAIN - directly visible details only
|
||||
BODY: VISIBLE|NOT_VISIBLE|UNCERTAIN - directly visible details only
|
||||
POSE: directly visible posture only
|
||||
VIBE: only expressions or eye contact directly visible; otherwise UNCERTAIN
|
||||
EXTRAS: directly visible clothing, tattoos, piercings, toys, and setting only
|
||||
|
||||
EXPLICIT_HOOKS:
|
||||
- Give 1 to 3 short, blunt adult phrases derived only from VISIBLE_FACTS.
|
||||
- Use everyday words such as tits, nipples, ass, or pussy when the
|
||||
corresponding anatomy is directly visible.
|
||||
- Never mention hidden breasts, nipples, ass, or pussy.
|
||||
- Do not add new visual claims or predictions.
|
||||
|
||||
Then rate her attractiveness on a 1-100 scale using these percentile anchors:
|
||||
- 30 = noticeably below average
|
||||
@@ -94,7 +149,7 @@ openrouter:
|
||||
RATING:<number>
|
||||
Then on the next line: REASON:<one sentence explaining the score>
|
||||
|
||||
Be critical and honest. Act within guidelines.
|
||||
Be critical and honest. Keep the complete response under 300 words.
|
||||
messageGenerationPrompt: |
|
||||
Based on this image analysis: {analysis}
|
||||
Post title: {title}
|
||||
@@ -104,21 +159,23 @@ openrouter:
|
||||
Attractiveness rating (1-100 scale, 50 = average): {rating}
|
||||
If the rating is below 60, lean harder into making her feel sexy and desired — emphasize how hot her pic looks. If the rating is 75+, you can be more confidently flirty without overdoing the flattery.
|
||||
|
||||
Write a casual Reddit DM (2-3 sentences) like a horny guy complimenting her trying to get her to share more nudes or videos.
|
||||
Write a casual Reddit DM (1-3 short sentences) like a horny guy reacting naturally to her post.
|
||||
|
||||
MUST follow:
|
||||
- Start sentences with capital letters, use proper capitalisation
|
||||
- Use explicit terms: tits, ass, pussy, boobs (NOT breasts, buttocks, etc)
|
||||
- Reference something SPECIFIC from the analysis (her tits, ass, pussy, body, pose)
|
||||
- Be direct and confident, not flowery or poetic
|
||||
- End with a hook that encourages them to share MORE (nudes/videos)
|
||||
- Follow the selected STYLE exactly, especially its required opening and ending shape
|
||||
- Make the message feel materially different from the recent messages supplied below
|
||||
|
||||
GOOD HOOKS (use these styles):
|
||||
- "I'd love if you have any other pics to share?"
|
||||
- "I bet they'd look great coated..."
|
||||
- "Got any more pics?"
|
||||
POSSIBLE HOOKS (only when the selected STYLE calls for one):
|
||||
- A specific question about the post
|
||||
- A playful prediction or challenge
|
||||
- A conditional invitation to share something else
|
||||
- A statement of desire with no question
|
||||
|
||||
BAD HOOKS (never use):
|
||||
BAD HOOKS:
|
||||
- "How's your day?"
|
||||
- "What are you up to?"
|
||||
- Generic small talk questions
|
||||
@@ -130,12 +187,11 @@ openrouter:
|
||||
- Use clinical terms like "breasts", "buttocks", "posterior", "physique"
|
||||
- Write long elaborate sentences
|
||||
- Use all lowercase
|
||||
- Default to opening with "Damn", "Wow", "Holy shit", or the same reaction used recently
|
||||
- Default to closing with "Got any more pics?", "More please", or a near-copy of a recent closing
|
||||
- Use em dashes (—) or en dashes (–). Use commas, full stops, or separate sentences instead. This is a dead giveaway for AI writing.
|
||||
|
||||
Examples of good messages:
|
||||
- "Damn those tits are perfect. I'd love to see more if you're willing😏"
|
||||
- "Holy shit that ass is incredible. Got any more pics?"
|
||||
- "Those curves are insane, love how your tits look in that pose. More please?"
|
||||
- Mention, mirror, or acknowledge the recipient's location
|
||||
- Claim or imply you are in the same, nearby, or local area as the recipient, calculate distance, or suggest meeting in person
|
||||
|
||||
Just write the message, nothing else.
|
||||
|
||||
@@ -150,15 +206,15 @@ openrouter:
|
||||
|
||||
YOUR PERSONA:
|
||||
- You are male, based in the UK, pick a random age between 30 and 35 for this message
|
||||
- Roughly half the time, mention (or imply) you're married/have a wife and are looking for something on the side; the other half, don't mention relationship status at all
|
||||
- Don't force the married angle if their post wouldn't vibe with it, read the room
|
||||
- Do not mention relationship status unless the selected STYLE explicitly requires it
|
||||
- Use British English spelling and phrasing (e.g. "mate", "knackered", "fancy", "cheeky") where it feels natural, not forced
|
||||
- You may state that you are in the UK, but never mention or acknowledge the recipient's location
|
||||
|
||||
Write a casual Reddit DM (1-2 sentences) opening a chat.
|
||||
|
||||
MUST follow:
|
||||
- Lead with your age/gender/location tag (e.g. "32M UK", "34M UK married") when it fits naturally
|
||||
- Reference something SPECIFIC from their title/body (their mood, what they're into, vibe, age, location)
|
||||
- Include your age/gender/location naturally, but place it where the selected STYLE requires
|
||||
- Reference something SPECIFIC from their title/body (their mood, what they're into, vibe, or age), but never their location
|
||||
- Be confident and direct, flirty but not creepy
|
||||
- Use proper capitalisation, no AI/clinical language
|
||||
- End with a hook that invites a reply (ask them what they're into, offer something playful)
|
||||
@@ -168,52 +224,102 @@ openrouter:
|
||||
- Open with "Hey there" / "How's your day"
|
||||
- Write more than 2 sentences
|
||||
- Be generic, "wanna chat?" alone is too weak
|
||||
- Reuse the opening words, sentence pattern, or closing question from a recent message
|
||||
- Claim to be a different age/gender than the persona above
|
||||
- Mention or acknowledge the recipient's location
|
||||
- Say you are also/nearby/local to the recipient, calculate distance, or suggest meeting in person
|
||||
- Use em dashes (—) or en dashes (–) anywhere in the message. Use commas, full stops, or separate sentences instead. This is a dead giveaway for AI writing.
|
||||
|
||||
Examples of good openers:
|
||||
- "32M UK, married and bored while the wife's out. Your post caught my eye, what are you into tonight?"
|
||||
- "34M UK here, love that you're up late looking for trouble. Fancy telling me what gets you going?"
|
||||
- "33M from the UK, your vibe is exactly my type. Tell me what you'd want a stranger to do to you."
|
||||
|
||||
Just write the message, nothing else.
|
||||
|
||||
growthCommentPrompt: |
|
||||
Write one short, genuine comment on this r/{subreddit} thread. You are an ordinary UK redditor,
|
||||
not a marketer and not an assistant.
|
||||
|
||||
Title: {title}
|
||||
Body: {selftext}
|
||||
Flair: {flair}
|
||||
|
||||
MUST follow:
|
||||
- Respond to something SPECIFIC the thread says: a detail, a question it raises, or relevant
|
||||
ordinary experience of your own
|
||||
- 1-3 sentences, British English, sound like someone typing quickly
|
||||
- Plain and useful, or plainly sympathetic; adding nothing is better than padding
|
||||
|
||||
DO NOT:
|
||||
- Include any link, domain, email address, or reference to another platform
|
||||
- Mention your profile or posts, or ask anyone to message, DM, or follow you
|
||||
- Tag another user
|
||||
- Open with a compliment about the post, or restate the title
|
||||
- Invent credentials, statistics, or facts you could not know
|
||||
- Give medical, legal, or financial advice
|
||||
- Use em dashes, or write filler such as "this!", "great post", or "commenting for visibility"
|
||||
|
||||
strategies:
|
||||
explorationRate: 0.2 # 20% of the time try a random strategy instead of the best
|
||||
minSamples: 5 # Min messages sent before trusting a strategy's response rate
|
||||
minSamples: 20 # Mature outcomes required before exploiting a strategy
|
||||
responseWindowHours: 12 # Age unanswered sends before including them in strategy performance
|
||||
variants:
|
||||
- id: compliment_direct
|
||||
- id: reaction_statement
|
||||
weight: 1
|
||||
promptOverride: |
|
||||
STYLE: Focus on a direct, specific compliment about ONE body part from the analysis.
|
||||
Keep it short and punchy. End with a simple "got more?" style hook.
|
||||
- id: teasing
|
||||
STYLE: REACTION STATEMENT. Open with a short physical reaction that is NOT "Damn", "Wow", or "Holy shit".
|
||||
Mention one specific visible detail. Finish with a confident statement of desire, not a question and not a request for more.
|
||||
- id: question_first
|
||||
weight: 1
|
||||
promptOverride: |
|
||||
STYLE: Be more teasing and playful. Light humor, like you're flirting.
|
||||
End with a flirty challenge or dare that makes her want to respond.
|
||||
- id: detailed_appreciation
|
||||
STYLE: QUESTION FIRST. The very first sentence must be a specific, answerable question about her post, pose, outfit, or title.
|
||||
Follow with one concise explicit compliment. Do not ask whether she has more pics.
|
||||
- id: title_callback
|
||||
weight: 1
|
||||
promptOverride: |
|
||||
STYLE: Reference 2-3 specific things from the analysis to show you actually looked.
|
||||
Be enthusiastic but not over the top. End with asking for more.
|
||||
STYLE: TITLE CALLBACK. Open by naturally echoing or answering the post title, without quoting it verbatim.
|
||||
Turn that callback into a playful compliment. End with an optional, conditional invitation rather than a generic request.
|
||||
- id: teasing_challenge
|
||||
weight: 1
|
||||
promptOverride: |
|
||||
STYLE: TEASING CHALLENGE. Open with a playful assumption, prediction, or mild challenge based on one visible detail.
|
||||
Do not open with a compliment. End with a short dare or question that is specific to the challenge.
|
||||
- id: concise_observation
|
||||
weight: 1
|
||||
promptOverride: |
|
||||
STYLE: CONCISE OBSERVATION. Write exactly one sentence about the most distinctive visible detail in the analysis.
|
||||
Do not ask a question, request more content, or begin with an interjection.
|
||||
- id: imagined_scenario
|
||||
weight: 1
|
||||
promptOverride: |
|
||||
STYLE: IMAGINED SCENARIO. Open with a brief, concrete scenario inspired by the pose or setting, then say what it makes you want.
|
||||
End on the scenario itself. Do not use a generic compliment opener or ask "got more?".
|
||||
|
||||
chatStrategies:
|
||||
explorationRate: 0.2
|
||||
minSamples: 5
|
||||
minSamples: 20
|
||||
responseWindowHours: 12
|
||||
variants:
|
||||
- id: chat_direct
|
||||
- id: chat_identity_first
|
||||
weight: 1
|
||||
promptOverride: |
|
||||
STYLE: Direct opener. Open with your own age/gender, reference theirs, and ask one specific question about what they're into. No em dashes.
|
||||
- id: chat_playful
|
||||
STYLE: IDENTITY FIRST. Open with your age/gender/location tag, then make one specific reference to their post and ask one answerable question.
|
||||
- id: chat_question_first
|
||||
weight: 1
|
||||
promptOverride: |
|
||||
STYLE: Playful and teasing. Light humour, mild flirty challenge, end with a hook that's hard to ignore. No em dashes.
|
||||
- id: chat_mirror
|
||||
STYLE: QUESTION FIRST. Open with a specific question prompted by their title or body. Put your age/gender/location naturally in the second sentence.
|
||||
- id: chat_post_callback
|
||||
weight: 1
|
||||
promptOverride: |
|
||||
STYLE: Mirror their energy. Match the vibe of their post (shy/horny/bored/dom/sub) and respond in kind. Be specific to what they wrote. No em dashes.
|
||||
STYLE: POST CALLBACK. Open by responding to a distinctive phrase, request, or mood from their post. Add your age/gender/location later and close with a playful offer.
|
||||
- id: chat_playful_assumption
|
||||
weight: 1
|
||||
promptOverride: |
|
||||
STYLE: PLAYFUL ASSUMPTION. Start with a light, post-specific assumption about the kind of chat or trouble they want. Include your identity naturally and end with a mild challenge.
|
||||
- id: chat_mirror_statement
|
||||
weight: 1
|
||||
promptOverride: |
|
||||
STYLE: MIRROR STATEMENT. Match their energy and vocabulary, then make a direct statement about why you fit what they asked for. Include your identity, but do not end with a question.
|
||||
- id: chat_married_disclosure
|
||||
weight: 1
|
||||
promptOverride: |
|
||||
STYLE: MARRIED DISCLOSURE. Mention naturally in the first sentence that you are married or have a wife, then connect it to one specific detail from their post that caught your attention. Include your age, gender, and UK location naturally, and end with one answerable, post-specific hook. Do not claim an open marriage, your wife's permission or involvement, secrecy, unhappiness, or any other detail about the marriage.
|
||||
|
||||
rateLimit:
|
||||
minDelayBetweenMessagesMs: 15000
|
||||
|
||||
@@ -15,6 +15,9 @@ services:
|
||||
environment:
|
||||
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
|
||||
- WEB_DASHBOARD_TOKEN=${WEB_DASHBOARD_TOKEN}
|
||||
- REDDIT_BOT_AGENT_READ_TOKEN=${REDDIT_BOT_AGENT_READ_TOKEN:-}
|
||||
- REDDIT_BOT_AGENT_CONTROL_TOKEN=${REDDIT_BOT_AGENT_CONTROL_TOKEN:-}
|
||||
- NTFY_AUTH_TOKEN=${NTFY_AUTH_TOKEN:-}
|
||||
|
||||
volumes:
|
||||
reddit_bot_data:
|
||||
@@ -0,0 +1,60 @@
|
||||
# RedditBot documentation
|
||||
|
||||
This directory is the starting point for agents and maintainers. It explains the system at the level needed to make safe changes; source code, tests, database migrations, and the generated API reference remain authoritative.
|
||||
|
||||
## Read this first
|
||||
|
||||
RedditBot is a stateful, multi-account outreach and conversation application. It discovers eligible Reddit posts, prepares AI-generated openers, sends them through Reddit's Matrix chat service, follows replies under bounded policies, records outcomes, and exposes an authenticated operator dashboard and agent API.
|
||||
|
||||
The most important operational fact is that local and production configuration may be live-capable. Installing dependencies, building, and testing are safe. Starting the backend, invoking control endpoints, importing sessions, sending messages, or deploying may affect real Reddit accounts and requires explicit authorization.
|
||||
|
||||
## Documentation map
|
||||
|
||||
| Document | Use it when |
|
||||
| --- | --- |
|
||||
| [Product goals and safety](product-and-safety.md) | You need the purpose, product boundaries, eligibility policy, or definition of success. |
|
||||
| [Architecture](architecture.md) | You need the system shape, process lifecycle, dependency boundaries, or repository map. |
|
||||
| [Runtime flows](data-flow.md) | You are changing discovery, screening, candidate delivery, replies, chat sync, media, or realtime updates. |
|
||||
| [Human-led conversation learning](conversation-learning.md) | You are changing attribution, credit, review, retrieval, evaluation, rollout, or framework integration. |
|
||||
| [Service map](services.md) | You need to find the module that owns a responsibility or identify extension seams. |
|
||||
| [Data model](database.md) | You are changing persistence, migrations, account scoping, idempotency, or analytics. |
|
||||
| [Configuration](configuration.md) | You are adding a setting, changing precedence, or diagnosing startup validation. |
|
||||
| [API](api.md) | You are using or changing HTTP/MCP operations. This file is generated. |
|
||||
| [Frontend](frontend.md) | You are changing the React dashboard, shared contracts, queries, routes, or SSE behavior. |
|
||||
| [Development and testing](development.md) | You are setting up locally, implementing a change, or choosing verification commands. |
|
||||
| [Deployment and operations](deployment.md) | You are diagnosing or performing Docker/Dokploy work, backups, recovery, or live verification. |
|
||||
|
||||
## Fast orientation by task
|
||||
|
||||
- Candidate selection or outreach: read product-and-safety, data-flow, services, and database.
|
||||
- Conversation automation: read product-and-safety, data-flow, and the conversation state/claim sections in database.
|
||||
- Dashboard/API work: read frontend, api, and architecture.
|
||||
- Authentication or account work: read architecture, configuration, database, and deployment.
|
||||
- Production incident: read deployment first, then the flow and service that own the failing state.
|
||||
- Schema or config change: read database/configuration and update both transport contracts and tests where applicable.
|
||||
|
||||
## Sources of truth
|
||||
|
||||
Use this order when facts conflict:
|
||||
|
||||
1. Observable behavior and focused tests.
|
||||
2. Shared Zod transport contracts in `packages/api-contracts/src/`.
|
||||
3. Runtime Zod configuration in `src/config/index.ts` and migrations in `src/database/client.ts`.
|
||||
4. Owning implementation under `src/` or `apps/dashboard/src/`.
|
||||
5. Generated `docs/api.md` and live `/api/openapi.json`.
|
||||
6. Narrative documentation in this directory.
|
||||
|
||||
Documentation should explain why boundaries exist and where behavior lives. Avoid copying entire schemas, hard-coded production identifiers, current counts, or secrets into prose.
|
||||
|
||||
## Keeping documentation current
|
||||
|
||||
Every code change must include the documentation changes needed to keep this map accurate. In particular:
|
||||
|
||||
- Add or remove links here when documents change.
|
||||
- Update architecture and service ownership when dependencies move.
|
||||
- Update runtime flows when ordering, eligibility, retries, or idempotency changes.
|
||||
- Update configuration and data model documents with schema changes.
|
||||
- Run `npm run docs:api` after modifying `src/web/api-catalog.ts`.
|
||||
- Keep historical plans out of current-state documents; use Git history for retired designs.
|
||||
|
||||
See the repository-level [`agents.md`](../agents.md) for the mandatory agent workflow.
|
||||
+137
-1015
File diff suppressed because it is too large.
Load diff
+133
-903
File diff suppressed because it is too large.
Load diff
+98
-434
@@ -1,485 +1,149 @@
|
||||
# Configuration Guide
|
||||
# Configuration
|
||||
|
||||
Complete reference for configuring RedditBot.
|
||||
## Precedence and lifecycle
|
||||
|
||||
---
|
||||
Configuration is validated by `ConfigSchema` in `src/config/index.ts`.
|
||||
|
||||
## Table of Contents
|
||||
1. `DATABASE_PATH` identifies the database before application settings can be read.
|
||||
2. If `app_config` exists, the application-owned configuration is loaded from SQLite.
|
||||
3. Otherwise, `config.yaml` is loaded once and the validated application-owned subset is seeded into SQLite.
|
||||
4. Environment-owned secrets and database location are injected on every start.
|
||||
|
||||
1. [Configuration Overview](#configuration-overview)
|
||||
2. [Configuration Storage](#configuration-storage)
|
||||
3. [Environment Variables](#environment-variables)
|
||||
4. [Application Settings](#application-settings)
|
||||
5. [Runtime Configuration](#runtime-configuration)
|
||||
6. [Configuration Validation](#configuration-validation)
|
||||
7. [Troubleshooting](#troubleshooting)
|
||||
After the initial seed, editing `config.yaml` does not replace an existing `app_config` row. Use the authenticated settings/API workflow or a deliberate database migration. The config route validates the complete result before saving.
|
||||
|
||||
---
|
||||
## Environment-owned values
|
||||
|
||||
## Configuration Overview
|
||||
| Variable | Required | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `OPENROUTER_API_KEY` | Yes | OpenRouter authentication. |
|
||||
| `WEB_DASHBOARD_TOKEN` | When web is enabled | Administrator UI/API credential. |
|
||||
| `DATABASE_PATH` | No | SQLite location; defaults to `./data/reddit-bot.db`. |
|
||||
| `NTFY_AUTH_TOKEN` | No | Bearer token for a protected ntfy topic. |
|
||||
| `REDDIT_BOT_AGENT_READ_TOKEN` | No | Read-only API/SSE bearer credential. |
|
||||
| `REDDIT_BOT_AGENT_CONTROL_TOKEN` | No | Read plus versioned control/job bearer credential. |
|
||||
| `REDDIT_BOT_API_URL` | MCP only | Base URL; defaults to `http://127.0.0.1:3000`. |
|
||||
| `REDDIT_BOT_AGENT_TOKEN` | MCP only | Preferred MCP bearer credential override. |
|
||||
|
||||
RedditBot uses a layered configuration approach:
|
||||
Never document real values. `.env` is ignored and may contain production credentials; do not print or blindly source it.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Priority Order (High → Low) │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ 1. Environment Variables │
|
||||
│ (OPENROUTER_API_KEY, WEB_DASHBOARD_TOKEN) │
|
||||
│ │
|
||||
│ 2. config.yaml │
|
||||
│ (Primary configuration file) │
|
||||
│ │
|
||||
│ 3. Defaults │
|
||||
│ (Hardcoded defaults in code) │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
## Application sections
|
||||
|
||||
Configuration is loaded at startup by `loadConfig()` in `src/config/index.ts`, which:
|
||||
### `reddit`
|
||||
|
||||
1. Reads the singleton `app_config` record from SQLite
|
||||
2. Imports `config.yaml` only when upgrading an existing database that has no settings record
|
||||
3. Validates the entire configuration tree using Zod
|
||||
4. Returns a strongly-typed `Config` object
|
||||
- `subreddits`: at least one item with `name`, `enabled`, `type`, optional `weight`, `tone`, optional scripted opener, and conversation policy.
|
||||
- `pollIntervalMs`: legacy/scheduled polling cadence.
|
||||
- `userAgent`: Reddit JSON request identifier.
|
||||
- `targetGenders`: allowed parsed values.
|
||||
- `filterSellers`, `sellerKeywords`, `sellerDomains`: seller policy inputs; see also the `conversations` section, whose off-platform kill is independent of this flag. The defaults treat Telegram references, including `t.me` and `telegram.me` links, as seller signals alongside paid-content platforms and payment prompts.
|
||||
- `prioritizeRecentPosts`, `recentWindowMinutes`: recency/fallback behavior.
|
||||
- `maxReadyCandidates`: hard ceiling for the active candidate target; valid range 1–500. Runtime lowers the actual target to what authenticated running accounts can send within `recentWindowMinutes` under current rolling-hour and message-spacing limits.
|
||||
- `physiqueTargeting`: optional image-candidate filter with `enabled`, inclusive `minScore`, and inclusive `maxScore` values from 1 to 10. It is disabled by default. When enabled, a model score outside the range or an image without enough visible body evidence to score is rejected before opener generation. Saving the setting also reconciles prepared image candidates in the ready and delivery-retry stages: candidates that no longer qualify are rejected, while retained candidates receive the newly applied range snapshot shown in the funnel. A candidate already leased for sending is not changed mid-send. The setting is hot-read by existing account runtimes; chat sources are unaffected.
|
||||
|
||||
Application-owned settings are edited from the dashboard and saved to SQLite. `OPENROUTER_API_KEY`, `WEB_DASHBOARD_TOKEN`, and `DATABASE_PATH` remain environment-owned bootstrap values and are never stored in `app_config`.
|
||||
Physique targeting is owned only by its dedicated Monitoring controls. It is excluded from the advanced JSON response. Older cached dashboards may still submit a duplicate advanced copy; the server accepts but ignores that copy so it cannot overwrite the dedicated value persisted in `app_config`.
|
||||
|
||||
---
|
||||
Subreddit `type` is `image` or `chat`. Conversation policy contains `mode`, `maxAutomatedTurns`, and `rapportTurnsBeforeMediaAsk`; hybrid rapport turns must be lower than the maximum.
|
||||
|
||||
## Configuration Storage
|
||||
### `conversations`
|
||||
|
||||
### Legacy config.yaml
|
||||
- `offPlatformKill` (default `true`): end a conversation outright when the reply pushes it to another app, to contact details, or to a payment rail. Independent of `reddit.filterSellers`.
|
||||
- `killOnSellerKeyword` (default `true`): a configured seller keyword or domain also ends the conversation. Set `false` to hand those to an operator instead.
|
||||
- `aiReplyScreening.enabled` (default `false`): second-stage classifier on inbound replies, run only when the deterministic rules pass. Uses `openrouter.chatModel` and its fallbacks, and fails open.
|
||||
- `aiReplyScreening.killConfidence` (default `0.8`) / `flagConfidence` (default `0.6`): thresholds at which a seller/off-platform/scam/bot verdict kills or hands off.
|
||||
|
||||
`config.yaml` is retained temporarily as a one-time migration source for existing installations. Once every deployed database contains an `app_config` row, it can be removed in a follow-up release.
|
||||
### `openrouter`
|
||||
|
||||
```
|
||||
RedditBot/
|
||||
├── config.yaml # ← Main configuration
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
└── src/
|
||||
```
|
||||
Contains image/chat primary and fallback models plus the analysis, message, and chat-opener prompt templates. The API key is environment-only. Model fallback and prompt changes affect cost, behavior, and experiment attribution and need focused tests plus operator-visible metadata.
|
||||
|
||||
### Data Directory
|
||||
`growthCommentPrompt` is the template the account grower uses to write a public comment on someone else's thread. It supports `{subreddit}`, `{title}`, `{selftext}`, and `{flair}`. The thread is appended separately as fenced untrusted data and the deterministic safety gate runs on the result regardless of prompt wording, so editing this template changes tone, not the safety boundary.
|
||||
|
||||
Runtime data is stored in `data/` (created automatically):
|
||||
### `rateLimit`
|
||||
|
||||
```
|
||||
data/
|
||||
├── reddit-bot.db # SQLite database
|
||||
├── browser/ # Browser session data (cookies, storage)
|
||||
├── media/ # Downloaded media files
|
||||
│ ├── index.json # Media index (SHA-1 → filename)
|
||||
│ ├── abc123.jpg # Media file 1
|
||||
│ └── def456.png # Media file 2
|
||||
└── chats/ # Cached conversation JSON files
|
||||
├── chat_abc123.json
|
||||
└── chat_def456.json
|
||||
```
|
||||
- `minDelayBetweenMessagesMs`;
|
||||
- `maxMessagesPerHour`;
|
||||
- `jitterMs`.
|
||||
|
||||
---
|
||||
These are per account. Reddit JSON request pacing is separately controlled by `RedditRequestCoordinator` and is process-global.
|
||||
|
||||
## Environment Variables
|
||||
### `browser`
|
||||
|
||||
Environment variables provide secrets and bootstrap settings that are intentionally excluded from SQLite.
|
||||
- `sessionDir`: root for persistent account browser profiles.
|
||||
- `dryRun`: when true, candidate preparation may run but remote sends must not.
|
||||
|
||||
| Variable | Required | Type | Description | Example |
|
||||
|---|---|---|---|---|
|
||||
| `OPENROUTER_API_KEY` | Yes | string | OpenRouter API key for AI services | `sk-or-v1-abc123...` |
|
||||
| `WEB_DASHBOARD_TOKEN` | Yes when dashboard enabled | string | Dashboard access token | `long-random-secret` |
|
||||
| `DATABASE_PATH` | No | string | SQLite database location | `./data/reddit-bot.db` |
|
||||
Treat changing `dryRun` from true to false as enabling a real side effect.
|
||||
|
||||
### Setting Environment Variables
|
||||
### `database`
|
||||
|
||||
**Option 1: `.env` file** (recommended for development)
|
||||
Contains `path`, but runtime ownership is `DATABASE_PATH`, not the persisted application config.
|
||||
|
||||
Create a `.env` file in the project root:
|
||||
### `media`
|
||||
|
||||
```env
|
||||
OPENROUTER_API_KEY=sk-or-v1-abc123...
|
||||
WEB_DASHBOARD_TOKEN=long-random-secret
|
||||
```
|
||||
Controls periodic sync enablement and interval. Media work remains authentication-bound.
|
||||
|
||||
> **Note**: Add `.env` to `.gitignore` - never commit credentials.
|
||||
### `pushNotifications`
|
||||
|
||||
**Option 2: Shell export**
|
||||
Publishes selected application events to a single ntfy topic so they reach a phone. Holds the enabled flag, server URL, topic, ntfy priority, and a boolean per event (`imageReceived`, `replyReceived`, `messageFailed`, `botStopped`, `rateLimited`, `sessionExpired`, `syncFailed`). Editable from Settings → Notifications, which writes through `PUT /api/push-notifications`; `POST /api/push-notifications/test` sends a fixed test notification. The access token is environment-owned (`NTFY_AUTH_TOKEN`), is never persisted to the settings record, and is never returned by the API — the response only reports `hasAuthToken`.
|
||||
|
||||
```bash
|
||||
export OPENROUTER_API_KEY=sk-or-v1-abc123...
|
||||
export WEB_DASHBOARD_TOKEN=long-random-secret
|
||||
```
|
||||
Delivery is best-effort: an unreachable or rejecting ntfy server is logged and never propagated into the path that produced the event.
|
||||
|
||||
**Option 3: Docker**
|
||||
### `strategies` and `chatStrategies`
|
||||
|
||||
Pass via `docker-compose.yml` or Docker CLI:
|
||||
Each has exploration rate, minimum mature samples, response window, and weighted variants. Variants include an ID and prompt override. Image and chat sources use separate sections so their results are not compared as one experiment.
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- OPENROUTER_API_KEY=sk-or-v1-abc123...
|
||||
- WEB_DASHBOARD_TOKEN=long-random-secret
|
||||
```
|
||||
### `web`
|
||||
|
||||
---
|
||||
|
||||
## Application Settings
|
||||
|
||||
Use **Settings** in the dashboard for monitoring, rate limits, dry-run behaviour, subreddits, models, prompts, filtering, media sync, and strategy configuration. The advanced JSON editor exposes settings that do not yet have dedicated controls; its payload is validated by the server before it is saved.
|
||||
Controls enabled state, port, optional tokens after environment injection, and dangerous debug endpoint enablement. Production secrets should never live in YAML.
|
||||
|
||||
The YAML examples below document the legacy field layout used during one-time migration.
|
||||
### Account growth
|
||||
|
||||
Complete reference for every configuration option.
|
||||
Growth is configured per account through `/api/growth`, not through `config.yaml` or `app_config`, because the community list, allowance, and pause state belong to one Reddit identity. The stores are `account_growth_settings` and `account_growth_subreddits`; see `docs/database.md`.
|
||||
|
||||
### Reddit Settings
|
||||
Operator-settable per account: enabled, the daily comment limit, minimum spacing, jitter, quiet hours, NSFW opt-in, the community list with per-community weight and daily cap, and pause/resume. `browser.dryRun` also suppresses growth posting.
|
||||
|
||||
```yaml
|
||||
reddit:
|
||||
# Subreddits to monitor for new posts
|
||||
subreddits:
|
||||
- gonewild
|
||||
- ratemyboobs
|
||||
- HomemadeNsfw
|
||||
- realgirls
|
||||
- plussize_gonewild
|
||||
# ... add or remove subreddits
|
||||
`dailyLimit` is null by default, which leaves the allowance to the adaptive trust level the verification sweep maintains (2/3/5/8/12 per day at levels 0-4). Setting a whole number between 1 and 50 overrides that in either direction and is enforced verbatim; the trust level continues to be tracked and reported as `adaptiveAllowance` so the earned figure stays visible. Raising the limit above the earned allowance removes the adaptive backoff after a bad comment, so the dashboard says so. It does not affect the removal/shadowban/captcha pause, which is a safety stop rather than a rate control and cannot be disabled by any limit.
|
||||
|
||||
# Minutes between polling cycles
|
||||
pollInterval: 15
|
||||
A per-community `dailyCap` only distributes the account allowance; caps are not additive and cannot raise the account total.
|
||||
|
||||
# Target gender for image analysis filtering
|
||||
# Options: "F" (female), "M" (male)
|
||||
targetGender: "F"
|
||||
|
||||
# Keywords used to detect sellers/spam profiles
|
||||
# Posts from users matching these keywords are skipped
|
||||
sellerKeywords:
|
||||
- seller
|
||||
- onlyfans
|
||||
- link in bio
|
||||
- check my profile
|
||||
- promo
|
||||
- discount
|
||||
- affiliate
|
||||
```
|
||||
|
||||
### OpenRouter AI Settings
|
||||
|
||||
```yaml
|
||||
openrouter:
|
||||
# OpenRouter API key (can also be set via OPENROUTER_API_KEY env var)
|
||||
apiKey: "sk-or-v1-abc123..."
|
||||
|
||||
# Primary AI model for image analysis
|
||||
# Models must be available on OpenRouter
|
||||
model: "meta-llama/llama-3.2-90b-vision-preview"
|
||||
Community weights feed a new weighted-random pick for each discovered growth thread. The grower queues only one new thread at a time, drains due retries and existing queue work before polling another community, and exposes an account-scoped queue clear that removes only unposted discovered/retryable rows.
|
||||
|
||||
# Fallback models tried if the primary model refuses or fails
|
||||
fallbackModels:
|
||||
- "google/gemini-2.0-flash-001"
|
||||
- "meta-llama/llama-3.2-11b-vision-instruct"
|
||||
Browsing sessions are configured separately per account through `/api/growth/{accountId}/browsing` and reuse the enabled growth-community list and weights. Sessions default off with a configurable 3–6 selections-per-local-day range and a 60/40 post/comment split, respect quiet hours, and exclude NSFW unless enabled. The daily minimum, daily maximum, and selections per session accept positive whole numbers without a product-imposed upper ceiling; the maximum must not be lower than the minimum. The service derives one stable target inside the configured daily range for each account and local day. The operator also chooses a minimum gap of at least 45 minutes, and the service adds a random delay of up to `jitterMs` on top before persisting the next due time. A scheduled session is capped by the remaining daily target, while a manually requested session bypasses that target and uses the configured session size. `autoVoteEnabled` is a separate, default-off operator setting; it can submit selected upvotes only in running mode when browser dry-run is off. `nextDueAt` is service-owned durable state and cannot be set through the API.
|
||||
|
||||
# Quick detection model (cheaper/faster, used for gender pre-filter)
|
||||
quickModel: "google/gemini-2.0-flash-lite-001"
|
||||
## Runtime-editable subset
|
||||
|
||||
# System prompt for image analysis
|
||||
# The AI will describe the image and detect gender
|
||||
systemPrompt: |
|
||||
Analyze this image and provide:
|
||||
1. Gender detected (F or M)
|
||||
2. Detailed description of the image content
|
||||
3. Notable features (hair color, clothing, setting, etc.)
|
||||
The dashboard/API exposes a deliberate subset: rate limits, dry-run, recency and ready-funnel limits, subreddit definitions/conversation behavior, and selected advanced configuration. Shared transport schemas live in `packages/api-contracts/src/config.ts`; the backend source schema is still the final validator.
|
||||
|
||||
Return as JSON: {"gender": "F", "description": "...", "features": [...]}
|
||||
When adding a runtime-editable setting, update all of:
|
||||
|
||||
# System prompt for message generation
|
||||
# Used when crafting DMs to post authors
|
||||
messagePrompt: |
|
||||
Generate a natural, friendly DM for this Reddit post.
|
||||
|
||||
Image analysis: {description}
|
||||
Post title: {title}
|
||||
Strategy: {strategy}
|
||||
|
||||
Guidelines:
|
||||
- Be complimentary but not creepy
|
||||
- Reference something specific from the image
|
||||
- Keep it under 2 sentences
|
||||
- Sound like a genuine person, not a bot
|
||||
- Do NOT ask for anything in return
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
```yaml
|
||||
rateLimit:
|
||||
# Minimum delay between messages (milliseconds)
|
||||
# Higher values reduce detection risk
|
||||
minDelayMs: 30000
|
||||
|
||||
# Maximum messages per hour
|
||||
# Bot will pause when this limit is reached
|
||||
maxMessagesPerHour: 20
|
||||
|
||||
# Random jitter added to delay (milliseconds)
|
||||
# Adds unpredictability to timing
|
||||
jitterMs: 15000
|
||||
```
|
||||
|
||||
**Timing example with defaults:**
|
||||
```
|
||||
Minimum delay: 30,000ms (30 seconds)
|
||||
Jitter: 0 to 15,000ms (0-15 seconds)
|
||||
Effective delay: 30-45 seconds between messages
|
||||
Max messages: 20 per hour
|
||||
```
|
||||
|
||||
### Browser Settings
|
||||
|
||||
```yaml
|
||||
browser:
|
||||
# Persistent managed-browser data populated only by verified imports
|
||||
sessionDir: "./data/browser-session"
|
||||
dryRun: false
|
||||
```
|
||||
|
||||
**Browser session persistence:**
|
||||
|
||||
```
|
||||
data/browser/
|
||||
├── Default/
|
||||
│ ├── Cookies # Reddit session cookies
|
||||
│ ├── Local Storage # Local storage data
|
||||
│ └── Preferences # Browser preferences
|
||||
├── Login Data # Saved login info
|
||||
└── Preferences # Browser state
|
||||
```
|
||||
|
||||
> **Important**: Keep this directory intact. Deleting it requires re-login.
|
||||
|
||||
### Database Settings
|
||||
|
||||
```yaml
|
||||
database:
|
||||
# Path to SQLite database file
|
||||
path: "./data/reddit-bot.db"
|
||||
```
|
||||
|
||||
The database is auto-created on first run. Migrations run automatically on startup.
|
||||
|
||||
### Media Sync Settings
|
||||
|
||||
```yaml
|
||||
mediaSync:
|
||||
# Enable periodic media synchronization
|
||||
enabled: true
|
||||
|
||||
# Interval between sync cycles (minutes)
|
||||
intervalMinutes: 30
|
||||
```
|
||||
|
||||
**Sync behavior:**
|
||||
- **Incremental sync** (automatic): Downloads media from top 10 conversations
|
||||
- **Full sync** (manual via dashboard): Downloads media from all conversations
|
||||
|
||||
### Auto-Reply Settings
|
||||
|
||||
```yaml
|
||||
autoReply:
|
||||
# Enable automatic chat replies
|
||||
enabled: true
|
||||
|
||||
# Auto-send replies (true) or mark as pending for approval (false)
|
||||
autoSend: false
|
||||
|
||||
# Minutes between checking for new messages
|
||||
checkInterval: 10
|
||||
```
|
||||
|
||||
**Reply modes:**
|
||||
|
||||
| autoSend | Behavior |
|
||||
|---|---|
|
||||
| `true` | Bot sends AI-generated replies automatically |
|
||||
| `false` | Bot suggests replies, user approves via dashboard |
|
||||
|
||||
### Strategy Variants (A/B Testing)
|
||||
|
||||
```yaml
|
||||
strategyVariants:
|
||||
# Variant 1: Compliment-based
|
||||
- name: "compliment"
|
||||
prompt: |
|
||||
Write a friendly compliment about the person's appearance.
|
||||
Be genuine and specific. Keep it short and natural.
|
||||
enabled: true
|
||||
|
||||
# Variant 2: Question-based
|
||||
- name: "question"
|
||||
prompt: |
|
||||
Write a friendly message that includes a question.
|
||||
Engage them in conversation naturally.
|
||||
enabled: true
|
||||
|
||||
# Variant 3: Casual observation
|
||||
- name: "observation"
|
||||
prompt: |
|
||||
Write a casual observation about the photo or setting.
|
||||
Sound like you're commenting to a friend.
|
||||
enabled: true
|
||||
```
|
||||
|
||||
### Web Dashboard Settings
|
||||
|
||||
```yaml
|
||||
web:
|
||||
# Port for the web dashboard
|
||||
port: 3000
|
||||
```
|
||||
|
||||
### Global Settings
|
||||
|
||||
```yaml
|
||||
# Global dry run mode
|
||||
# When true, all actions are logged but not actually performed
|
||||
dryRun: false
|
||||
|
||||
# Number of successful message examples to include in prompts
|
||||
fewShotExamples: 3
|
||||
|
||||
# Minimum responses needed before trusting strategy stats
|
||||
minStrategySamples: 10
|
||||
|
||||
# Exploration rate for A/B testing (0.0 to 1.0)
|
||||
# 0.15 = 15% of messages use random variants (exploration)
|
||||
explorationRate: 0.15
|
||||
```
|
||||
1. `ConfigSchema` and defaults.
|
||||
2. `config.yaml` seed/example when appropriate.
|
||||
3. `ConfigStore` persistence rules if ownership changes.
|
||||
4. Shared request/response schemas.
|
||||
5. The config route's merge/write/read-back behavior.
|
||||
6. Dashboard model/control.
|
||||
7. backend, contract, route, and dashboard tests.
|
||||
8. this document.
|
||||
|
||||
---
|
||||
|
||||
## Runtime Configuration
|
||||
|
||||
The following settings can be modified via the web dashboard without restarting the bot:
|
||||
|
||||
| Setting | Endpoint | Type | Description |
|
||||
|---|---|---|---|
|
||||
| `rateLimit.minDelayMs` | `PUT /api/config` | number | Message delay |
|
||||
| `rateLimit.maxMessagesPerHour` | `PUT /api/config` | number | Hourly cap |
|
||||
| `rateLimit.jitterMs` | `PUT /api/config` | number | Jitter range |
|
||||
| `reddit.pollInterval` | `PUT /api/config` | number | Poll frequency |
|
||||
| `dryRun` | `PUT /api/config` | boolean | Dry run mode |
|
||||
|
||||
### Updating Runtime Config
|
||||
|
||||
Via dashboard:
|
||||
1. Open the settings modal
|
||||
2. Modify desired values
|
||||
3. Click "Save"
|
||||
|
||||
Via API:
|
||||
```bash
|
||||
curl -X PUT http://localhost:3000/api/config \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"rateLimit": {
|
||||
"minDelayMs": 45000,
|
||||
"maxMessagesPerHour": 15,
|
||||
"jitterMs": 20000
|
||||
},
|
||||
"dryRun": true
|
||||
}'
|
||||
```
|
||||
|
||||
> **Note**: Runtime changes are in-memory only. They are lost when the bot restarts. To make changes persistent, update `config.yaml`.
|
||||
## Validation and change safety
|
||||
|
||||
---
|
||||
|
||||
## Configuration Validation
|
||||
- Use Zod for every external or persisted shape.
|
||||
- Reject unknown transport fields with strict contracts.
|
||||
- Validate relationships (not just types), such as the hybrid turn constraint.
|
||||
- Do not log the full config because it contains injected secrets in memory.
|
||||
- Configuration that affects running account objects may not be fully hot-reloaded; inspect the owning service before claiming immediate effect.
|
||||
- A production `PUT /api/config` should be followed by an authenticated GET read-back. Do not infer persistence from a 2xx alone.
|
||||
- A subreddit listing 404 is persisted as disabled so every account runtime stops polling it.
|
||||
|
||||
All configuration is validated using Zod at startup. Invalid configuration will cause the bot to exit with a descriptive error.
|
||||
|
||||
### Validation Rules
|
||||
|
||||
| Field | Validation |
|
||||
|---|---|
|
||||
| `reddit.subreddits` | Must be non-empty array of strings |
|
||||
| `reddit.pollInterval` | Must be positive integer |
|
||||
| `reddit.targetGender` | Must be "F" or "M" |
|
||||
| `openrouter.apiKey` | Must be non-empty string |
|
||||
| `openrouter.model` | Must be non-empty string |
|
||||
| `openrouter.fallbackModels` | Must be non-empty array |
|
||||
| `rateLimit.minDelayMs` | Must be positive integer |
|
||||
| `rateLimit.maxMessagesPerHour` | Must be positive integer |
|
||||
| `rateLimit.jitterMs` | Must be non-negative integer |
|
||||
| `browser.sessionDir` | Must be non-empty string |
|
||||
| `browser.dryRun` | Must be boolean |
|
||||
| `database.path` | Must be non-empty string |
|
||||
| `mediaSync.enabled` | Must be boolean |
|
||||
| `mediaSync.intervalMinutes` | Must be positive integer (if enabled) |
|
||||
| `autoReply.enabled` | Must be boolean |
|
||||
| `autoReply.autoSend` | Must be boolean |
|
||||
| `autoReply.checkInterval` | Must be positive integer (if enabled) |
|
||||
| `strategyVariants` | Must be array with name + prompt |
|
||||
| `web.port` | Must be integer 1-65535 |
|
||||
| `dryRun` | Must be boolean |
|
||||
## Local safe setup
|
||||
|
||||
### Common Validation Errors
|
||||
Install dependencies and run builds/tests without starting the application. If a local backend is explicitly needed, use a dry-run configuration and verify the database path/port before starting. Importing a real session or calling the start control is a separate live-capable action.
|
||||
### `learning`
|
||||
|
||||
**Error**: `Invalid config: openrouter.apiKey must be a string`
|
||||
**Fix**: Set `OPENROUTER_API_KEY` environment variable or add `apiKey` to `config.yaml`
|
||||
- `captureEnabled` controls attribution and extraction; it does not enable generation.
|
||||
- `generationEnabled` permits retrieved examples to affect generation only when `shadowMode` is also false.
|
||||
- `shadowMode` records which examples would have been selected while leaving prompts unchanged.
|
||||
- `maturityHours` delays eligibility so later negative signals can revoke apparent success.
|
||||
- `maxContextExamples`, `humanExampleSlots`, and `autonomousExampleSlots` bound and separate retrieval cohorts; slot totals may not exceed the context maximum.
|
||||
- `maxTurnsPerExample` bounds durable redacted context.
|
||||
- `minimumAttributionConfidence` accepts `exact`, `fingerprint`, or `inferred`.
|
||||
- `policyVersion` versions retrieval/evaluation audit records.
|
||||
|
||||
**Error**: `Invalid config: reddit.subreddits must be a non-empty array`
|
||||
**Fix**: Add at least one subreddit to `reddit.subreddits` in `config.yaml`
|
||||
|
||||
**Error**: `Invalid config: browser.sessionDir must be a string`
|
||||
**Fix**: Ensure `browser.sessionDir` is set in `config.yaml`
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Configuration Not Loading
|
||||
|
||||
**Symptom**: Bot fails to start with validation errors.
|
||||
|
||||
**Steps**:
|
||||
1. Verify `config.yaml` is in the project root
|
||||
2. Check YAML syntax (use a YAML linter)
|
||||
3. Verify environment variables are set correctly
|
||||
4. Check bot logs for specific validation errors
|
||||
|
||||
### Account Authentication Not Working
|
||||
|
||||
**Symptom**: Account work remains paused.
|
||||
|
||||
**Steps**:
|
||||
1. Sign in to Reddit in local Chrome
|
||||
2. Open Reddit Chat and wait for the extension badge to show `OK`
|
||||
3. Export and upload a fresh combined session dump
|
||||
4. Check dashboard logs for Reddit or Matrix identity verification errors
|
||||
|
||||
### AI Not Working
|
||||
|
||||
**Symptom**: Image analysis fails or returns errors.
|
||||
|
||||
**Steps**:
|
||||
1. Verify `OPENROUTER_API_KEY` is valid
|
||||
2. Check API key has sufficient balance
|
||||
3. Verify `openrouter.model` is available on OpenRouter
|
||||
4. Check fallback models are set correctly
|
||||
5. Review logs for specific AI errors
|
||||
|
||||
### Browser Session Expired
|
||||
|
||||
**Symptom**: Bot stops with "Browser session expired" error.
|
||||
|
||||
**Steps**:
|
||||
1. Open Reddit Chat in the local Chrome profile containing the extension
|
||||
2. Export and upload a fresh session dump
|
||||
3. Delete the downloaded dump after verification
|
||||
|
||||
### Port Already in Use
|
||||
|
||||
**Symptom**: Dashboard fails to start with "Port already in use".
|
||||
|
||||
**Steps**:
|
||||
1. Change `web.port` in `config.yaml`
|
||||
2. Or kill the process using the port: `lsof -i :3000`
|
||||
The safe default is capture enabled, generation disabled, and shadow mode enabled. Enabling injected use is a configuration change and still does not deploy or start the bot.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Human-led conversation learning
|
||||
|
||||
## Purpose
|
||||
|
||||
The loop learns from the decision that caused a useful outcome, not merely from the last AI message before it. Manual interventions are first-class evidence because an operator may repair tone, timing, boundaries, or a media invitation before a recipient shares an image.
|
||||
|
||||
## Pipeline and gates
|
||||
|
||||
```text
|
||||
authoritative Matrix timeline
|
||||
-> event attribution (exact > fingerprint > inferred)
|
||||
-> outcome trajectory and causal credit
|
||||
-> identifier-redacted decision examples
|
||||
-> maturity / review / invalidation gates
|
||||
-> account-scoped, quota-controlled retrieval
|
||||
-> shadow audit or injected prompt context
|
||||
-> offline evaluation record
|
||||
```
|
||||
|
||||
Capture and use are independent. The default configuration captures and audits retrieval in shadow mode, but cannot affect generated replies. Injection requires both `generationEnabled: true` and `shadowMode: false`.
|
||||
|
||||
## Attribution and credit
|
||||
|
||||
Authoritative Matrix event IDs link openers, automated claims, dashboard manual sends, and exchange-library sends. Fingerprints support legacy backfill only. An unmatched outbound event is human-owned for the automation safety boundary, but inferred learning is held back when exact attribution is required.
|
||||
|
||||
Credit classes are:
|
||||
|
||||
- `human-created-success`: the human introduced the successful decision.
|
||||
- `human-improved-success`: AI attempted the step and the human replaced or improved it.
|
||||
- `human-completed-ai-trajectory`: the human intervention helped complete an AI-started exchange without directly making the media ask.
|
||||
- `autonomous-success`: the bounded automated policy made the attributed ask.
|
||||
- `recipient-initiated-media`: media arrived without an attributed ask.
|
||||
- `uncertain`: provenance is insufficient for causal use.
|
||||
|
||||
Image receipt does not override a decline, boundary, or upsell outcome. Those outcomes invalidate the trajectory and all derived decisions. Operator exclusion is sticky across re-sync; explicit inclusion can reverse review/exclusion, but cannot resurrect an invalidated safety outcome.
|
||||
|
||||
## Retrieval and evaluation
|
||||
|
||||
Retrieval stays within the owning account, excludes the current room, filters confidence, prefers the same policy/stage/community, and fills separate human and autonomous quotas. Stored context is bounded and redacted. The generation ledger stores selected decision IDs, mode, policy version, and an optional prompt hash—not prompt text.
|
||||
|
||||
`npm run learning:eval -- --db <path> --policy <version>` versions the current eligible dataset and enforces hard gates for leaked identifiers, invalid decision states, and empty outputs. Evaluation is deliberately local and deterministic. More subjective pairwise or model-graded evaluation can be added behind the same saved-run contract.
|
||||
|
||||
## Infrastructure and framework boundary
|
||||
|
||||
No new Dokploy service is required. SQLite, the existing backend, Matrix synchronization, OpenRouter client, and React dashboard are sufficient. Deployment needs only the normal application image plus a database backup before the migration; it does not require a vector database, queue, object store, or hosted tracing system.
|
||||
|
||||
The repository/provider/evaluation interfaces are the framework seams. A hosted platform such as Langfuse or an experiment tool can later receive generation and evaluation records, and DSPy-style optimization or model fine-tuning can consume exported reviewed examples. None is placed in the online reply path until volume, evaluation quality, and operational need justify the extra infrastructure.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Deploy with capture enabled, generation disabled, and shadow mode enabled.
|
||||
2. Run a full chat sync to backfill authoritative histories.
|
||||
3. Review inferred and fingerprint-attributed trajectories in `/learning`.
|
||||
4. Let examples mature and run the offline evaluation against a backup/non-production copy.
|
||||
5. Compare human, autonomous, and recipient-initiated cohorts; do not blend them into the opener strategy metric.
|
||||
6. Enable generation while retaining shadow mode to exercise selection/auditing.
|
||||
7. Only after review and evaluation, turn shadow mode off for a small account/policy cohort; rollback is setting it true again.
|
||||
|
||||
Starting, restarting, deploying, or enabling the live bot remains a separate explicit operational action.
|
||||
+119
-858
File diff suppressed because it is too large.
Load diff
+92
-511
@@ -1,550 +1,131 @@
|
||||
# Database Schema
|
||||
# Data model and persistence
|
||||
|
||||
Complete reference for the SQLite database schema, migrations, and query patterns.
|
||||
## Storage model
|
||||
|
||||
---
|
||||
The application uses one `better-sqlite3` database, normally `data/reddit-bot.db`, in WAL mode with foreign keys enabled. The persistent Docker volume also holds browser profiles and downloaded media, so a database-only backup is not a complete account/content backup.
|
||||
|
||||
## Table of Contents
|
||||
`src/database/client.ts` is the schema authority. Migrations are ordered functions guarded by SQLite `PRAGMA user_version` and run inside transactions. The repository does not use an ORM.
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [Database File](#database-file)
|
||||
3. [Schema](#schema)
|
||||
4. [Indexes](#indexes)
|
||||
5. [Migrations](#migrations)
|
||||
6. [Query Patterns](#query-patterns)
|
||||
7. [Repository Methods](#repository-methods)
|
||||
8. [Data Retention](#data-retention)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
RedditBot uses SQLite (via `better-sqlite3`) for persistent storage. The database tracks processed posts, sent messages, and successful message/response pairs.
|
||||
|
||||
**Features**:
|
||||
- WAL mode for better concurrent read performance
|
||||
- Foreign key constraints enabled
|
||||
- Progressive migrations for schema evolution
|
||||
- Repository pattern for data access
|
||||
|
||||
---
|
||||
|
||||
## Database File
|
||||
|
||||
**Default path**: `./data/reddit-bot.db`
|
||||
|
||||
Configured in `config.yaml`:
|
||||
|
||||
```yaml
|
||||
database:
|
||||
path: "./data/reddit-bot.db"
|
||||
```
|
||||
|
||||
The database file is auto-created on first run.
|
||||
|
||||
---
|
||||
|
||||
## Schema
|
||||
|
||||
### Table: `processed_posts`
|
||||
|
||||
Tracks all posts that have been discovered and processed.
|
||||
|
||||
```sql
|
||||
CREATE TABLE processed_posts (
|
||||
id TEXT PRIMARY KEY, -- Reddit post ID
|
||||
subreddit TEXT NOT NULL, -- Source subreddit
|
||||
author TEXT NOT NULL, -- Post author username
|
||||
title TEXT, -- Post title
|
||||
image_url TEXT, -- Original image URL
|
||||
image_analysis TEXT, -- AI-generated JSON description
|
||||
generated_message TEXT, -- AI-generated DM text
|
||||
processed_at INTEGER NOT NULL, -- Unix timestamp
|
||||
message_sent INTEGER DEFAULT 0, -- 0 = not sent, 1 = sent
|
||||
error TEXT -- Error message if processing failed
|
||||
);
|
||||
```
|
||||
|
||||
**Column Details**:
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|---|---|---|---|
|
||||
| `id` | TEXT | PRIMARY KEY | Reddit post ID (e.g., "abc123") |
|
||||
| `subreddit` | TEXT | NOT NULL | Source subreddit name |
|
||||
| `author` | TEXT | NOT NULL | Post author username |
|
||||
| `title` | TEXT | | Post title (nullable if unavailable) |
|
||||
| `image_url` | TEXT | | Original image URL |
|
||||
| `image_analysis` | TEXT | | JSON string from AI analysis |
|
||||
| `generated_message` | TEXT | | The DM text that was generated |
|
||||
| `processed_at` | INTEGER | NOT NULL | Unix timestamp when processed |
|
||||
| `message_sent` | INTEGER | DEFAULT 0 | 0 = not sent, 1 = successfully sent |
|
||||
| `error` | TEXT | | Error message if processing failed |
|
||||
|
||||
**Example Row**:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "abc123",
|
||||
"subreddit": "gonewild",
|
||||
"author": "user123",
|
||||
"title": "Summer vibes",
|
||||
"image_url": "https://i.redd.it/abc123.jpg",
|
||||
"image_analysis": "{\"gender\":\"F\",\"description\":\"Young woman...\"}",
|
||||
"generated_message": "Hey, you look amazing...",
|
||||
"processed_at": 1716234567,
|
||||
"message_sent": 1,
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Table: `sent_messages`
|
||||
|
||||
Tracks all DMs sent by the bot.
|
||||
|
||||
```sql
|
||||
CREATE TABLE sent_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
post_id TEXT NOT NULL,
|
||||
recipient TEXT NOT NULL,
|
||||
subject TEXT DEFAULT 'Stunning',
|
||||
message TEXT NOT NULL,
|
||||
sent_at INTEGER NOT NULL,
|
||||
strategy_id TEXT,
|
||||
got_response INTEGER DEFAULT 0,
|
||||
response_checked_at INTEGER,
|
||||
FOREIGN KEY (post_id) REFERENCES processed_posts(id)
|
||||
);
|
||||
```
|
||||
|
||||
**Column Details**:
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|---|---|---|---|
|
||||
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT | Internal ID |
|
||||
| `post_id` | TEXT | NOT NULL, FK | Source post ID |
|
||||
| `recipient` | TEXT | NOT NULL | Recipient username |
|
||||
| `subject` | TEXT | DEFAULT 'Stunning' | DM subject line |
|
||||
| `message` | TEXT | NOT NULL | DM body text |
|
||||
| `sent_at` | INTEGER | NOT NULL | Unix timestamp when sent |
|
||||
| `strategy_id` | TEXT | | A/B test variant name |
|
||||
| `got_response` | INTEGER | DEFAULT 0 | 0 = no response, 1 = responded |
|
||||
| `response_checked_at` | INTEGER | | Timestamp of last response check |
|
||||
|
||||
**Example Row**:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"post_id": "abc123",
|
||||
"recipient": "user123",
|
||||
"subject": "Stunning",
|
||||
"message": "Hey, you look amazing...",
|
||||
"sent_at": 1716234567,
|
||||
"strategy_id": "compliment",
|
||||
"got_response": 1,
|
||||
"response_checked_at": 1716238167
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Table: `successful_messages`
|
||||
|
||||
Stores successful message/response pairs for few-shot learning.
|
||||
|
||||
```sql
|
||||
CREATE TABLE successful_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sent_message_id INTEGER,
|
||||
original_message TEXT NOT NULL,
|
||||
image_analysis TEXT,
|
||||
post_title TEXT,
|
||||
response_snippet TEXT,
|
||||
subreddit TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (sent_message_id) REFERENCES sent_messages(id)
|
||||
);
|
||||
```
|
||||
|
||||
**Column Details**:
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|---|---|---|---|
|
||||
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT | Internal ID |
|
||||
| `sent_message_id` | INTEGER | FK, nullable | Related sent message ID |
|
||||
| `original_message` | TEXT | NOT NULL | The DM that got a response |
|
||||
| `image_analysis` | TEXT | | Context from image analysis |
|
||||
| `post_title` | TEXT | | Original post title |
|
||||
| `response_snippet` | TEXT | | First reply text |
|
||||
| `subreddit` | TEXT | | For weighted example selection |
|
||||
| `created_at` | INTEGER | NOT NULL | Unix timestamp |
|
||||
|
||||
**Example Row**:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"sent_message_id": 1,
|
||||
"original_message": "Hey, you look amazing...",
|
||||
"image_analysis": "{\"gender\":\"F\",\"description\":\"...\"}",
|
||||
"post_title": "Summer vibes",
|
||||
"response_snippet": "Thank you so much! 😊",
|
||||
"subreddit": "gonewild",
|
||||
"created_at": 1716238167
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Indexes
|
||||
|
||||
### `processed_posts` Indexes
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_posts_subreddit ON processed_posts(subreddit);
|
||||
CREATE INDEX idx_posts_author ON processed_posts(author);
|
||||
```
|
||||
|
||||
**Purpose**: Fast lookup by subreddit (for processing stats) and by author (for skip-checking).
|
||||
|
||||
---
|
||||
|
||||
### `sent_messages` Indexes
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_messages_recipient ON sent_messages(recipient);
|
||||
CREATE INDEX idx_messages_strategy ON sent_messages(strategy_id);
|
||||
CREATE INDEX idx_messages_response ON sent_messages(got_response);
|
||||
```
|
||||
|
||||
**Purpose**:
|
||||
- `idx_messages_recipient`: Fast lookup when matching responses
|
||||
- `idx_messages_strategy`: Analytics by strategy variant
|
||||
- `idx_messages_response`: Filter for responded/not-responded
|
||||
|
||||
---
|
||||
## Ownership categories
|
||||
|
||||
### `successful_messages` Indexes
|
||||
### Account registry and account content
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_success_subreddit ON successful_messages(subreddit);
|
||||
CREATE INDEX idx_success_message ON successful_messages(sent_message_id);
|
||||
```
|
||||
- `reddit_accounts`: verified Reddit/Matrix identity, encrypted-at-rest only to the degree provided by the host/volume, cookie JSON, Matrix credentials, active/status timestamps.
|
||||
- `reddit_conversations`: account-scoped cached Matrix conversation payloads.
|
||||
- `reddit_account_sync_state`: account-scoped sync cursor, full/delta timestamps, and invitation cooldown.
|
||||
- `reddit_recipient_account_status`: process-global, case-insensitive recipient username status (`active`, `suspended`, or `deleted`) and last confirmed check time.
|
||||
- `reddit_media`: account-scoped private-media metadata and relative file path.
|
||||
- `media_library_assets`: process-global operator-owned image metadata, overlapping anatomy tags, SFW/NSFW classification, consent time, enablement, dimensions, hash, and private relative path.
|
||||
- `media_library_sends`: account/conversation/inbound-event reservation and confirmed/failed Matrix delivery audit for a shared library asset.
|
||||
- `account_growth_settings`: per-account commenting switch, adaptive trust level, optional operator daily-limit override (`daily_limit`, null means adaptive), spacing/jitter, quiet hours, NSFW opt-in, and the operator-clearable pause reason.
|
||||
- `account_growth_subreddits`: per-account community list with weight, daily cap, and any Reddit-imposed disable reason or cooldown.
|
||||
- `account_growth_comments`: durable growth comment records from discovery through posting and verification, including the exact text posted, score history, removal and public-visibility verdicts.
|
||||
- `account_browsing_settings`: disabled-by-default per-account browsing policy, positive whole-number daily range and selections per session without a product ceiling, post/comment weights, quiet hours, minimum spacing plus jitter, NSFW policy, default-off automatic-upvote setting, and durable next due time.
|
||||
- `account_browsing_decisions`: minimal post/comment identity, permalink, decision/reason, durable vote status (`simulated`, `pending`, `submitted`, or `failed`), and scheduled-session audit. Reddit text is deliberately not retained.
|
||||
|
||||
**Purpose**:
|
||||
- `idx_success_subreddit`: Weighted example selection by subreddit
|
||||
- `idx_success_message`: Link back to sent message
|
||||
The special legacy-unassigned account exists only to preserve rows created before account ownership was introduced. New runtime data must use a verified account ID.
|
||||
|
||||
---
|
||||
### Outreach and outcomes
|
||||
|
||||
## Migrations
|
||||
- `processed_posts`: account/post key, source metadata, analysis, generated opener, send marker, and terminal error/rejection reason.
|
||||
- `sent_messages`: account-owned opener/reply attribution, strategy/experiment/model metadata, Matrix room, response/image flags, and source type.
|
||||
- `successful_messages`: mature positive examples for prompt context; terminal negative reclassification removes the linked example.
|
||||
- `conversation_outcomes`: normalized outcome for a sent message (`engaged`, `declined`, `boundary`, `upsell`, or `unknown`).
|
||||
- `activity_log`: operator-facing events with optional recipient/post context.
|
||||
- `reddit_users`: global mapping between Reddit fullname/username and Matrix identity.
|
||||
- `recipient_suppressions`: process-global recipient reservation/contact record used to prevent cross-account duplicates.
|
||||
|
||||
Migrations are defined in `src/database/client.ts` and run automatically on every startup.
|
||||
### Durable work and idempotency
|
||||
|
||||
### Migration Logic
|
||||
- `candidate_queue`: global candidate snapshot, source/priority, state, prepared payload, worker lease, retry timing, selected account, and rejection/error details. Image preparation snapshots retain the generated opener, grounded analysis, both AI scores, the physique range used, source-image URL, and experiment/model/tone metadata for delivery and operator review.
|
||||
- `delivery_outbox`: account-scoped prepared delivery and idempotency status retained for legacy/recovery paths.
|
||||
- `conversation_states`: account/conversation policy and derived state JSON.
|
||||
- `conversation_automation_claims`: account/conversation/inbound-event claim with intended reply and `pending`, `sent`, or terminal `superseded` state plus audit timestamps.
|
||||
- `runtime_state`: small process controls such as whether bot work is running.
|
||||
|
||||
```typescript
|
||||
// Pseudo-code for migration process
|
||||
function runMigrations(db: Database) {
|
||||
// Enable features
|
||||
db.exec("PRAGMA journal_mode=WAL;");
|
||||
db.exec("PRAGMA foreign_keys=ON;");
|
||||
### Configuration and telemetry
|
||||
|
||||
// Create tables if they don't exist
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS processed_posts (...)`);
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS sent_messages (...)`);
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS successful_messages (...)`);
|
||||
- `app_config`: one secret-free validated application configuration record.
|
||||
- `ai_usage`: requested/actual model, token/cost, latency, success/error, purpose, and account attribution.
|
||||
- `legacy_content_imports`: records one-time imports from legacy content stores.
|
||||
|
||||
// Create indexes if they don't exist
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_posts_subreddit ON processed_posts(subreddit)`);
|
||||
// ... more indexes
|
||||
## Critical invariants
|
||||
|
||||
// Safe column additions (for schema evolution)
|
||||
db.exec(`ALTER TABLE processed_posts ADD COLUMN error TEXT`);
|
||||
}
|
||||
```
|
||||
1. Account-produced rows carry `account_id`; cross-account views must opt into global queries explicitly.
|
||||
2. Recipient identity comparisons are case-insensitive and global for outreach suppression.
|
||||
3. A sent message is persisted only after remote confirmation.
|
||||
4. Candidate and automation claims are lease/claim based; a worker must own the expected lease before it changes state.
|
||||
5. Conversation policy is snapshotted in state JSON when automation starts for that conversation.
|
||||
6. Secrets and the database path are not stored in `app_config`.
|
||||
7. Private media paths in SQLite are relative to the persistent data root; HTTP access remains authenticated.
|
||||
8. Received `reddit_media` and operator `media_library_assets` are separate ownership domains; no automatic copy or promotion exists. Received-media identity represents the account-scoped Matrix message occurrence, so repeated use of one remote asset can produce multiple gallery rows that safely share the same private relative file path.
|
||||
9. A library image content hash has at most one reservation per account conversation, even if the same file is later re-uploaded under a new asset ID; an inbound request also has at most one selected asset. Failed or uncertain attempts remain reserved to prevent duplicates.
|
||||
11. Exactly one account may comment on a given thread. `account_growth_comments` enforces this with a unique index on `post_id` across all accounts, because two owned accounts commenting on the same post is the easiest pattern for Reddit to correlate.
|
||||
12. A growth comment interrupted while posting is rejected, never retried. The process cannot know whether the write reached Reddit, and a duplicate comment cannot be withdrawn.
|
||||
13. Browsing decisions are unique per account and thing. Before selecting, the service also checks for a `simulated-upvote` by any other owned account, but account-specific rejections do not unnecessarily reserve an item globally.
|
||||
14. A browsing selection is persisted as `pending` before any vote request. It becomes `submitted` only after Reddit confirms the authenticated upvote, or `failed` with the safe error reason; unconfirmed selections are never retried. Browsing rows never contribute to adaptive comment-growth promotion.
|
||||
|
||||
### Migration Safety
|
||||
10. Recipient account status changes only after a definitive Reddit profile response. Transient lookup failures retain the previous value, and the shared check timestamp prevents duplicate lookups across account runtimes during the four-hour interval.
|
||||
|
||||
- All `CREATE TABLE` statements use `IF NOT EXISTS`
|
||||
- All `CREATE INDEX` statements use `IF NOT EXISTS`
|
||||
- Column additions use `ALTER TABLE ADD COLUMN IF NOT EXISTS` pattern
|
||||
- Migrations are idempotent (safe to run multiple times)
|
||||
## Repository map
|
||||
|
||||
### Adding New Columns
|
||||
`src/database/repositories.ts` contains the domain repositories for posts/messages/outcomes/activity/outbox/state/claims/users/recipient history. `account-repositories.ts` owns account records and cached account content. Candidate and AI telemetry have dedicated repositories.
|
||||
|
||||
When adding new columns to existing tables:
|
||||
Prefer narrow repository methods such as `claimReady`, `markSent`, or `getByChatRoomId` to exposing SQL or generic CRUD. This keeps transaction and ownership rules near the schema.
|
||||
|
||||
```typescript
|
||||
// In src/database/client.ts
|
||||
const tableInfo = db.prepare(`PRAGMA table_info(processed_posts)`).all();
|
||||
const hasErrorColumn = tableInfo.some((col: any) => col.name === 'error');
|
||||
## Candidate leases
|
||||
|
||||
if (!hasErrorColumn) {
|
||||
db.exec("ALTER TABLE processed_posts ADD COLUMN error TEXT");
|
||||
}
|
||||
```
|
||||
Discovery inserts candidates without overwriting terminal work. Screening and dispatch use a worker ID plus lease expiry. On startup all interrupted leases can be recovered; during operation expired leases are recovered and due retries promoted. A transient account failure belongs to the selected account, so delivery can be made visible quickly for another eligible runtime without losing the prepared candidate.
|
||||
|
||||
---
|
||||
Any new candidate state needs:
|
||||
|
||||
## Query Patterns
|
||||
- an allowed predecessor and terminal/retry semantics;
|
||||
- a lease ownership rule;
|
||||
- recovery behavior after process death;
|
||||
- counts/reasons for the operator funnel;
|
||||
- tests for stale lease, wrong owner, restart, and duplicate post/author cases.
|
||||
|
||||
### Post Processing
|
||||
## Conversation reply claims
|
||||
|
||||
**Check if post was already processed**:
|
||||
The unique identity is account + conversation + inbound message key. The claim persists the intended reply before the remote send. A pending claim is not blindly retried: Matrix history is checked first. After rate-limit waiting, Matrix is refreshed again; a newer event, boundary, received image, seller signal, operator-owned state, or repeated media request changes the claim to `superseded` and records `superseded_at`. This closes both the process-crash window between remote acceptance and local completion and the stale-send window while a reply is queued.
|
||||
|
||||
```sql
|
||||
SELECT id FROM processed_posts WHERE id = ?;
|
||||
```
|
||||
Changes to this flow must prove at-most-once observable replies across retry and restart tests.
|
||||
|
||||
**Mark post as processed**:
|
||||
Outcome rows are re-evaluated from the complete attributed recipient transcript. Negative outcomes take precedence over reciprocal activity. `successful_messages` rows are created only after the conversation becomes terminal/human-owned and are deleted by `sent_message_id` when later evidence changes the outcome.
|
||||
|
||||
```sql
|
||||
INSERT OR REPLACE INTO processed_posts
|
||||
(id, subreddit, author, title, image_url, image_analysis,
|
||||
generated_message, processed_at, message_sent, error)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
```
|
||||
## Configuration persistence
|
||||
|
||||
**Mark post as sent**:
|
||||
On an empty database, `config.yaml` is parsed, environment-owned values are injected, the result is validated, and the application-owned subset is saved to `app_config`. Later starts load SQLite first. Dashboard changes update that record.
|
||||
|
||||
```sql
|
||||
UPDATE processed_posts
|
||||
SET message_sent = 1
|
||||
WHERE id = ?;
|
||||
```
|
||||
The persisted record excludes:
|
||||
|
||||
**Update post with analysis**:
|
||||
- `OPENROUTER_API_KEY`;
|
||||
- dashboard/agent tokens;
|
||||
- `DATABASE_PATH`.
|
||||
|
||||
```sql
|
||||
UPDATE processed_posts
|
||||
SET image_analysis = ?, generated_message = ?
|
||||
WHERE id = ?;
|
||||
```
|
||||
Those values are supplied by the environment on every load.
|
||||
|
||||
---
|
||||
## Adding a migration
|
||||
|
||||
### Message Tracking
|
||||
1. Inspect the final schema and all repository assumptions.
|
||||
2. Append one migration function; never reorder or modify an already-released migration.
|
||||
3. Make the operation transactional and compatible with existing data.
|
||||
4. Use `addColumnIfNotExists` only where repeatability is appropriate; table rebuilds need explicit foreign-key handling and `foreign_key_check`.
|
||||
5. Update repository types and operations.
|
||||
6. Add tests for a fresh in-memory database and, when relevant, an older schema upgraded in place.
|
||||
7. Update this document and affected API/contracts.
|
||||
|
||||
**Record a sent message**:
|
||||
Do not edit a live SQLite file by hand. Back it up, stop writers when required, apply code-owned migrations, and verify `integrity_check`, `foreign_key_check`, row counts, and application reads.
|
||||
|
||||
```sql
|
||||
INSERT INTO sent_messages
|
||||
(post_id, recipient, subject, message, sent_at, strategy_id)
|
||||
VALUES (?, ?, 'Stunning', ?, ?, ?);
|
||||
```
|
||||
## Backup and recovery
|
||||
|
||||
**Get recent messages**:
|
||||
For a running WAL database, use SQLite's online backup mechanism or stop the container before copying the database plus `-wal`/`-shm` state. A full production backup should include the entire persistent volume because sessions and media live beside the database. Validate that the backup can be opened and contains expected tables before any destructive reset.
|
||||
### Conversation learning
|
||||
|
||||
```sql
|
||||
SELECT * FROM sent_messages
|
||||
ORDER BY sent_at DESC
|
||||
LIMIT ?;
|
||||
```
|
||||
- `conversation_events` stores durable Matrix provenance and only a SHA-256 digest of event text.
|
||||
- `conversation_learning_trajectories` stores revocable outcome attribution, maturity, confidence, and credit class.
|
||||
- `conversation_learning_decisions` stores bounded redacted contexts and the operator decision that led toward an outcome.
|
||||
- `learning_generation_context` audits shadow or injected retrieval without storing the generated prompt.
|
||||
- `learning_evaluation_runs` versions offline hard-gate results.
|
||||
|
||||
**Update response status**:
|
||||
|
||||
```sql
|
||||
UPDATE sent_messages
|
||||
SET got_response = 1, response_checked_at = ?
|
||||
WHERE id = ?;
|
||||
```
|
||||
|
||||
**Check if user was previously messaged**:
|
||||
|
||||
```sql
|
||||
SELECT COUNT(*) FROM sent_messages WHERE recipient = ?;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Strategy Analytics
|
||||
|
||||
**Get strategy statistics**:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
strategy_id,
|
||||
COUNT(*) as sent,
|
||||
SUM(got_response) as responded
|
||||
FROM sent_messages
|
||||
WHERE strategy_id IS NOT NULL
|
||||
GROUP BY strategy_id;
|
||||
```
|
||||
|
||||
**Get response rate by subreddit**:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
p.subreddit,
|
||||
COUNT(*) as sent,
|
||||
SUM(s.got_response) as responded
|
||||
FROM sent_messages s
|
||||
JOIN processed_posts p ON s.post_id = p.id
|
||||
GROUP BY p.subreddit;
|
||||
```
|
||||
|
||||
**Get daily response trend**:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
DATE(sent_at, 'unixepoch') as date,
|
||||
COUNT(*) as sent,
|
||||
SUM(got_response) as responded
|
||||
FROM sent_messages
|
||||
GROUP BY DATE(sent_at, 'unixepoch')
|
||||
ORDER BY date DESC
|
||||
LIMIT 30;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Few-Shot Learning
|
||||
|
||||
**Get successful examples for a subreddit**:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
original_message,
|
||||
image_analysis,
|
||||
post_title,
|
||||
response_snippet
|
||||
FROM successful_messages
|
||||
WHERE subreddit = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 3;
|
||||
```
|
||||
|
||||
**Store successful pair**:
|
||||
|
||||
```sql
|
||||
INSERT INTO successful_messages
|
||||
(sent_message_id, original_message, image_analysis,
|
||||
post_title, response_snippet, subreddit, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Repository Methods
|
||||
|
||||
### PostsRepository
|
||||
|
||||
```typescript
|
||||
class PostsRepository {
|
||||
/** Check if a post was already processed */
|
||||
getById(id: string): ProcessedPost | undefined
|
||||
|
||||
/** Mark a post as processed */
|
||||
insert(post: ProcessedPostInsert): void
|
||||
|
||||
/** Mark a post as having a message sent */
|
||||
markSent(id: string): void
|
||||
|
||||
/** Update post with AI analysis results */
|
||||
updateAnalysis(id: string, analysis: string, message: string): void
|
||||
|
||||
/** Get total count of processed posts */
|
||||
count(): number
|
||||
|
||||
/** Get count by subreddit */
|
||||
countBySubreddit(subreddit: string): number
|
||||
}
|
||||
```
|
||||
|
||||
### MessagesRepository
|
||||
|
||||
```typescript
|
||||
class MessagesRepository {
|
||||
/** Record a sent message */
|
||||
insert(message: MessageInsert): number
|
||||
|
||||
/** Get recent sent messages */
|
||||
getRecent(limit: number): SentMessage[]
|
||||
|
||||
/** Get messages by recipient */
|
||||
getByRecipient(recipient: string): SentMessage[]
|
||||
|
||||
/** Update response status */
|
||||
markResponded(id: number, checkedAt: number): void
|
||||
|
||||
/** Get strategy statistics */
|
||||
getStrategyStats(): StrategyStat[]
|
||||
|
||||
/** Get response rate by subreddit */
|
||||
getSubredditStats(): SubredditStat[]
|
||||
|
||||
/** Get daily response trend */
|
||||
getDailyTrend(days: number): DailyTrend[]
|
||||
|
||||
/** Get total count of sent messages */
|
||||
count(): number
|
||||
|
||||
/** Get count of responded messages */
|
||||
countResponded(): number
|
||||
}
|
||||
```
|
||||
|
||||
### SuccessfulMessagesRepository
|
||||
|
||||
```typescript
|
||||
class SuccessfulMessagesRepository {
|
||||
/** Store a successful message/response pair */
|
||||
insert(pair: SuccessfulMessageInsert): void
|
||||
|
||||
/** Get examples for few-shot learning */
|
||||
getExamples(subreddit: string, count: number): Example[]
|
||||
|
||||
/** Get total count */
|
||||
count(): number
|
||||
|
||||
/** Get recent examples */
|
||||
getRecent(limit: number): SuccessfulMessage[]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Retention
|
||||
|
||||
### Current Behavior
|
||||
|
||||
All data is retained indefinitely. There is no automatic cleanup or retention policy.
|
||||
|
||||
| Table | Retention | Notes |
|
||||
|---|---|---|
|
||||
| `processed_posts` | Indefinite | Grows with each post processed |
|
||||
| `sent_messages` | Indefinite | Grows with each message sent |
|
||||
| `successful_messages` | Indefinite | Grows with each response received |
|
||||
|
||||
### Estimated Growth
|
||||
|
||||
With default settings (20 messages/hour, 24/7 operation):
|
||||
|
||||
| Timeframe | Posts | Messages | Responses (est. 30%) |
|
||||
|---|---|---|---|
|
||||
| 1 day | ~480 | ~288 | ~86 |
|
||||
| 1 week | ~3,360 | ~2,016 | ~605 |
|
||||
| 1 month | ~14,400 | ~8,640 | ~2,592 |
|
||||
| 1 year | ~175,200 | ~105,120 | ~31,536 |
|
||||
|
||||
### Recommended Cleanup (Future)
|
||||
|
||||
Consider implementing automated cleanup:
|
||||
|
||||
```sql
|
||||
-- Archive old processed posts
|
||||
CREATE TABLE processed_posts_archive AS
|
||||
SELECT * FROM processed_posts
|
||||
WHERE processed_at < ?;
|
||||
|
||||
-- Delete archived posts
|
||||
DELETE FROM processed_posts
|
||||
WHERE processed_at < ?;
|
||||
|
||||
-- Keep last N months of data
|
||||
```
|
||||
|
||||
> **Note**: Successful messages should NEVER be deleted as they are used for few-shot learning.
|
||||
`sent_messages.matrix_event_id` and `conversation_automation_claims.matrix_event_id` provide exact opener/reply attribution. `media_library_sends.initiated_by` distinguishes operator library shares from automated exchanges. `sent_messages.image_outcome_credit` is a projection for analytics; the trajectory tables remain authoritative.
|
||||
+94
-347
@@ -1,391 +1,138 @@
|
||||
# Deployment Guide
|
||||
# Deployment and operations
|
||||
|
||||
Complete guide for deploying RedditBot in various environments.
|
||||
## Production model
|
||||
|
||||
---
|
||||
The production unit is the `reddit-bot` Docker Compose service. The image uses the Playwright base, installs/builds the Node application, serves the compiled React dashboard and Express API on port 3000, and stores state under `/app/data` on the named `reddit_bot_data` volume.
|
||||
|
||||
## Table of Contents
|
||||
The container does not run VNC, accept Reddit passwords, or scrape a Matrix token from a login browser. Account authentication enters through a locally exported session dump and is persisted in the sensitive volume.
|
||||
|
||||
1. [Prerequisites](#prerequisites)
|
||||
2. [Local Development](#local-development)
|
||||
3. [Docker Deployment](#docker-deployment)
|
||||
4. [Docker Compose](#docker-compose)
|
||||
5. [Dokploy Deployment](#dokploy-deployment)
|
||||
6. [Production Considerations](#production-considerations)
|
||||
7. [Monitoring & Maintenance](#monitoring--maintenance)
|
||||
8. [Troubleshooting](#troubleshooting-1)
|
||||
## Authority and safety
|
||||
|
||||
---
|
||||
Deployment, restart, start/resume, session import, configuration mutation, send actions, and data deletion are separate operations. Only perform the ones the user explicitly requested. In particular, “commit and push” does not authorize a Dokploy deployment or bot restart.
|
||||
|
||||
## Prerequisites
|
||||
Before any live operation:
|
||||
|
||||
### Required
|
||||
1. Confirm the target is the RedditBot Compose service, not the whole Dokploy host/instance.
|
||||
2. Inspect current Git revision, worktree/remote state, deployment state, bot running/paused state, account runtimes, and recent errors.
|
||||
3. Identify whether the action can send messages or change account state.
|
||||
4. Back up affected persistent state before destructive or migration-heavy work.
|
||||
5. Define observable success criteria and a recovery path.
|
||||
|
||||
| Requirement | Version | Notes |
|
||||
|---|---|---|
|
||||
| Node.js | 20.19+ or 22.12+ | Required by the locked Vite toolchain |
|
||||
| npm | Bundled with Node | Use `npm ci` for lockfile-exact installs |
|
||||
| OpenRouter API Key | - | Sign up at [openrouter.ai](https://openrouter.ai) |
|
||||
| Reddit Account | - | With DM permissions enabled |
|
||||
## Required environment
|
||||
|
||||
### Recommended
|
||||
The Compose service passes:
|
||||
|
||||
| Requirement | Notes |
|
||||
|---|---|
|
||||
| 1 GB RAM | Minimum for browser automation |
|
||||
| 1 CPU core | More for higher message throughput |
|
||||
| 5 GB disk | For database, media, and browser data |
|
||||
- `OPENROUTER_API_KEY`;
|
||||
- `WEB_DASHBOARD_TOKEN`;
|
||||
- optional `REDDIT_BOT_AGENT_READ_TOKEN`;
|
||||
- optional `REDDIT_BOT_AGENT_CONTROL_TOKEN`.
|
||||
|
||||
---
|
||||
Dokploy access credentials are stored in the ignored repository `.env`. Use them only when access is explicitly requested. Never print the file, include tokens in commands that will be echoed, commit them, or copy response metadata containing credentials. Follow redirects for Dokploy API calls.
|
||||
|
||||
## Local Development
|
||||
## Persistence
|
||||
|
||||
### 1. Clone and Install
|
||||
`reddit_bot_data:/app/data` contains:
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd RedditBot
|
||||
npm install
|
||||
```
|
||||
- SQLite database and WAL files;
|
||||
- imported browser/account sessions;
|
||||
- cached Matrix credentials/state;
|
||||
- downloaded private media;
|
||||
- per-account browser profiles.
|
||||
|
||||
### 2. Configure
|
||||
A volume replacement or deletion is therefore a destructive account-and-content reset. Verify a readable backup before touching it and state exactly what will be lost. Removing a Compose application and deleting its volume are not the same action.
|
||||
|
||||
Create `config.yaml`:
|
||||
## Deployment procedure
|
||||
|
||||
```yaml
|
||||
reddit:
|
||||
subreddits:
|
||||
- gonewild
|
||||
pollInterval: 15
|
||||
targetGender: "F"
|
||||
sellerKeywords:
|
||||
- seller
|
||||
- onlyfans
|
||||
Use the actual current Dokploy API/Compose identifiers from a read-only lookup; do not copy identifiers into documentation or assume an older deployment still has the same ID.
|
||||
|
||||
openrouter:
|
||||
apiKey: "sk-or-v1-your-key-here"
|
||||
model: "meta-llama/llama-3.2-90b-vision-preview"
|
||||
fallbackModels:
|
||||
- "google/gemini-2.0-flash-001"
|
||||
1. Confirm the intended commit exists on the tracked remote branch.
|
||||
2. Read current Compose/deployment status and capture the prior image/revision if available.
|
||||
3. Confirm whether the bot must remain paused through deployment. Do not change persisted running state unless authorized.
|
||||
4. Trigger the bounded Compose deployment.
|
||||
5. Poll the deployment record to a terminal success/failure state; an accepted trigger is not completion.
|
||||
6. Verify the running container is the new deployment and is not restarting.
|
||||
7. Verify public `GET /healthz` and authenticated `GET /api/v1/readyz`/overview as appropriate.
|
||||
8. Verify account runtime count/state, bot paused/running state, sync/cooldown state, and recent sanitized logs.
|
||||
9. Verify the persistent volume is mounted and pre-deployment data is readable.
|
||||
|
||||
rateLimit:
|
||||
minDelayMs: 30000
|
||||
maxMessagesPerHour: 20
|
||||
jitterMs: 15000
|
||||
Do not use a green container health badge as the only evidence. The process may be healthy while account runtimes are logged out, the bot is paused, Matrix is degraded, or candidate/reply work is blocked.
|
||||
|
||||
browser:
|
||||
sessionDir: "./data/browser-session"
|
||||
dryRun: true
|
||||
## Health semantics
|
||||
|
||||
database:
|
||||
path: "./data/reddit-bot.db"
|
||||
- `/healthz`: unauthenticated process liveness only; intentionally reveals no private state.
|
||||
- `/api/v1/readyz`: authenticated component readiness and degraded authentication state.
|
||||
- `/api/v1/overview`: consolidated runtime, account, sync, rate-limit, candidate/reply, and recent-error view.
|
||||
- `/api/stats`, `/api/attention`, `/api/outreach`: operator-level workload detail.
|
||||
- `/api/events/stream`: realtime signal, not a replacement for snapshot checks.
|
||||
|
||||
web:
|
||||
port: 3000
|
||||
Success should distinguish:
|
||||
|
||||
dryRun: true # Start in dry run mode
|
||||
```
|
||||
- process/container running;
|
||||
- API reachable and authenticated;
|
||||
- database/volume mounted;
|
||||
- accounts restored and verified;
|
||||
- bot intentionally paused or running;
|
||||
- no crash loop or new high-severity errors;
|
||||
- requested workload actually completed.
|
||||
|
||||
Set environment variables:
|
||||
The authentication lease deliberately expires while an account is idle. A
|
||||
start request revalidates the persisted Reddit and Matrix credentials before
|
||||
changing the run state, so an otherwise valid paused account does not become
|
||||
stuck behind the start safety gate. A failed revalidation still returns a
|
||||
conflict and requires a fresh extension session import.
|
||||
|
||||
```bash
|
||||
export OPENROUTER_API_KEY="sk-or-v1-your-key"
|
||||
export WEB_DASHBOARD_TOKEN="long-random-secret"
|
||||
```
|
||||
## Account session operations
|
||||
|
||||
### 3. Import account session
|
||||
The supported workflow is:
|
||||
|
||||
Start the dashboard, then upload a combined Reddit and Matrix dump created by the local Chrome extension. Until both identities verify, account work remains paused.
|
||||
1. Load `extensions/reddit-session-exporter` into the operator's local Chrome.
|
||||
2. Sign in to Reddit locally and open Reddit Chat.
|
||||
3. Export a combined Reddit cookie + Matrix session JSON.
|
||||
4. Upload it through Settings > Access.
|
||||
5. Wait for both Reddit and Matrix identities to verify and for the matching runtime to be replaced/started.
|
||||
6. Delete the local export file.
|
||||
|
||||
### 4. Start the Bot
|
||||
The dump is a bearer credential bundle. Do not inspect or paste it into logs. An account with one valid half is still unavailable for work.
|
||||
|
||||
```bash
|
||||
# Terminal 1: backend with TypeScript execution
|
||||
npm run dev
|
||||
## Incident diagnosis
|
||||
|
||||
# Terminal 2: Vite dashboard; proxies /api to port 3000
|
||||
npm run dashboard:dev
|
||||
### No new outreach
|
||||
|
||||
# Production: compile server and dashboard, then start
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
Check, in order: persisted bot running state, the per-account delivery states on Overview, enabled/running account count, full authentication, reply backlog, account send timer/hourly cap, account-specific Matrix cooldown, global Reddit cooldown, candidate funnel counts/reasons, enabled subreddit count, recent intake/screening logs, and OpenRouter failures.
|
||||
|
||||
### 5. Access Dashboard
|
||||
Do not infer “no candidates” from “fetched N posts.” Inspect already processed/contacted, age/gender, seller, expired, AI skip, ready, retry, and rejected counts.
|
||||
|
||||
Open the Vite URL (normally http://localhost:5173) during frontend development.
|
||||
Production dashboard checks use http://localhost:3000.
|
||||
### Conversations appear stale
|
||||
|
||||
---
|
||||
Separate Matrix remote acceptance from cached/dashboard visibility. Inspect Matrix identity/whoami, sync cursor/status, last full/delta sync, new event IDs, username enrichment, HTTP conversation snapshot, SSE validation/reconnect, and client query invalidation. A stale dashboard can coexist with a successful remote send.
|
||||
|
||||
## Docker Deployment
|
||||
### Reddit 403/429/404
|
||||
|
||||
### Build the Image
|
||||
- 429: honor `retryAfterMs` through the shared coordinator and avoid additional account requests.
|
||||
- repeated/broad 403: treat as a possible service-wide anti-abuse block; inspect successful requests and shared cooldown rather than declaring every subreddit private.
|
||||
- listing 404: the code disables that configured subreddit persistently.
|
||||
|
||||
```bash
|
||||
docker build -t reddit-bot .
|
||||
```
|
||||
### Database problems
|
||||
|
||||
### Run the Container
|
||||
Stop writers if recovery needs file operations. Back up the entire data directory, inspect SQLite `integrity_check`/`foreign_key_check`, WAL state, disk capacity/permissions, migration version, and recent logs. Do not create an empty replacement database over the original.
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name reddit-bot \
|
||||
-p 3000:3000 \
|
||||
-v $(pwd)/data:/app/data \
|
||||
-e OPENROUTER_API_KEY=sk-or-v1-your-key \
|
||||
-e WEB_DASHBOARD_TOKEN=long-random-secret \
|
||||
reddit-bot:latest
|
||||
```
|
||||
## Backup and destructive recovery
|
||||
|
||||
### Dockerfile behavior
|
||||
For routine backups, prefer a volume-level snapshot or a consistent SQLite online backup plus browser/media directories. For a reset:
|
||||
|
||||
The image is based on the Playwright Jammy image because the remaining
|
||||
authenticated browser operations need Chromium and its system libraries. It
|
||||
does not install a display server, VNC, or consumer Google Chrome.
|
||||
1. State whether scope is one account, selected content, the RedditBot volume, the Compose application, or the Dokploy instance.
|
||||
2. Capture and validate a backup.
|
||||
3. Stop relevant writers.
|
||||
4. Resolve explicit volume/container/app identifiers with read-only inspection.
|
||||
5. Perform only the authorized deletion.
|
||||
6. Recreate/deploy, then verify health, schema, accounts, and persistence.
|
||||
7. Report what was removed and whether/how it can be restored.
|
||||
|
||||
That build produces both `dist/` for the Express server and
|
||||
`apps/dashboard/dist/` for Vite assets. Port 3000 serves the dashboard/API and
|
||||
the container starts the Node process directly. This is a single-stage image and retains development dependencies
|
||||
needed by the current build/runtime layout.
|
||||
## Routine checks
|
||||
|
||||
---
|
||||
- Review attention items, failed/retrying candidates, reply backlog age, account authentication, invitation/global cooldowns, model errors/cost, and disk/volume health.
|
||||
- Review response/image rates only with mature sample counts and by source/strategy/account context.
|
||||
- Back up before migrations, account resets, or Compose/volume changes.
|
||||
- Rotate dashboard, agent, OpenRouter, Reddit, and Matrix credentials after suspected exposure.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
### docker-compose.yml
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
reddit-bot:
|
||||
build: .
|
||||
container_name: reddit-bot
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
|
||||
- WEB_DASHBOARD_TOKEN=${WEB_DASHBOARD_TOKEN}
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "http://localhost:3000/api/stats"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
```
|
||||
|
||||
### Start with Compose
|
||||
|
||||
```bash
|
||||
# Set environment variables
|
||||
cp .env.example .env
|
||||
# Edit .env with your credentials
|
||||
|
||||
# Build and start
|
||||
docker compose up -d --build
|
||||
|
||||
# View logs
|
||||
docker compose logs -f reddit-bot
|
||||
|
||||
# Stop
|
||||
docker compose down
|
||||
|
||||
# Restart
|
||||
docker compose restart
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dokploy Deployment
|
||||
|
||||
Dokploy is a self-hosted platform for deploying applications. See `DOKPLOY_DEPLOY.md` for detailed instructions.
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. **Create Application**: In Dokploy dashboard, create a new Docker application
|
||||
2. **Set Environment Variables**:
|
||||
- `OPENROUTER_API_KEY`
|
||||
- `WEB_DASHBOARD_TOKEN`
|
||||
3. **Mount Volume**: Mount `./data` for persistent storage
|
||||
4. **Set Port**: Map container port 3000 to host port
|
||||
5. **Deploy**: Push to repository and trigger deployment
|
||||
|
||||
### Dokploy Configuration
|
||||
|
||||
```yaml
|
||||
# Dokploy-specific settings
|
||||
app:
|
||||
type: docker
|
||||
repository: <your-repo>
|
||||
branch: main
|
||||
dockerfilePath: Dockerfile
|
||||
|
||||
environment:
|
||||
OPENROUTER_API_KEY: $OPENROUTER_API_KEY
|
||||
WEB_DASHBOARD_TOKEN: $WEB_DASHBOARD_TOKEN
|
||||
|
||||
ports:
|
||||
- container: 3000
|
||||
host: 3000
|
||||
|
||||
volumes:
|
||||
- source: reddit-bot-data
|
||||
target: /app/data
|
||||
type: bind
|
||||
|
||||
restartPolicy: unless-stopped
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Production Considerations
|
||||
|
||||
### Security
|
||||
|
||||
1. **Network Isolation**: Keep the dashboard on a trusted network during the
|
||||
migration cutover
|
||||
2. **Environment Variables**: Never commit credentials; use secrets management
|
||||
3. **Dashboard Access**: Authenticated Express hosting for React assets, API,
|
||||
SSE, debug routes, and private media is a required cutover gate, not a
|
||||
currently proven guarantee
|
||||
4. **Browser Data**: Protect `data/browser/` directory
|
||||
|
||||
### Performance
|
||||
|
||||
1. **Account dumps**: Delete downloaded dumps after the dashboard verifies them
|
||||
2. **WAL Mode**: SQLite WAL mode is enabled by default for concurrent reads
|
||||
3. **Rate Limiting**: Adjust based on Reddit account age and trust level
|
||||
4. **Message Volume**: Start low (10-20/hour) and increase gradually
|
||||
|
||||
### Reliability
|
||||
|
||||
1. **Auto-restart**: Use `restart: unless-stopped` in Docker
|
||||
2. **Health Checks**: Monitor `/api/stats` endpoint
|
||||
3. **Logging**: Review logs regularly for errors
|
||||
4. **Backups**: Regularly backup `data/reddit-bot.db`
|
||||
|
||||
### Monitoring
|
||||
|
||||
1. **Dashboard**: Check web dashboard for real-time stats
|
||||
2. **Response Rate**: Monitor response rates per strategy/subreddit
|
||||
3. **Error Rate**: Watch for AI refusals, send failures
|
||||
4. **Browser Health**: Verify session isn't expired
|
||||
|
||||
### Scaling
|
||||
|
||||
RedditBot is designed as a single-instance application. For scaling:
|
||||
|
||||
1. **Multiple Accounts**: Run separate instances per Reddit account
|
||||
2. **Subreddit Split**: Distribute subreddits across instances
|
||||
3. **Centralized DB**: Consider moving to PostgreSQL for multi-instance
|
||||
|
||||
---
|
||||
|
||||
## Monitoring & Maintenance
|
||||
|
||||
### Daily Checks
|
||||
|
||||
- [ ] Dashboard shows bot is running
|
||||
- [ ] Response rate is reasonable (>10%)
|
||||
- [ ] No repeated errors in logs
|
||||
- [ ] Browser session is active
|
||||
|
||||
### Weekly Tasks
|
||||
|
||||
- [ ] Review analytics (strategy performance, subreddit performance)
|
||||
- [ ] Check disk usage (media files, database)
|
||||
- [ ] Verify backup is working
|
||||
- [ ] Review successful messages for quality
|
||||
|
||||
### Monthly Tasks
|
||||
|
||||
- [ ] Backup database
|
||||
- [ ] Review and update strategy variants
|
||||
- [ ] Check for OpenRouter model updates
|
||||
- [ ] Review and update seller keywords
|
||||
- [ ] Verify browser session is still valid
|
||||
|
||||
### Backup Procedure
|
||||
|
||||
```bash
|
||||
# Stop the bot
|
||||
docker compose stop
|
||||
|
||||
# Backup database
|
||||
cp data/reddit-bot.db data/reddit-bot.db.backup.$(date +%Y%m%d)
|
||||
|
||||
# Backup browser session (optional)
|
||||
tar czf data/browser.backup.$(date +%Y%m%d).tar.gz data/browser/
|
||||
|
||||
# Restart the bot
|
||||
docker compose start
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Bot Won't Start
|
||||
|
||||
**Symptom**: Bot exits immediately on startup.
|
||||
|
||||
**Steps**:
|
||||
1. Check logs: `docker compose logs reddit-bot`
|
||||
2. Verify `config.yaml` syntax
|
||||
3. Verify environment variables are set
|
||||
4. Check for Zod validation errors in logs
|
||||
|
||||
### Account Import Fails
|
||||
|
||||
**Symptom**: Account work stays paused or import verification fails.
|
||||
|
||||
**Steps**:
|
||||
1. Sign in to Reddit in local Chrome
|
||||
2. Open Reddit Chat and wait for the extension badge to show `OK`
|
||||
3. Export and upload a new combined session dump
|
||||
4. Check logs for Reddit or Matrix identity errors
|
||||
|
||||
### AI Analysis Fails
|
||||
|
||||
**Symptom**: All messages show "AI refusal" or analysis errors.
|
||||
|
||||
**Steps**:
|
||||
1. Verify API key is valid and has balance
|
||||
2. Check model availability on OpenRouter
|
||||
3. Try changing to a different model
|
||||
4. Review logs for specific error messages
|
||||
|
||||
### High Error Rate
|
||||
|
||||
**Symptom**: Many messages fail to send.
|
||||
|
||||
**Steps**:
|
||||
1. Check rate limiting (reduce maxMessagesPerHour)
|
||||
2. Increase minDelayMs and jitterMs
|
||||
3. Check for IP bans (try different network)
|
||||
4. Review Reddit account standing
|
||||
|
||||
### Dashboard Not Accessible
|
||||
|
||||
**Symptom**: Cannot reach http://localhost:3000.
|
||||
|
||||
**Steps**:
|
||||
1. Verify bot is running (`docker compose ps`)
|
||||
2. Check port is not in use: `lsof -i :3000`
|
||||
3. Verify firewall allows port 3000
|
||||
4. Check web.port in config.yaml
|
||||
|
||||
### Database Issues
|
||||
|
||||
**Symptom**: Database errors or corruption.
|
||||
|
||||
**Steps**:
|
||||
1. Stop the bot
|
||||
2. Backup the database
|
||||
3. Try opening with SQLite viewer to check integrity
|
||||
4. If corrupted, restore from backup
|
||||
5. Consider enabling automatic backups
|
||||
The concise account activation instructions in [`../DOKPLOY_DEPLOY.md`](../DOKPLOY_DEPLOY.md) complement this operational guide.
|
||||
Loaded 100 of 322 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user