Compare commits
78
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
8df2d648dd | ||
|
|
48492d5238 | ||
|
|
0e8ba713a8 | ||
|
|
16a31335d0 |
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`
|
- `OPENROUTER_API_KEY`
|
||||||
- `WEB_DASHBOARD_TOKEN`
|
- `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.
|
Do not configure Reddit usernames, passwords, display servers, CDP endpoints, or remote-browser ports.
|
||||||
|
|
||||||
## Account activation
|
## Account activation
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ FROM mcr.microsoft.com/playwright:v1.57.0-jammy
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
# Agent Guidelines & Code Standards
|
# 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
|
## Refactoring Goal
|
||||||
Refactor code to improve maintainability, testability, and clarity. Break down large components into smaller, single-purpose classes/modules.
|
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.
|
- **Logging**: Use `winston` for structured logging. Do not use `console.log` in production code.
|
||||||
|
|
||||||
## Workflow for Agents
|
## Workflow for Agents
|
||||||
1. **Analyze**: Before writing code, read the file and understand the context.
|
1. **Orient**: Read `docs/README.md`, the relevant topic documents, and the owning code/tests.
|
||||||
2. **Plan**: Identify what needs to change and check if the change violates SRP.
|
2. **Analyze**: Before writing code, read the file and understand the context.
|
||||||
3. **Implement**: Make the scoped change. Create new small files when SRP calls for it.
|
3. **Plan**: Identify what needs to change and check if the change violates SRP.
|
||||||
4. **Test**: Add or update useful tests as appropriate for the change.
|
4. **Implement**: Make the scoped change. Create new small files when SRP calls for it.
|
||||||
5. **Refactor**: Tidy the implementation and tests while preserving behavior.
|
5. **Test**: Add or update useful tests as appropriate for the change.
|
||||||
6. **Verify**: Run the relevant tests and type checks (`npm run build` or `tsc --noEmit`) in proportion to 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,
|
botStatsSchema,
|
||||||
controlResponseSchema,
|
controlResponseSchema,
|
||||||
timersSchema,
|
timersSchema,
|
||||||
|
type BotMode,
|
||||||
type BotStats,
|
type BotStats,
|
||||||
type ControlResponse,
|
type ControlResponse,
|
||||||
type Timers,
|
type Timers,
|
||||||
@@ -36,7 +37,7 @@ async function parseResponse<T>(response: Response, schema: ZodType<T>): Promise
|
|||||||
export interface DashboardApi {
|
export interface DashboardApi {
|
||||||
getStats(): Promise<BotStats>;
|
getStats(): Promise<BotStats>;
|
||||||
getTimers(): Promise<Timers>;
|
getTimers(): Promise<Timers>;
|
||||||
setRunning(isRunning: boolean): Promise<ControlResponse>;
|
setMode(mode: BotMode): Promise<ControlResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDashboardApi(fetchImplementation?: typeof fetch): DashboardApi {
|
export function createDashboardApi(fetchImplementation?: typeof fetch): DashboardApi {
|
||||||
@@ -49,9 +50,15 @@ export function createDashboardApi(fetchImplementation?: typeof fetch): Dashboar
|
|||||||
async getTimers() {
|
async getTimers() {
|
||||||
return parseResponse(await request('/api/timers'), timersSchema);
|
return parseResponse(await request('/api/timers'), timersSchema);
|
||||||
},
|
},
|
||||||
async setRunning(isRunning) {
|
async setMode(mode) {
|
||||||
const endpoint = isRunning ? '/api/start' : '/api/stop';
|
return parseResponse(
|
||||||
return parseResponse(await request(endpoint, { method: 'POST' }), controlResponseSchema);
|
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';
|
} from '../../../../packages/api-contracts/src/index.js';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
BotMode,
|
||||||
ControlResponse,
|
ControlResponse,
|
||||||
StatsResponse as BotStats,
|
StatsResponse as BotStats,
|
||||||
TimersResponse as Timers,
|
TimersResponse as Timers,
|
||||||
|
|||||||
@@ -8,20 +8,24 @@ export const dashboardQueryKeys = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function useStatsQuery() {
|
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() {
|
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();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: dashboardApi.setRunning,
|
mutationFn: dashboardApi.setMode,
|
||||||
onSuccess(response) {
|
onSuccess(response) {
|
||||||
queryClient.setQueryData<BotStats>(dashboardQueryKeys.stats, (current) =>
|
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 { useState } from 'react';
|
||||||
import { NavLink, Outlet } from 'react-router-dom';
|
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 { ShellStatusPanel } from './shell-status-panel.js';
|
||||||
|
import { NotificationBell, NotificationProvider, NotificationViewport } from './notifications.js';
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const [isNavigationOpen, setIsNavigationOpen] = useState(false);
|
const [isNavigationOpen, setIsNavigationOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<NotificationProvider><div className="app-shell">
|
||||||
<a className="skip-link" href="#main-content">Skip to content</a>
|
<a className="skip-link" href="#main-content">Skip to content</a>
|
||||||
<aside className={isNavigationOpen ? 'sidebar is-open' : 'sidebar'}>
|
<aside className={isNavigationOpen ? 'sidebar is-open' : 'sidebar'}>
|
||||||
<div className="brand">
|
<div className="brand">
|
||||||
@@ -16,9 +17,13 @@ export function AppShell() {
|
|||||||
<strong>Reddit Bot</strong>
|
<strong>Reddit Bot</strong>
|
||||||
<span>Operator console</span>
|
<span>Operator console</span>
|
||||||
</div>
|
</div>
|
||||||
|
<NotificationBell />
|
||||||
</div>
|
</div>
|
||||||
<nav aria-label="Primary navigation" id="primary-navigation">
|
<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
|
<NavLink
|
||||||
aria-label={item.label}
|
aria-label={item.label}
|
||||||
className={({ isActive }) => (isActive ? 'nav-link is-active' : 'nav-link')}
|
className={({ isActive }) => (isActive ? 'nav-link is-active' : 'nav-link')}
|
||||||
@@ -31,6 +36,8 @@ export function AppShell() {
|
|||||||
<small>{item.description}</small>
|
<small>{item.description}</small>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<ShellStatusPanel />
|
<ShellStatusPanel />
|
||||||
</aside>
|
</aside>
|
||||||
@@ -45,6 +52,8 @@ export function AppShell() {
|
|||||||
<div className="workspace">
|
<div className="workspace">
|
||||||
<header className="mobile-header">
|
<header className="mobile-header">
|
||||||
<span>Reddit Bot</span>
|
<span>Reddit Bot</span>
|
||||||
|
<div className="mobile-header-actions">
|
||||||
|
<NotificationBell />
|
||||||
<button
|
<button
|
||||||
aria-controls="primary-navigation"
|
aria-controls="primary-navigation"
|
||||||
aria-expanded={isNavigationOpen}
|
aria-expanded={isNavigationOpen}
|
||||||
@@ -55,11 +64,13 @@ export function AppShell() {
|
|||||||
>
|
>
|
||||||
<span aria-hidden="true">{isNavigationOpen ? '×' : '☰'}</span>
|
<span aria-hidden="true">{isNavigationOpen ? '×' : '☰'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main id="main-content" tabIndex={-1}>
|
<main id="main-content" tabIndex={-1}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<NotificationViewport />
|
||||||
|
</div></NotificationProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,13 +2,29 @@ export interface NavigationItem {
|
|||||||
label: string;
|
label: string;
|
||||||
path: string;
|
path: string;
|
||||||
description: 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[] = [
|
export const navigationItems: readonly NavigationItem[] = [
|
||||||
{ label: 'Overview', path: '/', description: 'System health and current activity' },
|
{ label: 'Overview', path: '/', description: 'System health and current activity', group: 'Monitor' },
|
||||||
{ label: 'Conversations', path: '/conversations', description: 'Inbox and message history' },
|
{ label: 'Needs attention', path: '/attention', description: 'Handoffs, failures, and account risks', group: 'Monitor' },
|
||||||
{ label: 'Media', path: '/media', description: 'Private media and sync controls' },
|
{ label: 'Outreach funnel', path: '/outreach', description: 'Active posts moving toward delivery', group: 'Monitor' },
|
||||||
{ label: 'Performance', path: '/performance', description: 'Response and strategy trends' },
|
{ label: 'Conversations', path: '/conversations', description: 'Inbox and message history', group: 'Monitor' },
|
||||||
{ label: 'Operations', path: '/operations', description: 'Jobs, events, and diagnostics' },
|
{ label: 'Media', path: '/media', description: 'Private media and sync controls', group: 'Content' },
|
||||||
{ label: 'Settings', path: '/settings', description: 'Runtime and access configuration' },
|
{ 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: '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,12 @@ import { OperationsPage } from '../features/operations/operations-page.js';
|
|||||||
import { PerformancePage } from '../features/performance/performance-page.js';
|
import { PerformancePage } from '../features/performance/performance-page.js';
|
||||||
import { SettingsRoute } from '../features/settings/settings-route.js';
|
import { SettingsRoute } from '../features/settings/settings-route.js';
|
||||||
import { RouteErrorPage } from './route-error-page.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';
|
||||||
|
|
||||||
export function createDashboardRoutes(): RouteObject[] {
|
export function createDashboardRoutes(): RouteObject[] {
|
||||||
return [
|
return [
|
||||||
@@ -16,11 +22,17 @@ export function createDashboardRoutes(): RouteObject[] {
|
|||||||
errorElement: <RouteErrorPage />,
|
errorElement: <RouteErrorPage />,
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <OverviewPage /> },
|
{ index: true, element: <OverviewPage /> },
|
||||||
|
{ path: 'attention', element: <AttentionPage /> },
|
||||||
|
{ path: 'outreach', element: <OutreachPage /> },
|
||||||
{ path: 'conversations', element: <ConversationRoute /> },
|
{ path: 'conversations', element: <ConversationRoute /> },
|
||||||
{ path: 'conversations/:conversationId', element: <ConversationRoute /> },
|
{ path: 'conversations/:conversationId', element: <ConversationRoute /> },
|
||||||
{ path: 'media', element: <MediaRoute /> },
|
{ path: 'media', element: <MediaRoute /> },
|
||||||
|
{ path: 'media-library', element: <MediaLibraryPage /> },
|
||||||
{ path: 'performance', element: <PerformancePage /> },
|
{ path: 'performance', element: <PerformancePage /> },
|
||||||
{ path: 'operations', element: <OperationsPage /> },
|
{ path: 'operations', element: <OperationsPage /> },
|
||||||
|
{ path: 'subreddits', element: <SubredditsPage /> },
|
||||||
|
{ path: 'accounts', element: <AccountsPage /> },
|
||||||
|
{ path: 'growth', element: <GrowthPage /> },
|
||||||
{ path: 'settings', element: <SettingsRoute /> },
|
{ path: 'settings', element: <SettingsRoute /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
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 { ConfirmationDialog } from '../components/confirmation-dialog.js';
|
||||||
import { useSyncStatusQuery } from '../features/operations/operations-queries.js';
|
import { useSyncStatusQuery } from '../features/operations/operations-queries.js';
|
||||||
import { useLoginStatusQuery } from '../features/settings/settings-queries.js';
|
import { useLoginStatusQuery } from '../features/settings/settings-queries.js';
|
||||||
@@ -12,15 +13,15 @@ export function ShellStatusPanel() {
|
|||||||
const reddit = useLoginStatusQuery();
|
const reddit = useLoginStatusQuery();
|
||||||
const sync = useSyncStatusQuery();
|
const sync = useSyncStatusQuery();
|
||||||
const realtime = useDashboardRealtime();
|
const realtime = useDashboardRealtime();
|
||||||
const control = useSetRunningMutation();
|
const control = useSetModeMutation();
|
||||||
const [confirming, setConfirming] = useState<'start' | 'stop' | null>(null);
|
const [confirming, setConfirming] = useState<BotMode | null>(null);
|
||||||
const status = deriveShellStatus({ stats: stats.data, reddit: reddit.data, sync: sync.data, realtime: realtime.connection });
|
const status = deriveShellStatus({ stats: stats.data, reddit: reddit.data, sync: sync.data, realtime: realtime.connection });
|
||||||
return <>
|
return <>
|
||||||
<section aria-label="Platform status" className="shell-status">
|
<section aria-label="Platform status" className="shell-status">
|
||||||
<strong>{status.bot}</strong><span>{status.reddit}</span><span>{status.realtime}</span><span>{status.sync}</span>
|
<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}
|
{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>
|
</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 { 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 {
|
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 };
|
reddit?: { ready: boolean; loggedIn?: boolean; status?: string; error?: string };
|
||||||
realtime: RealtimeConnectionSnapshot;
|
realtime: RealtimeConnectionSnapshot;
|
||||||
sync?: { syncing: boolean; syncingKind?: string | null };
|
sync?: { syncing: boolean; syncingKind?: string | null };
|
||||||
@@ -13,7 +20,7 @@ export interface ShellStatus {
|
|||||||
realtime: string;
|
realtime: string;
|
||||||
cooldown: string | null;
|
cooldown: string | null;
|
||||||
sync: string;
|
sync: string;
|
||||||
controlAction: 'start' | 'stop';
|
controlAction: BotMode;
|
||||||
controlDisabled: boolean;
|
controlDisabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,12 +35,14 @@ export function deriveShellStatus(input: ShellStatusInput): ShellStatus {
|
|||||||
? input.reddit.loggedIn ? `Reddit ${input.reddit.status ?? 'authenticated'}` : 'Reddit login required'
|
? input.reddit.loggedIn ? `Reddit ${input.reddit.status ?? 'authenticated'}` : 'Reddit login required'
|
||||||
: 'Reddit session unavailable';
|
: 'Reddit session unavailable';
|
||||||
return {
|
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,
|
reddit,
|
||||||
realtime,
|
realtime,
|
||||||
cooldown: stats?.invitationCooldown?.reason ?? null,
|
cooldown: stats?.invitationCooldown?.reason ?? null,
|
||||||
sync: input.sync?.syncing ? `${sentence(input.sync.syncingKind ?? 'unknown')} sync active` : 'No active sync',
|
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,
|
controlDisabled: stats === undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { RedditAccountOwner } from '../../../../packages/api-contracts/src/index.js';
|
||||||
|
import { RedditUsernameLink } from './reddit-username-link.js';
|
||||||
|
|
||||||
|
export function AccountOwnerBadge({ owner }: { owner?: RedditAccountOwner }) {
|
||||||
|
return (
|
||||||
|
<span className={owner ? 'account-owner-badge' : 'account-owner-badge is-unassigned'}>
|
||||||
|
<span aria-hidden="true">Account</span>
|
||||||
|
{owner ? <RedditUsernameLink username={owner.redditUsername} /> : <strong>Unassigned legacy content</strong>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,39 +1,52 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import type { BotMode } from '../api/contracts.js';
|
||||||
|
import { Modal } from './modal.js';
|
||||||
|
|
||||||
interface ConfirmationDialogProps {
|
interface ConfirmationDialogProps {
|
||||||
action: 'start' | 'stop';
|
action: BotMode;
|
||||||
|
errorMessage?: string | null;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
onCancel(): void;
|
onCancel(): void;
|
||||||
onConfirm(): void;
|
onConfirm(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConfirmationDialog({ action, isPending, onCancel, onConfirm }: ConfirmationDialogProps) {
|
const copy: Record<BotMode, { title: string; body: string; confirm: string; tone: string }> = {
|
||||||
const cancelButtonRef = useRef<HTMLButtonElement>(null);
|
running: {
|
||||||
const isStarting = action === 'start';
|
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(() => {
|
export function ConfirmationDialog({ action, errorMessage = null, isPending, onCancel, onConfirm }: ConfirmationDialogProps) {
|
||||||
cancelButtonRef.current?.focus();
|
const { title, body, confirm, tone } = copy[action];
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div aria-labelledby="confirmation-title" aria-modal="true" className="dialog-backdrop" role="dialog">
|
<Modal className="dialog-card" isDismissLocked={isPending} labelledBy="confirmation-title" onClose={onCancel}>
|
||||||
<div className="dialog-card">
|
|
||||||
<span className="eyebrow">Confirm action</span>
|
<span className="eyebrow">Confirm action</span>
|
||||||
<h2 id="confirmation-title">{isStarting ? 'Start bot?' : 'Stop bot?'}</h2>
|
<h2 id="confirmation-title">{title}</h2>
|
||||||
<p>
|
<p>{body}</p>
|
||||||
{isStarting
|
{errorMessage ? <p className="dialog-error" role="alert">{errorMessage}</p> : null}
|
||||||
? 'The bot will resume automated processing.'
|
|
||||||
: 'The bot will pause after its current operation.'}
|
|
||||||
</p>
|
|
||||||
<div className="dialog-actions">
|
<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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button className={isStarting ? 'button primary' : 'button danger'} disabled={isPending} onClick={onConfirm} type="button">
|
<button className={tone} disabled={isPending} onClick={onConfirm} type="button">
|
||||||
{isPending ? 'Applying…' : `Confirm ${action}`}
|
{isPending ? 'Applying…' : confirm}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 {
|
export interface OriginPost {
|
||||||
title: string;
|
title: string;
|
||||||
url: string;
|
url: string;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
subreddit?: string;
|
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 }) {
|
export function OriginPostCard({ post }: { post: OriginPost }) {
|
||||||
|
const [isImageRevealed, setIsImageRevealed] = useState(false);
|
||||||
|
const [isImagePreviewOpen, setIsImagePreviewOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
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}
|
<div className="origin-post-card">
|
||||||
<span>
|
{post.imageUrl ? (
|
||||||
<small>Started from {post.subreddit ? `r/${post.subreddit}` : 'Reddit'}</small>
|
<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>
|
<strong>{post.title}</strong>
|
||||||
<span>Open original post</span>
|
<span>Open original post <span aria-hidden="true">↗</span></span>
|
||||||
</span>
|
|
||||||
</a>
|
</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 }) {
|
export function OriginPostButton({ children, post }: { children: ReactNode; post: OriginPost }) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<button className="origin-post-trigger" onClick={() => setIsOpen(true)} ref={triggerRef} type="button">
|
<button className="origin-post-trigger" onClick={() => setIsOpen(true)} type="button">
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
{isOpen ? (
|
{isOpen ? (
|
||||||
<div
|
<Modal
|
||||||
aria-labelledby="origin-post-dialog-title"
|
className="origin-post-dialog"
|
||||||
aria-modal="true"
|
closeOnBackdrop
|
||||||
className="origin-post-backdrop"
|
labelledBy="origin-post-dialog-title"
|
||||||
onMouseDown={(event) => { if (event.target === event.currentTarget) close(); }}
|
onClose={() => setIsOpen(false)}
|
||||||
role="dialog"
|
|
||||||
>
|
>
|
||||||
<article className="origin-post-dialog">
|
<article>
|
||||||
<div className="origin-post-dialog-heading">
|
<div className="origin-post-dialog-heading">
|
||||||
<span>Conversation origin</span>
|
<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>
|
</div>
|
||||||
{post.imageUrl ? <img alt={`Source post: ${post.title}`} loading="lazy" src={post.imageUrl} /> : null}
|
{post.imageUrl ? <img alt={`Source post: ${post.title}`} loading="lazy" src={post.imageUrl} /> : null}
|
||||||
<div className="origin-post-dialog-copy">
|
<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>
|
<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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</Modal>
|
||||||
) : null}
|
) : 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 {
|
import {
|
||||||
ConversationsResponseSchema,
|
ConversationsResponseSchema,
|
||||||
ConversationDetailResponseSchema,
|
ConversationDetailResponseSchema,
|
||||||
|
ConversationMediaShareOptionsResponseSchema,
|
||||||
ErrorResponseSchema,
|
ErrorResponseSchema,
|
||||||
ProcessedPostsResponseSchema,
|
ProcessedPostsResponseSchema,
|
||||||
SendReplyRequestSchema,
|
SendReplyRequestSchema,
|
||||||
|
ShareConversationMediaRequestSchema,
|
||||||
StartConversationRequestSchema,
|
StartConversationRequestSchema,
|
||||||
StartConversationResponseSchema,
|
StartConversationResponseSchema,
|
||||||
SuccessResponseSchema,
|
SuccessResponseSchema,
|
||||||
SuggestedReplyResponseSchema,
|
SuggestedReplyResponseSchema,
|
||||||
|
BulkMarkConversationsReadRequestSchema,
|
||||||
|
BulkMarkConversationsReadResponseSchema,
|
||||||
|
SellerScanResponseSchema,
|
||||||
|
MarkConversationReadRequestSchema,
|
||||||
|
MarkConversationUnreadRequestSchema,
|
||||||
|
SetConversationStatusRequestSchema,
|
||||||
type ActiveChat,
|
type ActiveChat,
|
||||||
|
type BulkMarkConversationsReadResponse,
|
||||||
type ConversationDetailResponse,
|
type ConversationDetailResponse,
|
||||||
|
type ConversationMediaShareOptions,
|
||||||
|
type ConversationTriageStatus,
|
||||||
type ProcessedPost,
|
type ProcessedPost,
|
||||||
|
type SellerScanResponse,
|
||||||
type SuccessResponse,
|
type SuccessResponse,
|
||||||
} from '../../../../../packages/api-contracts/src/index.js';
|
} from '../../../../../packages/api-contracts/src/index.js';
|
||||||
import { createDashboardRequest } from '../../api/request.js';
|
import { createDashboardRequest } from '../../api/request.js';
|
||||||
@@ -34,9 +46,24 @@ export interface ConversationApi {
|
|||||||
detail(id: string): Promise<ConversationDetailResponse>;
|
detail(id: string): Promise<ConversationDetailResponse>;
|
||||||
suggestReply(id: string): Promise<string>;
|
suggestReply(id: string): Promise<string>;
|
||||||
suggestMediaAsk(id: string): Promise<string>;
|
suggestMediaAsk(id: string): Promise<string>;
|
||||||
|
mediaShareOptions(id: string): Promise<ConversationMediaShareOptions>;
|
||||||
processedPosts(username: string): Promise<ProcessedPost[]>;
|
processedPosts(username: string): Promise<ProcessedPost[]>;
|
||||||
sendReply(id: string, message: string): Promise<SuccessResponse>;
|
sendReply(id: string, message: string): Promise<SuccessResponse>;
|
||||||
startConversation(username: string, message: string): Promise<ActiveChat>;
|
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 {
|
export function createConversationApi(fetchImplementation?: typeof fetch): ConversationApi {
|
||||||
@@ -66,6 +93,12 @@ export function createConversationApi(fetchImplementation?: typeof fetch): Conve
|
|||||||
);
|
);
|
||||||
return response.suggestedReply;
|
return response.suggestedReply;
|
||||||
},
|
},
|
||||||
|
async mediaShareOptions(id) {
|
||||||
|
return parseResponse(
|
||||||
|
await request(`${conversationPath(id)}/shareable-media`),
|
||||||
|
ConversationMediaShareOptionsResponseSchema,
|
||||||
|
);
|
||||||
|
},
|
||||||
async processedPosts(username) {
|
async processedPosts(username) {
|
||||||
return parseResponse(
|
return parseResponse(
|
||||||
await request(`/api/posts/by-author/${encodeURIComponent(username.trim().replace(/^u\//i, ''))}`),
|
await request(`/api/posts/by-author/${encodeURIComponent(username.trim().replace(/^u\//i, ''))}`),
|
||||||
@@ -83,8 +116,20 @@ export function createConversationApi(fetchImplementation?: typeof fetch): Conve
|
|||||||
SuccessResponseSchema,
|
SuccessResponseSchema,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
async startConversation(username, message) {
|
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({
|
const body = StartConversationRequestSchema.parse({
|
||||||
|
accountId,
|
||||||
username: username.trim().replace(/^u\//i, ''),
|
username: username.trim().replace(/^u\//i, ''),
|
||||||
message: message.trim(),
|
message: message.trim(),
|
||||||
});
|
});
|
||||||
@@ -98,6 +143,46 @@ export function createConversationApi(fetchImplementation?: typeof fetch): Conve
|
|||||||
);
|
);
|
||||||
return response.chat;
|
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,98 +1,285 @@
|
|||||||
import { useCallback, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import type { ConversationDetailResponse } from '../../../../../packages/api-contracts/src/index.js';
|
import type {
|
||||||
|
ChatMessage,
|
||||||
|
ConversationDetailResponse,
|
||||||
|
MediaLibraryAsset,
|
||||||
|
} from '../../../../../packages/api-contracts/src/index.js';
|
||||||
import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
|
import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
|
||||||
import { OriginPostCard } from '../../components/origin-post.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 { 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 {
|
interface ConversationDetailProps {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
onClose(): void;
|
onClose(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMessageTime(timestamp: number): string {
|
|
||||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mediaPath(localPath: string): string {
|
function mediaPath(localPath: string): string {
|
||||||
return `/media/${localPath.split('/').map(encodeURIComponent).join('/')}`;
|
return `/media/${localPath.split(/[\\/]/).map(encodeURIComponent).join('/')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MessageHistory({ conversation }: { conversation: ConversationDetailResponse }) {
|
/** Auto-dismisses a success flag after a few seconds. */
|
||||||
if (!conversation.messages || conversation.messages.length === 0) {
|
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 <div className="conversation-empty-detail">No messages have been loaded for this conversation.</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ol aria-label="Message history" className="conversation-messages">
|
<div className="conversation-messages-shell">
|
||||||
{conversation.messages.map((message) => (
|
<ol aria-label="Message history" className="conversation-messages" onScroll={handleScroll} ref={listRef}>
|
||||||
<li className={message.isSelf ? 'is-self' : 'is-recipient'} key={`${message.timestamp}-${message.sender}-${message.text}`}>
|
{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>
|
<div>
|
||||||
<strong>{message.isSelf ? 'You' : <RedditUsernameLink username={conversation.username} />}</strong>
|
<strong>{message.isSelf
|
||||||
<time dateTime={new Date(message.timestamp).toISOString()}>{formatMessageTime(message.timestamp)}</time>
|
? `You${conversation.owner ? ` (u/${conversation.owner.redditUsername})` : ''}`
|
||||||
|
: <RedditUsernameLink username={conversation.username} />}</strong>
|
||||||
|
{message.isAi ? <span aria-label="Sent by AI" className="conversation-ai-badge">AI</span> : null}
|
||||||
|
<time dateTime={new Date(message.timestamp).toISOString()} title={formatExactTime(message.timestamp)}>
|
||||||
|
{formatTimeOfDay(message.timestamp)}
|
||||||
|
</time>
|
||||||
</div>
|
</div>
|
||||||
<p>{message.text}</p>
|
<p>{message.text}</p>
|
||||||
{message.media?.length ? (
|
{message.media?.length ? (
|
||||||
<div className="conversation-message-images">
|
<div className="conversation-message-images">
|
||||||
{message.media.map((media) => (
|
{message.media.map((media) => {
|
||||||
<a
|
const mediaKey = `${message.timestamp}-${media.localPath}`;
|
||||||
aria-label={`View original image ${message.isSelf ? 'sent by you' : `received from ${redditUsernameLabel(conversation.username)}`}`}
|
const sender = message.isSelf ? 'sent by you' : `received from ${redditUsernameLabel(conversation.username)}`;
|
||||||
href={mediaPath(media.localPath)}
|
const isRevealed = revealedMedia.has(mediaKey);
|
||||||
key={`${message.timestamp}-${media.localPath}`}
|
|
||||||
rel="noreferrer"
|
return (
|
||||||
target="_blank"
|
<div className="conversation-message-image" key={mediaKey}>
|
||||||
>
|
|
||||||
<img
|
<img
|
||||||
alt={`Image ${message.isSelf ? 'sent by you' : `received from ${redditUsernameLabel(conversation.username)}`}`}
|
alt={`Image ${sender}`}
|
||||||
|
className={isRevealed ? undefined : 'is-blurred'}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
src={mediaPath(media.localPath)}
|
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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</li>
|
</li>,
|
||||||
))}
|
];
|
||||||
|
})}
|
||||||
</ol>
|
</ol>
|
||||||
|
{!isAtBottom ? (
|
||||||
|
<button className="conversation-jump-latest" onClick={scrollToBottom} type="button">
|
||||||
|
Jump to latest
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConversationComposer({ conversation }: { conversation: ConversationDetailResponse }) {
|
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 [isConfirming, setIsConfirming] = useState(false);
|
||||||
const [wasSent, setWasSent] = 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 sendButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
const suggestionMutation = useSuggestReplyMutation(conversation.roomId ?? conversation.username);
|
const mediaShareSummaryRef = useRef<HTMLElement>(null);
|
||||||
const mediaAskMutation = useSuggestMediaAskMutation(conversation.roomId ?? conversation.username);
|
const suggestionMutation = useSuggestReplyMutation(conversationId);
|
||||||
const sendMutation = useSendReplyMutation(conversation.roomId ?? conversation.username);
|
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 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(() => {
|
const closeConfirmation = useCallback(() => {
|
||||||
setIsConfirming(false);
|
setIsConfirming(false);
|
||||||
queueMicrotask(() => sendButtonRef.current?.focus());
|
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() {
|
function suggestReply() {
|
||||||
setWasSent(false);
|
setWasSent(false);
|
||||||
suggestionMutation.mutate(undefined, { onSuccess: (suggestion) => setDraft(suggestion) });
|
suggestionMutation.mutate(undefined, { onSuccess: applySuggestion });
|
||||||
}
|
}
|
||||||
|
|
||||||
function suggestMediaAsk() {
|
function suggestMediaAsk() {
|
||||||
setWasSent(false);
|
setWasSent(false);
|
||||||
mediaAskMutation.mutate(undefined, { onSuccess: (suggestion) => setDraft(suggestion) });
|
mediaAskMutation.mutate(undefined, { onSuccess: applySuggestion });
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendReply() {
|
function sendReply() {
|
||||||
if (!canSend) return;
|
if (!canSend) return;
|
||||||
sendMutation.mutate(draft, {
|
sendMutation.mutate(draft, {
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
setDraft('');
|
updateDraft('');
|
||||||
|
setRestorableDraft(null);
|
||||||
setWasSent(true);
|
setWasSent(true);
|
||||||
},
|
},
|
||||||
onSettled: closeConfirmation,
|
onSettled: closeConfirmation,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shareMedia() {
|
||||||
|
if (!selectedAsset) return;
|
||||||
|
mediaShareMutation.mutate(selectedAsset.id, {
|
||||||
|
onSuccess() {
|
||||||
|
setSelectedAssetId(null);
|
||||||
|
setWasMediaShared(true);
|
||||||
|
},
|
||||||
|
onSettled: closeMediaConfirmation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-labelledby="reply-composer-heading" className="conversation-composer">
|
<section aria-labelledby="reply-composer-heading" className="conversation-composer">
|
||||||
<div className="conversation-section-heading">
|
<div className="conversation-section-heading">
|
||||||
@@ -100,29 +287,106 @@ function ConversationComposer({ conversation }: { conversation: ConversationDeta
|
|||||||
<button disabled={suggestionMutation.isPending} onClick={suggestReply} type="button">
|
<button disabled={suggestionMutation.isPending} onClick={suggestReply} type="button">
|
||||||
{suggestionMutation.isPending ? 'Generating…' : 'Suggest reply'}
|
{suggestionMutation.isPending ? 'Generating…' : 'Suggest reply'}
|
||||||
</button>
|
</button>
|
||||||
<button disabled={mediaAskMutation.isPending} onClick={suggestMediaAsk} type="button">
|
<button disabled={mediaAskMutation.isPending || !automationActive} onClick={suggestMediaAsk} type="button">
|
||||||
{mediaAskMutation.isPending ? 'Generating media ask…' : 'Suggest media ask'}
|
{mediaAskMutation.isPending ? 'Generating media ask…' : 'Suggest media ask'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<label className="sr-only" htmlFor="conversation-reply">Reply to {redditUsernameLabel(conversation.username)}</label>
|
<label className="sr-only" htmlFor="conversation-reply">Reply to {redditUsernameLabel(conversation.username)}</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="conversation-reply"
|
id="conversation-reply"
|
||||||
|
maxLength={REDDIT_CHAT_MAX_MESSAGE_LENGTH}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setDraft(event.target.value);
|
updateDraft(event.target.value);
|
||||||
|
setRestorableDraft(null);
|
||||||
setWasSent(false);
|
setWasSent(false);
|
||||||
}}
|
}}
|
||||||
placeholder="Write or generate a reply"
|
placeholder="Write or generate a reply"
|
||||||
rows={5}
|
rows={5}
|
||||||
value={draft}
|
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}
|
{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}
|
{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}
|
{sendMutation.isError ? <div className="conversation-inline-error" role="alert">{sendMutation.error.message}</div> : null}
|
||||||
{wasSent ? (
|
{wasSent ? (
|
||||||
<div aria-label="Reply sent" className="conversation-inline-success" role="status">Reply sent to <RedditUsernameLink username={conversation.username} /></div>
|
<div aria-label="Reply sent" className="conversation-inline-success" role="status">Reply sent to <RedditUsernameLink username={conversation.username} /></div>
|
||||||
) : null}
|
) : 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">
|
<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
|
<button
|
||||||
aria-label="Review and send reply"
|
aria-label="Review and send reply"
|
||||||
className="conversation-primary"
|
className="conversation-primary"
|
||||||
@@ -142,12 +406,151 @@ function ConversationComposer({ conversation }: { conversation: ConversationDeta
|
|||||||
onConfirm={sendReply}
|
onConfirm={sendReply}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{isConfirmingMedia && selectedAsset ? (
|
||||||
|
<MediaShareConfirmationDialog
|
||||||
|
areImagesRevealed={areSavedImagesRevealed}
|
||||||
|
asset={selectedAsset}
|
||||||
|
isPending={mediaShareMutation.isPending}
|
||||||
|
onCancel={closeMediaConfirmation}
|
||||||
|
onConfirm={shareMedia}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</section>
|
</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) {
|
export function ConversationDetail({ conversationId, onClose }: ConversationDetailProps) {
|
||||||
const detailQuery = useConversationDetailQuery(conversationId);
|
const detailQuery = useConversationDetailQuery(conversationId);
|
||||||
|
const [now] = useState(() => Date.now());
|
||||||
|
useAutoMarkRead(conversationId, detailQuery.data);
|
||||||
|
|
||||||
if (detailQuery.isPending) {
|
if (detailQuery.isPending) {
|
||||||
return <div aria-label="Loading conversation" className="conversation-detail-state" role="status">Loading conversation…</div>;
|
return <div aria-label="Loading conversation" className="conversation-detail-state" role="status">Loading conversation…</div>;
|
||||||
@@ -163,16 +566,40 @@ export function ConversationDetail({ conversationId, onClose }: ConversationDeta
|
|||||||
}
|
}
|
||||||
|
|
||||||
const conversation = detailQuery.data;
|
const conversation = detailQuery.data;
|
||||||
|
const status = triageStatus(conversation);
|
||||||
return (
|
return (
|
||||||
<article className="conversation-detail">
|
<article className="conversation-detail">
|
||||||
<header>
|
<header>
|
||||||
<div>
|
<div className="conversation-detail-identity">
|
||||||
<span className="conversation-eyebrow">Conversation</span>
|
<span className="conversation-eyebrow">Conversation</span>
|
||||||
<h2><RedditUsernameLink username={conversation.username} /></h2>
|
<h2><RedditUsernameLink username={conversation.username} /></h2>
|
||||||
{conversation.unread ? <span className="conversation-badge">Unread</span> : null}
|
<span className="conversation-detail-badges">
|
||||||
|
<AccountOwnerBadge owner={conversation.owner} />
|
||||||
|
{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>
|
||||||
|
<div className="conversation-detail-controls">
|
||||||
|
<ConversationTriageActions
|
||||||
|
conversation={conversation}
|
||||||
|
conversationId={conversationId}
|
||||||
|
onResolved={onClose}
|
||||||
|
/>
|
||||||
<button aria-label="Close conversation" onClick={onClose} type="button">Close</button>
|
<button aria-label="Close conversation" onClick={onClose} type="button">Close</button>
|
||||||
|
</div>
|
||||||
</header>
|
</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 ? (
|
{conversation.syncInProgress ? (
|
||||||
<div className="conversation-sync-notice" role="status">
|
<div className="conversation-sync-notice" role="status">
|
||||||
A {conversation.syncInProgress.kind ?? 'chat'} sync is in progress. Messages may be stale.
|
A {conversation.syncInProgress.kind ?? 'chat'} sync is in progress. Messages may be stale.
|
||||||
@@ -185,11 +612,12 @@ export function ConversationDetail({ conversationId, onClose }: ConversationDeta
|
|||||||
url: conversation.triggeringPost.url,
|
url: conversation.triggeringPost.url,
|
||||||
imageUrl: conversation.triggeringPost.imageUrl,
|
imageUrl: conversation.triggeringPost.imageUrl,
|
||||||
subreddit: conversation.triggeringPost.subreddit,
|
subreddit: conversation.triggeringPost.subreddit,
|
||||||
|
postedAt: conversation.triggeringPost.postedAt,
|
||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<MessageHistory conversation={conversation} />
|
<MessageHistory conversation={conversation} now={now} />
|
||||||
<ConversationComposer conversation={conversation} />
|
<ConversationComposer conversation={conversation} conversationId={conversationId} />
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +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 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 { conversationApi } from './conversation-api.js';
|
||||||
|
import { ownedConversationId } from './conversation-identity.js';
|
||||||
|
|
||||||
export const conversationQueryKeys = {
|
export const conversationQueryKeys = {
|
||||||
all: ['conversations'] as const,
|
all: ['conversations'] as const,
|
||||||
detail: (id: string) => ['conversations', 'detail', id] as const,
|
detail: (id: string) => ['conversations', 'detail', id] as const,
|
||||||
processedPosts: (username: string) => ['conversations', 'processed-posts', username] 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() {
|
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) {
|
export function useConversationDetailQuery(id: string | null) {
|
||||||
@@ -16,6 +25,7 @@ export function useConversationDetailQuery(id: string | null) {
|
|||||||
queryKey: conversationQueryKeys.detail(id ?? ''),
|
queryKey: conversationQueryKeys.detail(id ?? ''),
|
||||||
queryFn: () => conversationApi.detail(id ?? ''),
|
queryFn: () => conversationApi.detail(id ?? ''),
|
||||||
enabled: id !== null,
|
enabled: id !== null,
|
||||||
|
refetchInterval: CONVERSATION_REFRESH_INTERVAL_MS,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +37,14 @@ export function useSuggestMediaAskMutation(id: string) {
|
|||||||
return useMutation({ mutationFn: () => conversationApi.suggestMediaAsk(id) });
|
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) {
|
export function useProcessedPostsQuery(username: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: conversationQueryKeys.processedPosts(username),
|
queryKey: conversationQueryKeys.processedPosts(username),
|
||||||
@@ -48,11 +66,141 @@ export function useSendReplyMutation(id: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useStartConversationMutation() {
|
export function useShareConversationMediaMutation(id: string) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ username, message }: { username: string; message: string }) =>
|
mutationFn: (assetId: string) => conversationApi.shareMedia(id, assetId),
|
||||||
conversationApi.startConversation(username, message),
|
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({
|
||||||
|
mutationFn: ({ accountId, username, message }: { accountId: string; username: string; message: string }) =>
|
||||||
|
conversationApi.startConversation(accountId, username, message),
|
||||||
async onSuccess() {
|
async onSuccess() {
|
||||||
await queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all });
|
await queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
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() {
|
export function ConversationRoute() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { conversationId } = useParams<{ conversationId: string }>();
|
const { conversationId } = useParams<{ conversationId: string }>();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const filterValue = searchParams.get('filter');
|
const filterValue = searchParams.get('filter');
|
||||||
|
const sortValue = searchParams.get('sort');
|
||||||
const filters: ConversationFilters = {
|
const filters: ConversationFilters = {
|
||||||
search: searchParams.get('q') ?? '',
|
search: searchParams.get('q') ?? '',
|
||||||
filter:
|
showSellers: searchParams.get('showSellers') === 'true',
|
||||||
filterValue === 'unread'
|
filter: isConversationFilter(filterValue) ? filterValue : DEFAULT_CONVERSATION_FILTERS.filter,
|
||||||
|| filterValue === 'awaiting-reply'
|
sort: isConversationSort(sortValue) ? sortValue : DEFAULT_CONVERSATION_FILTERS.sort,
|
||||||
|| filterValue === 'received-images'
|
account: searchParams.get('account') ?? DEFAULT_CONVERSATION_FILTERS.account,
|
||||||
? filterValue
|
|
||||||
: 'all',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function selectConversation(nextConversationId: string | null) {
|
function selectConversation(nextConversationId: string | null) {
|
||||||
@@ -29,6 +34,9 @@ export function ConversationRoute() {
|
|||||||
const params = new URLSearchParams(searchParams);
|
const params = new URLSearchParams(searchParams);
|
||||||
if (next.search) params.set('q', next.search); else params.delete('q');
|
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.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 });
|
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,18 +1,40 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { ActiveChat } from '../../../../../packages/api-contracts/src/index.js';
|
import type { ActiveChat, SellerScanResponse } from '../../../../../packages/api-contracts/src/index.js';
|
||||||
import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
|
import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
|
||||||
import { ConversationDetail } from './conversation-detail.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 { StartConversationForm } from './start-conversation-form.js';
|
||||||
import { ProcessedUserLookup } from './processed-user-lookup.js';
|
import { ProcessedUserLookup } from './processed-user-lookup.js';
|
||||||
|
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
||||||
import './conversations.css';
|
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 {
|
const SMALL_VIEWPORT_QUERY = '(max-width: 51.9999rem)';
|
||||||
search: string;
|
|
||||||
filter: ConversationFilter;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConversationsPageProps {
|
export interface ConversationsPageProps {
|
||||||
selectedConversationId?: string | null;
|
selectedConversationId?: string | null;
|
||||||
@@ -21,67 +43,354 @@ export interface ConversationsPageProps {
|
|||||||
onFiltersChange?(filters: ConversationFilters): void;
|
onFiltersChange?(filters: ConversationFilters): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function conversationId(conversation: ActiveChat): string {
|
interface ConversationRowProps {
|
||||||
return conversation.roomId ?? conversation.username;
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchesFilter(conversation: ActiveChat, filter: ConversationFilter): boolean {
|
|
||||||
if (filter === 'unread') return conversation.unread;
|
|
||||||
if (filter === 'awaiting-reply') return conversation.isFromUs;
|
|
||||||
if (filter === 'received-images') return conversation.hasReceivedImages === true;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConversationListItem({ conversation, isSelected, onSelect }: {
|
|
||||||
conversation: ActiveChat;
|
conversation: ActiveChat;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
isChecked: boolean;
|
||||||
|
/** Only worth the row space when more than one account is in play. */
|
||||||
|
showOwner: boolean;
|
||||||
|
now: number;
|
||||||
onSelect(): void;
|
onSelect(): void;
|
||||||
}) {
|
onToggleChecked(): void;
|
||||||
const state = conversation.unread ? 'Unread' : conversation.isFromUs ? 'Awaiting reply' : 'Up to date';
|
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 (
|
return (
|
||||||
<li className={isSelected ? 'conversation-list-item is-selected' : 'conversation-list-item'}>
|
<li className={itemClasses.join(' ')}>
|
||||||
<button
|
<label className="conversation-list-check">
|
||||||
aria-label={`${redditUsernameLabel(conversation.username)}, ${state}${conversation.hasReceivedImages ? ', Image received' : ''}`}
|
<input
|
||||||
aria-pressed={isSelected}
|
aria-label={`Select ${redditUsernameLabel(conversation.username)}`}
|
||||||
className="conversation-list-target"
|
checked={isChecked}
|
||||||
onClick={onSelect}
|
onChange={onToggleChecked}
|
||||||
type="button"
|
type="checkbox"
|
||||||
/>
|
/>
|
||||||
<span className="conversation-list-heading"><strong><RedditUsernameLink username={conversation.username} /></strong><small>{state}</small></span>
|
</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}
|
{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>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConversationsPage({ selectedConversationId, onSelectedConversationChange, filters, onFiltersChange }: ConversationsPageProps) {
|
export function ConversationsPage({
|
||||||
|
selectedConversationId, onSelectedConversationChange, filters, onFiltersChange,
|
||||||
|
}: ConversationsPageProps) {
|
||||||
const conversationsQuery = useConversationsQuery();
|
const conversationsQuery = useConversationsQuery();
|
||||||
const [internalSelectedId, setInternalSelectedId] = useState<string | null>(null);
|
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 [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 activeConversationId = selectedConversationId !== undefined ? selectedConversationId : internalSelectedId;
|
||||||
const activeFilters = filters ?? localFilters;
|
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);
|
if (filters === undefined) setLocalFilters(next);
|
||||||
onFiltersChange?.(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 visibleConversations = useMemo(() => {
|
||||||
const normalizedSearch = activeFilters.search.trim().toLowerCase();
|
if (!frozenOrder) return liveConversations;
|
||||||
return (conversationsQuery.data ?? []).filter((conversation) => {
|
const rank = new Map(frozenOrder.map((id, index) => [id, index]));
|
||||||
const matchesSearch = normalizedSearch.length === 0
|
return liveConversations
|
||||||
|| conversation.username.toLowerCase().includes(normalizedSearch)
|
.filter((conversation) => rank.has(ownedConversationId(conversation)))
|
||||||
|| conversation.lastMessageSnippet.toLowerCase().includes(normalizedSearch);
|
.sort((left, right) =>
|
||||||
return matchesSearch && matchesFilter(conversation, activeFilters.filter);
|
(rank.get(ownedConversationId(left)) ?? 0) - (rank.get(ownedConversationId(right)) ?? 0));
|
||||||
});
|
}, [liveConversations, frozenOrder]);
|
||||||
}, [activeFilters, conversationsQuery.data]);
|
|
||||||
|
const pendingCount = liveConversations.length - visibleConversations.length;
|
||||||
|
|
||||||
|
const visibleIds = useMemo(
|
||||||
|
() => visibleConversations.map((conversation) => ownedConversationId(conversation)),
|
||||||
|
[visibleConversations],
|
||||||
|
);
|
||||||
|
|
||||||
function selectConversation(id: string | null) {
|
function selectConversation(id: string | null) {
|
||||||
if (selectedConversationId === undefined) setInternalSelectedId(id);
|
if (selectedConversationId === undefined) setInternalSelectedId(id);
|
||||||
onSelectedConversationChange?.(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 (
|
return (
|
||||||
<div className="conversations-page">
|
<div className="conversations-page">
|
||||||
<header className="conversations-header">
|
<header className="conversations-header">
|
||||||
@@ -90,11 +399,46 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
|
|||||||
<h1>Conversations</h1>
|
<h1>Conversations</h1>
|
||||||
<p>Review replies, draft carefully, and send only after confirmation.</p>
|
<p>Review replies, draft carefully, and send only after confirmation.</p>
|
||||||
</div>
|
</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">
|
<button className="conversation-primary" onClick={() => setIsStartingConversation((current) => !current)} type="button">
|
||||||
{isStartingConversation ? 'Close new conversation' : 'New conversation'}
|
{isStartingConversation ? 'Close new conversation' : 'New conversation'}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</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 ? (
|
{isStartingConversation ? (
|
||||||
<section aria-labelledby="new-conversation-heading" className="new-conversation-panel">
|
<section aria-labelledby="new-conversation-heading" className="new-conversation-panel">
|
||||||
<h2 id="new-conversation-heading">Start a conversation</h2>
|
<h2 id="new-conversation-heading">Start a conversation</h2>
|
||||||
@@ -110,21 +454,107 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
|
|||||||
<input
|
<input
|
||||||
id="conversation-search"
|
id="conversation-search"
|
||||||
onChange={(event) => updateFilters({ ...activeFilters, search: event.target.value })}
|
onChange={(event) => updateFilters({ ...activeFilters, search: event.target.value })}
|
||||||
placeholder="Search conversations"
|
placeholder="Search conversations (/)"
|
||||||
|
ref={searchRef}
|
||||||
type="search"
|
type="search"
|
||||||
value={activeFilters.search}
|
value={activeFilters.search}
|
||||||
/>
|
/>
|
||||||
<label className="sr-only" htmlFor="conversation-filter">Filter conversations</label>
|
<div aria-label="Filter conversations" className="conversation-filter-pills" role="group">
|
||||||
<select
|
{CONVERSATION_FILTERS.map((option) => (
|
||||||
id="conversation-filter"
|
<button
|
||||||
onChange={(event) => updateFilters({ ...activeFilters, filter: event.target.value as ConversationFilter })}
|
aria-label={`${option.label}, ${counts[option.value]}`}
|
||||||
value={activeFilters.filter}
|
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.label}
|
||||||
<option value="unread">Unread</option>
|
<span className="conversation-filter-count">{counts[option.value]}</span>
|
||||||
<option value="awaiting-reply">Awaiting reply</option>
|
</button>
|
||||||
<option value="received-images">Received images</option>
|
))}
|
||||||
|
</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>
|
</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>
|
</div>
|
||||||
{conversationsQuery.isPending ? (
|
{conversationsQuery.isPending ? (
|
||||||
<div aria-label="Loading conversations" className="conversation-list-state" role="status">Loading conversations…</div>
|
<div aria-label="Loading conversations" className="conversation-list-state" role="status">Loading conversations…</div>
|
||||||
@@ -137,22 +567,49 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
|
|||||||
) : conversationsQuery.data.length === 0 ? (
|
) : conversationsQuery.data.length === 0 ? (
|
||||||
<div className="conversation-list-state"><strong>No conversations yet.</strong><span>Start one when you are ready.</span></div>
|
<div className="conversation-list-state"><strong>No conversations yet.</strong><span>Start one when you are ready.</span></div>
|
||||||
) : visibleConversations.length === 0 ? (
|
) : 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">
|
<ul className="conversation-list">
|
||||||
{visibleConversations.map((conversation) => (
|
{visibleConversations.map((conversation) => {
|
||||||
<ConversationListItem
|
const id = ownedConversationId(conversation);
|
||||||
|
return (
|
||||||
|
<ConversationRow
|
||||||
conversation={conversation}
|
conversation={conversation}
|
||||||
isSelected={conversationId(conversation) === activeConversationId}
|
isChecked={checkedIds.has(id)}
|
||||||
key={conversationId(conversation)}
|
isSelected={id === activeConversationId}
|
||||||
onSelect={() => selectConversation(conversationId(conversation))}
|
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>
|
</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>
|
||||||
|
|
||||||
<section aria-label="Selected conversation" className="conversation-detail-panel">
|
<section aria-label="Selected conversation" className="conversation-detail-panel" ref={detailPanelRef}>
|
||||||
{activeConversationId ? (
|
{activeConversationId ? (
|
||||||
<ConversationDetail conversationId={activeConversationId} key={activeConversationId} onClose={() => selectConversation(null)} />
|
<ConversationDetail conversationId={activeConversationId} key={activeConversationId} onClose={() => selectConversation(null)} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -14,14 +14,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.conversations-header h1 { margin-bottom: .4rem; }
|
.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); }
|
.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-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-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, .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 { 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; }
|
.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 { padding: 1rem; margin-bottom: 1rem; background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; }
|
||||||
.new-conversation-panel h2 { margin-bottom: 1rem; }
|
.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; }
|
.start-conversation-form { display: grid; gap: .8rem; }
|
||||||
.conversation-field { display: grid; gap: .35rem; }
|
.conversation-field { display: grid; gap: .35rem; }
|
||||||
.conversation-field label { color: var(--muted); font-size: .8rem; font-weight: 700; }
|
.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-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-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-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 { 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:hover { background: var(--surface-raised); }
|
||||||
.conversation-list-item.is-selected { background: var(--accent-dark); border-color: #28593d; }
|
.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-item.is-checked { border-color: var(--accent); }
|
||||||
.conversation-list-target:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
.conversation-list-item.is-unread .conversation-list-heading strong { font-weight: 900; }
|
||||||
.conversation-list-item > span { position: relative; z-index: 1; pointer-events: none; }
|
.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-item .reddit-username-link { pointer-events: auto; }
|
.conversation-list-check { display: flex; align-items: start; padding-top: .3rem; }
|
||||||
.conversation-list-item > span:last-child { overflow: hidden; color: var(--muted); font-size: .82rem; text-overflow: ellipsis; white-space: nowrap; }
|
.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 { 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-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, .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-list-state strong, .conversation-detail-state strong { color: #e9f1ec; }
|
||||||
.conversation-detail { min-height: 32rem; }
|
/* Column layout: history takes the slack, the composer stays reachable. */
|
||||||
.conversation-detail > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; padding: 1rem; border-bottom: 1px solid var(--border); }
|
.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 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 { 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; }
|
.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; }
|
.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 { 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-self { justify-self: end; background: var(--accent-dark); }
|
||||||
.conversation-messages li.is-recipient { justify-self: start; background: var(--surface-raised); }
|
.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 time { color: var(--muted); font-size: .7rem; }
|
||||||
.conversation-messages p { margin: 0; line-height: 1.45; white-space: pre-wrap; }
|
.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 { 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-image { position: relative; overflow: hidden; 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; transition: filter 160ms ease, transform 160ms ease; }
|
||||||
.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-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-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, .conversation-actions { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
|
||||||
.conversation-section-heading h3 { margin: 0; }
|
.conversation-section-heading h3 { margin: 0; }
|
||||||
.conversation-actions > span { color: var(--muted); font-size: .75rem; }
|
.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-error { color: #ffd1ce; background: #461b18; border: 1px solid #80352f; }
|
||||||
.conversation-inline-success { color: #b9f3ce; background: #163a26; border: 1px solid #28593d; }
|
.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-media-share { display: grid; gap: .7rem; padding: .75rem; background: #0b1410; border: 1px solid var(--border); border-radius: .65rem; }
|
||||||
.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[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 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 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; }
|
.conversation-dialog .conversation-actions { justify-content: flex-end; }
|
||||||
@@ -81,5 +176,57 @@
|
|||||||
@media (min-width: 52rem) {
|
@media (min-width: 52rem) {
|
||||||
.conversations-page { padding: 3rem 2rem 5rem; }
|
.conversations-page { padding: 3rem 2rem 5rem; }
|
||||||
.start-conversation-form { grid-template-columns: minmax(12rem, .5fr) minmax(18rem, 1fr) auto; align-items: end; }
|
.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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
||||||
import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
|
import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
|
||||||
import { useProcessedPostsQuery } from './conversation-queries.js';
|
import { useProcessedPostsQuery } from './conversation-queries.js';
|
||||||
|
|
||||||
@@ -6,24 +7,34 @@ export function ProcessedUserLookup() {
|
|||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const postsQuery = useProcessedPostsQuery(username);
|
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 (
|
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>
|
<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>
|
<label htmlFor="processed-user">Lookup Reddit username</label>
|
||||||
<input id="processed-user" onChange={(event) => setInput(event.target.value)} value={input} />
|
<input autoComplete="off" id="processed-user" onChange={(event) => setInput(event.target.value)} value={input} />
|
||||||
<button disabled={input.trim().length === 0} type="submit">Look up user</button>
|
</div>
|
||||||
|
<button className="conversation-primary" disabled={input.trim().length === 0} type="submit">Look up user</button>
|
||||||
</form>
|
</form>
|
||||||
{postsQuery.isPending && username ? <p role="status">Looking up processed posts…</p> : null}
|
{postsQuery.isFetching && username ? <p role="status">Looking up processed posts...</p> : null}
|
||||||
{postsQuery.isError ? <p role="alert">{postsQuery.error.message}</p> : null}
|
{postsQuery.isError ? <p className="conversation-inline-error" role="alert">{postsQuery.error.message}</p> : null}
|
||||||
{postsQuery.data ? (
|
{showResults && postsQuery.data ? (
|
||||||
postsQuery.data.length === 0 ? <p>No processed posts found for <RedditUsernameLink username={username} />.</p> : (
|
postsQuery.data.length === 0 ? <p>No processed posts found for <RedditUsernameLink username={username} />.</p> : (
|
||||||
<ol aria-label={`Processed posts for ${redditUsernameLabel(username)}`}>
|
<ol aria-label={`Processed posts for ${redditUsernameLabel(username)}`}>
|
||||||
{postsQuery.data.map((post) => <li key={post.id}><strong>{post.title}</strong><span>r/{post.subreddit} · {post.messageSent ? 'Message sent' : post.error ?? 'Processed'}</span></li>)}
|
{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>
|
</ol>
|
||||||
)
|
)
|
||||||
) : null}
|
) : 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 {
|
interface SendConfirmationDialogProps {
|
||||||
draft: string;
|
draft: string;
|
||||||
@@ -8,31 +8,29 @@ interface SendConfirmationDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SendConfirmationDialog({ draft, isPending, onCancel, onConfirm }: 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 (
|
return (
|
||||||
<div aria-labelledby="send-confirmation-title" aria-modal="true" className="conversation-dialog-backdrop" role="dialog">
|
<Modal
|
||||||
<div className="conversation-dialog">
|
className="dialog-card conversation-dialog"
|
||||||
|
isDismissLocked={isPending}
|
||||||
|
labelledBy="send-confirmation-title"
|
||||||
|
onClose={onCancel}
|
||||||
|
>
|
||||||
<span className="conversation-eyebrow">Confirm message</span>
|
<span className="conversation-eyebrow">Confirm message</span>
|
||||||
<h2 id="send-confirmation-title">Send this reply?</h2>
|
<h2 id="send-confirmation-title">Send this reply?</h2>
|
||||||
<p>This sends immediately through Reddit chat.</p>
|
<p>This sends immediately through Reddit chat.</p>
|
||||||
<blockquote>{draft}</blockquote>
|
<blockquote>{draft}</blockquote>
|
||||||
<div className="conversation-actions">
|
<div className="conversation-actions">
|
||||||
<button disabled={isPending} onClick={onCancel} ref={cancelButtonRef} type="button">Cancel</button>
|
<button autoFocus disabled={isPending} onClick={onCancel} type="button">Cancel</button>
|
||||||
<button className="conversation-primary" disabled={isPending} onClick={onConfirm} type="button">
|
<button
|
||||||
|
aria-busy={isPending || undefined}
|
||||||
|
className="conversation-primary"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={onConfirm}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
{isPending ? 'Sending…' : 'Confirm send'}
|
{isPending ? 'Sending…' : 'Confirm send'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
||||||
import { useStartConversationMutation } from './conversation-queries.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 {
|
interface StartConversationFormProps {
|
||||||
onStarted(conversationId: string): void;
|
onStarted(conversationId: string): void;
|
||||||
@@ -9,21 +14,45 @@ interface StartConversationFormProps {
|
|||||||
export function StartConversationForm({ onStarted }: StartConversationFormProps) {
|
export function StartConversationForm({ onStarted }: StartConversationFormProps) {
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
|
const accountsQuery = useRedditAccountsQuery();
|
||||||
|
const runningAccounts = (accountsQuery.data ?? []).filter((account) => account.isRunning);
|
||||||
|
const [selectedAccountId, setSelectedAccountId] = useState('');
|
||||||
|
const accountId = selectedAccountId || runningAccounts[0]?.accountId || '';
|
||||||
const [successUsername, setSuccessUsername] = useState<string | null>(null);
|
const [successUsername, setSuccessUsername] = useState<string | null>(null);
|
||||||
const mutation = useStartConversationMutation();
|
const mutation = useStartConversationMutation();
|
||||||
const canSubmit = username.trim().length > 0 && message.trim().length > 0 && !mutation.isPending;
|
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>) {
|
function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!canSubmit) return;
|
if (!canSubmit) return;
|
||||||
mutation.mutate(
|
mutation.mutate(
|
||||||
{ username, message },
|
{ accountId, username, message },
|
||||||
{
|
{
|
||||||
onSuccess(chat) {
|
onSuccess(chat) {
|
||||||
setSuccessUsername(chat.username);
|
setSuccessUsername(chat.username);
|
||||||
setUsername('');
|
setUsername('');
|
||||||
setMessage('');
|
setMessage('');
|
||||||
onStarted(chat.roomId ?? chat.username);
|
onStarted(ownedConversationId(chat));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -31,6 +60,12 @@ export function StartConversationForm({ onStarted }: StartConversationFormProps)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="start-conversation-form" onSubmit={submit}>
|
<form className="start-conversation-form" onSubmit={submit}>
|
||||||
|
<div className="conversation-field">
|
||||||
|
<label htmlFor="new-conversation-account">Send from account</label>
|
||||||
|
<select id="new-conversation-account" onChange={(event) => setSelectedAccountId(event.target.value)} value={accountId}>
|
||||||
|
{runningAccounts.map((account) => <option key={account.accountId} value={account.accountId}>u/{account.redditUsername}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div className="conversation-field">
|
<div className="conversation-field">
|
||||||
<label htmlFor="new-conversation-username">Reddit username</label>
|
<label htmlFor="new-conversation-username">Reddit username</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import type { ZodType } from 'zod';
|
||||||
|
import {
|
||||||
|
ErrorResponseSchema,
|
||||||
|
GrowthAccountResponseSchema,
|
||||||
|
GrowthOverviewResponseSchema,
|
||||||
|
type GrowthAccountDetail,
|
||||||
|
type GrowthAccountSummary,
|
||||||
|
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 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,506 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type {
|
||||||
|
GrowthAccountDetail,
|
||||||
|
GrowthAccountSummary,
|
||||||
|
GrowthComment,
|
||||||
|
GrowthSubreddit,
|
||||||
|
} 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,
|
||||||
|
useUpdateGrowthSettingsMutation,
|
||||||
|
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,
|
||||||
|
}: {
|
||||||
|
accountId: string;
|
||||||
|
subreddit: GrowthSubreddit;
|
||||||
|
}) {
|
||||||
|
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} today</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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 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)));
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => setDelayMinutes(String(Math.round(settings.minDelayMs / 60_000))),
|
||||||
|
[settings.minDelayMs],
|
||||||
|
);
|
||||||
|
|
||||||
|
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>Allowance</dt>
|
||||||
|
<dd>{stats.postedLast24h} / {settings.dailyAllowance} today</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Trust level</dt>
|
||||||
|
<dd>{settings.trustLevel} of 4</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">
|
||||||
|
The allowance rises on its own while comments survive and earn upvotes, and falls back
|
||||||
|
the moment one is removed or heavily downvoted. Two removals in a row, or a comment the
|
||||||
|
public cannot see, pauses the account for you.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<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} 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>
|
||||||
|
|
||||||
|
<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,94 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type {
|
||||||
|
GrowthAccountDetail,
|
||||||
|
GrowthSettingsUpdateRequest,
|
||||||
|
GrowthSubredditCreateRequest,
|
||||||
|
GrowthSubredditUpdateRequest,
|
||||||
|
} from '../../../../../packages/api-contracts/src/index.js';
|
||||||
|
import {
|
||||||
|
addGrowthSubreddit,
|
||||||
|
fetchGrowthAccount,
|
||||||
|
fetchGrowthOverview,
|
||||||
|
pauseGrowth,
|
||||||
|
removeGrowthSubreddit,
|
||||||
|
resumeGrowth,
|
||||||
|
updateGrowthSettings,
|
||||||
|
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 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,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';
|
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[] {
|
export function filterMedia(items: MediaItem[], filters: MediaFilters, now: number): MediaItem[] {
|
||||||
const rangeMs = {
|
const rangeMs = {
|
||||||
all: null,
|
all: null,
|
||||||
@@ -23,11 +29,13 @@ export function filterMedia(items: MediaItem[], filters: MediaFilters, now: numb
|
|||||||
'30d': 30 * 86_400_000,
|
'30d': 30 * 86_400_000,
|
||||||
}[filters.dateRange];
|
}[filters.dateRange];
|
||||||
|
|
||||||
return items.filter((item) => {
|
return items
|
||||||
|
.filter((item) => {
|
||||||
if (filters.conversation !== 'all' && mediaConversation(item) !== filters.conversation) return false;
|
if (filters.conversation !== 'all' && mediaConversation(item) !== filters.conversation) return false;
|
||||||
if (filters.sender !== 'all' && item.sender !== filters.sender) return false;
|
if (filters.sender !== 'all' && item.sender !== filters.sender) return false;
|
||||||
if (filters.type !== 'all' && mediaType(item) !== filters.type) return false;
|
if (filters.type !== 'all' && mediaType(item) !== filters.type) return false;
|
||||||
if (rangeMs !== null && item.timestamp < now - rangeMs) return false;
|
if (rangeMs !== null && item.timestamp < now - rangeMs) return false;
|
||||||
return true;
|
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 {
|
interface FullSyncDialogProps {
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
@@ -7,27 +7,24 @@ interface FullSyncDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FullSyncDialog({ isPending, onCancel, onConfirm }: FullSyncDialogProps) {
|
export function FullSyncDialog({ isPending, onCancel, onConfirm }: FullSyncDialogProps) {
|
||||||
const cancelButtonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
cancelButtonRef.current?.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div aria-labelledby="full-sync-title" aria-modal="true" className="dialog-backdrop" role="dialog">
|
<Modal
|
||||||
<div className="dialog-card">
|
className="dialog-card"
|
||||||
|
isDismissLocked={isPending}
|
||||||
|
labelledBy="full-sync-title"
|
||||||
|
onClose={onCancel}
|
||||||
|
>
|
||||||
<span className="eyebrow">Confirm expensive action</span>
|
<span className="eyebrow">Confirm expensive action</span>
|
||||||
<h2 id="full-sync-title">Run full media sync?</h2>
|
<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>
|
<p>This scans every conversation and can take several minutes. Routine refreshes should use Sync new media.</p>
|
||||||
<div className="dialog-actions">
|
<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
|
Cancel
|
||||||
</button>
|
</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'}
|
{isPending ? 'Syncing…' : 'Run full sync'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { MediaItem } from '../../../../../packages/api-contracts/src/index.js';
|
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 { 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 {
|
interface MediaCardProps {
|
||||||
item: MediaItem;
|
item: MediaItem;
|
||||||
@@ -14,24 +17,32 @@ function formatMediaDate(timestamp: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MediaCard({ item, isRevealed, onToggleReveal, onOpenPreview }: MediaCardProps) {
|
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 (
|
return (
|
||||||
<article className="media-card">
|
<article className="media-card">
|
||||||
<div className="media-preview">
|
<div className="media-preview">
|
||||||
<button
|
<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"
|
className="media-preview-button"
|
||||||
disabled={!isRevealed}
|
onClick={() => {
|
||||||
onClick={onOpenPreview}
|
if (isRevealed) onOpenPreview();
|
||||||
|
else onToggleReveal();
|
||||||
|
}}
|
||||||
type="button"
|
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>
|
</button>
|
||||||
{!isRevealed ? <span className="privacy-label">Private media hidden</span> : null}
|
{!isRevealed ? <span className="privacy-label">Private media hidden</span> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="media-card-body">
|
<div className="media-card-body">
|
||||||
|
<AccountOwnerBadge owner={item.owner} />
|
||||||
<div>
|
<div>
|
||||||
<strong><RedditUsernameLink showPrefix={false} username={item.sender} /></strong>
|
<strong><span className="media-link-prefix">From</span> <RedditUsernameLink showPrefix={false} username={item.sender} /></strong>
|
||||||
<span>{item.chatUsername
|
<span><span className="media-link-prefix">Conversation</span> {item.chatUsername
|
||||||
? <RedditUsernameLink showPrefix={false} username={item.chatUsername} />
|
? <RedditUsernameLink showPrefix={false} username={item.chatUsername} />
|
||||||
: mediaConversation(item)}</span>
|
: mediaConversation(item)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,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>Type</dt><dd>{mediaType(item) === 'gif' ? 'GIF' : 'Image'}</dd></div>
|
||||||
<div><dt>Received</dt><dd>{formatMediaDate(item.timestamp)}</dd></div>
|
<div><dt>Received</dt><dd>{formatMediaDate(item.timestamp)}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
|
{conversationPath ? (
|
||||||
|
<Link className="button secondary media-conversation-link" to={conversationPath}>
|
||||||
|
Open conversation
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
|
aria-pressed={isRevealed}
|
||||||
className="button secondary media-reveal"
|
className="button secondary media-reveal"
|
||||||
onClick={onToggleReveal}
|
onClick={onToggleReveal}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { filterMedia, mediaConversation, type MediaFilters } from './filters.js';
|
import { filterMedia, mediaAlt, mediaConversation, type MediaFilters } from './filters.js';
|
||||||
import { FullSyncDialog } from './full-sync-dialog.js';
|
import { FullSyncDialog } from './full-sync-dialog.js';
|
||||||
import { MediaCard } from './media-card.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 { useMediaQuery, useMediaSyncStatusQuery, useTriggerMediaSyncMutation } from './queries.js';
|
||||||
import './media.css';
|
import './media.css';
|
||||||
|
|
||||||
@@ -13,6 +13,9 @@ export const initialMediaFilters: MediaFilters = {
|
|||||||
dateRange: 'all',
|
dateRange: 'all',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PAGE_SIZE = 48;
|
||||||
|
const SUCCESS_NOTICE_MS = 5_000;
|
||||||
|
|
||||||
function errorMessage(error: Error | null): string {
|
function errorMessage(error: Error | null): string {
|
||||||
return error?.message ?? 'Something went wrong.';
|
return error?.message ?? 'Something went wrong.';
|
||||||
}
|
}
|
||||||
@@ -36,9 +39,11 @@ export function MediaPage({ filters: controlledFilters, onFiltersChange }: Media
|
|||||||
const [isRevealAll, setIsRevealAll] = useState(false);
|
const [isRevealAll, setIsRevealAll] = useState(false);
|
||||||
const [isFullSyncDialogOpen, setIsFullSyncDialogOpen] = useState(false);
|
const [isFullSyncDialogOpen, setIsFullSyncDialogOpen] = useState(false);
|
||||||
const [previewItemId, setPreviewItemId] = useState<string | null>(null);
|
const [previewItemId, setPreviewItemId] = useState<string | null>(null);
|
||||||
|
const [visibleLimit, setVisibleLimit] = useState(PAGE_SIZE);
|
||||||
|
|
||||||
const items = mediaQuery.data ?? [];
|
const items = mediaQuery.data ?? [];
|
||||||
const visibleItems = useMemo(() => filterMedia(items, filters, Date.now()), [items, filters]);
|
const visibleItems = useMemo(() => filterMedia(items, filters, Date.now()), [items, filters]);
|
||||||
|
const displayedItems = visibleItems.slice(0, visibleLimit);
|
||||||
const conversations = useMemo(
|
const conversations = useMemo(
|
||||||
() => [...new Set(items.map(mediaConversation))].sort((left, right) => left.localeCompare(right)),
|
() => [...new Set(items.map(mediaConversation))].sort((left, right) => left.localeCompare(right)),
|
||||||
[items],
|
[items],
|
||||||
@@ -48,12 +53,37 @@ export function MediaPage({ filters: controlledFilters, onFiltersChange }: Media
|
|||||||
[items],
|
[items],
|
||||||
);
|
);
|
||||||
const isSyncBusy = statusQuery.data?.isSyncing === true || syncMutation.isPending;
|
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 isSyncSuccess = syncMutation.isSuccess;
|
||||||
const next = { ...filters, [key]: value };
|
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);
|
if (controlledFilters === undefined) setLocalFilters(next);
|
||||||
onFiltersChange?.(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) {
|
function toggleItem(itemId: string) {
|
||||||
@@ -90,7 +120,15 @@ export function MediaPage({ filters: controlledFilters, onFiltersChange }: Media
|
|||||||
<div className="page-state error-state" role="alert">
|
<div className="page-state error-state" role="alert">
|
||||||
<strong>Media unavailable</strong>
|
<strong>Media unavailable</strong>
|
||||||
<span>{errorMessage(mediaQuery.error)}</span>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -106,11 +144,9 @@ export function MediaPage({ filters: controlledFilters, onFiltersChange }: Media
|
|||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
{items.length > 0 ? (
|
{items.length > 0 ? (
|
||||||
<button
|
<button
|
||||||
|
aria-pressed={isRevealAll}
|
||||||
className="button secondary"
|
className="button secondary"
|
||||||
onClick={() => {
|
onClick={() => setIsRevealAll((current) => !current)}
|
||||||
setIsRevealAll((current) => !current);
|
|
||||||
setRevealedIds(new Set());
|
|
||||||
}}
|
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{isRevealAll ? 'Blur all media' : 'Reveal all media'}
|
{isRevealAll ? 'Blur all media' : 'Reveal all media'}
|
||||||
@@ -127,7 +163,7 @@ export function MediaPage({ filters: controlledFilters, onFiltersChange }: Media
|
|||||||
>
|
>
|
||||||
Full sync
|
Full sync
|
||||||
</button>
|
</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'}
|
{isSyncBusy ? 'Syncing…' : 'Sync new media'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,7 +174,15 @@ export function MediaPage({ filters: controlledFilters, onFiltersChange }: Media
|
|||||||
{statusQuery.isError ? (
|
{statusQuery.isError ? (
|
||||||
<div role="alert">
|
<div role="alert">
|
||||||
<span>{errorMessage(statusQuery.error)}</span>
|
<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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{statusQuery.data ? (
|
{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>
|
<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>
|
</select>
|
||||||
</label>
|
</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>
|
</section>
|
||||||
|
|
||||||
{visibleItems.length === 0 ? (
|
{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">
|
<section aria-label="Media gallery" className="media-grid">
|
||||||
{visibleItems.map((item) => (
|
{displayedItems.map((item) => (
|
||||||
<MediaCard
|
<MediaCard
|
||||||
isRevealed={isRevealAll || revealedIds.has(item.id)}
|
isRevealed={isRevealAll || revealedIds.has(item.id)}
|
||||||
item={item}
|
item={item}
|
||||||
@@ -204,6 +263,14 @@ export function MediaPage({ filters: controlledFilters, onFiltersChange }: Media
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</section>
|
</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)}
|
onConfirm={() => runSync(true)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,37 +1,119 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { MediaItem } from '../../../../../packages/api-contracts/src/index.js';
|
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 {
|
interface MediaPreviewDialogProps {
|
||||||
item: MediaItem;
|
entries: MediaPreviewEntry[];
|
||||||
|
index: number;
|
||||||
|
onNavigate?(index: number): void;
|
||||||
onClose(): 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(() => {
|
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) {
|
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);
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
return () => document.removeEventListener('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 (
|
return (
|
||||||
<div
|
<Modal className="media-preview-dialog" closeOnBackdrop label={entry.label} onClose={onClose}>
|
||||||
aria-label={`Media from ${item.sender}`}
|
|
||||||
aria-modal="true"
|
|
||||||
className="media-preview-dialog-backdrop"
|
|
||||||
onClick={(event) => {
|
|
||||||
if (event.target === event.currentTarget) onClose();
|
|
||||||
}}
|
|
||||||
role="dialog"
|
|
||||||
>
|
|
||||||
<div className="media-preview-dialog-content">
|
<div className="media-preview-dialog-content">
|
||||||
<img alt={`Media from ${item.sender}`} className="media-preview-dialog-image" src={item.urlPath} />
|
{!isLoaded ? (
|
||||||
<button aria-label="Close media preview" className="media-preview-dialog-close" onClick={onClose} ref={closeButtonRef} type="button">×</button>
|
<span className="media-preview-dialog-loading" role="status">
|
||||||
|
<span className="spinner" />
|
||||||
|
<span className="sr-only">Loading full-size media</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<img
|
||||||
|
alt={entry.alt}
|
||||||
|
className={isLoaded ? 'media-preview-dialog-image' : 'media-preview-dialog-image is-loading'}
|
||||||
|
onLoad={() => setIsLoaded(true)}
|
||||||
|
src={entry.src}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
aria-label="Close media preview"
|
||||||
|
autoFocus
|
||||||
|
className="media-preview-dialog-close"
|
||||||
|
onClick={onClose}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
{canNavigate ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
aria-label="Previous media"
|
||||||
|
className="media-preview-dialog-nav is-previous"
|
||||||
|
disabled={!hasPrevious}
|
||||||
|
onClick={goPrevious}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="Next media"
|
||||||
|
className="media-preview-dialog-nav is-next"
|
||||||
|
disabled={!hasNext}
|
||||||
|
onClick={goNext}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<div className="media-preview-dialog-caption">
|
||||||
|
<strong>{entry.captionTitle}</strong>
|
||||||
|
{entry.captionSubtitle ? <span>{entry.captionSubtitle}</span> : null}
|
||||||
|
{canNavigate ? <span>{index + 1} of {entries.length}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,13 +3,15 @@
|
|||||||
.media-sync-status > div[role="alert"] { width: 100%; justify-content: space-between; }
|
.media-sync-status > div[role="alert"] { width: 100%; justify-content: space-between; }
|
||||||
.media-sync-status .spinner { width: 1rem; height: 1rem; }
|
.media-sync-status .spinner { width: 1rem; height: 1rem; }
|
||||||
.media-notice { padding: .8rem 1rem; margin-bottom: 1rem; border-radius: .75rem; font-weight: 700; }
|
.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.success { color: var(--success-text); background: var(--success-bg); border: 1px solid var(--success-border); }
|
||||||
.media-notice.error { color: #ffd1ce; background: #461b18; border: 1px solid #80352f; }
|
.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 { 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 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 { 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-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-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 { 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; }
|
.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 { 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 { display: flex; justify-content: space-between; gap: .75rem; }
|
||||||
.media-card-body > div:first-child span { color: var(--muted); }
|
.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 { display: grid; gap: .4rem; margin: 0; }
|
||||||
.media-card-body dl div { display: flex; justify-content: space-between; gap: .75rem; }
|
.media-card-body dl div { display: flex; justify-content: space-between; gap: .75rem; }
|
||||||
.media-card-body dt { color: var(--muted); }
|
.media-card-body dt { color: var(--muted); }
|
||||||
.media-card-body dd { margin: 0; text-align: right; }
|
.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-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-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 { 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-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 (min-width: 40rem) {
|
||||||
.media-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.media-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useEffect, useReducer, useRef, useState } from 'react';
|
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 { useDashboardRealtime } from '../../app/realtime-provider.js';
|
||||||
|
import { Modal } from '../../components/modal.js';
|
||||||
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
||||||
import { RedditUserText } from '../../components/reddit-user-text.js';
|
import { RedditUserText } from '../../components/reddit-user-text.js';
|
||||||
import { OriginPostButton } from '../../components/origin-post.js';
|
import { OriginPostButton } from '../../components/origin-post.js';
|
||||||
|
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
||||||
import {
|
import {
|
||||||
createLiveConsoleState,
|
createLiveConsoleState,
|
||||||
reduceLiveConsoleState,
|
reduceLiveConsoleState,
|
||||||
@@ -27,9 +29,10 @@ function ActivityView({ items }: { items: readonly ActivityItem[] }) {
|
|||||||
<p className="feature-empty">No recent operator activity.</p>
|
<p className="feature-empty">No recent operator activity.</p>
|
||||||
) : (
|
) : (
|
||||||
<ol className="operations-activity-list">
|
<ol className="operations-activity-list">
|
||||||
{items.map((item) => (
|
{items.map((item, index) => (
|
||||||
<li key={`${item.timestamp}-${item.message}`}>
|
<li key={`${item.timestamp}-${index}`}>
|
||||||
<span className={`activity-marker ${item.type}`} aria-hidden="true" />
|
<span className={`activity-marker ${item.type}`} aria-hidden="true" />
|
||||||
|
<AccountOwnerBadge owner={item.owner} />
|
||||||
<div><strong>{item.postUrl && item.postTitle ? (
|
<div><strong>{item.postUrl && item.postTitle ? (
|
||||||
<OriginPostButton post={{ title: item.postTitle, url: item.postUrl, imageUrl: item.imageUrl }}>
|
<OriginPostButton post={{ title: item.postTitle, url: item.postUrl, imageUrl: item.imageUrl }}>
|
||||||
{item.message}
|
{item.message}
|
||||||
@@ -70,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 {
|
export interface OperationsPageProps {
|
||||||
realtimeEnabled?: boolean;
|
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) {
|
export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps) {
|
||||||
const realtime = useDashboardRealtime();
|
const realtime = useDashboardRealtime();
|
||||||
const activityQuery = useActivityQuery();
|
const activityQuery = useActivityQuery();
|
||||||
@@ -82,12 +129,16 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps)
|
|||||||
const syncMutation = useTriggerChatSyncMutation();
|
const syncMutation = useTriggerChatSyncMutation();
|
||||||
const fetchAllMutation = useFetchAllChatsMutation();
|
const fetchAllMutation = useFetchAllChatsMutation();
|
||||||
const [consoleClearNotice, setConsoleClearNotice] = useState(false);
|
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(
|
const [consoleState, dispatchConsole] = useReducer(
|
||||||
reduceLiveConsoleState,
|
reduceLiveConsoleState,
|
||||||
createLiveConsoleState(500),
|
createLiveConsoleState(500),
|
||||||
);
|
);
|
||||||
const connection = realtimeEnabled ? realtime.connection : { status: 'idle', isStale: true } as const;
|
const connection = realtimeEnabled ? realtime.connection : { status: 'idle', isStale: true } as const;
|
||||||
const consoleEnd = useRef<HTMLDivElement>(null);
|
const consoleEnd = useRef<HTMLDivElement>(null);
|
||||||
|
const consoleViewport = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
logsQuery.data?.forEach((entry, index) => {
|
logsQuery.data?.forEach((entry, index) => {
|
||||||
@@ -124,22 +175,54 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps)
|
|||||||
const visibleEntries = selectVisibleConsoleEntries(consoleState);
|
const visibleEntries = selectVisibleConsoleEntries(consoleState);
|
||||||
const syncBusy = syncQuery.data?.syncing === true || syncMutation.isPending || fetchAllMutation.isPending;
|
const syncBusy = syncQuery.data?.syncing === true || syncMutation.isPending || fetchAllMutation.isPending;
|
||||||
|
|
||||||
function confirmAndRun(kind: 'full' | 'fetch-all') {
|
function runExpensiveSync(kind: 'full' | 'fetch-all') {
|
||||||
const confirmed = globalThis.confirm(kind === 'full'
|
setPendingSyncConfirm(null);
|
||||||
? '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;
|
|
||||||
if (kind === 'full') syncMutation.mutate('full'); else fetchAllMutation.mutate();
|
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(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
consoleState.isAutoScrollEnabled &&
|
consoleState.isAutoScrollEnabled &&
|
||||||
|
isPinnedToBottom &&
|
||||||
!consoleState.isPaused &&
|
!consoleState.isPaused &&
|
||||||
typeof consoleEnd.current?.scrollIntoView === 'function'
|
typeof consoleEnd.current?.scrollIntoView === 'function'
|
||||||
) {
|
) {
|
||||||
consoleEnd.current.scrollIntoView({ block: 'nearest' });
|
consoleEnd.current.scrollIntoView({ block: 'nearest' });
|
||||||
}
|
}
|
||||||
}, [consoleState.entries, consoleState.isAutoScrollEnabled, consoleState.isPaused]);
|
}, [consoleState.entries, consoleState.isAutoScrollEnabled, consoleState.isPaused, isPinnedToBottom]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page operations-page">
|
<div className="page operations-page">
|
||||||
@@ -155,7 +238,7 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps)
|
|||||||
</section>
|
</section>
|
||||||
<section aria-labelledby="sync-jobs-heading" className="panel feature-panel" role="region">
|
<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>
|
<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}
|
{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}
|
{fetchAllMutation.isSuccess ? <p role="status">Fetched {fetchAllMutation.data.count} chats.</p> : null}
|
||||||
{syncMutation.isError ? <p role="alert">{syncMutation.error.message}</p> : null}
|
{syncMutation.isError ? <p role="alert">{syncMutation.error.message}</p> : null}
|
||||||
@@ -163,22 +246,52 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps)
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<section aria-labelledby="live-console-heading" className="panel console-panel" role="region">
|
<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">
|
<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>
|
<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>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>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>
|
<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>
|
</div>
|
||||||
{consoleClearNotice ? <p role="status">Cleared from this browser view. Server logs were not deleted.</p> : null}
|
{consoleClearNotice ? <p role="status">Cleared from this browser view. Server logs were not deleted.</p> : null}
|
||||||
<div aria-live="polite" className="live-console" role="log">
|
<p aria-live="polite" className="sr-only" role="status">{pauseAnnouncement}</p>
|
||||||
{logsQuery.isPending ? <p>Loading logs…</p> : visibleEntries.length === 0 ? <p>No logs match the current filters.</p> : visibleEntries.map((entry) => (
|
<div className="live-console" onScroll={handleConsoleScroll} ref={consoleViewport} role="log">
|
||||||
<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>
|
{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 ref={consoleEnd} />
|
||||||
</div>
|
</div>
|
||||||
|
{!isPinnedToBottom ? (
|
||||||
|
<button className="button secondary console-jump" onClick={jumpToLatest} type="button">Jump to latest</button>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
{pendingSyncConfirm ? (
|
||||||
|
<ConfirmSyncDialog
|
||||||
|
kind={pendingSyncConfirm}
|
||||||
|
onCancel={() => setPendingSyncConfirm(null)}
|
||||||
|
onConfirm={() => runExpensiveSync(pendingSyncConfirm)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
.operations-page { display: grid; gap: .8rem; }
|
.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 { 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: #ffe1a0; background: #2a2111; border-color: #59451e; }
|
.connection-banner.stale { color: var(--warning-text); background: var(--warning-bg); border-color: var(--warning-border); }
|
||||||
.operations-summary-grid { display: grid; gap: .8rem; }
|
.operations-summary-grid { display: grid; gap: .8rem; }
|
||||||
.operations-activity-list { display: grid; gap: .8rem; padding: 0; margin: 0; list-style: none; }
|
.operations-activity-list { display: grid; gap: .8rem; padding: 0; margin: 0; list-style: none; }
|
||||||
.operations-activity-list li { display: flex; gap: .75rem; }
|
.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 { display: flex; flex-direction: row; align-items: center; }
|
||||||
.console-controls .checkbox-control input { min-height: auto; }
|
.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; }
|
.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 time { color: #708178; }
|
||||||
.console-entry strong { color: #8da49a; }
|
.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.warn strong { color: var(--warning); }
|
||||||
.console-entry.error strong { color: var(--danger); }
|
.console-entry.error strong { color: var(--danger); }
|
||||||
.live-console > p { color: var(--muted); text-align: center; }
|
.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) {
|
@media (min-width: 56rem) {
|
||||||
.operations-summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.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; }
|
.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',
|
'message.failed',
|
||||||
'conversation.updated',
|
'conversation.updated',
|
||||||
'reply.received',
|
'reply.received',
|
||||||
|
'media.received',
|
||||||
'sync.started',
|
'sync.started',
|
||||||
'sync.progress',
|
'sync.progress',
|
||||||
'sync.completed',
|
'sync.completed',
|
||||||
@@ -122,6 +123,8 @@ export function queryKeysForDashboardEvent(event: DashboardEvent): readonly Quer
|
|||||||
case 'conversation.updated':
|
case 'conversation.updated':
|
||||||
case 'reply.received':
|
case 'reply.received':
|
||||||
return [['conversations'], ['operations', 'activity']];
|
return [['conversations'], ['operations', 'activity']];
|
||||||
|
case 'media.received':
|
||||||
|
return [['conversations'], ['media'], ['operations', 'activity']];
|
||||||
case 'sync.started':
|
case 'sync.started':
|
||||||
case 'sync.progress':
|
case 'sync.progress':
|
||||||
return [['operations', 'sync-status']];
|
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,11 +1,21 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { dashboardQueryKeys, useSetRunningMutation, useStatsQuery, useTimersQuery } from '../../api/queries.js';
|
import { dashboardQueryKeys, useSetModeMutation, useStatsQuery, useTimersQuery } from '../../api/queries.js';
|
||||||
import type { BotStats, Timers } from '../../api/contracts.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 { ConfirmationDialog } from '../../components/confirmation-dialog.js';
|
||||||
import { RedditUserText } from '../../components/reddit-user-text.js';
|
import { RedditUserText } from '../../components/reddit-user-text.js';
|
||||||
import { OriginPostButton } from '../../components/origin-post.js';
|
import { OriginPostButton } from '../../components/origin-post.js';
|
||||||
import { RecentMessagesPanel } from './recent-messages.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 {
|
function errorMessage(error: Error | null): string {
|
||||||
return error?.message ?? 'Something went wrong.';
|
return error?.message ?? 'Something went wrong.';
|
||||||
@@ -15,6 +25,27 @@ function formatTime(timestamp: number): string {
|
|||||||
return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(timestamp);
|
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 }) {
|
function MetricCard({ label, value, detail, metric }: { label: string; value: string | number; detail: string; metric: string }) {
|
||||||
return (
|
return (
|
||||||
<article className="metric-card">
|
<article className="metric-card">
|
||||||
@@ -26,7 +57,8 @@ function MetricCard({ label, value, detail, metric }: { label: string; value: st
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TimersPanel({ timers }: { timers: Timers }) {
|
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 (
|
return (
|
||||||
<section aria-labelledby="timers-heading" className="panel">
|
<section aria-labelledby="timers-heading" className="panel">
|
||||||
<div className="panel-heading">
|
<div className="panel-heading">
|
||||||
@@ -39,7 +71,9 @@ function TimersPanel({ timers }: { timers: Timers }) {
|
|||||||
<dl className="definition-list">
|
<dl className="definition-list">
|
||||||
<div>
|
<div>
|
||||||
<dt>Next message</dt>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<dt>Hourly allowance</dt>
|
<dt>Hourly allowance</dt>
|
||||||
@@ -54,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 }) {
|
function ActivityPanel({ stats }: { stats: BotStats }) {
|
||||||
return (
|
return (
|
||||||
<section aria-labelledby="activity-heading" className="panel activity-panel">
|
<section aria-labelledby="activity-heading" className="panel activity-panel">
|
||||||
@@ -70,14 +163,14 @@ function ActivityPanel({ stats }: { stats: BotStats }) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ol className="activity-list">
|
<ol className="activity-list">
|
||||||
{stats.recentActivity.map((item) => (
|
{stats.recentActivity.map((item, index) => (
|
||||||
<li key={`${item.timestamp}-${item.message}`}>
|
<li key={`${item.timestamp}-${index}`}>
|
||||||
<span className={`activity-marker ${item.type}`} aria-hidden="true" />
|
<span className={`activity-marker ${item.type}`} aria-hidden="true" />
|
||||||
<div><strong>{item.postUrl && item.postTitle ? (
|
<div><strong>{item.postUrl && item.postTitle ? (
|
||||||
<OriginPostButton post={{ title: item.postTitle, url: item.postUrl, imageUrl: item.imageUrl }}>
|
<OriginPostButton post={{ title: item.postTitle, url: item.postUrl, imageUrl: item.imageUrl }}>
|
||||||
{item.message}
|
{item.message}
|
||||||
</OriginPostButton>
|
</OriginPostButton>
|
||||||
) : <RedditUserText>{item.message}</RedditUserText>}</strong><small>{formatTime(item.timestamp)}</small></div>
|
) : <RedditUserText>{item.message}</RedditUserText>}</strong><AccountOwnerBadge owner={item.owner} /><small>{formatTime(item.timestamp)}</small></div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
@@ -90,8 +183,8 @@ export function OverviewPage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const statsQuery = useStatsQuery();
|
const statsQuery = useStatsQuery();
|
||||||
const timersQuery = useTimersQuery();
|
const timersQuery = useTimersQuery();
|
||||||
const controlMutation = useSetRunningMutation();
|
const controlMutation = useSetModeMutation();
|
||||||
const [confirmationAction, setConfirmationAction] = useState<'start' | 'stop' | null>(null);
|
const [confirmationAction, setConfirmationAction] = useState<BotMode | null>(null);
|
||||||
const isRefreshing = (statsQuery.isFetching || timersQuery.isFetching) && !statsQuery.isPending;
|
const isRefreshing = (statsQuery.isFetching || timersQuery.isFetching) && !statsQuery.isPending;
|
||||||
|
|
||||||
async function refreshOverview() {
|
async function refreshOverview() {
|
||||||
@@ -103,12 +196,21 @@ export function OverviewPage() {
|
|||||||
|
|
||||||
function confirmControl() {
|
function confirmControl() {
|
||||||
if (!confirmationAction) return;
|
if (!confirmationAction) return;
|
||||||
controlMutation.mutate(confirmationAction === 'start', {
|
controlMutation.mutate(confirmationAction, {
|
||||||
onSuccess: () => setConfirmationAction(null),
|
onSuccess: () => setConfirmationAction(null),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (statsQuery.isPending) {
|
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>;
|
return <div aria-label="Loading overview" className="page-state" role="status"><span className="spinner" />Loading overview…</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +225,8 @@ export function OverviewPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stats = statsQuery.data;
|
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 (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
@@ -135,6 +238,7 @@ export function OverviewPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
<button
|
<button
|
||||||
|
aria-busy={isRefreshing ? 'true' : undefined}
|
||||||
aria-label={isRefreshing ? 'Refreshing overview' : 'Refresh overview'}
|
aria-label={isRefreshing ? 'Refreshing overview' : 'Refresh overview'}
|
||||||
className="button secondary"
|
className="button secondary"
|
||||||
disabled={isRefreshing}
|
disabled={isRefreshing}
|
||||||
@@ -143,21 +247,23 @@ export function OverviewPage() {
|
|||||||
>
|
>
|
||||||
{isRefreshing ? 'Refreshing…' : 'Refresh'}
|
{isRefreshing ? 'Refreshing…' : 'Refresh'}
|
||||||
</button>
|
</button>
|
||||||
|
{offeredModes.map((mode) => (
|
||||||
<button
|
<button
|
||||||
aria-label={`${action === 'start' ? 'Start' : 'Stop'} bot`}
|
className={modeCopy[mode].buttonTone}
|
||||||
className={action === 'start' ? 'button primary' : 'button danger'}
|
key={mode}
|
||||||
onClick={() => setConfirmationAction(action)}
|
onClick={() => setConfirmationAction(mode)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{action === 'start' ? 'Start bot' : 'Stop bot'}
|
{modeCopy[mode].action}
|
||||||
</button>
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section aria-label="Bot state" className="status-strip">
|
<section aria-label="Bot state" className="status-strip">
|
||||||
<div>
|
<div>
|
||||||
<span className={`status-dot ${stats.isRunning ? 'running' : 'stopped'}`} aria-hidden="true" />
|
<span className={`status-dot ${modeCopy[stats.mode].dot}`} aria-hidden="true" />
|
||||||
<div><small>Bot status</small><strong data-bot-status>{stats.isRunning ? 'Running' : 'Stopped'}</strong></div>
|
<div><small>Bot status</small><strong data-bot-status>{modeCopy[stats.mode].label}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
{stats.dryRunMode ? <span className="pill neutral">Dry run</span> : null}
|
{stats.dryRunMode ? <span className="pill neutral">Dry run</span> : null}
|
||||||
<span className="status-note">{stats.messagesThisHour} of {stats.maxMessagesPerHour} messages used this hour</span>
|
<span className="status-note">{stats.messagesThisHour} of {stats.maxMessagesPerHour} messages used this hour</span>
|
||||||
@@ -165,18 +271,22 @@ export function OverviewPage() {
|
|||||||
|
|
||||||
{stats.invitationCooldown ? (
|
{stats.invitationCooldown ? (
|
||||||
<div className="cooldown-banner" role="status">
|
<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>
|
<span>{stats.invitationCooldown.reason}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<section aria-label="Key metrics" className="metrics-grid">
|
<section aria-label="Key metrics" className="metrics-grid">
|
||||||
<MetricCard detail="All recorded sends" label="Messages sent" metric="messages-sent" value={stats.totalMessagesSent} />
|
<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={stats.totalResponses} />
|
<MetricCard detail={`${stats.responseRate}% response rate`} label="Responses" metric="responses" value={formatMetricValue(stats.totalResponses)} />
|
||||||
<MetricCard detail="Posts evaluated" label="Processed" metric="processed" value={stats.totalProcessed} />
|
<MetricCard detail="Posts evaluated" label="Processed" metric="processed" value={formatMetricValue(stats.totalProcessed)} />
|
||||||
<MetricCard detail={`Limit ${stats.maxMessagesPerHour}`} label="This hour" metric="messages-hour" value={stats.messagesThisHour} />
|
<MetricCard detail={`Limit ${formatMetricValue(stats.maxMessagesPerHour)}`} label="This hour" metric="messages-hour" value={formatMetricValue(stats.messagesThisHour)} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<AccountStatusPanel accounts={stats.accounts} />
|
||||||
|
|
||||||
<div className="overview-grid">
|
<div className="overview-grid">
|
||||||
{timersQuery.isPending ? (
|
{timersQuery.isPending ? (
|
||||||
<section aria-label="Loading timers" className="panel panel-loading" role="status"><span className="spinner" />Loading sending window…</section>
|
<section aria-label="Loading timers" className="panel panel-loading" role="status"><span className="spinner" />Loading sending window…</section>
|
||||||
@@ -193,12 +303,12 @@ export function OverviewPage() {
|
|||||||
</div>
|
</div>
|
||||||
<RecentMessagesPanel />
|
<RecentMessagesPanel />
|
||||||
|
|
||||||
{controlMutation.isError ? <div className="toast error" role="alert">{errorMessage(controlMutation.error)}</div> : null}
|
|
||||||
{confirmationAction ? (
|
{confirmationAction ? (
|
||||||
<ConfirmationDialog
|
<ConfirmationDialog
|
||||||
action={confirmationAction}
|
action={confirmationAction}
|
||||||
|
errorMessage={controlMutation.isError ? errorMessage(controlMutation.error) : null}
|
||||||
isPending={controlMutation.isPending}
|
isPending={controlMutation.isPending}
|
||||||
onCancel={() => setConfirmationAction(null)}
|
onCancel={() => { setConfirmationAction(null); controlMutation.reset(); }}
|
||||||
onConfirm={confirmControl}
|
onConfirm={confirmControl}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { MessagesResponseSchema, type SentMessage } from '../../../../../packages/api-contracts/src/index.js';
|
import { MessagesResponseSchema, type SentMessage } from '../../../../../packages/api-contracts/src/index.js';
|
||||||
import { dashboardRequest } from '../../api/request.js';
|
import { dashboardRequest } from '../../api/request.js';
|
||||||
|
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
||||||
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
||||||
|
|
||||||
async function loadRecentMessages(): Promise<SentMessage[]> {
|
async function loadRecentMessages(): Promise<SentMessage[]> {
|
||||||
@@ -19,13 +20,24 @@ export function RecentMessagesPanel() {
|
|||||||
return (
|
return (
|
||||||
<section aria-labelledby="recent-messages-heading" className="panel">
|
<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>
|
<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.isPending ? <div className="panel-loading" role="status"><span className="spinner" />Loading recent messages…</div> : 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.isError ? (
|
||||||
{query.data?.length === 0 ? <p>No sent messages recorded yet.</p> : null}
|
<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 ? (
|
{query.data && query.data.length > 0 ? (
|
||||||
<ol aria-label="Recent sent messages" className="activity-list">
|
<ol aria-label="Recent sent messages" className="activity-list">
|
||||||
{query.data.map((message) => (
|
{query.data.map((message) => (
|
||||||
<li key={message.id}><div><strong><RedditUsernameLink username={message.recipient} /></strong><span>{message.message}</span><small>{formatSentAt(message.sentAt)} · {message.gotResponse ? 'Responded' : 'No response recorded'}</small></div></li>
|
<li key={message.id}><div><strong><RedditUsernameLink username={message.recipient} /></strong><AccountOwnerBadge owner={message.owner} /><span>{message.message}</span><small>{formatSentAt(message.sentAt)} · {message.gotResponse ? 'Responded' : 'No response recorded'}</small></div></li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
StrategyPerformanceResponseSchema,
|
StrategyPerformanceResponseSchema,
|
||||||
SubredditPerformanceResponseSchema,
|
SubredditPerformanceResponseSchema,
|
||||||
SuccessfulExamplesResponseSchema,
|
SuccessfulExamplesResponseSchema,
|
||||||
|
FunnelResponseSchema,
|
||||||
|
AiUsageSummaryResponseSchema,
|
||||||
} from '../../../../../packages/api-contracts/src/index.js';
|
} from '../../../../../packages/api-contracts/src/index.js';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
import { createDashboardRequest } from '../../api/request.js';
|
import { createDashboardRequest } from '../../api/request.js';
|
||||||
@@ -11,6 +13,8 @@ type SubredditPerformanceResponse = z.infer<typeof SubredditPerformanceResponseS
|
|||||||
type StrategyPerformanceResponse = z.infer<typeof StrategyPerformanceResponseSchema>;
|
type StrategyPerformanceResponse = z.infer<typeof StrategyPerformanceResponseSchema>;
|
||||||
type DailyTrendResponse = z.infer<typeof DailyTrendResponseSchema>;
|
type DailyTrendResponse = z.infer<typeof DailyTrendResponseSchema>;
|
||||||
type SuccessfulExamplesResponse = z.infer<typeof SuccessfulExamplesResponseSchema>;
|
type SuccessfulExamplesResponse = z.infer<typeof SuccessfulExamplesResponseSchema>;
|
||||||
|
type FunnelResponse = z.infer<typeof FunnelResponseSchema>;
|
||||||
|
type AiUsageSummaryResponse = z.infer<typeof AiUsageSummaryResponseSchema>;
|
||||||
|
|
||||||
async function parseResponse<T>(
|
async function parseResponse<T>(
|
||||||
response: Response,
|
response: Response,
|
||||||
@@ -35,6 +39,8 @@ export interface PerformanceApi {
|
|||||||
getStrategyPerformance(): Promise<StrategyPerformanceResponse>;
|
getStrategyPerformance(): Promise<StrategyPerformanceResponse>;
|
||||||
getDailyTrend(days: number): Promise<DailyTrendResponse>;
|
getDailyTrend(days: number): Promise<DailyTrendResponse>;
|
||||||
getSuccessfulExamples(): Promise<SuccessfulExamplesResponse>;
|
getSuccessfulExamples(): Promise<SuccessfulExamplesResponse>;
|
||||||
|
getFunnel(): Promise<FunnelResponse>;
|
||||||
|
getAiUsage(days: number): Promise<AiUsageSummaryResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPerformanceApi(
|
export function createPerformanceApi(
|
||||||
@@ -66,6 +72,18 @@ export function createPerformanceApi(
|
|||||||
SuccessfulExamplesResponseSchema,
|
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;
|
} 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 { z } from 'zod';
|
||||||
import type {
|
import type {
|
||||||
DailyTrendResponseSchema,
|
DailyTrendResponseSchema,
|
||||||
StrategyPerformanceResponseSchema,
|
StrategyPerformanceResponseSchema,
|
||||||
SubredditPerformanceResponseSchema,
|
SubredditPerformanceResponseSchema,
|
||||||
SuccessfulExample,
|
SuccessfulExample,
|
||||||
|
FunnelResponseSchema,
|
||||||
|
AiUsageSummaryResponseSchema,
|
||||||
} from '../../../../../packages/api-contracts/src/index.js';
|
} from '../../../../../packages/api-contracts/src/index.js';
|
||||||
import {
|
import {
|
||||||
useDailyTrendQuery,
|
useDailyTrendQuery,
|
||||||
useStrategyPerformanceQuery,
|
useStrategyPerformanceQuery,
|
||||||
useSubredditPerformanceQuery,
|
useSubredditPerformanceQuery,
|
||||||
useSuccessfulExamplesQuery,
|
useSuccessfulExamplesQuery,
|
||||||
|
useFunnelQuery,
|
||||||
|
useAiUsageQuery,
|
||||||
} from './performance-queries.js';
|
} from './performance-queries.js';
|
||||||
import './performance.css';
|
import './performance.css';
|
||||||
|
|
||||||
@@ -17,6 +23,61 @@ const LOW_CONFIDENCE_SAMPLE_SIZE = 20;
|
|||||||
type SubredditPerformance = z.infer<typeof SubredditPerformanceResponseSchema>[number];
|
type SubredditPerformance = z.infer<typeof SubredditPerformanceResponseSchema>[number];
|
||||||
type StrategyPerformance = z.infer<typeof StrategyPerformanceResponseSchema>[number];
|
type StrategyPerformance = z.infer<typeof StrategyPerformanceResponseSchema>[number];
|
||||||
type DailyTrend = z.infer<typeof DailyTrendResponseSchema>[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 }) {
|
function SampleSize({ totalSent }: { totalSent: number }) {
|
||||||
const isLowConfidence = totalSent < LOW_CONFIDENCE_SAMPLE_SIZE;
|
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({
|
function PerformanceTable({
|
||||||
label,
|
label,
|
||||||
rows,
|
rows,
|
||||||
@@ -37,10 +133,18 @@ function PerformanceTable({
|
|||||||
rows: readonly (SubredditPerformance | StrategyPerformance)[];
|
rows: readonly (SubredditPerformance | StrategyPerformance)[];
|
||||||
rowLabel: (row: SubredditPerformance | StrategyPerformance) => string;
|
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 (
|
return (
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
<table aria-label={label} className="performance-table">
|
<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>
|
<tbody>
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<tr key={rowLabel(row)}>
|
<tr key={rowLabel(row)}>
|
||||||
@@ -49,10 +153,10 @@ function PerformanceTable({
|
|||||||
{'configured' in row && row.configured ? <span className="configured-label">Configured</span> : null}
|
{'configured' in row && row.configured ? <span className="configured-label">Configured</span> : null}
|
||||||
</th>
|
</th>
|
||||||
<td>{row.totalResponses}</td>
|
<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.totalImages}</td>
|
||||||
<td>{row.imageRate}%</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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</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 (
|
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) => (
|
{rows.map((row) => (
|
||||||
<li key={row.date}>
|
<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>
|
<span className="trend-track" aria-hidden="true"><span style={{ width: `${Math.max(2, Math.min(100, row.responseRate))}%` }} /></span>
|
||||||
<strong>{row.responseRate}%</strong>
|
<strong>{row.responseRate}%</strong>
|
||||||
<span>{row.totalImages} images ({row.imageRate}%)</span>
|
<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() {
|
export function PerformancePage() {
|
||||||
|
const [rangeDays, setRangeDays] = useState<TrendRange>(30);
|
||||||
const subreddits = useSubredditPerformanceQuery();
|
const subreddits = useSubredditPerformanceQuery();
|
||||||
const strategies = useStrategyPerformanceQuery();
|
const strategies = useStrategyPerformanceQuery();
|
||||||
const trend = useDailyTrendQuery(30);
|
const trend = useDailyTrendQuery(rangeDays);
|
||||||
const examples = useSuccessfulExamplesQuery();
|
const examples = useSuccessfulExamplesQuery();
|
||||||
const queries = [subreddits, strategies, trend, examples];
|
const funnel = useFunnelQuery();
|
||||||
if (queries.some((query) => query.isPending)) {
|
const aiUsage = useAiUsageQuery(rangeDays);
|
||||||
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>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page performance-page">
|
<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>
|
<header className="page-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><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 className="performance-grid">
|
<div aria-label="Date range" className="header-actions" role="group">
|
||||||
<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>
|
{TREND_RANGES.map((days) => (
|
||||||
<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>
|
<button
|
||||||
|
aria-pressed={rangeDays === days}
|
||||||
|
className={rangeDays === days ? 'button primary' : 'button secondary'}
|
||||||
|
key={days}
|
||||||
|
onClick={() => setRangeDays(days)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{days} days
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,8 @@ export const performanceQueryKeys = {
|
|||||||
strategies: ['performance', 'strategies'] as const,
|
strategies: ['performance', 'strategies'] as const,
|
||||||
trend: (days: number) => ['performance', 'trend', days] as const,
|
trend: (days: number) => ['performance', 'trend', days] as const,
|
||||||
successfulExamples: ['performance', 'successful-examples'] 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() {
|
export function useSubredditPerformanceQuery() {
|
||||||
@@ -35,3 +37,17 @@ export function useSuccessfulExamplesQuery() {
|
|||||||
queryFn: performanceApi.getSuccessfulExamples,
|
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; }
|
.configured-label, .confidence-warning { display: block; margin-top: .2rem; color: var(--accent); font-size: .68rem; font-weight: 750; }
|
||||||
.confidence-warning { color: var(--warning); }
|
.confidence-warning { color: var(--warning); }
|
||||||
.sample-size { white-space: nowrap; color: var(--muted); }
|
.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 { 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 { height: .5rem; overflow: hidden; background: var(--border); border-radius: 999px; }
|
||||||
.trend-track span { display: block; height: 100%; background: var(--accent); border-radius: inherit; }
|
.trend-track span { display: block; height: 100%; background: var(--accent); border-radius: inherit; }
|
||||||
.example-grid { display: grid; gap: .75rem; }
|
.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 blockquote { margin: 1rem 0; padding-left: 1rem; border-left: 2px solid var(--accent); }
|
||||||
.successful-example p { margin-bottom: 0; color: var(--muted); }
|
.successful-example p { margin-bottom: 0; color: var(--muted); }
|
||||||
.feature-empty { 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,67 +0,0 @@
|
|||||||
import { useRef } from 'react';
|
|
||||||
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
|
||||||
import { useImportRedditSessionMutation, useLoginStatusQuery } from './settings-queries.js';
|
|
||||||
|
|
||||||
function errorMessage(error: Error | null): string {
|
|
||||||
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 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 session</span><h2 id="access-heading">Access</h2></div><span className={session.ready && 'status' in session && session.loggedIn ? 'pill success' : 'pill warning'}>{sessionLabel(session)}</span></div>
|
|
||||||
<p className="settings-copy">When Reddit is logged out, account work is paused and waiting for a fresh session dump from the extension.</p>
|
|
||||||
{username ? <p className="settings-copy">Signed in as <strong><RedditUsernameLink username={username} /></strong>.</p> : null}
|
|
||||||
<div className="access-actions">
|
|
||||||
<input accept="application/json" aria-label="Reddit session export" className="sr-only" onChange={(event) => void importSession(event.target.files?.[0])} ref={importInput} type="file" />
|
|
||||||
<button className="button primary" disabled={importMutation.isPending} onClick={() => importInput.current?.click()} type="button">{importMutation.isPending ? 'Importing session…' : 'Import Reddit session'}</button>
|
|
||||||
<button className="button secondary" disabled={statusQuery.isFetching} onClick={() => void statusQuery.refetch()} type="button">{statusQuery.isFetching ? 'Checking session…' : 'Recheck session'}</button>
|
|
||||||
</div>
|
|
||||||
<p className="settings-copy">Open Reddit Chat in Chrome, wait for the extension badge to show OK, then upload its JSON dump here. It contains login credentials; do not share it.</p>
|
|
||||||
{importMutation.isSuccess ? <div className="settings-notice success" role="status">Reddit session imported and verified.</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 {
|
interface RiskDialogProps {
|
||||||
reasons: string[];
|
reasons: string[];
|
||||||
@@ -8,27 +8,19 @@ interface RiskDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RiskDialog({ reasons, isPending, onCancel, onConfirm }: RiskDialogProps) {
|
export function RiskDialog({ reasons, isPending, onCancel, onConfirm }: RiskDialogProps) {
|
||||||
const cancelButtonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
cancelButtonRef.current?.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div aria-labelledby="risk-settings-title" aria-modal="true" className="dialog-backdrop" role="dialog">
|
<Modal className="dialog-card" isDismissLocked={isPending} labelledBy="risk-settings-title" onClose={onCancel}>
|
||||||
<div className="dialog-card">
|
|
||||||
<span className="eyebrow">Confirm operational risk</span>
|
<span className="eyebrow">Confirm operational risk</span>
|
||||||
<h2 id="risk-settings-title">Confirm risky settings</h2>
|
<h2 id="risk-settings-title">Confirm risky settings</h2>
|
||||||
<ul className="risk-list">
|
<ul className="risk-list">
|
||||||
{reasons.map((reason) => <li key={reason}>{reason}</li>)}
|
{reasons.map((reason) => <li key={reason}>{reason}</li>)}
|
||||||
</ul>
|
</ul>
|
||||||
<div className="dialog-actions">
|
<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">
|
<button className="button danger" disabled={isPending} onClick={onConfirm} type="button">
|
||||||
{isPending ? 'Applying…' : 'Apply risky settings'}
|
{isPending ? 'Applying…' : 'Apply risky settings'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,11 +5,13 @@ import {
|
|||||||
ConfigUpdateResponseSchema,
|
ConfigUpdateResponseSchema,
|
||||||
ErrorResponseSchema,
|
ErrorResponseSchema,
|
||||||
LoginStatusResponseSchema,
|
LoginStatusResponseSchema,
|
||||||
|
RedditAccountsResponseSchema,
|
||||||
RedditCookieImportRequestSchema,
|
RedditCookieImportRequestSchema,
|
||||||
RedditCookieImportResponseSchema,
|
RedditCookieImportResponseSchema,
|
||||||
type ConfigResponse,
|
type ConfigResponse,
|
||||||
type ConfigUpdateRequest,
|
type ConfigUpdateRequest,
|
||||||
type LoginStatusResponse,
|
type LoginStatusResponse,
|
||||||
|
type RedditAccountSummary,
|
||||||
} from '../../../../../packages/api-contracts/src/index.js';
|
} from '../../../../../packages/api-contracts/src/index.js';
|
||||||
import { dashboardRequest } from '../../api/request.js';
|
import { dashboardRequest } from '../../api/request.js';
|
||||||
|
|
||||||
@@ -46,7 +48,11 @@ export function fetchLoginStatus(): Promise<LoginStatusResponse> {
|
|||||||
return request('/api/login/status').then((response) => parseResponse(response, LoginStatusResponseSchema));
|
return request('/api/login/status').then((response) => parseResponse(response, LoginStatusResponseSchema));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importRedditSession(exported: unknown): Promise<void> {
|
export function fetchRedditAccounts(): Promise<RedditAccountSummary[]> {
|
||||||
|
return request('/api/accounts').then((response) => parseResponse(response, RedditAccountsResponseSchema));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importRedditSession(exported: unknown): Promise<RedditAccountSummary> {
|
||||||
const body = RedditCookieImportRequestSchema.parse(exported);
|
const body = RedditCookieImportRequestSchema.parse(exported);
|
||||||
const response = await parseResponse(
|
const response = await parseResponse(
|
||||||
await request('/api/login/session-import', {
|
await request('/api/login/session-import', {
|
||||||
@@ -57,4 +63,5 @@ export async function importRedditSession(exported: unknown): Promise<void> {
|
|||||||
RedditCookieImportResponseSchema,
|
RedditCookieImportResponseSchema,
|
||||||
);
|
);
|
||||||
if (!response.ok) throw new Error(response.error);
|
if (!response.ok) throw new Error(response.error);
|
||||||
|
return response.account;
|
||||||
}
|
}
|
||||||
@@ -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 { z } from 'zod';
|
||||||
import {
|
import {
|
||||||
ConfigUpdateRequestSchema,
|
ConfigUpdateRequestSchema,
|
||||||
SubredditConfigSchema,
|
|
||||||
ToneKeySchema,
|
|
||||||
type ConfigResponse,
|
type ConfigResponse,
|
||||||
type ConfigUpdateRequest,
|
|
||||||
} from '../../../../../packages/api-contracts/src/index.js';
|
} from '../../../../../packages/api-contracts/src/index.js';
|
||||||
|
import type { ConfigCardValidation } from './use-config-card.js';
|
||||||
|
|
||||||
export interface SubredditDraft {
|
/** Above this hourly ceiling a save is treated as operationally risky. */
|
||||||
name: string;
|
export const RISKY_MESSAGES_PER_HOUR = 20;
|
||||||
type: 'image' | 'chat';
|
|
||||||
weight: string;
|
|
||||||
tone: z.infer<typeof ToneKeySchema>;
|
|
||||||
scripted: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SettingsDraft {
|
export interface PacingDraft {
|
||||||
minDelayMinutes: string;
|
minDelayMinutes: string;
|
||||||
maxMessagesPerHour: string;
|
maxMessagesPerHour: string;
|
||||||
jitterSeconds: string;
|
jitterSeconds: string;
|
||||||
dryRun: boolean;
|
|
||||||
prioritizeRecentPosts: boolean;
|
prioritizeRecentPosts: boolean;
|
||||||
recentWindowMinutes: string;
|
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;
|
advancedConfigJson: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ValidatedSettings {
|
export function createPacingDraft(config: ConfigResponse): PacingDraft {
|
||||||
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 {
|
|
||||||
return {
|
return {
|
||||||
minDelayMinutes: String(config.rateLimit.minDelayBetweenMessagesMs / 60_000),
|
minDelayMinutes: String(config.rateLimit.minDelayBetweenMessagesMs / 60_000),
|
||||||
maxMessagesPerHour: String(config.rateLimit.maxMessagesPerHour),
|
maxMessagesPerHour: String(config.rateLimit.maxMessagesPerHour),
|
||||||
jitterSeconds: String(config.rateLimit.jitterMs / 1_000),
|
jitterSeconds: String(config.rateLimit.jitterMs / 1_000),
|
||||||
dryRun: config.browser.dryRun,
|
|
||||||
prioritizeRecentPosts: config.reddit.prioritizeRecentPosts,
|
prioritizeRecentPosts: config.reddit.prioritizeRecentPosts,
|
||||||
recentWindowMinutes: String(config.reddit.recentWindowMinutes),
|
recentWindowMinutes: String(config.reddit.recentWindowMinutes),
|
||||||
subreddits: config.subreddits.map((subreddit) => ({
|
maxReadyCandidates: String(config.reddit.maxReadyCandidates),
|
||||||
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) : '',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationResult {
|
export function createSafetyDraft(config: ConfigResponse): SafetyDraft {
|
||||||
const requiredErrors: Record<string, string> = {};
|
return { dryRun: config.browser.dryRun };
|
||||||
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.';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.'),
|
||||||
});
|
});
|
||||||
if (Object.keys(requiredErrors).length > 0) return { errors: requiredErrors };
|
|
||||||
|
|
||||||
let advanced: Record<string, unknown> | undefined;
|
const requiredPacingFields: ReadonlyArray<{ key: keyof PacingDraft; message: string }> = [
|
||||||
if (draft.advancedConfigJson.trim() !== '') {
|
{ key: 'minDelayMinutes', message: 'Minimum delay is required.' },
|
||||||
try {
|
{ key: 'maxMessagesPerHour', message: 'Hourly limit is required.' },
|
||||||
const value: unknown = JSON.parse(draft.advancedConfigJson);
|
{ key: 'jitterSeconds', message: 'Jitter is required.' },
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
{ key: 'recentWindowMinutes', message: 'Recent window is required.' },
|
||||||
return { errors: { advancedConfigJson: 'Advanced configuration must be a JSON object.' } };
|
{ key: 'maxReadyCandidates', message: 'Ready-post limit is required.' },
|
||||||
}
|
];
|
||||||
advanced = value as Record<string, unknown>;
|
|
||||||
} catch {
|
export function validatePacingDraft(draft: PacingDraft): ConfigCardValidation {
|
||||||
return { errors: { advancedConfigJson: 'Advanced configuration is not valid JSON.' } };
|
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),
|
minDelayMinutes: Number(draft.minDelayMinutes),
|
||||||
maxMessagesPerHour: Number(draft.maxMessagesPerHour),
|
maxMessagesPerHour: Number(draft.maxMessagesPerHour),
|
||||||
jitterSeconds: Number(draft.jitterSeconds),
|
jitterSeconds: Number(draft.jitterSeconds),
|
||||||
dryRun: draft.dryRun,
|
|
||||||
prioritizeRecentPosts: draft.prioritizeRecentPosts,
|
prioritizeRecentPosts: draft.prioritizeRecentPosts,
|
||||||
recentWindowMinutes: Number(draft.recentWindowMinutes),
|
recentWindowMinutes: Number(draft.recentWindowMinutes),
|
||||||
subreddits: draft.subreddits.map((subreddit) => ({
|
maxReadyCandidates: Number(draft.maxReadyCandidates),
|
||||||
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,
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const errors: Record<string, string> = {};
|
|
||||||
for (const issue of parsed.error.issues) {
|
for (const issue of parsed.error.issues) {
|
||||||
const path = issue.path.join('.');
|
const path = issue.path.join('.');
|
||||||
if (!(path in errors)) errors[path] = issue.message;
|
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: {
|
rateLimit: {
|
||||||
minDelayBetweenMessagesMs: parsed.data.minDelayMinutes * 60_000,
|
minDelayBetweenMessagesMs: parsed.data.minDelayMinutes * 60_000,
|
||||||
maxMessagesPerHour: parsed.data.maxMessagesPerHour,
|
maxMessagesPerHour: parsed.data.maxMessagesPerHour,
|
||||||
jitterMs: parsed.data.jitterSeconds * 1_000,
|
jitterMs: parsed.data.jitterSeconds * 1_000,
|
||||||
},
|
},
|
||||||
browser: { dryRun: parsed.data.dryRun },
|
|
||||||
reddit: {
|
reddit: {
|
||||||
prioritizeRecentPosts: parsed.data.prioritizeRecentPosts,
|
prioritizeRecentPosts: parsed.data.prioritizeRecentPosts,
|
||||||
recentWindowMinutes: parsed.data.recentWindowMinutes,
|
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: {},
|
errors: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isDraftDirty(draft: SettingsDraft, persisted: SettingsDraft): boolean {
|
export function pacingRisks(draft: PacingDraft, persisted: PacingDraft): string[] {
|
||||||
return JSON.stringify(draft) !== JSON.stringify(persisted);
|
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 { useSettingsQuery } from './settings-queries.js';
|
||||||
import './settings.css';
|
import './settings.css';
|
||||||
|
|
||||||
export type SettingsSection = 'monitoring' | 'rate-limits' | 'behaviour' | 'access';
|
/** In-page section anchors. Every section is on one page; these only jump. */
|
||||||
|
const sections: ReadonlyArray<{ anchor: string; label: string }> = [
|
||||||
interface SettingsPageProps {
|
{ anchor: 'safety', label: 'Safety' },
|
||||||
section: SettingsSection;
|
{ anchor: 'pacing', label: 'Pacing' },
|
||||||
onSectionChange(section: SettingsSection): void;
|
{ anchor: 'targeting', label: 'Targeting' },
|
||||||
}
|
{ anchor: 'notifications', label: 'Notifications' },
|
||||||
|
{ anchor: 'advanced', label: 'Advanced' },
|
||||||
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' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function errorMessage(error: Error | null): string {
|
function errorMessage(error: Error | null): string {
|
||||||
return error?.message ?? 'Something went wrong.';
|
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 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 (
|
return (
|
||||||
|
<UnsavedChangesProvider>
|
||||||
<div className="page settings-page">
|
<div className="page settings-page">
|
||||||
<header className="page-header">
|
<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>
|
</header>
|
||||||
<nav aria-label="Settings sections" className="settings-navigation">
|
|
||||||
{sections.map((item) => (
|
{hasConfig ? (
|
||||||
<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>
|
<nav aria-label="Settings sections" className="section-rail">
|
||||||
|
{sections.map((section) => (
|
||||||
|
<a href={`#${section.anchor}`} key={section.anchor}>{section.label}</a>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</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}
|
) : 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>
|
</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,12 +1,37 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
|
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
|
||||||
import { fetchLoginStatus, fetchSettings, importRedditSession, saveSettings } from './settings-api.js';
|
import { fetchLoginStatus, fetchRedditAccounts, fetchSettings, importRedditSession, saveSettings } from './settings-api.js';
|
||||||
|
import {
|
||||||
|
fetchPushNotifications,
|
||||||
|
savePushNotifications,
|
||||||
|
sendTestPushNotification,
|
||||||
|
} from './push-notifications-api.js';
|
||||||
|
|
||||||
export const settingsQueryKeys = {
|
export const settingsQueryKeys = {
|
||||||
config: ['dashboard', 'config'] as const,
|
config: ['dashboard', 'config'] as const,
|
||||||
loginStatus: ['dashboard', 'login', 'status'] 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() {
|
export function useSettingsQuery() {
|
||||||
return useQuery({ queryKey: settingsQueryKeys.config, queryFn: fetchSettings });
|
return useQuery({ queryKey: settingsQueryKeys.config, queryFn: fetchSettings });
|
||||||
}
|
}
|
||||||
@@ -30,12 +55,23 @@ export function useLoginStatusQuery() {
|
|||||||
return useQuery({ queryKey: settingsQueryKeys.loginStatus, queryFn: fetchLoginStatus });
|
return useQuery({ queryKey: settingsQueryKeys.loginStatus, queryFn: fetchLoginStatus });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useRedditAccountsQuery() {
|
||||||
|
return useQuery({ queryKey: settingsQueryKeys.accounts, queryFn: fetchRedditAccounts });
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateAccountContent(queryClient: ReturnType<typeof useQueryClient>) {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: settingsQueryKeys.loginStatus });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: settingsQueryKeys.accounts });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['dashboard', 'conversations'] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['dashboard', 'media'] });
|
||||||
|
}
|
||||||
|
|
||||||
export function useImportRedditSessionMutation() {
|
export function useImportRedditSessionMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: importRedditSession,
|
mutationFn: importRedditSession,
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
void queryClient.invalidateQueries({ queryKey: settingsQueryKeys.loginStatus });
|
invalidateAccountContent(queryClient);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,28 @@
|
|||||||
import { useSearchParams } from 'react-router-dom';
|
import { Navigate, useSearchParams } from 'react-router-dom';
|
||||||
import { SettingsPage, type SettingsSection } from './settings-page.js';
|
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 {
|
/** Former tab names that are now in-page anchors on the settings page. */
|
||||||
return validSections.find((section) => section === value) ?? 'monitoring';
|
const anchorSections: Record<string, string> = {
|
||||||
}
|
monitoring: 'pacing',
|
||||||
|
'rate-limits': 'pacing',
|
||||||
|
notifications: 'notifications',
|
||||||
|
};
|
||||||
|
|
||||||
export function SettingsRoute() {
|
export function SettingsRoute() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const section = parseSection(searchParams.get('section'));
|
const section = searchParams.get('section');
|
||||||
|
|
||||||
function selectSection(nextSection: SettingsSection) {
|
const relocated = section ? relocatedSections[section] : undefined;
|
||||||
setSearchParams({ section: nextSection }, { replace: true });
|
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,48 +1,12 @@
|
|||||||
.settings-page { max-width: 72rem; }
|
.settings-page { max-width: 62rem; }
|
||||||
.settings-navigation { display: grid; grid-template-columns: 1fr; gap: .5rem; margin-bottom: 1rem; }
|
.settings-stack { display: grid; gap: 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; }
|
|
||||||
|
|
||||||
@media (min-width: 42rem) {
|
/* Effective-throughput readout under the pacing fields. */
|
||||||
.settings-navigation { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.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; }
|
||||||
.settings-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
.throughput-readout strong { font-size: 1.05rem; }
|
||||||
.subreddit-row { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
.throughput-readout span { color: inherit; font-size: .82rem; opacity: .85; }
|
||||||
.scripted-field { grid-column: 1 / -1; }
|
.throughput-readout.is-unknown { color: var(--muted); background: var(--surface-raised); border-color: var(--border); font-size: .85rem; }
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 70rem) {
|
.advanced-keys { display: grid; gap: .45rem; }
|
||||||
.settings-navigation { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
.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; }
|
||||||
|
}
|
||||||
+177
-15
@@ -12,13 +12,24 @@
|
|||||||
--accent-dark: #153c28;
|
--accent-dark: #153c28;
|
||||||
--danger: #ff8a84;
|
--danger: #ff8a84;
|
||||||
--warning: #f3c76b;
|
--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; }
|
* { box-sizing: border-box; }
|
||||||
body { margin: 0; min-width: 320px; min-height: 100vh; }
|
body { margin: 0; min-width: 320px; min-height: 100vh; }
|
||||||
button, input { font: inherit; }
|
button, input { font: inherit; }
|
||||||
button { cursor: pointer; }
|
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; }
|
a { color: inherit; }
|
||||||
.reddit-username-link { color: var(--accent); text-decoration: underline; text-decoration-thickness: .08em; text-underline-offset: .16em; }
|
.reddit-username-link { color: var(--accent); text-decoration: underline; text-decoration-thickness: .08em; text-underline-offset: .16em; }
|
||||||
.reddit-username-link:hover { color: #aaf0c4; }
|
.reddit-username-link:hover { color: #aaf0c4; }
|
||||||
@@ -33,6 +44,9 @@ a { color: inherit; }
|
|||||||
.brand div { display: grid; gap: .1rem; }
|
.brand div { display: grid; gap: .1rem; }
|
||||||
.brand span:last-child, .brand div span { color: var(--muted); font-size: .75rem; }
|
.brand span:last-child, .brand div span { color: var(--muted); font-size: .75rem; }
|
||||||
nav { display: grid; gap: .35rem; }
|
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 { 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 span { color: #dfe9e3; font-weight: 650; }
|
||||||
.nav-link small { font-size: .72rem; line-height: 1.3; }
|
.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); }
|
.navigation-backdrop { position: fixed; inset: 0; z-index: 20; width: 100%; border: 0; background: rgba(0, 0, 0, .6); }
|
||||||
.workspace { min-height: 100vh; }
|
.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 { 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; }
|
.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); }
|
main { min-height: calc(100vh - 4rem); }
|
||||||
.page { width: min(78rem, 100%); margin: 0 auto; padding: 1.5rem 1rem 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-strip small { color: var(--muted); }
|
||||||
.status-dot { width: .7rem; height: .7rem; border-radius: 50%; }
|
.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.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); }
|
.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 { 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.neutral { color: #d4dfda; background: #2b3832; }
|
||||||
.pill.success { color: #aaf0c4; background: #163a26; }
|
.pill.success { color: var(--success-text); background: var(--success-bg); }
|
||||||
.pill.warning { color: #ffe1a0; background: #42351b; }
|
.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; }
|
.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; }
|
.cooldown-banner span { font-size: .85rem; }
|
||||||
.metrics-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .75rem; margin-bottom: .75rem; }
|
.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, .panel { background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; }
|
||||||
.metric-card { display: grid; gap: .3rem; padding: 1rem; }
|
.metric-card { display: grid; gap: .3rem; padding: 1rem; }
|
||||||
.metric-card > span, .metric-card small { color: var(--muted); }
|
.metric-card > span, .metric-card small { color: var(--muted); }
|
||||||
.metric-card strong { font-size: 1.75rem; letter-spacing: -.04em; }
|
.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; }
|
.overview-grid { display: grid; gap: .75rem; }
|
||||||
.panel { padding: 1rem; }
|
.panel { padding: 1rem; }
|
||||||
.panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 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); }
|
.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 { 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-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); }
|
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); }
|
||||||
.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); }
|
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 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 { 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-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 { display: grid; gap: .7rem; padding: 1rem; }
|
||||||
.origin-post-dialog-copy h2 { margin: 0; font-size: 1.25rem; }
|
.origin-post-dialog-copy h2 { margin: 0; font-size: 1.25rem; }
|
||||||
.origin-post-dialog-copy small { color: var(--muted); }
|
.origin-post-dialog-copy small { color: var(--muted); }
|
||||||
.origin-post-dialog-copy a { justify-self: start; text-decoration: none; }
|
.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 { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: .8rem; align-items: center; }
|
||||||
.origin-post-card > img { width: 5rem; height: 5rem; object-fit: cover; background: #050907; border-radius: .45rem; }
|
.origin-post-image { position: relative; width: 5rem; height: 5rem; overflow: hidden; background: #050907; border-radius: .45rem; }
|
||||||
.origin-post-card > span { display: grid; gap: .2rem; min-width: 0; }
|
.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-card small, .origin-post-card span span { color: var(--muted); font-size: .75rem; }
|
.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; }
|
.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 { flex: 0 0 auto; width: .55rem; height: .55rem; margin-top: .3rem; border-radius: 50%; background: var(--muted); }
|
||||||
.activity-marker.sent, .activity-marker.response { background: var(--accent); }
|
.activity-marker.sent, .activity-marker.response { background: var(--accent); }
|
||||||
@@ -112,12 +156,129 @@ 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); }
|
.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; }
|
.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); } }
|
@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.modal { color: inherit; border: 0; background: transparent; }
|
||||||
.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::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-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; }
|
.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 { 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: #ffd1ce; background: #461b18; border: 1px solid #80352f; }
|
.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);
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: .75rem;
|
||||||
|
gap: .35rem;
|
||||||
|
padding: .2rem .55rem;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-owner-badge.is-unassigned {
|
||||||
|
background: color-mix(in srgb, #d99a24 12%, transparent);
|
||||||
|
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) {
|
@media (min-width: 48rem) {
|
||||||
.sidebar { visibility: visible; transform: none; }
|
.sidebar { visibility: visible; transform: none; }
|
||||||
@@ -127,5 +288,6 @@ h2 { margin-bottom: 0; font-size: 1.1rem; }
|
|||||||
.page { padding: 3rem 2rem 5rem; }
|
.page { padding: 3rem 2rem 5rem; }
|
||||||
.navigation-backdrop { display: none; }
|
.navigation-backdrop { display: none; }
|
||||||
.metrics-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
.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); }
|
.overview-grid { grid-template-columns: minmax(18rem, .8fr) minmax(22rem, 1.2fr); }
|
||||||
}
|
}
|
||||||
+144
-50
@@ -48,8 +48,37 @@ reddit:
|
|||||||
filterSellers: true # Skip profiles that appear to be OnlyFans/content sellers
|
filterSellers: true # Skip profiles that appear to be OnlyFans/content sellers
|
||||||
prioritizeRecentPosts: true # Prefer posts within recentWindowMinutes; older posts only as fallback
|
prioritizeRecentPosts: true # Prefer posts within recentWindowMinutes; older posts only as fallback
|
||||||
recentWindowMinutes: 30
|
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
|
# 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
|
||||||
|
|
||||||
|
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:
|
openrouter:
|
||||||
imageAnalysisModel: "google/gemini-2.5-flash-lite"
|
imageAnalysisModel: "google/gemini-2.5-flash-lite"
|
||||||
imageAnalysisFallbackModels:
|
imageAnalysisFallbackModels:
|
||||||
@@ -67,19 +96,33 @@ openrouter:
|
|||||||
- "meta-llama/llama-4-maverick" # ~$0.38/M
|
- "meta-llama/llama-4-maverick" # ~$0.38/M
|
||||||
- "deepseek/deepseek-chat-v3.1" # ~$0.50/M
|
- "deepseek/deepseek-chat-v3.1" # ~$0.50/M
|
||||||
imageAnalysisPrompt: |
|
imageAnalysisPrompt: |
|
||||||
You're analyzing images.
|
Analyze this adult image using only directly visible evidence.
|
||||||
|
|
||||||
Describe the following features:
|
Strict grounding rules:
|
||||||
- breasts: size (small/medium/big/huge), shape, nipple details, perky/saggy, cleavage
|
- Never infer, predict, or imagine obscured anatomy, off-frame body parts,
|
||||||
- ASS: shape (round/bubble/thick), size, how it looks bent over or spread
|
hidden details, or how something would look in another pose.
|
||||||
- vagina: visible? shaved/trimmed/bush, lips visible, spread or closed
|
- Do not infer overall body type from a close crop.
|
||||||
- BODY: thick/slim/curvy/fit, belly, thighs, waist, tan lines
|
- If something is covered, cropped out, or ambiguous, mark it NOT_VISIBLE
|
||||||
- POSE: bent over, spreading, on all fours, lying back, standing
|
or UNCERTAIN.
|
||||||
- VIBE: shy/confident/slutty/teasing, eye contact, facial expression
|
- Explicit language does not permit inference. Never mention pussy unless
|
||||||
- EXTRAS: tattoos, piercings, lingerie, toys, setting
|
external genital detail is directly visible.
|
||||||
|
|
||||||
Be graphic. Describe exactly what you see. No clinical terms.
|
Use exactly this structure:
|
||||||
This helps generate encouranging messages that reference specific things about their body.
|
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:
|
Then rate her attractiveness on a 1-100 scale using these percentile anchors:
|
||||||
- 30 = noticeably below average
|
- 30 = noticeably below average
|
||||||
@@ -94,7 +137,7 @@ openrouter:
|
|||||||
RATING:<number>
|
RATING:<number>
|
||||||
Then on the next line: REASON:<one sentence explaining the score>
|
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: |
|
messageGenerationPrompt: |
|
||||||
Based on this image analysis: {analysis}
|
Based on this image analysis: {analysis}
|
||||||
Post title: {title}
|
Post title: {title}
|
||||||
@@ -104,21 +147,23 @@ openrouter:
|
|||||||
Attractiveness rating (1-100 scale, 50 = average): {rating}
|
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.
|
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:
|
MUST follow:
|
||||||
- Start sentences with capital letters, use proper capitalisation
|
- Start sentences with capital letters, use proper capitalisation
|
||||||
- Use explicit terms: tits, ass, pussy, boobs (NOT breasts, buttocks, etc)
|
- Use explicit terms: tits, ass, pussy, boobs (NOT breasts, buttocks, etc)
|
||||||
- Reference something SPECIFIC from the analysis (her tits, ass, pussy, body, pose)
|
- Reference something SPECIFIC from the analysis (her tits, ass, pussy, body, pose)
|
||||||
- Be direct and confident, not flowery or poetic
|
- 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):
|
POSSIBLE HOOKS (only when the selected STYLE calls for one):
|
||||||
- "I'd love if you have any other pics to share?"
|
- A specific question about the post
|
||||||
- "I bet they'd look great coated..."
|
- A playful prediction or challenge
|
||||||
- "Got any more pics?"
|
- A conditional invitation to share something else
|
||||||
|
- A statement of desire with no question
|
||||||
|
|
||||||
BAD HOOKS (never use):
|
BAD HOOKS:
|
||||||
- "How's your day?"
|
- "How's your day?"
|
||||||
- "What are you up to?"
|
- "What are you up to?"
|
||||||
- Generic small talk questions
|
- Generic small talk questions
|
||||||
@@ -130,12 +175,11 @@ openrouter:
|
|||||||
- Use clinical terms like "breasts", "buttocks", "posterior", "physique"
|
- Use clinical terms like "breasts", "buttocks", "posterior", "physique"
|
||||||
- Write long elaborate sentences
|
- Write long elaborate sentences
|
||||||
- Use all lowercase
|
- 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.
|
- Use em dashes (—) or en dashes (–). Use commas, full stops, or separate sentences instead. This is a dead giveaway for AI writing.
|
||||||
|
- Mention, mirror, or acknowledge the recipient's location
|
||||||
Examples of good messages:
|
- Claim or imply you are in the same, nearby, or local area as the recipient, calculate distance, or suggest meeting in person
|
||||||
- "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?"
|
|
||||||
|
|
||||||
Just write the message, nothing else.
|
Just write the message, nothing else.
|
||||||
|
|
||||||
@@ -150,15 +194,15 @@ openrouter:
|
|||||||
|
|
||||||
YOUR PERSONA:
|
YOUR PERSONA:
|
||||||
- You are male, based in the UK, pick a random age between 30 and 35 for this message
|
- 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
|
- Do not mention relationship status unless the selected STYLE explicitly requires it
|
||||||
- Don't force the married angle if their post wouldn't vibe with it, read the room
|
|
||||||
- Use British English spelling and phrasing (e.g. "mate", "knackered", "fancy", "cheeky") where it feels natural, not forced
|
- 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.
|
Write a casual Reddit DM (1-2 sentences) opening a chat.
|
||||||
|
|
||||||
MUST follow:
|
MUST follow:
|
||||||
- Lead with your age/gender/location tag (e.g. "32M UK", "34M UK married") when it fits naturally
|
- 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, age, location)
|
- 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
|
- Be confident and direct, flirty but not creepy
|
||||||
- Use proper capitalisation, no AI/clinical language
|
- Use proper capitalisation, no AI/clinical language
|
||||||
- End with a hook that invites a reply (ask them what they're into, offer something playful)
|
- End with a hook that invites a reply (ask them what they're into, offer something playful)
|
||||||
@@ -168,52 +212,102 @@ openrouter:
|
|||||||
- Open with "Hey there" / "How's your day"
|
- Open with "Hey there" / "How's your day"
|
||||||
- Write more than 2 sentences
|
- Write more than 2 sentences
|
||||||
- Be generic, "wanna chat?" alone is too weak
|
- 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
|
- 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.
|
- 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.
|
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:
|
strategies:
|
||||||
explorationRate: 0.2 # 20% of the time try a random strategy instead of the best
|
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:
|
variants:
|
||||||
- id: compliment_direct
|
- id: reaction_statement
|
||||||
weight: 1
|
weight: 1
|
||||||
promptOverride: |
|
promptOverride: |
|
||||||
STYLE: Focus on a direct, specific compliment about ONE body part from the analysis.
|
STYLE: REACTION STATEMENT. Open with a short physical reaction that is NOT "Damn", "Wow", or "Holy shit".
|
||||||
Keep it short and punchy. End with a simple "got more?" style hook.
|
Mention one specific visible detail. Finish with a confident statement of desire, not a question and not a request for more.
|
||||||
- id: teasing
|
- id: question_first
|
||||||
weight: 1
|
weight: 1
|
||||||
promptOverride: |
|
promptOverride: |
|
||||||
STYLE: Be more teasing and playful. Light humor, like you're flirting.
|
STYLE: QUESTION FIRST. The very first sentence must be a specific, answerable question about her post, pose, outfit, or title.
|
||||||
End with a flirty challenge or dare that makes her want to respond.
|
Follow with one concise explicit compliment. Do not ask whether she has more pics.
|
||||||
- id: detailed_appreciation
|
- id: title_callback
|
||||||
weight: 1
|
weight: 1
|
||||||
promptOverride: |
|
promptOverride: |
|
||||||
STYLE: Reference 2-3 specific things from the analysis to show you actually looked.
|
STYLE: TITLE CALLBACK. Open by naturally echoing or answering the post title, without quoting it verbatim.
|
||||||
Be enthusiastic but not over the top. End with asking for more.
|
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:
|
chatStrategies:
|
||||||
explorationRate: 0.2
|
explorationRate: 0.2
|
||||||
minSamples: 5
|
minSamples: 20
|
||||||
|
responseWindowHours: 12
|
||||||
variants:
|
variants:
|
||||||
- id: chat_direct
|
- id: chat_identity_first
|
||||||
weight: 1
|
weight: 1
|
||||||
promptOverride: |
|
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.
|
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_playful
|
- id: chat_question_first
|
||||||
weight: 1
|
weight: 1
|
||||||
promptOverride: |
|
promptOverride: |
|
||||||
STYLE: Playful and teasing. Light humour, mild flirty challenge, end with a hook that's hard to ignore. No em dashes.
|
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_mirror
|
- id: chat_post_callback
|
||||||
weight: 1
|
weight: 1
|
||||||
promptOverride: |
|
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:
|
rateLimit:
|
||||||
minDelayBetweenMessagesMs: 15000
|
minDelayBetweenMessagesMs: 15000
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
|
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
|
||||||
- WEB_DASHBOARD_TOKEN=${WEB_DASHBOARD_TOKEN}
|
- 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:
|
volumes:
|
||||||
reddit_bot_data:
|
reddit_bot_data:
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# 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. |
|
||||||
|
| [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.
|
||||||
+132
-1015
File diff suppressed because it is too large.
Load diff
+105
-879
File diff suppressed because it is too large.
Load diff
+84
-440
@@ -1,485 +1,129 @@
|
|||||||
# 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)
|
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.
|
||||||
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)
|
|
||||||
|
|
||||||
---
|
## 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.
|
||||||
|
|
||||||
```
|
## Application sections
|
||||||
┌──────────────────────────────────────────────────────────────┐
|
|
||||||
│ 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) │
|
|
||||||
└──────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
Configuration is loaded at startup by `loadConfig()` in `src/config/index.ts`, which:
|
### `reddit`
|
||||||
|
|
||||||
1. Reads the singleton `app_config` record from SQLite
|
- `subreddits`: at least one item with `name`, `enabled`, `type`, optional `weight`, `tone`, optional scripted opener, and conversation policy.
|
||||||
2. Imports `config.yaml` only when upgrading an existing database that has no settings record
|
- `pollIntervalMs`: legacy/scheduled polling cadence.
|
||||||
3. Validates the entire configuration tree using Zod
|
- `userAgent`: Reddit JSON request identifier.
|
||||||
4. Returns a strongly-typed `Config` object
|
- `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`
|
||||||
|
|
||||||
```
|
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.
|
||||||
RedditBot/
|
|
||||||
├── config.yaml # ← Main configuration
|
|
||||||
├── package.json
|
|
||||||
├── tsconfig.json
|
|
||||||
└── src/
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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`
|
||||||
|
|
||||||
```
|
- `minDelayBetweenMessagesMs`;
|
||||||
data/
|
- `maxMessagesPerHour`;
|
||||||
├── reddit-bot.db # SQLite database
|
- `jitterMs`.
|
||||||
├── 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
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
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 |
|
Treat changing `dryRun` from true to false as enabling a real side effect.
|
||||||
|---|---|---|---|---|
|
|
||||||
| `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` |
|
|
||||||
|
|
||||||
### 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
|
Controls periodic sync enablement and interval. Media work remains authentication-bound.
|
||||||
OPENROUTER_API_KEY=sk-or-v1-abc123...
|
|
||||||
WEB_DASHBOARD_TOKEN=long-random-secret
|
|
||||||
```
|
|
||||||
|
|
||||||
> **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
|
Delivery is best-effort: an unreachable or rejecting ntfy server is logged and never propagated into the path that produced the event.
|
||||||
export OPENROUTER_API_KEY=sk-or-v1-abc123...
|
|
||||||
export WEB_DASHBOARD_TOKEN=long-random-secret
|
|
||||||
```
|
|
||||||
|
|
||||||
**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
|
### `web`
|
||||||
environment:
|
|
||||||
- OPENROUTER_API_KEY=sk-or-v1-abc123...
|
|
||||||
- WEB_DASHBOARD_TOKEN=long-random-secret
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
Controls enabled state, port, optional tokens after environment injection, and dangerous debug endpoint enablement. Production secrets should never live in YAML.
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
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, minimum spacing, jitter, quiet hours, NSFW opt-in, the community list with per-community weight and daily cap, and pause/resume. The daily allowance itself is **not** operator-settable — it is derived from an adaptive trust level that the verification sweep maintains. `browser.dryRun` also suppresses growth posting.
|
||||||
|
|
||||||
```yaml
|
## Runtime-editable subset
|
||||||
reddit:
|
|
||||||
# Subreddits to monitor for new posts
|
|
||||||
subreddits:
|
|
||||||
- gonewild
|
|
||||||
- ratemyboobs
|
|
||||||
- HomemadeNsfw
|
|
||||||
- realgirls
|
|
||||||
- plussize_gonewild
|
|
||||||
# ... add or remove subreddits
|
|
||||||
|
|
||||||
# Minutes between polling cycles
|
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.
|
||||||
pollInterval: 15
|
|
||||||
|
|
||||||
# Target gender for image analysis filtering
|
When adding a runtime-editable setting, update all of:
|
||||||
# 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"
|
|
||||||
|
|
||||||
# Fallback models tried if the primary model refuses or fails
|
1. `ConfigSchema` and defaults.
|
||||||
fallbackModels:
|
2. `config.yaml` seed/example when appropriate.
|
||||||
- "google/gemini-2.0-flash-001"
|
3. `ConfigStore` persistence rules if ownership changes.
|
||||||
- "meta-llama/llama-3.2-11b-vision-instruct"
|
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.
|
||||||
|
|
||||||
# Quick detection model (cheaper/faster, used for gender pre-filter)
|
## Validation and change safety
|
||||||
quickModel: "google/gemini-2.0-flash-lite-001"
|
|
||||||
|
|
||||||
# System prompt for image analysis
|
- Use Zod for every external or persisted shape.
|
||||||
# The AI will describe the image and detect gender
|
- Reject unknown transport fields with strict contracts.
|
||||||
systemPrompt: |
|
- Validate relationships (not just types), such as the hybrid turn constraint.
|
||||||
Analyze this image and provide:
|
- Do not log the full config because it contains injected secrets in memory.
|
||||||
1. Gender detected (F or M)
|
- Configuration that affects running account objects may not be fully hot-reloaded; inspect the owning service before claiming immediate effect.
|
||||||
2. Detailed description of the image content
|
- A production `PUT /api/config` should be followed by an authenticated GET read-back. Do not infer persistence from a 2xx alone.
|
||||||
3. Notable features (hair color, clothing, setting, etc.)
|
- A subreddit listing 404 is persisted as disabled so every account runtime stops polling it.
|
||||||
|
|
||||||
Return as JSON: {"gender": "F", "description": "...", "features": [...]}
|
## Local safe setup
|
||||||
|
|
||||||
# System prompt for message generation
|
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.
|
||||||
# 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
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration Validation
|
|
||||||
|
|
||||||
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 |
|
|
||||||
|
|
||||||
### Common Validation Errors
|
|
||||||
|
|
||||||
**Error**: `Invalid config: openrouter.apiKey must be a string`
|
|
||||||
**Fix**: Set `OPENROUTER_API_KEY` environment variable or add `apiKey` to `config.yaml`
|
|
||||||
|
|
||||||
**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`
|
|
||||||
+103
-862
@@ -1,892 +1,133 @@
|
|||||||
# Data Flow Documentation
|
# Runtime flows
|
||||||
|
|
||||||
Complete documentation of data flows through the RedditBot system.
|
## Candidate funnel
|
||||||
|
|
||||||
---
|
The production multi-account path is three independent loops backed by `candidate_queue`.
|
||||||
|
|
||||||
## Table of Contents
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
1. [Overview](#overview)
|
[*] --> discovered: listing intake
|
||||||
2. [Post Processing Flow](#post-processing-flow)
|
discovered --> screening: leased by screening worker
|
||||||
3. [Response Monitoring Flow](#response-monitoring-flow)
|
screening --> ready: eligibility and preparation pass
|
||||||
4. [Chat Interaction Flow](#chat-interaction-flow)
|
screening --> retry_wait: transient failure
|
||||||
5. [Strategy Selection Flow](#strategy-selection-flow)
|
screening --> rejected: terminal eligibility or AI failure
|
||||||
6. [Media Sync Flow](#media-sync-flow)
|
ready --> sending: leased to available account
|
||||||
7. [Auto-Reply Flow](#auto-reply-flow)
|
sending --> sent: Matrix confirms conversation
|
||||||
8. [Dashboard Data Flow](#dashboard-data-flow)
|
sending --> retry_wait: account cooldown or transient failure
|
||||||
9. [Error Recovery Flows](#error-recovery-flows)
|
sending --> rejected: terminal delivery result
|
||||||
10. [Data Lifecycle](#data-lifecycle)
|
retry_wait --> discovered: screening retry due
|
||||||
|
retry_wait --> ready: delivery retry due
|
||||||
---
|
discovered --> expired: outside configured recent window
|
||||||
|
ready --> expired: outside configured recent window
|
||||||
## Overview
|
|
||||||
|
|
||||||
RedditBot processes data through several interconnected pipelines:
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
|
|
||||||
│ Reddit API │────►│ Monitor │────►│ Processor │
|
|
||||||
│ (Posts) │ │ Service │ │ Service │
|
|
||||||
└─────────────┘ └──────────────┘ └──────┬──────┘
|
|
||||||
│
|
|
||||||
┌──────────────┐ ▼
|
|
||||||
│ Response │ ┌──────────────┐
|
|
||||||
│ Checker │◄────│ OpenRouter │
|
|
||||||
└──────────────┘ │ (AI) │
|
|
||||||
└──────────────┘
|
|
||||||
│
|
|
||||||
┌──────────────┐ ▼
|
|
||||||
│ Chat │ ┌──────────────┐
|
|
||||||
│ Service │◄────│ Message │
|
|
||||||
└──────────────┘ │ Sender │
|
|
||||||
└──────────────┘
|
|
||||||
│
|
|
||||||
┌──────────────┐ ▼
|
|
||||||
│ Media │ ┌──────────────┐
|
|
||||||
│ Service │◄────│ SQLite DB │
|
|
||||||
└──────────────┘ └──────────────┘
|
|
||||||
│
|
|
||||||
┌──────────────┐ │
|
|
||||||
│ Dashboard │◄──────────┘
|
|
||||||
│ (Express) │
|
|
||||||
└──────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
### 1. Discovery
|
||||||
|
|
||||||
## Post Processing Flow
|
The discovery loop rotates through enabled subreddits and authenticated runtimes. A runtime fetches Reddit `/new` data using the process-shared `RedditRequestCoordinator`. Intake deduplicates post IDs and authors, excludes processed/contacted history, stores the full post snapshot, source type, weight, and timestamps, and logs exact intake counts.
|
||||||
|
|
||||||
The primary data flow for discovering, analyzing, and messaging about new posts.
|
The queue grows to a demand-based target rather than a fixed backlog. The target is the smaller of configured `reddit.maxReadyCandidates` and the work that authenticated running accounts can still send within the recent-post window, accounting for rolling hourly capacity and minimum message spacing. Intake admits only the current deficit, newest Reddit posts first, pauses at zero outbound capacity, and reports fetched posts left unevaluated because the funnel is full. Reddit request pacing, not a tight application poll, remains the primary request limiter.
|
||||||
|
|
||||||
### Stage 1: Discovery
|
### 2. Screening and preparation
|
||||||
|
|
||||||
```
|
A screening worker leases one discovered candidate. `MonitorService.screenCandidate()` applies cheap rules before expensive/networked rules:
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ MonitorService │
|
1. Global processed-post and recipient-history checks.
|
||||||
│ │
|
2. For chat sources, self-declared gender and adult age parsing.
|
||||||
│ 1. Shuffle subreddit order │
|
3. Seller evidence from the post, profile/about (including structured social links when exposed and narrow NSFW link-funnel language), and recent activity.
|
||||||
│ 2. For each subreddit: │
|
4. Source-specific preparation in `ProcessorService`.
|
||||||
│ ┌─────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ RedditApi.fetchPosts(subreddit) │ │
|
For image posts, preparation downloads and validates the image, detects target gender, and performs model analysis. The analysis includes a separate 1–10 visible-physique score only when enough of the body is visible. Its strict anchors place a broadly average visible physique at 5, reserve 8–10 for rare athletic or model-like physiques, and explicitly forbid using 6 as a safe default; nudity, breast size, face, pose, and photographic quality do not affect it. If physique targeting is enabled, an unknown score or a score outside the configured inclusive range becomes an explicit rejection before strategy selection and opener generation. Passing image posts then select an image strategy and generate an opener. Chat posts do not use physique targeting; they select a chat strategy and generate from the post text. Both paths apply outbound safety checks and return either a prepared immutable result or an explicit skip reason.
|
||||||
│ │ │ │
|
|
||||||
│ │ HTTP: GET /r/{subreddit}/new.json?limit=100 │ │
|
Terminal results become rejected candidates. Rate limits and transient failures become retryable candidates. Prepared candidates become ready; the persisted snapshot includes the opener, grounded image analysis, attractiveness and physique scores, the physique range used, source-image URL, and experiment/model/tone metadata. This lets a different account deliver without repeating AI work and lets the authenticated operator inspect the exact preparation context. Saving a physique-range change transactionally re-evaluates prepared image candidates in ready and delivery-retry stages, rejects those outside the new range, and refreshes the displayed range snapshot for retained candidates. A candidate already leased for sending is left unchanged until that send resolves.
|
||||||
│ │ │ │
|
|
||||||
│ │ Response: { │ │
|
### 3. Dispatch
|
||||||
│ │ data: { │ │
|
|
||||||
│ │ children: [ │ │
|
An account is eligible for new outreach only when its runtime is running, both identities verify, it has no actionable reply backlog, there is no invitation cooldown, and its rate limiter permits a send. Available accounts are ordered by their current hourly send count.
|
||||||
│ │ { │ │
|
|
||||||
│ │ data: { │ │
|
Matrix cooldowns retain the operation that triggered them. A room-creation or invitation limit pauses new outreach but does not suppress replies in existing rooms; a limit returned while sending into an existing room pauses those sends as well.
|
||||||
│ │ id: "abc123", │ │
|
|
||||||
│ │ subreddit: "gonewild", │ │
|
Screening, retry promotion, dispatch, and the operator's active-queue view are ordered by the Reddit post creation time, newest first; source priority is only a tie-breaker. The dispatch worker leases the newest ready candidate for an account, materializes the processed post in that account's repository, applies the account rate limiter, checks dry-run, and atomically claims the recipient globally. `ChatService.startConversationDetailed()` returns one of:
|
||||||
│ │ author: "user123", │ │
|
|
||||||
│ │ title: "Summer vibes", │ │
|
- `sent`: persist the message and outcome metadata, commit recipient history, update activity, and mark the candidate sent;
|
||||||
│ │ url: "https://...", │ │
|
- `terminal`: release the recipient reservation and reject the candidate;
|
||||||
│ │ preview: { reddits: [...] }, │ │
|
- `retryable`: release/defer so another healthy account can claim it soon.
|
||||||
│ │ thumbnail: "https://...", │ │
|
|
||||||
│ │ post_hint: "image", │ │
|
Only Matrix confirmation makes a send successful. A healthy HTTP process, room identifier lookup, or attempted request does not.
|
||||||
│ │ over_18: true │ │
|
|
||||||
│ │ } │ │
|
## Reddit request and cooldown flow
|
||||||
│ │ } │ │
|
|
||||||
│ │ ] │ │
|
Every runtime shares one `RedditRequestCoordinator`, because Reddit can enforce limits by origin as well as account. JSON requests are serialized with a minimum interval; failed requests consume spacing too. A 429 extends the global cooldown using the server delay plus a buffer. Broad 403 behavior can also result in coordinated backoff. A listing 404 is treated as terminal for that subreddit and persisted as `enabled=false`.
|
||||||
│ │ } │ │
|
|
||||||
│ │ } │ │
|
Do not add local retries around this layer without preserving the shared schedule. Per-account bursts defeat the coordinator.
|
||||||
│ └─────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
## Reply-first conversation automation
|
||||||
│ 3. Filter posts: │
|
|
||||||
│ ├─► post_hint === "image" │
|
Explicit bot clients can send to a username through `POST /api/v1/messages` or the MCP `send_message_to_user` tool without selecting an account. The selector compares cached conversations case-insensitively and reuses the account that most recently interacted with that recipient. If no conversation exists, it chooses an authenticated running account with no invitation cooldown, preferring the lowest hourly send count. The selected account's normal rate limiter still owns the send. An API reply to an existing conversation marks automation as user-overridden so autonomous replies do not race the external caller.
|
||||||
│ ├─► author exists and is not deleted │
|
|
||||||
│ ├─► image_url can be extracted │
|
Each account runtime refreshes recent chats and processes actionable inbound conversations oldest-first before it can accept new outreach. After the initial conservative startup scan, the persisted latest Matrix message event plus the worker's prior-scan watermark is authoritative: a newly observed inbound event newer than the confirmed opener remains actionable even if Matrix's presentation-only unread notification count has already returned to zero. On startup, read-marked rooms without a pending reply claim are not revived. Durable event claims, conversation policy state, and a later outbound event prevent duplicate replies.
|
||||||
│ └─► Author not in seller/spam list │
|
|
||||||
│ │
|
Outgoing Matrix events are attributed against the recorded opener, durable automated-reply fingerprints, and confirmed exchange-library event records. If synchronization observes an unmatched outgoing message, the conversation becomes operator-owned before another reply can be generated. An unmatched outgoing message also supersedes, rather than confirms, a pending automated claim; only the claimed reply text can reconcile that claim as sent.
|
||||||
│ 4. Cross-reference with DB: │
|
|
||||||
│ ├─► PostsRepository.getById(postId) → skip if exists │
|
```mermaid
|
||||||
│ └─► MessagesRepository.getByAuthor(author) → skip if sent │
|
flowchart TD
|
||||||
│ │
|
IN["Unread inbound Matrix event"] --> VERIFIED{"Confirmed after a recorded opener?"}
|
||||||
│ 5. Return newest valid post │
|
VERIFIED -- no --> IGNORE["Ignore as unverified"]
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
VERIFIED -- yes --> CLAIM{"Durable event claim exists?"}
|
||||||
|
CLAIM -- sent --> IGNORE
|
||||||
|
CLAIM -- pending --> RECON["Reconcile against Matrix history"]
|
||||||
|
CLAIM -- no --> STATE["Load or snapshot conversation policy"]
|
||||||
|
STATE --> STOP{"Manual, seller, image, boundary, unsafe, or turn limit?"}
|
||||||
|
STOP -- yes --> HANDOFF["Persist terminal/human-required outcome"]
|
||||||
|
STOP -- no --> GEN["Generate policy-specific reply"]
|
||||||
|
GEN --> SAFE{"Outbound checks pass?"}
|
||||||
|
SAFE -- no --> HANDOFF
|
||||||
|
SAFE -- yes --> PERSIST["Persist intended reply claim"]
|
||||||
|
PERSIST --> WAIT["Wait for the account rate limiter"]
|
||||||
|
WAIT --> RECHECK{"Refresh Matrix; claimed event still latest and safe?"}
|
||||||
|
RECHECK -- no --> SUPERSEDE["Mark claim superseded; persist boundary/handoff if present"]
|
||||||
|
RECHECK -- yes --> SNAPSHOT["Persist intended conversation state"]
|
||||||
|
SNAPSHOT --> SEND["Matrix send"]
|
||||||
|
SEND --> MARK["Mark claim sent and record latency"]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Stage 2: Image Processing
|
The inbound event key, intended reply, claim status, and conversation state make retries idempotent. Immediately after any limiter wait, automation refreshes Matrix and revalidates the latest inbound event, boundaries, images, seller evidence, policy status, and the one-media-request limit. Newer state makes the old claim terminally `superseded`; it is never sent. If the process dies after Matrix accepted a reply, reconciliation finds the outbound event and marks the old claim sent instead of sending it again.
|
||||||
|
|
||||||
```
|
Legacy conversations may make at most one optional autonomous media request, including a request already present in the opener. The hybrid policy retains its single rapport-gated media ask. Deterministic, sequence-aware refusal checks run before generation and again at send time; generated media asks must pass the same non-explicit, non-coercive validator.
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ ProcessorService │
|
|
||||||
│ │
|
|
||||||
│ 1. Download Image │
|
|
||||||
│ ┌─────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ ImageService.downloadImage(url) │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ HTTP: GET {image_url} │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ Response: Binary image data │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ Validation: │ │
|
|
||||||
│ │ ├─► MIME type: image/jpeg | png | webp │ │
|
|
||||||
│ │ ├─► File size: < 10MB │ │
|
|
||||||
│ │ └─► Not empty/broken │ │
|
|
||||||
│ └─────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ 2. Convert to base64 │
|
|
||||||
│ ┌─────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ Buffer.toString('base64') │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ Result: "data:image/jpeg;base64,/9j/4AAQSkZJR..." │ │
|
|
||||||
│ └─────────────────────────────────────────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Stage 3: Gender Detection (Pre-Filter)
|
## Chat and media synchronization
|
||||||
|
|
||||||
```
|
`ChatService` communicates with Reddit's Matrix homeserver, maintains an account-scoped SQLite-backed cache, resolves `t2_` identities to Reddit usernames, and emits sync progress/new-reply callbacks. Startup chooses full or delta sync from persisted cursor state. Operators can explicitly run tracked sync jobs through the versioned API.
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ OpenRouterService │
|
|
||||||
│ │
|
|
||||||
│ 1. detectGenderOnly(imageBase64) │
|
|
||||||
│ ┌─────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ HTTP: POST https://openrouter.ai/api/v1/chat/ │ │
|
|
||||||
│ │ completions │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ Request: { │ │
|
|
||||||
│ │ model: "fast-model", │ │
|
|
||||||
│ │ messages: [ │ │
|
|
||||||
│ │ { │ │
|
|
||||||
│ │ role: "user", │ │
|
|
||||||
│ │ content: [ │ │
|
|
||||||
│ │ { type: "text", text: "Is this F or M?" }, │ │
|
|
||||||
│ │ { type: "image_url", image_url: {...} } │ │
|
|
||||||
│ │ ] │ │
|
|
||||||
│ │ } │ │
|
|
||||||
│ │ ] │ │
|
|
||||||
│ │ } │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ Response: { │ │
|
|
||||||
│ │ choices: [{ message: { content: "F" } }] │ │
|
|
||||||
│ │ } │ │
|
|
||||||
│ └─────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ 3. Compare with config.targetGender │
|
|
||||||
│ ├─► Match: continue to full analysis │
|
|
||||||
│ └─► No match: SKIP post (log: "Gender mismatch, skipping") │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Stage 4: Strategy Selection
|
Each authenticated account runtime also checks the Reddit profiles represented in its cached conversations every four hours. Profile results are shared process-wide by case-insensitive username, so a recently checked recipient is not looked up again by another account runtime. A valid profile is recorded as active, Reddit's explicit suspension marker is recorded separately, and only an HTTP 404 is recorded as deleted; authentication, rate-limit, network, server, and malformed-response failures do not change the previous state. The dashboard removes deleted recipients from actionable conversation queues and exposes them through the dedicated **Account deleted** bucket.
|
||||||
|
|
||||||
```
|
`MediaService` resolves Matrix media, downloads it into the persistent data directory, and writes an account-scoped media index. Each Matrix message occurrence gets its own gallery record, while repeated occurrences of the same remote asset reuse one private file. Generic download content types are verified from the image bytes before an item is rejected as unsupported.
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ StrategySelector │
|
|
||||||
│ │
|
|
||||||
│ 1. Get strategy statistics from DB │
|
|
||||||
│ ┌─────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ MessagesRepository.getStrategyStats() │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ Result: { │ │
|
|
||||||
│ │ variant_a: { sent: 50, responded: 15, rate: 0.30 },│
|
|
||||||
│ │ variant_b: { sent: 30, responded: 12, rate: 0.40 },│
|
|
||||||
│ │ variant_c: { sent: 5, responded: 1, rate: 0.20 } │
|
|
||||||
│ │ } │ │
|
|
||||||
│ └─────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ 2. Apply exploration/exploit algorithm │
|
|
||||||
│ ├─► Random roll < explorationRate → pick random variant │
|
|
||||||
│ └─► Otherwise → pick highest response rate (min samples) │
|
|
||||||
│ │
|
|
||||||
│ 3. Return selected variant name │
|
|
||||||
│ Example: "variant_b" │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Stage 5: Full AI Analysis
|
`MediaSyncService` schedules incremental work only while the account is authenticated. Incremental sync consumes the rooms changed since the durable Matrix cursor; the reported count is the number of rooms Matrix actually returned, not an assumed inbox limit. The cursor advances only after media attachment and cache persistence succeed, so a transient media failure is retried. When Matrix marks a room timeline as limited, chat sync paginates that room's history before advancing the cursor to close the omitted-event gap.
|
||||||
|
|
||||||
```
|
Full media sync walks every joined room and paginates until Matrix reports the end of history. Per-room history or media failures leave the full-sync marker unset and make an operator-triggered full media sync fail visibly; a later full sync can retry the incomplete rooms. Startup may retain the successfully loaded partial cache for availability, but it does not claim that a full backfill completed.
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ OpenRouterService │
|
|
||||||
│ │
|
|
||||||
│ 1. analyzeImage(imageBase64) │
|
|
||||||
│ ┌─────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ HTTP: POST https://openrouter.ai/api/v1/chat/ │ │
|
|
||||||
│ │ completions │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ Request: { │ │
|
|
||||||
│ │ model: "primary-model", │ │
|
|
||||||
│ │ messages: [ │ │
|
|
||||||
│ │ { │ │
|
|
||||||
│ │ role: "system", │ │
|
|
||||||
│ │ content: config.openrouter.systemPrompt │ │
|
|
||||||
│ │ }, │ │
|
|
||||||
│ │ { │ │
|
|
||||||
│ │ role: "user", │ │
|
|
||||||
│ │ content: [ │ │
|
|
||||||
│ │ { type: "text", text: "Describe this image" },│
|
|
||||||
│ │ { type: "image_url", image_url: {...} } │ │
|
|
||||||
│ │ ] │ │
|
|
||||||
│ │ } │ │
|
|
||||||
│ │ ] │ │
|
|
||||||
│ │ } │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ Response: { │ │
|
|
||||||
│ │ choices: [{ │ │
|
|
||||||
│ │ message: { │ │
|
|
||||||
│ │ content: '{"gender":"F","description":"..."}' │ │
|
|
||||||
│ │ } │ │
|
|
||||||
│ │ }] │ │
|
|
||||||
│ │ } │ │
|
|
||||||
│ └─────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ 2. Check for refusal │
|
|
||||||
│ ├─► If refused → try fallback models │
|
|
||||||
│ └─► If all failed → mark post with error, skip │
|
|
||||||
│ │
|
|
||||||
│ 3. Parse and return ImageAnalysis │
|
|
||||||
│ { gender: "F", description: "Young woman, blonde hair..." }│
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Stage 6: Message Generation
|
The media listing reads persisted indexes for every stored account, including accounts whose runtimes are stopped or require login, and exposes received items only: records attributed to the owning Reddit/Matrix account are excluded, including historical self-sender forms. Private media is served behind the same authorization boundary as the dashboard and APIs.
|
||||||
|
|
||||||
```
|
The process-global exchange library is separate. Dashboard uploads are decoded and re-encoded with `sharp`, which validates the actual JPEG/PNG/WebP contents, applies orientation, and removes EXIF/location/camera metadata. OpenRouter then assigns overlapping anatomy tags plus SFW/NSFW classification. Failed or low-confidence classifications remain disabled until the operator corrects them.
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ OpenRouterService │
|
|
||||||
│ │
|
|
||||||
│ 1. Retrieve few-shot examples │
|
|
||||||
│ ┌─────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ SuccessfulMessagesRepository.getExamples( │ │
|
|
||||||
│ │ subreddit, count: 3 │ │
|
|
||||||
│ │ ) │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ Result: [ │ │
|
|
||||||
│ │ { │ │
|
|
||||||
│ │ original_message: "Hey, you look amazing...", │ │
|
|
||||||
│ │ response_snippet: "Thank you so much!", │ │
|
|
||||||
│ │ subreddit: "gonewild" │ │
|
|
||||||
│ │ }, │ │
|
|
||||||
│ │ // ... more examples │ │
|
|
||||||
│ │ ] │ │
|
|
||||||
│ └─────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ 2. Generate message │
|
|
||||||
│ ┌─────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ HTTP: POST https://openrouter.ai/api/v1/chat/ │ │
|
|
||||||
│ │ completions │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ Request: { │ │
|
|
||||||
│ │ model: "primary-model", │ │
|
|
||||||
│ │ messages: [ │ │
|
|
||||||
│ │ { role: "system", content: messagePrompt }, │ │
|
|
||||||
│ │ { role: "user", content: [ │ │
|
|
||||||
│ │ `Image: ${imageAnalysis.description}`, │ │
|
|
||||||
│ │ `Post title: ${postTitle}`, │ │
|
|
||||||
│ │ `Strategy: ${selectedVariant}`, │ │
|
|
||||||
│ │ `Examples: ${JSON.stringify(examples)}` │ │
|
|
||||||
│ │ ] │ │
|
|
||||||
│ │ } │ │
|
|
||||||
│ │ ] │ │
|
|
||||||
│ │ } │ │
|
|
||||||
│ └─────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ 3. Return generated message │
|
|
||||||
│ "Hey, I came across your post and..." │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Stage 7: DM Sending
|
For each latest inbound text event, `MediaLibraryExchangeService` detects direct or indirect image requests before normal reply generation. Exact requests require the matching tag. Generic requests use the conversation's completed library-send count to broaden the eligible safety tier and randomly choose an unused asset. A unique durable reservation on account + conversation + asset prevents reuse, while account + conversation + inbound event makes restart handling idempotent.
|
||||||
|
|
||||||
```
|
After the normal reply limiter waits, automation refreshes Matrix and rechecks that the same inbound request is still latest and that no image, boundary, seller signal, or other terminal state has appeared. It then uploads the private file to Matrix, sends an `m.image` event with a stable transaction ID, and only after confirmation sends the optional reciprocal text. If the image is confirmed but the text is not, the asset remains consumed and the conversation is handed to the operator rather than risking a duplicate.
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ MessagePage │
|
|
||||||
│ │
|
|
||||||
│ 1. Navigate to compose page │
|
|
||||||
│ ┌─────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ page.goto("https://www.reddit.com/compose?to= │ │
|
|
||||||
│ │ user123") │ │
|
|
||||||
│ └─────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ 2. Fill recipient field │
|
|
||||||
│ ┌─────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ page.evaluate(() => { │ │
|
|
||||||
│ │ const input = document.querySelector( │ │
|
|
||||||
│ │ 'faceplate-autocomplete input' │ │
|
|
||||||
│ │ ); │ │
|
|
||||||
│ │ input.value = "user123"; │ │
|
|
||||||
│ │ input.dispatchEvent(new Event('input', │ │
|
|
||||||
│ │ { bubbles: true })); │ │
|
|
||||||
│ │ }); │ │
|
|
||||||
│ └─────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ 3. Fill subject field ("Stunning") │
|
|
||||||
│ 4. Fill body field (generated message) │
|
|
||||||
│ 5. Click send button │
|
|
||||||
│ 6. Verify success (check for confirmation) │
|
|
||||||
│ │
|
|
||||||
│ Result: true (sent) or false (failed) │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Stage 8: Database Recording
|
The conversation UI also supports an explicitly confirmed manual choice from this same eligible set. Its read endpoint reports request-matched options without exposing filesystem paths. The send endpoint refreshes the conversation inside the owning account's rate limiter, rechecks the latest inbound request and safety boundaries, reserves the selected asset in the same durable ledger, and uses the same Matrix image-plus-reciprocal delivery path. A successful manual share marks automation as user-overridden.
|
||||||
|
|
||||||
```
|
Conversation performance is derived from the complete attributed inbound transcript. Engagement cannot become a prompt-learning example while automation is active, and a later decline, boundary, or upsell removes an earlier example before future prompts load it.
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ Database │
|
|
||||||
│ │
|
|
||||||
│ 1. INSERT INTO processed_posts │
|
|
||||||
│ VALUES ( │
|
|
||||||
│ 'abc123', -- post_id │
|
|
||||||
│ 'gonewild', -- subreddit │
|
|
||||||
│ 'user123', -- author │
|
|
||||||
│ 'Summer vibes', -- title │
|
|
||||||
│ 'https://...', -- image_url │
|
|
||||||
│ '{"gender":"F",...}', -- image_analysis │
|
|
||||||
│ 'Hey, I came across...', -- generated_message │
|
|
||||||
│ 1716234567, -- processed_at │
|
|
||||||
│ 1, -- message_sent │
|
|
||||||
│ NULL -- error │
|
|
||||||
│ ); │
|
|
||||||
│ │
|
|
||||||
│ 2. INSERT INTO sent_messages │
|
|
||||||
│ VALUES ( │
|
|
||||||
│ NULL, -- id (auto) │
|
|
||||||
│ 'abc123', -- post_id │
|
|
||||||
│ 'user123', -- recipient │
|
|
||||||
│ 'Stunning', -- subject │
|
|
||||||
│ 'Hey, I came across...', -- message │
|
|
||||||
│ 1716234567, -- sent_at │
|
|
||||||
│ 'variant_b', -- strategy_id │
|
|
||||||
│ 0, - - got_response │
|
|
||||||
│ NULL -- response_checked_at │
|
|
||||||
│ ); │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
## Dashboard data and events
|
||||||
|
|
||||||
## Response Monitoring Flow
|
Routes assemble snapshots from repositories and injected services. `LogBroadcaster`/the realtime event hub publishes typed events for bot state, sends, failures, conversations, replies, sync, sessions, rate limits, and logs. The React client validates incoming events, invalidates matching TanStack Query keys, and reconnects with replay support. Cached screens remain visible but stale during a disconnect.
|
||||||
|
|
||||||
How the bot detects and records user replies to sent DMs.
|
## Failure classification
|
||||||
|
|
||||||
```
|
Use three categories consistently:
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ ResponseCheckerService │
|
|
||||||
│ │
|
|
||||||
│ 1. Navigate to inbox │
|
|
||||||
│ ┌─────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ page.goto("https://www.reddit.com/message/inbox") │ │
|
|
||||||
│ └─────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ 2. Parse inbox items │
|
|
||||||
│ ┌─────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ Extract from page: │ │
|
|
||||||
│ │ [ │ │
|
|
||||||
│ │ { │ │
|
|
||||||
│ │ from: "user456", │ │
|
|
||||||
│ │ subject: "Re: Stunning", │ │
|
|
||||||
│ │ snippet: "Thank you so much! 😊", │ │
|
|
||||||
│ │ timestamp: 1716234000, │ │
|
|
||||||
│ │ link: "/message/xyz789" │ │
|
|
||||||
│ │ }, │ │
|
|
||||||
│ │ // ... more messages │ │
|
|
||||||
│ │ ] │ │
|
|
||||||
│ └─────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ 3. Match responses to sent messages │
|
|
||||||
│ ┌─────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ For each inbox item: │ │
|
|
||||||
│ │ ├─► MessagesRepository.getByRecipient(from) │ │
|
|
||||||
│ │ ├─► Find unmatched message within time window │ │
|
|
||||||
│ │ ├─► Update: got_response = 1 │ │
|
|
||||||
│ │ ├─► Update: response_checked_at = now │ │
|
|
||||||
│ │ └─► Store in successful_messages │ │
|
|
||||||
│ └─────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ 4. Update strategy statistics │
|
|
||||||
│ ┌─────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ MessagesRepository.updateStrategyStats( │ │
|
|
||||||
│ │ strategy_id, got_response: true │ │
|
|
||||||
│ │ ) │ │
|
|
||||||
│ └─────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ 5. Log results │
|
|
||||||
│ └─► "Found 3 responses, updated 2 strategies" │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
- Terminal: the candidate/action is unsuitable or impossible; persist a reason and do not retry automatically.
|
||||||
|
- Retryable: service limits, temporary auth/network errors, or account cooldown; preserve the candidate and retry time.
|
||||||
|
- Human-required: policy or safety cannot continue autonomously; stop automation and surface an attention item.
|
||||||
|
|
||||||
## Chat Interaction Flow
|
Avoid converting errors into generic skips. Operational diagnosis depends on preserving which stage failed and why.
|
||||||
|
|
||||||
How the bot interacts with Reddit chat conversations.
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ ChatService │
|
|
||||||
│ │
|
|
||||||
│ ──── PHASE 1: FETCH CHATS ────────────────────────────────── │
|
|
||||||
│ │
|
|
||||||
│ 1. Navigate to chat page │
|
|
||||||
│ page.goto("https://chat.reddit.com") │
|
|
||||||
│ │
|
|
||||||
│ 2. Traverse shadow DOM for conversations │
|
|
||||||
│ ┌─────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ page.evaluate(() => { │ │
|
|
||||||
│ │ // JS injection to pierce shadow DOM │ │
|
|
||||||
│ │ const roots = document.querySelectorAll( │ │
|
|
||||||
│ │ 'faceplate-chat-list' │ │
|
|
||||||
│ │ ); │ │
|
|
||||||
│ │ const chats = []; │ │
|
|
||||||
│ │ roots.forEach(root => { │ │
|
|
||||||
│ │ const shadow = root.shadowRoot; │ │
|
|
||||||
│ │ const items = shadow.querySelectorAll( │ │
|
|
||||||
│ │ '.chat-item' │ │
|
|
||||||
│ │ ); │ │
|
|
||||||
│ │ items.forEach(item => { │ │
|
|
||||||
│ │ chats.push({ │ │
|
|
||||||
│ │ id: item.dataset.chatId, │ │
|
|
||||||
│ │ participant: item.querySelector( │ │
|
|
||||||
│ │ '.username' │ │
|
|
||||||
│ │ ).textContent, │ │
|
|
||||||
│ │ lastMessage: item.querySelector( │ │
|
|
||||||
│ │ '.message' │ │
|
|
||||||
│ │ ).textContent, │ │
|
|
||||||
│ │ timestamp: item.dataset.timestamp │ │
|
|
||||||
│ │ }); │ │
|
|
||||||
│ │ }); │ │
|
|
||||||
│ │ }); │ │
|
|
||||||
│ │ return chats; │ │
|
|
||||||
│ │ }); │ │
|
|
||||||
│ └─────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ 3. Cache results │
|
|
||||||
│ └─► Save to memory + disk JSON │
|
|
||||||
│ │
|
|
||||||
│ ──── PHASE 2: LOAD CONVERSATION ──────────────────────────── │
|
|
||||||
│ │
|
|
||||||
│ 1. Check cache │
|
|
||||||
│ ├─► If cached and fresh: return from memory │
|
|
||||||
│ └─► If not: fetch from disk or Reddit │
|
|
||||||
│ │
|
|
||||||
│ 2. Fetch full history (pagination) │
|
|
||||||
│ ┌─────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ For each page of messages: │ │
|
|
||||||
│ │ ├─► Navigate to chat view │ │
|
|
||||||
│ │ ├─► Extract messages from shadow DOM │ │
|
|
||||||
│ │ ├─► Scroll to load more (infinite scroll) │ │
|
|
||||||
│ │ └─► Repeat until all loaded │ │
|
|
||||||
│ └─────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ 3. Return conversation object │
|
|
||||||
│ { │
|
|
||||||
│ chatId: "abc123", │
|
|
||||||
│ participants: ["user456", "bot"], │
|
|
||||||
│ messages: [ │
|
|
||||||
│ { author: "user456", text: "Hey!", timestamp: ... }, │
|
|
||||||
│ { author: "bot", text: "Hi there!", timestamp: ... }, │
|
|
||||||
│ // ... more messages │
|
|
||||||
│ ], │
|
|
||||||
│ lastUpdated: 1716234567 │
|
|
||||||
│ } │
|
|
||||||
│ │
|
|
||||||
│ ──── PHASE 3: SEND MESSAGE ───────────────────────────────── │
|
|
||||||
│ │
|
|
||||||
│ 1. Navigate to chat view │
|
|
||||||
│ 2. Fill message input (shadow DOM) │
|
|
||||||
│ 3. Apply typing delay (human-like) │
|
|
||||||
│ ┌─────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ const typingDelay = random(500, 2000); │ │
|
|
||||||
│ │ await page.waitForTimeout(typingDelay); │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ // Simulate typing character by character │ │
|
|
||||||
│ │ for (const char of message) { │ │
|
|
||||||
│ │ input.value += char; │ │
|
|
||||||
│ │ await page.waitForTimeout(random(30, 100)); │ │
|
|
||||||
│ │ } │ │
|
|
||||||
│ └─────────────────────────────────────────────────────┘ │
|
|
||||||
│ 4. Submit message │
|
|
||||||
│ 5. Verify delivery (check for sent indicator) │
|
|
||||||
│ │
|
|
||||||
│ Result: true (delivered) or false (failed) │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Strategy Selection Flow
|
|
||||||
|
|
||||||
How A/B test variants are selected and updated.
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ StrategySelector │
|
|
||||||
│ │
|
|
||||||
│ SELECT STRATEGY │
|
|
||||||
│ ──────────────────────────────────────────────────────────── │
|
|
||||||
│ │
|
|
||||||
│ Input: subreddit, author │
|
|
||||||
│ │
|
|
||||||
│ 1. Get stats from DB │
|
|
||||||
│ ┌─────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ SELECT strategy_id, │ │
|
|
||||||
│ │ COUNT(*) as sent, │ │
|
|
||||||
│ │ SUM(got_response) as responded │ │
|
|
||||||
│ │ FROM sent_messages │ │
|
|
||||||
│ │ GROUP BY strategy_id │ │
|
|
||||||
│ └─────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ 2. Calculate response rates │
|
|
||||||
│ variant_a: 15/50 = 30% │
|
|
||||||
│ variant_b: 12/30 = 40% │
|
|
||||||
│ variant_c: 1/5 = 20% │
|
|
||||||
│ │
|
|
||||||
│ 3. Filter by minimum samples │
|
|
||||||
│ minSamples = 10 │
|
|
||||||
│ variant_c excluded (only 5 sends) │
|
|
||||||
│ │
|
|
||||||
│ 4. Explore vs Exploit │
|
|
||||||
│ random() < explorationRate (e.g., 0.15)? │
|
|
||||||
│ ├─► Yes: Random pick from enabled variants │
|
|
||||||
│ └─► No: Pick highest rate (variant_b at 40%) │
|
|
||||||
│ │
|
|
||||||
│ Output: selected variant name │
|
|
||||||
│ │
|
|
||||||
│ ──── UPDATE STATS ────────────────────────────────────────── │
|
|
||||||
│ │
|
|
||||||
│ Input: strategy_id, got_response (boolean) │
|
|
||||||
│ │
|
|
||||||
│ 1. Record in sent_messages │
|
|
||||||
│ UPDATE sent_messages │
|
|
||||||
│ SET got_response = 1, │
|
|
||||||
│ response_checked_at = now │
|
|
||||||
│ WHERE id = ? │
|
|
||||||
│ │
|
|
||||||
│ 2. If successful: store in successful_messages │
|
|
||||||
│ INSERT INTO successful_messages │
|
|
||||||
│ VALUES (original_message, image_analysis, post_title, │
|
|
||||||
│ response_snippet, subreddit, now) │
|
|
||||||
│ │
|
|
||||||
│ 3. Update strategy statistics │
|
|
||||||
│ (reflected in next selectStrategy call) │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Media Sync Flow
|
|
||||||
|
|
||||||
How media files are downloaded and synchronized from chat conversations.
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ MediaSyncService │
|
|
||||||
│ │
|
|
||||||
│ ──── TRIGGER ──────────────────────────────────────────────── │
|
|
||||||
│ │
|
|
||||||
│ Scheduled: every N minutes (config.mediaSync.intervalMinutes) │
|
|
||||||
│ Manual: POST /api/media/sync (incremental) │
|
|
||||||
│ Manual: POST /api/media/sync/full (full) │
|
|
||||||
│ │
|
|
||||||
│ ──── INCREMENTAL SYNC ────────────────────────────────────── │
|
|
||||||
│ │
|
|
||||||
│ 1. Get top 10 conversations from ChatService │
|
|
||||||
│ 2. For each conversation: │
|
|
||||||
│ ├─► Check if media already downloaded (index lookup) │
|
|
||||||
│ ├─► If not: download media │
|
|
||||||
│ │ └─► MediaService.downloadMedia(chatId) │
|
|
||||||
│ └─► Update index │
|
|
||||||
│ │
|
|
||||||
│ ──── FULL SYNC ────────────────────────────────────────────── │
|
|
||||||
│ │
|
|
||||||
│ 1. Get ALL conversations from ChatService │
|
|
||||||
│ 2. For each conversation: │
|
|
||||||
│ ├─► Download any missing media │
|
|
||||||
│ └─► Update index │
|
|
||||||
│ │
|
|
||||||
│ ──── MEDIA DOWNLOAD (MediaService) ────────────────────────── │
|
|
||||||
│ │
|
|
||||||
│ 1. Extract media URLs from conversation messages │
|
|
||||||
│ 2. For each URL: │
|
|
||||||
│ ├─► Check if Matrix thumbnail │
|
|
||||||
│ │ └─► Resolve to full resolution URL │
|
|
||||||
│ ├─► Download via Playwright │
|
|
||||||
│ ├─► Compute SHA-1 hash │
|
|
||||||
│ ├─► Check if already exists (dedup) │
|
|
||||||
│ ├─► Save to data/media/{hash}.{ext} │
|
|
||||||
│ └─► Update index JSON │
|
|
||||||
│ │
|
|
||||||
│ Output: Array of saved file paths │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Auto-Reply Flow
|
|
||||||
|
|
||||||
How the bot automatically replies to chat messages.
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ AutoReplyService │
|
|
||||||
│ │
|
|
||||||
│ Scheduled: every N minutes (config.autoReply.checkInterval) │
|
|
||||||
│ │
|
|
||||||
│ 1. Get cached conversations │
|
|
||||||
│ 2. For each conversation: │
|
|
||||||
│ │ │
|
|
||||||
│ ├─► Check for new/unread messages │
|
|
||||||
│ │ └─► If no new messages: continue │
|
|
||||||
│ │ │
|
|
||||||
│ ├─► Detect upsell/spam keywords │
|
|
||||||
│ │ ├─► "onlyfans", "link in bio", "check my..." │
|
|
||||||
│ │ ├─► If detected: flag conversation, skip reply │
|
|
||||||
│ │ └─► If not: continue │
|
|
||||||
│ │ │
|
|
||||||
│ ├─► Generate AI reply suggestion │
|
|
||||||
│ │ ┌─────────────────────────────────────────────────┐ │
|
|
||||||
│ │ │ OpenRouter.generateReply(conversationHistory) │ │
|
|
||||||
│ │ │ │ │
|
|
||||||
│ │ │ Request: { │ │
|
|
||||||
│ │ │ messages: [ │ │
|
|
||||||
│ │ │ { role: "system", content: replyPrompt }, │ │
|
|
||||||
│ │ │ { role: "user", content: conversation } │ │
|
|
||||||
│ │ │ ] │ │
|
|
||||||
│ │ │ } │ │
|
|
||||||
│ │ │ │ │
|
|
||||||
│ │ │ Response: "Thanks for reaching out! 😊" │ │
|
|
||||||
│ │ │ │ │
|
|
||||||
│ │ │ Reply: "Thanks for reaching out! 😊" │ │
|
|
||||||
│ │ └─────────────────────────────────────────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ├─► If autoSend = true: │
|
|
||||||
│ │ └─► ChatService.sendMessage(chatId, reply) │
|
|
||||||
│ │ └─► Log: "Auto-sent reply to {user}" │
|
|
||||||
│ │ │
|
|
||||||
│ └─► If autoSend = false: │
|
|
||||||
│ └─► Mark as pending (visible in dashboard) │
|
|
||||||
│ └─► User can approve/reject via dashboard │
|
|
||||||
│ │
|
|
||||||
│ Output: Summary of actions taken │
|
|
||||||
│ "Checked 15 conversations: 3 new messages, 1 reply sent" │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dashboard Data Flow
|
|
||||||
|
|
||||||
How data flows from services to the React operator console.
|
|
||||||
|
|
||||||
### Current React model
|
|
||||||
|
|
||||||
1. Feature-local API clients request authoritative snapshots from `/api/*`.
|
|
||||||
2. Shared Zod schemas in `packages/api-contracts` validate HTTP responses and
|
|
||||||
realtime envelopes at runtime.
|
|
||||||
3. TanStack Query caches snapshots, preserves them during background refresh,
|
|
||||||
and owns mutation pending/error/success state.
|
|
||||||
4. Operations opens `/api/events/stream`. Each typed event invalidates the
|
|
||||||
affected query keys, such as stats, timers, conversations, media, or
|
|
||||||
operations activity.
|
|
||||||
5. Browser `EventSource` reconnects automatically. The client marks cached
|
|
||||||
state stale while reconnecting instead of blanking the UI.
|
|
||||||
|
|
||||||
```text
|
|
||||||
services/repositories -> Express /api snapshots -> Zod -> TanStack Query -> React routes
|
|
||||||
| ^
|
|
||||||
+-> realtime event hub -> /api/events/stream --------+
|
|
||||||
```
|
|
||||||
|
|
||||||
### Retired legacy model
|
|
||||||
|
|
||||||
The historical diagram below is retained for migration context only.
|
|
||||||
`public/index.html` has been removed and is no longer served.
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ WebServer (Express) │
|
|
||||||
│ │
|
|
||||||
│ ──── REST API ─────────────────────────────────────────────── │
|
|
||||||
│ │
|
|
||||||
│ GET /api/stats │
|
|
||||||
│ ├─► Read from: │
|
|
||||||
│ │ ├─► Bot running state (in-memory) │
|
|
||||||
│ │ ├─► PostsRepository.countProcessed() │
|
|
||||||
│ │ ├─► MessagesRepository.countSent() │
|
|
||||||
│ │ ├─► MessagesRepository.countResponded() │
|
|
||||||
│ │ └─► Calculate response rate │
|
|
||||||
│ └─► Return: { running, processed, sent, responded, rate } │
|
|
||||||
│ │
|
|
||||||
│ GET /api/messages │
|
|
||||||
│ └─► MessagesRepository.getRecent(limit: 50) │
|
|
||||||
│ │
|
|
||||||
│ GET /api/analytics/by-strategy │
|
|
||||||
│ └─► MessagesRepository.getStrategyStats() │
|
|
||||||
│ │
|
|
||||||
│ PUT /api/config │
|
|
||||||
│ ├─► Validate new config values │
|
|
||||||
│ ├─► Update in-memory config │
|
|
||||||
│ └─► Return updated config │
|
|
||||||
│ │
|
|
||||||
│ ──── LEGACY SSE LOG STREAMING ─────────────────────────────── │
|
|
||||||
│ │
|
|
||||||
│ GET /api/logs/stream │
|
|
||||||
│ ├─► Client connects │
|
|
||||||
│ ├─► Send any pending log entries │
|
|
||||||
│ ├─► Subscribe to logger buffer │
|
|
||||||
│ ├─► Push new entries as they arrive │
|
|
||||||
│ │ └─► Event: log │
|
|
||||||
│ │ └─► Data: { level, message, timestamp } │
|
|
||||||
│ └─► On disconnect: unsubscribe │
|
|
||||||
│ │
|
|
||||||
│ ──── RETIRED LEGACY IMPLEMENTATION ────────────────────────── │
|
|
||||||
│ │
|
|
||||||
│ JavaScript polls API endpoints: │
|
|
||||||
│ ├─► /api/stats every 5 seconds │
|
|
||||||
│ ├─► /api/messages every 10 seconds │
|
|
||||||
│ ├─► /api/timers every 2 seconds │
|
|
||||||
│ └─► SSE /api/logs/stream (persistent connection) │
|
|
||||||
│ │
|
|
||||||
│ Updates DOM in real-time: │
|
|
||||||
│ ├─► Stats cards (processed, sent, responded, rate) │
|
|
||||||
│ ├─► Activity feed (scrolling list) │
|
|
||||||
│ ├─► Message viewer (click to expand) │
|
|
||||||
│ ├─► Chat browser (conversation list) │
|
|
||||||
│ ├─► Media gallery (image grid) │
|
|
||||||
│ └─► Rate limiter timers (countdown) │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Error Recovery Flows
|
|
||||||
|
|
||||||
### AI Refusal Recovery
|
|
||||||
|
|
||||||
```
|
|
||||||
1. OpenRouter.analyzeImage() returns refusal
|
|
||||||
│
|
|
||||||
├─► Detect refusal via heuristic patterns
|
|
||||||
│ ├─► "cannot analyze"
|
|
||||||
│ ├─► "against policy"
|
|
||||||
│ └─► "unable to process"
|
|
||||||
│
|
|
||||||
├─► Try next model in fallback chain
|
|
||||||
│ ├─► OpenRouter.fallbackModels[0]
|
|
||||||
│ ├─► OpenRouter.fallbackModels[1]
|
|
||||||
│ └─► ...
|
|
||||||
│
|
|
||||||
└─► If all models refuse:
|
|
||||||
├─► Mark post as processed with error
|
|
||||||
├─► Log: "All models refused to analyze post {id}"
|
|
||||||
└─► Continue to next post
|
|
||||||
```
|
|
||||||
|
|
||||||
### Network Error Recovery
|
|
||||||
|
|
||||||
```
|
|
||||||
1. RedditApi.fetchPosts() throws error
|
|
||||||
│
|
|
||||||
└─► withRetry() wrapper handles recovery:
|
|
||||||
├─► Retry 1: wait 5s
|
|
||||||
├─► Retry 2: wait 15s
|
|
||||||
├─► Retry 3: wait 45s
|
|
||||||
└─► After 3 failures:
|
|
||||||
├─► Log error
|
|
||||||
├─► Skip this subreddit
|
|
||||||
└─► Continue to next subreddit
|
|
||||||
```
|
|
||||||
|
|
||||||
### Account Session Recovery
|
|
||||||
|
|
||||||
```
|
|
||||||
1. Reddit or Matrix authentication is rejected
|
|
||||||
│
|
|
||||||
├─► Invalidate the shared account-auth gate
|
|
||||||
├─► Stop account-bound schedulers
|
|
||||||
└─► Wait for a fresh combined dump from the Chrome extension
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data Lifecycle
|
|
||||||
|
|
||||||
### Post Data
|
|
||||||
|
|
||||||
```
|
|
||||||
Discovered (MonitorService)
|
|
||||||
│
|
|
||||||
├─► Stored in processed_posts (pending)
|
|
||||||
│
|
|
||||||
├─► Processed (ProcessorService)
|
|
||||||
│ ├─► image_analysis updated
|
|
||||||
│ ├─► generated_message updated
|
|
||||||
│ └─► message_sent updated
|
|
||||||
│
|
|
||||||
└─► Archived (retention policy TBD)
|
|
||||||
└─► Kept indefinitely for analytics
|
|
||||||
```
|
|
||||||
|
|
||||||
### Message Data
|
|
||||||
|
|
||||||
```
|
|
||||||
Generated (ProcessorService)
|
|
||||||
│
|
|
||||||
├─► Stored in sent_messages (got_response = 0)
|
|
||||||
│
|
|
||||||
├─► Response checked (ResponseCheckerService)
|
|
||||||
│ ├─► got_response = 1
|
|
||||||
│ └─► response_checked_at set
|
|
||||||
│
|
|
||||||
├─► If responded:
|
|
||||||
│ └─► Copied to successful_messages (for learning)
|
|
||||||
│
|
|
||||||
└─► Retained indefinitely for analytics
|
|
||||||
```
|
|
||||||
|
|
||||||
### Learning Data
|
|
||||||
|
|
||||||
```
|
|
||||||
Successful pair created (ResponseCheckerService)
|
|
||||||
│
|
|
||||||
├─► Stored in successful_messages
|
|
||||||
│ ├─► original_message
|
|
||||||
│ ├─► image_analysis
|
|
||||||
│ ├─► post_title
|
|
||||||
│ ├─► response_snippet
|
|
||||||
│ └─► subreddit (for weighted selection)
|
|
||||||
│
|
|
||||||
├─► Retrieved for few-shot learning
|
|
||||||
│ └─► Included in OpenRouter.generateMessage() prompt
|
|
||||||
│
|
|
||||||
└─► Retained indefinitely (grows bot intelligence)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Chat Data
|
|
||||||
|
|
||||||
```
|
|
||||||
Conversation discovered (ChatService)
|
|
||||||
│
|
|
||||||
├─► Cached in memory
|
|
||||||
├─► Persisted to disk JSON
|
|
||||||
│
|
|
||||||
├─► Messages added as they arrive
|
|
||||||
│
|
|
||||||
├─► Media downloaded (MediaService)
|
|
||||||
│ └─► Stored in data/media/
|
|
||||||
│
|
|
||||||
└─► Retained until manually cleared
|
|
||||||
└─► No automatic cleanup (TBD)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Media Data
|
|
||||||
|
|
||||||
```
|
|
||||||
Media URL discovered
|
|
||||||
│
|
|
||||||
├─► Downloaded (MediaService)
|
|
||||||
│ ├─► Matrix thumbnails resolved
|
|
||||||
│ ├─► SHA-1 hash computed
|
|
||||||
│ └─► Saved to data/media/{hash}.{ext}
|
|
||||||
│
|
|
||||||
├─► Indexed in data/media/index.json
|
|
||||||
│
|
|
||||||
├─► Deduplicated (hash check before download)
|
|
||||||
│
|
|
||||||
└─► Retained until manually cleared
|
|
||||||
└─► No automatic cleanup (TBD)
|
|
||||||
```
|
|
||||||
+81
-513
@@ -1,550 +1,118 @@
|
|||||||
# 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)
|
## Ownership categories
|
||||||
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
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `successful_messages` Indexes
|
### Account registry and account content
|
||||||
|
|
||||||
```sql
|
- `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.
|
||||||
CREATE INDEX idx_success_subreddit ON successful_messages(subreddit);
|
- `reddit_conversations`: account-scoped cached Matrix conversation payloads.
|
||||||
CREATE INDEX idx_success_message ON successful_messages(sent_message_id);
|
- `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, 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.
|
||||||
|
|
||||||
**Purpose**:
|
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.
|
||||||
- `idx_success_subreddit`: Weighted example selection by subreddit
|
|
||||||
- `idx_success_message`: Link back to sent message
|
|
||||||
|
|
||||||
---
|
### 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
|
### Configuration and telemetry
|
||||||
// Pseudo-code for migration process
|
|
||||||
function runMigrations(db: Database) {
|
|
||||||
// Enable features
|
|
||||||
db.exec("PRAGMA journal_mode=WAL;");
|
|
||||||
db.exec("PRAGMA foreign_keys=ON;");
|
|
||||||
|
|
||||||
// Create tables if they don't exist
|
- `app_config`: one secret-free validated application configuration record.
|
||||||
db.exec(`CREATE TABLE IF NOT EXISTS processed_posts (...)`);
|
- `ai_usage`: requested/actual model, token/cost, latency, success/error, purpose, and account attribution.
|
||||||
db.exec(`CREATE TABLE IF NOT EXISTS sent_messages (...)`);
|
- `legacy_content_imports`: records one-time imports from legacy content stores.
|
||||||
db.exec(`CREATE TABLE IF NOT EXISTS successful_messages (...)`);
|
|
||||||
|
|
||||||
// Create indexes if they don't exist
|
## Critical invariants
|
||||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_posts_subreddit ON processed_posts(subreddit)`);
|
|
||||||
// ... more indexes
|
|
||||||
|
|
||||||
// Safe column additions (for schema evolution)
|
1. Account-produced rows carry `account_id`; cross-account views must opt into global queries explicitly.
|
||||||
db.exec(`ALTER TABLE processed_posts ADD COLUMN error TEXT`);
|
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.
|
||||||
|
|
||||||
### 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`
|
## Repository map
|
||||||
- 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)
|
|
||||||
|
|
||||||
### 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
|
## Candidate leases
|
||||||
// In src/database/client.ts
|
|
||||||
const tableInfo = db.prepare(`PRAGMA table_info(processed_posts)`).all();
|
|
||||||
const hasErrorColumn = tableInfo.some((col: any) => col.name === 'error');
|
|
||||||
|
|
||||||
if (!hasErrorColumn) {
|
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.
|
||||||
db.exec("ALTER TABLE processed_posts ADD COLUMN error TEXT");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
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
|
Changes to this flow must prove at-most-once observable replies across retry and restart tests.
|
||||||
SELECT id FROM processed_posts WHERE id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
**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
|
## Configuration persistence
|
||||||
INSERT OR REPLACE INTO processed_posts
|
|
||||||
(id, subreddit, author, title, image_url, image_analysis,
|
|
||||||
generated_message, processed_at, message_sent, error)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
|
||||||
```
|
|
||||||
|
|
||||||
**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
|
The persisted record excludes:
|
||||||
UPDATE processed_posts
|
|
||||||
SET message_sent = 1
|
|
||||||
WHERE id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
**Update post with analysis**:
|
- `OPENROUTER_API_KEY`;
|
||||||
|
- dashboard/agent tokens;
|
||||||
|
- `DATABASE_PATH`.
|
||||||
|
|
||||||
```sql
|
Those values are supplied by the environment on every load.
|
||||||
UPDATE processed_posts
|
|
||||||
SET image_analysis = ?, generated_message = ?
|
|
||||||
WHERE id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
## 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
|
## Backup and recovery
|
||||||
INSERT INTO sent_messages
|
|
||||||
(post_id, recipient, subject, message, sent_at, strategy_id)
|
|
||||||
VALUES (?, ?, 'Stunning', ?, ?, ?);
|
|
||||||
```
|
|
||||||
|
|
||||||
**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.
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT * FROM sent_messages
|
|
||||||
ORDER BY sent_at DESC
|
|
||||||
LIMIT ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
**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.
|
|
||||||
+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)
|
## Authority and safety
|
||||||
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)
|
|
||||||
|
|
||||||
---
|
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 |
|
## Required environment
|
||||||
|---|---|---|
|
|
||||||
| 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 |
|
|
||||||
|
|
||||||
### Recommended
|
The Compose service passes:
|
||||||
|
|
||||||
| Requirement | Notes |
|
- `OPENROUTER_API_KEY`;
|
||||||
|---|---|
|
- `WEB_DASHBOARD_TOKEN`;
|
||||||
| 1 GB RAM | Minimum for browser automation |
|
- optional `REDDIT_BOT_AGENT_READ_TOKEN`;
|
||||||
| 1 CPU core | More for higher message throughput |
|
- optional `REDDIT_BOT_AGENT_CONTROL_TOKEN`.
|
||||||
| 5 GB disk | For database, media, and browser data |
|
|
||||||
|
|
||||||
---
|
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
|
- SQLite database and WAL files;
|
||||||
git clone <repository-url>
|
- imported browser/account sessions;
|
||||||
cd RedditBot
|
- cached Matrix credentials/state;
|
||||||
npm install
|
- 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
|
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.
|
||||||
reddit:
|
|
||||||
subreddits:
|
|
||||||
- gonewild
|
|
||||||
pollInterval: 15
|
|
||||||
targetGender: "F"
|
|
||||||
sellerKeywords:
|
|
||||||
- seller
|
|
||||||
- onlyfans
|
|
||||||
|
|
||||||
openrouter:
|
1. Confirm the intended commit exists on the tracked remote branch.
|
||||||
apiKey: "sk-or-v1-your-key-here"
|
2. Read current Compose/deployment status and capture the prior image/revision if available.
|
||||||
model: "meta-llama/llama-3.2-90b-vision-preview"
|
3. Confirm whether the bot must remain paused through deployment. Do not change persisted running state unless authorized.
|
||||||
fallbackModels:
|
4. Trigger the bounded Compose deployment.
|
||||||
- "google/gemini-2.0-flash-001"
|
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:
|
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.
|
||||||
minDelayMs: 30000
|
|
||||||
maxMessagesPerHour: 20
|
|
||||||
jitterMs: 15000
|
|
||||||
|
|
||||||
browser:
|
## Health semantics
|
||||||
sessionDir: "./data/browser-session"
|
|
||||||
dryRun: true
|
|
||||||
|
|
||||||
database:
|
- `/healthz`: unauthenticated process liveness only; intentionally reveals no private state.
|
||||||
path: "./data/reddit-bot.db"
|
- `/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:
|
Success should distinguish:
|
||||||
port: 3000
|
|
||||||
|
|
||||||
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
|
## Account session operations
|
||||||
export OPENROUTER_API_KEY="sk-or-v1-your-key"
|
|
||||||
export WEB_DASHBOARD_TOKEN="long-random-secret"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
## Incident diagnosis
|
||||||
# Terminal 1: backend with TypeScript execution
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# Terminal 2: Vite dashboard; proxies /api to port 3000
|
### No new outreach
|
||||||
npm run dashboard:dev
|
|
||||||
|
|
||||||
# Production: compile server and dashboard, then 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.
|
||||||
npm run build
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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.
|
### Conversations appear stale
|
||||||
Production dashboard checks use http://localhost:3000.
|
|
||||||
|
|
||||||
---
|
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
|
### Database problems
|
||||||
docker build -t reddit-bot .
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
## Backup and destructive recovery
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
1. State whether scope is one account, selected content, the RedditBot volume, the Compose application, or the Dokploy instance.
|
||||||
authenticated browser operations need Chromium and its system libraries. It
|
2. Capture and validate a backup.
|
||||||
does not install a display server, VNC, or consumer Google Chrome.
|
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
|
## Routine checks
|
||||||
`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.
|
|
||||||
|
|
||||||
---
|
- 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
|
The concise account activation instructions in [`../DOKPLOY_DEPLOY.md`](../DOKPLOY_DEPLOY.md) complement this operational guide.
|
||||||
|
|
||||||
### 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
|
|
||||||
+84
-470
@@ -1,510 +1,124 @@
|
|||||||
# Development Guide
|
# Development and testing
|
||||||
|
|
||||||
Guide for contributing to RedditBot development.
|
## Prerequisites
|
||||||
|
|
||||||
---
|
- Node.js compatible with the lockfile/native `better-sqlite3` build.
|
||||||
|
- npm.
|
||||||
|
- Playwright system/browser dependencies for browser-backed tests or runtime work.
|
||||||
|
- No live account is required for unit, contract, route, or dashboard tests.
|
||||||
|
|
||||||
## Table of Contents
|
NodeNext imports use `.js` suffixes even when the source file is `.ts`. Vitest globals are disabled, so tests import `describe`, `it`, `expect`, and hooks explicitly.
|
||||||
|
|
||||||
1. [Project Structure](#project-structure)
|
## Safe setup
|
||||||
2. [Development Setup](#development-setup)
|
|
||||||
3. [Coding Standards](#coding-standards)
|
|
||||||
4. [Adding a New Service](#adding-a-new-service)
|
|
||||||
5. [Adding a New Strategy Variant](#adding-a-new-strategy-variant)
|
|
||||||
6. [Debugging](#debugging)
|
|
||||||
7. [Common Tasks](#common-tasks)
|
|
||||||
8. [Known Issues](#known-issues)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
RedditBot/
|
|
||||||
├── config.yaml # Main configuration
|
|
||||||
├── package.json # Dependencies and scripts
|
|
||||||
├── tsconfig.json # TypeScript config
|
|
||||||
├── Dockerfile # Container build
|
|
||||||
├── docker-compose.yml # Docker Compose
|
|
||||||
├── apps/
|
|
||||||
│ └── dashboard/ # React + Vite operator console
|
|
||||||
├── packages/
|
|
||||||
│ └── api-contracts/ # Shared Zod transport contracts
|
|
||||||
├── scripts/
|
|
||||||
│ ├── login.ts # Manual login helper
|
|
||||||
│ └── inspect-chat-dom.ts # DOM inspection debug script
|
|
||||||
└── src/
|
|
||||||
├── index.ts # Entry point / orchestrator
|
|
||||||
├── config/
|
|
||||||
│ └── index.ts # Config loader + Zod schema
|
|
||||||
├── database/
|
|
||||||
│ ├── client.ts # SQLite setup + migrations
|
|
||||||
│ └── repositories.ts # Repository classes
|
|
||||||
├── reddit/
|
|
||||||
│ └── api.ts # Reddit API client
|
|
||||||
├── ai/
|
|
||||||
│ └── openrouter.ts # OpenRouter AI client
|
|
||||||
├── browser/
|
|
||||||
│ ├── client.ts # Managed Playwright context
|
|
||||||
│ └── media-downloader.ts # Media download
|
|
||||||
├── services/
|
|
||||||
│ ├── monitor.ts # Post discovery
|
|
||||||
│ ├── processor.ts # Processing pipeline
|
|
||||||
│ ├── response-checker.ts # Response monitoring
|
|
||||||
│ ├── strategy-selector.ts # A/B testing
|
|
||||||
│ ├── chat.ts # Chat scraping + messaging
|
|
||||||
│ ├── media.ts # Media management
|
|
||||||
│ ├── media-sync.ts # Media sync scheduler
|
|
||||||
│ └── auto-reply.ts # Auto-reply service
|
|
||||||
├── utils/
|
|
||||||
│ ├── logger.ts # Winston logger
|
|
||||||
│ ├── rate-limiter.ts # Rate limiting
|
|
||||||
│ ├── retry.ts # Retry wrapper
|
|
||||||
│ └── image.ts # Image utilities
|
|
||||||
└── web/ # Express API, events, and dashboard hosting
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Development Setup
|
|
||||||
|
|
||||||
### 1. Install Dependencies
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm ci
|
||||||
|
npm run build
|
||||||
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Configure
|
Do not start `npm run dev` merely to verify a build. The repository `.env` and persisted database can make the process live-capable. Starting the backend, importing sessions, or toggling the bot to running requires explicit authorization and a confirmed dry-run/live intent.
|
||||||
|
|
||||||
Copy and edit `config.yaml`:
|
If native SQLite bindings fail after a Node upgrade, distinguish pure TypeScript/unit results from database verification. Rebuild/install a compatible `better-sqlite3`; `--ignore-scripts` is not a valid database test environment.
|
||||||
|
|
||||||
```bash
|
## Commands
|
||||||
cp config.yaml config.yaml.backup # Backup existing
|
|
||||||
# Edit config.yaml with your settings
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Set Environment Variables
|
| Command | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `npm run build` | Compile server and production dashboard. |
|
||||||
|
| `npm run build:server` | Run backend TypeScript compiler. |
|
||||||
|
| `npm run dashboard:typecheck` | Type-check the React application. |
|
||||||
|
| `npm run dashboard:build` | Build dashboard assets. |
|
||||||
|
| `npm test` | Run the complete Vitest suite once. |
|
||||||
|
| `npm run test:watch` | Watch tests during local development. |
|
||||||
|
| `npm run test:coverage` | Run with coverage. |
|
||||||
|
| `npm run docs:api` | Regenerate `docs/api.md`. |
|
||||||
|
| `npm run docs:api:check` | Fail if generated API documentation is stale. |
|
||||||
|
| `npm run mcp` | Start the stdio MCP adapter; requires an authorized running API. |
|
||||||
|
| `npm run dev` | Start the live-capable backend watcher. |
|
||||||
|
| `npm run dashboard:dev` | Start Vite, proxying API requests to port 3000. |
|
||||||
|
|
||||||
```bash
|
## Change workflow
|
||||||
export OPENROUTER_API_KEY="sk-or-v1-your-key"
|
|
||||||
export WEB_DASHBOARD_TOKEN="long-random-secret"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Account authentication
|
1. Read `agents.md`, `docs/README.md`, the routed topic docs, owning source, and focused tests.
|
||||||
|
2. Inspect `git status` before editing. Preserve unrelated user changes and do not broadly stage a dirty worktree.
|
||||||
|
3. Establish a baseline appropriate to the change. Code-changing tasks follow the repository test-first instructions; docs-only changes use documentation/link checks.
|
||||||
|
4. Identify the source-of-truth boundary: config schema, migration, service, shared transport contract, route, or UI feature.
|
||||||
|
5. Implement the smallest coherent behavior using dependency injection and single-purpose modules.
|
||||||
|
6. Add behavior-focused tests for observable inputs, outputs, state transitions, and side effects.
|
||||||
|
7. Update every affected document and regenerate API docs when the catalog changes.
|
||||||
|
8. Run focused tests while iterating, then full required checks before publishing.
|
||||||
|
9. Review the exact staged diff, commit only the coherent set, push, and verify branch/remote sync.
|
||||||
|
|
||||||
Start the app, then import a combined Reddit and Matrix session dump from the local Chrome extension under **Settings > Access**.
|
## Engineering practices
|
||||||
|
|
||||||
### 5. Start Development Server
|
### Types and validation
|
||||||
|
|
||||||
```bash
|
- No production `any`; use `unknown`, Zod, discriminated unions, or type guards.
|
||||||
# Terminal 1: backend reload with tsx
|
- Keep database/service-private types separate from transport contracts.
|
||||||
npm run dev
|
- Parse at boundaries and pass trusted domain values inward.
|
||||||
|
- Return explicit terminal/retryable/human-required results where callers must decide policy.
|
||||||
|
|
||||||
# Terminal 2: Vite frontend with /api proxied to port 3000
|
### Service design
|
||||||
npm run dashboard:dev
|
|
||||||
|
|
||||||
# Production build: server plus apps/dashboard/dist
|
- Constructor-inject external dependencies, clocks, fetchers, schedulers, and persistence seams.
|
||||||
npm run build && npm start
|
- Keep process-global coordination separate from account-scoped mutable state.
|
||||||
```
|
- Prefer a small policy/service over another conditional in a composition root or route.
|
||||||
|
- Preserve abort signals through queues, sleeps, and remote operations.
|
||||||
### 6. Access Dashboard
|
|
||||||
|
|
||||||
During frontend development, open the URL printed by Vite (normally
|
|
||||||
http://localhost:5173). The Express dashboard remains on http://localhost:3000
|
|
||||||
for production/fallback verification.
|
|
||||||
|
|
||||||
Useful frontend checks:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dashboard:typecheck
|
|
||||||
npm run dashboard:build
|
|
||||||
npx vitest run tests/dashboard
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Coding Standards
|
|
||||||
|
|
||||||
### TypeScript
|
|
||||||
|
|
||||||
- **Strict mode**: No `any` types (use `unknown` with type guards)
|
|
||||||
- **Interfaces**: Define interfaces for all data structures
|
|
||||||
- **Null checks**: Use optional chaining (`?.`) and nullish coalescing (`??`)
|
|
||||||
- **Async/Await**: Use async/await, not `.then()` chains
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Good
|
|
||||||
interface PostData {
|
|
||||||
id: string;
|
|
||||||
subreddit: string;
|
|
||||||
author: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchPost(id: string): Promise<PostData> {
|
|
||||||
const response = await api.get(`/posts/${id}`);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bad
|
|
||||||
async function fetchPost(id: any): Promise<any> {
|
|
||||||
return api.get(`/posts/${id}`).then(r => r.data);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Logging
|
### Logging
|
||||||
|
|
||||||
Use `winston` logger, never `console.log`:
|
Use the injected Winston logger. Include stage, account context (through child loggers), post/conversation IDs, and safe rejection/retry reasons. Never serialize raw Axios/Playwright errors that may include cookies or authorization headers; extract a safe message/status. Avoid high-frequency unchanged status logs.
|
||||||
|
|
||||||
```typescript
|
### Remote effects
|
||||||
// Good
|
|
||||||
this.logger.info("Processed post {id}", { id: post.id });
|
|
||||||
this.logger.warn("AI refusal detected", { postId: post.id });
|
|
||||||
this.logger.error("Failed to send DM", { error: err.message });
|
|
||||||
|
|
||||||
// Bad
|
- Check dry-run at the final side-effect boundary.
|
||||||
console.log("Processed post", post.id);
|
- Make retries idempotent and distinguish remote acceptance from local persistence.
|
||||||
```
|
- Reserve recipients/claims before sending and release only on safe failure paths.
|
||||||
|
- Treat 403, 404, and 429 according to their typed domain meaning rather than one generic retry.
|
||||||
|
|
||||||
### Error Handling
|
## Test layout and patterns
|
||||||
|
|
||||||
Use try/catch with structured error handling:
|
Tests live in `tests/`; dashboard tests are under `tests/dashboard/`, route/event tests under `tests/web/`, and shared contract tests under `packages/api-contracts/tests/`.
|
||||||
|
|
||||||
```typescript
|
Preferred patterns:
|
||||||
try {
|
|
||||||
await this.processPost(post);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error("Post processing failed", {
|
|
||||||
postId: post.id,
|
|
||||||
error: err instanceof Error ? err.message : String(err)
|
|
||||||
});
|
|
||||||
// Don't throw - allow other posts to be processed
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Service Structure
|
- in-memory SQLite with real migrations/repositories;
|
||||||
|
- fake clock/sleep/fetch functions injected into services;
|
||||||
|
- small service fakes implementing narrow interfaces;
|
||||||
|
- Testing Library for user-observable React behavior;
|
||||||
|
- representative payload parsing for strict Zod contracts;
|
||||||
|
- lifecycle/restart tests for queues, claims, and account runtimes.
|
||||||
|
|
||||||
Follow single responsibility:
|
Avoid deleting coverage to make a suite pass, assertions that pin private call order without behavioral value, ESM namespace mocking when injection is available, or tests that read the real `.env`/production data.
|
||||||
|
|
||||||
```typescript
|
## Verification matrix
|
||||||
class NewService {
|
|
||||||
constructor(
|
|
||||||
private config: Config,
|
|
||||||
private dependency: ExistingService,
|
|
||||||
private logger: Logger
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async doSomething(input: string): Promise<Result> {
|
| Change | Minimum focused verification | Final verification |
|
||||||
// Validate input
|
| --- | --- | --- |
|
||||||
if (!input) {
|
| Pure backend/service | owning Vitest files | `npm test`, `npm run build:server` |
|
||||||
throw new Error("Input is required");
|
| Database/migration | migration + repository tests | full tests and server build |
|
||||||
}
|
| API/contract | route + contract + auth tests; regenerate docs | full tests, build, `docs:api:check` |
|
||||||
|
| Dashboard | feature tests + typecheck | full tests and full build |
|
||||||
|
| Docker/deployment | compose/Docker tests + image build as appropriate | health, API, container, logs, and persistence after authorized deploy |
|
||||||
|
| Documentation only | generated-doc check + internal link scan | staged diff review |
|
||||||
|
|
||||||
// Do work
|
If the worktree has pre-existing failures or concurrent changes, record the exact baseline and do not attribute them to your change. Never hide a failed required check behind a passing focused test.
|
||||||
const result = await this.dependency.process(input);
|
|
||||||
|
|
||||||
// Return result
|
## Common implementation recipes
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### File Organization
|
### Add an endpoint
|
||||||
|
|
||||||
- One class per file (except repositories which share one file)
|
Create or extend a focused router, validate input, add shared output contracts, register metadata in `api-catalog.ts`, add authorization/behavior tests, consume via dashboard query helpers if needed, regenerate `docs/api.md`, and update the relevant narrative doc.
|
||||||
- Services in `src/services/`
|
|
||||||
- Utilities in `src/utils/`
|
|
||||||
- External integrations in their own folders (`src/ai/`, `src/reddit/`, `src/browser/`)
|
|
||||||
|
|
||||||
---
|
### Add persisted state
|
||||||
|
|
||||||
## Adding a New Service
|
Append a migration, add a narrow repository, wire it through the composition root/runtime, test fresh and restart/upgrade cases, and update `database.md`.
|
||||||
|
|
||||||
### 1. Create the Service File
|
### Change candidate or reply behavior
|
||||||
|
|
||||||
```typescript
|
Start with the durable state transition and idempotency rule. Test rejection/retry/success, process interruption, account ownership, and operator-visible reason counts. Update product, flow, service, and data docs as applicable.
|
||||||
// src/services/new-service.ts
|
|
||||||
import { Config } from '../config';
|
|
||||||
import { Logger } from '../utils/logger';
|
|
||||||
|
|
||||||
interface NewServiceDeps {
|
### Add dashboard state
|
||||||
config: Config;
|
|
||||||
logger: Logger;
|
|
||||||
// other dependencies
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Result {
|
Define the shared schema first, add a typed query/mutation, keep route/filter state in the URL where useful, invalidate snapshots from relevant SSE events, and verify loading/error/stale/accessibility states.
|
||||||
success: boolean;
|
|
||||||
data?: any;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class NewService {
|
|
||||||
constructor(private deps: NewServiceDeps) {}
|
|
||||||
|
|
||||||
async execute(input: string): Promise<Result> {
|
|
||||||
const { config, logger } = this.deps;
|
|
||||||
|
|
||||||
logger.info("NewService executing", { input });
|
|
||||||
|
|
||||||
// Implementation here
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Add to Dependencies Interface
|
|
||||||
|
|
||||||
If the service needs other services, add them to the interface:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface NewServiceDeps {
|
|
||||||
config: Config;
|
|
||||||
logger: Logger;
|
|
||||||
redditApi: RedditApi;
|
|
||||||
openrouter: OpenRouterService;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Wire Up in main()
|
|
||||||
|
|
||||||
In `src/index.ts`, add initialization:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const newService = new NewService({
|
|
||||||
config,
|
|
||||||
logger,
|
|
||||||
redditApi,
|
|
||||||
openrouter
|
|
||||||
});
|
|
||||||
|
|
||||||
// If it needs to run periodically:
|
|
||||||
setInterval(() => {
|
|
||||||
newService.execute(someInput).catch(err => {
|
|
||||||
logger.error("NewService error", { error: err.message });
|
|
||||||
});
|
|
||||||
}, intervalMs);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Add to the Web API and Dashboard (Optional)
|
|
||||||
|
|
||||||
If the service needs an API, add a focused router under `src/web/routes/` and
|
|
||||||
mount it from `src/web/server.ts`. Define its transport schema in
|
|
||||||
`packages/api-contracts`; do not expose repository rows directly.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
router.get('/new-service', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const result = await newService.execute(req.query.input as string);
|
|
||||||
res.json(result);
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: (err as Error).message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Add frontend behavior under `apps/dashboard/src/features/<feature>/` and drive
|
|
||||||
it with Testing Library and MSW tests under `tests/dashboard/`.
|
|
||||||
|
|
||||||
### 5. Update Config (If Needed)
|
|
||||||
|
|
||||||
Add new configuration options to:
|
|
||||||
- `config.yaml`
|
|
||||||
- `src/config/index.ts` Zod schema
|
|
||||||
- Configuration documentation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Adding a New Strategy Variant
|
|
||||||
|
|
||||||
### 1. Add to config.yaml
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
strategyVariants:
|
|
||||||
- name: "existing_variant"
|
|
||||||
prompt: "Existing prompt..."
|
|
||||||
enabled: true
|
|
||||||
|
|
||||||
- name: "new_variant"
|
|
||||||
prompt: |
|
|
||||||
Write a friendly message that references the image content.
|
|
||||||
Be specific and genuine. Keep it under 2 sentences.
|
|
||||||
enabled: true
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. No Code Changes Needed
|
|
||||||
|
|
||||||
The `StrategySelector` automatically picks up new variants from the config. The algorithm will:
|
|
||||||
|
|
||||||
1. Include the new variant in random exploration
|
|
||||||
2. Track its response rate once messages are sent
|
|
||||||
3. Promote it to exploitation if it has good stats
|
|
||||||
|
|
||||||
### 3. Monitor Performance
|
|
||||||
|
|
||||||
Check performance via the dashboard or API:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://localhost:3000/api/analytics/by-strategy
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Debugging
|
|
||||||
|
|
||||||
### Account debugging
|
|
||||||
|
|
||||||
Use dashboard session status and structured logs. The backend does not expose or launch an interactive login browser.
|
|
||||||
|
|
||||||
### DOM Inspection
|
|
||||||
|
|
||||||
Use the debug endpoint to inspect Reddit's shadow DOM:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:3000/api/debug/inspect-dom
|
|
||||||
```
|
|
||||||
|
|
||||||
Or run the standalone script:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx tsx scripts/inspect-chat-dom.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
### Logging
|
|
||||||
|
|
||||||
Adjust log level by modifying `src/utils/logger.ts`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const logger = winston.createLogger({
|
|
||||||
level: 'debug', // Change from 'info' to 'debug'
|
|
||||||
// ...
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database Inspection
|
|
||||||
|
|
||||||
Open the database with SQLite viewer:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install sqlite3 if needed
|
|
||||||
brew install sqlite3
|
|
||||||
|
|
||||||
# Open database
|
|
||||||
sqlite3 data/reddit-bot.db
|
|
||||||
|
|
||||||
# Query
|
|
||||||
SELECT * FROM sent_messages LIMIT 5;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Network Debugging
|
|
||||||
|
|
||||||
Enable axios debug:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// In src/reddit/api.ts
|
|
||||||
import axios from 'axios';
|
|
||||||
axios.defaults.debug = true;
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Common Tasks
|
|
||||||
|
|
||||||
### Add a New Subreddit
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# config.yaml
|
|
||||||
reddit:
|
|
||||||
subreddits:
|
|
||||||
- gonewild
|
|
||||||
- ratemyboobs
|
|
||||||
- new_subreddit_name # Add here
|
|
||||||
```
|
|
||||||
|
|
||||||
### Change AI Model
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# config.yaml
|
|
||||||
openrouter:
|
|
||||||
model: "new-model-name"
|
|
||||||
fallbackModels:
|
|
||||||
- "fallback-model-1"
|
|
||||||
- "fallback-model-2"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Adjust Rate Limits
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# config.yaml
|
|
||||||
rateLimit:
|
|
||||||
minDelayMs: 45000 # Increase delay
|
|
||||||
maxMessagesPerHour: 15 # Reduce cap
|
|
||||||
jitterMs: 20000 # More jitter
|
|
||||||
```
|
|
||||||
|
|
||||||
### Enable Dry Run
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# config.yaml
|
|
||||||
dryRun: true # Logs all actions without sending
|
|
||||||
```
|
|
||||||
|
|
||||||
Or via dashboard: `PUT /api/config` with `{"dryRun": true}`
|
|
||||||
|
|
||||||
### Add Database Column
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// In src/database/client.ts
|
|
||||||
function runMigrations(db: Database) {
|
|
||||||
// ... existing migrations ...
|
|
||||||
|
|
||||||
// Check if column exists
|
|
||||||
const tableInfo = db.prepare(`PRAGMA table_info(sent_messages)`).all();
|
|
||||||
const hasNewColumn = tableInfo.some((col: any) => col.name === 'new_column');
|
|
||||||
|
|
||||||
if (!hasNewColumn) {
|
|
||||||
db.exec("ALTER TABLE sent_messages ADD COLUMN new_column TEXT");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Add New API Endpoint
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// In src/web/server.ts
|
|
||||||
import { Request, Response } from 'express';
|
|
||||||
|
|
||||||
server.app.get('/api/new-endpoint', async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const result = await someService.doSomething();
|
|
||||||
res.json({ success: true, data: result });
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: (err as Error).message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Known Issues
|
|
||||||
|
|
||||||
### Hardcoded Username in ChatService
|
|
||||||
|
|
||||||
`ChatService` has `myUsername = 'Upset_Marsupial1006'` hardcoded in JavaScript evaluation scripts. This should be parameterized from config.
|
|
||||||
|
|
||||||
**Fix needed**: Pass username to `page.evaluate()` calls.
|
|
||||||
|
|
||||||
### Shadow DOM Fragility
|
|
||||||
|
|
||||||
Reddit frequently updates their `faceplate-*` components. Shadow DOM traversal code may break without warning.
|
|
||||||
|
|
||||||
**Mitigation**: Use `scripts/inspect-chat-dom.ts` to debug when selectors change.
|
|
||||||
|
|
||||||
### Dashboard Migration Boundary
|
|
||||||
|
|
||||||
The React application lives in `apps/dashboard`. A production build is required
|
|
||||||
before the Express server starts; there is no legacy HTML fallback.
|
|
||||||
|
|
||||||
The repository has automated service, contract, component, and transport tests.
|
|
||||||
Manual browser verification is still required for Reddit DOM changes and the
|
|
||||||
final dashboard cutover.
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
# React Dashboard Migration
|
|
||||||
|
|
||||||
## Outcome
|
|
||||||
|
|
||||||
The inline dashboard has been replaced by a typed, accessible React application.
|
|
||||||
Express remains the authenticated API, SSE, private-media, and production
|
|
||||||
static-asset host.
|
|
||||||
|
|
||||||
## Current status
|
|
||||||
|
|
||||||
The current routes are Overview, Conversations (including route-addressable
|
|
||||||
detail), Media, Performance, Operations, and validated Settings and Access.
|
|
||||||
Shared HTTP and realtime schemas live in `packages/api-contracts`, and
|
|
||||||
`/api/events/stream` supplies the typed SSE transport.
|
|
||||||
|
|
||||||
`npm run build` compiles the server and writes the Vite application to
|
|
||||||
`apps/dashboard/dist`. `src/web/dashboard-assets.ts` requires that build and
|
|
||||||
fails startup with an actionable error if it is absent. The legacy HTML has
|
|
||||||
been removed. Root pages, assets, APIs, SSE, and private media share the same
|
|
||||||
timing-safe dashboard authentication boundary.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
- `apps/dashboard`: React, TypeScript, Vite, React Router, and TanStack Query.
|
|
||||||
- `packages/api-contracts`: shared Zod schemas for HTTP and realtime transport.
|
|
||||||
- `src/web`: existing Express host, gradually adapted to the shared contracts.
|
|
||||||
- Backend service and database types stay private to the backend. Transport
|
|
||||||
contracts must not expose database rows as an accidental public API.
|
|
||||||
- Server state lives in TanStack Query. Route, filter, and selection state lives
|
|
||||||
in URLs. Local component state is reserved for ephemeral presentation state.
|
|
||||||
- Do not add a global client-state library until a demonstrated cross-feature
|
|
||||||
state requirement cannot be met by those boundaries.
|
|
||||||
|
|
||||||
## Navigation
|
|
||||||
|
|
||||||
The application shell provides a responsive sidebar or drawer with these routes:
|
|
||||||
|
|
||||||
| Route | Responsibility |
|
|
||||||
| --- | --- |
|
|
||||||
| `/` | Health, metrics, timers, limits, and important recent activity |
|
|
||||||
| `/conversations` | Searchable conversation inbox |
|
|
||||||
| `/conversations/:id` | Conversation history, context, suggestions, and sending |
|
|
||||||
| `/media` | Private blurred-by-default gallery and sync controls |
|
|
||||||
| `/performance` | Trends, strategies, subreddits, and successful examples |
|
|
||||||
| `/operations` | Domain activity, background jobs, and diagnostics |
|
|
||||||
| `/settings` | Validated runtime configuration and Reddit access state |
|
|
||||||
|
|
||||||
Overview surfaces bot state, dry-run state, cooldowns, timers, and the guarded
|
|
||||||
start/stop control. Operations owns connection health, sync state, activity,
|
|
||||||
and the bounded technical console.
|
|
||||||
|
|
||||||
## Realtime model
|
|
||||||
|
|
||||||
HTTP snapshots are authoritative. A typed SSE event stream updates or invalidates
|
|
||||||
the relevant query cache. Every event has an ID, type, occurrence timestamp, and
|
|
||||||
validated payload. The stream supports bounded replay and `Last-Event-ID` resume.
|
|
||||||
Relevant events invalidate TanStack Query keys so the next HTTP read remains
|
|
||||||
authoritative. Connection loss marks cached state stale while `EventSource`
|
|
||||||
reconnects.
|
|
||||||
|
|
||||||
Required event families are bot status, message sent or failed, conversation
|
|
||||||
updated, reply received, sync lifecycle and progress, rate-limit state, Reddit
|
|
||||||
session state, and technical logs.
|
|
||||||
|
|
||||||
Important domain events appear in a readable activity timeline. Raw logs remain
|
|
||||||
in Operations. Toasts are reserved for user-triggered outcomes requiring timely
|
|
||||||
feedback. Realtime disconnection never blanks cached content; it marks it stale
|
|
||||||
and falls back to visibility-aware polling where necessary.
|
|
||||||
|
|
||||||
## Feature slices
|
|
||||||
|
|
||||||
1. Capture and validate existing HTTP contracts and critical legacy journeys.
|
|
||||||
2. Add the frontend build, application shell, authentication handling, routing,
|
|
||||||
error boundaries, design tokens, query client, and test harness.
|
|
||||||
3. Deliver Overview as the first production vertical slice.
|
|
||||||
4. Add the typed realtime event core, SSE transport, reconnection, and query
|
|
||||||
invalidation.
|
|
||||||
5. Migrate Conversations, including explicit suggestion and send mutations.
|
|
||||||
6. Migrate Media, preserving authentication and blur-by-default privacy.
|
|
||||||
7. Migrate Performance with sample sizes and low-confidence warnings.
|
|
||||||
8. Migrate Operations, access/login, and route-backed validated Settings.
|
|
||||||
9. Run feature parity and security audits, switch Express to the Vite output,
|
|
||||||
remove the legacy HTML, and update operator and deployment documentation.
|
|
||||||
|
|
||||||
All slices now run from the React application; the migration phases above are
|
|
||||||
retained as implementation history.
|
|
||||||
|
|
||||||
## Engineering gates
|
|
||||||
|
|
||||||
- New behavior follows red-green-refactor with Vitest.
|
|
||||||
- Component tests use Testing Library and test observable behavior.
|
|
||||||
- HTTP tests use a controlled boundary such as MSW rather than mocking `fetch`
|
|
||||||
throughout feature tests.
|
|
||||||
- Contract tests prove representative current responses parse and malformed
|
|
||||||
payloads fail.
|
|
||||||
- Realtime tests cover ordering, replay, duplicate handling, reconnection, and
|
|
||||||
cache repair.
|
|
||||||
- Browser tests cover authentication, start/stop, conversation inspection,
|
|
||||||
sending confirmation, sync, settings persistence, and private media.
|
|
||||||
- Interactive controls are keyboard accessible and restore focus after dialogs.
|
|
||||||
- No production frontend or backend code uses `any` or `console.log`.
|
|
||||||
- Mutations display the confirmed server outcome and do not rely on optimistic
|
|
||||||
success for operational controls.
|
|
||||||
- Production builds are deterministic. The server serves only generated React
|
|
||||||
assets plus authenticated private content.
|
|
||||||
|
|
||||||
## Cutover evidence
|
|
||||||
|
|
||||||
Cutover verification covers API and SSE authentication, private media access,
|
|
||||||
SSE recovery, responsive navigation, frontend type-check/build, backend build,
|
|
||||||
the complete Vitest suite, and browser smoke journeys.
|
|
||||||
Loaded 100 of 309 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user