Compare commits
178
Commits
51438aa5db
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a009a41b4 | ||
|
|
7d039265e7 | ||
|
|
94049ed9c4 | ||
|
|
0c21202253 | ||
|
|
7af7b211fa | ||
|
|
b7fcc7db05 | ||
|
|
712226cd43 | ||
|
|
8d932fcf36 | ||
|
|
5e1fc5def9 | ||
|
|
12e2dc56f1 | ||
|
|
8523873831 | ||
|
|
2500f0a23c | ||
|
|
f77157d303 | ||
|
|
b5304e24e3 | ||
|
|
2447563c60 | ||
|
|
3bb1dda6bb | ||
|
|
79c508be2b | ||
|
|
c75e71f26e | ||
|
|
4798385d8b | ||
|
|
6b2e1bc380 | ||
|
|
e9a4c965ee | ||
|
|
0dfdda61fd | ||
|
|
a9cea7ed18 | ||
|
|
3f60f7f665 | ||
|
|
142378df33 | ||
|
|
687abab82b | ||
|
|
bc42c55555 | ||
|
|
c028637556 | ||
|
|
a71ad56f3c | ||
|
|
e890f74478 | ||
|
|
1fd154e1eb | ||
|
|
540590f43e | ||
|
|
643c775813 | ||
|
|
7bf1aa9d24 | ||
|
|
27012399ec | ||
|
|
e2d7dc4b82 | ||
|
|
b949b6ee37 | ||
|
|
5deac87cf1 | ||
|
|
f88db1ae7d | ||
|
|
f36e93d5be | ||
|
|
1ac6ad22ad | ||
|
|
14d92e14ee | ||
|
|
3a40651930 | ||
|
|
89eb4583f8 | ||
|
|
c43e534cf1 | ||
|
|
11b6300113 | ||
|
|
8ea1cf16d4 | ||
|
|
5c57ed337b | ||
|
|
1515b3e291 | ||
|
|
d8d635e1ee | ||
|
|
d0a8bd0e2f | ||
|
|
7defff422d | ||
|
|
38d175e4a1 | ||
|
|
827eb3a1e0 | ||
|
|
0a4e0064f2 | ||
|
|
4404ae75ff | ||
|
|
ac12a3146a | ||
|
|
725b5fc4e6 | ||
|
|
c8689af04d | ||
|
|
e984bf20ac | ||
|
|
82be197b79 | ||
|
|
f34129666e | ||
|
|
b573b88b08 | ||
|
|
488145340d | ||
|
|
c0cc661a2f | ||
|
|
84a133451f | ||
|
|
bf474dfe10 | ||
|
|
b8e4a1bb88 | ||
|
|
f98c5c732d | ||
|
|
dc3fc6373b | ||
|
|
70c156e1f7 | ||
|
|
910576921b | ||
|
|
c630303b47 | ||
|
|
742c06d0c4 | ||
|
|
e9083281a8 | ||
|
|
22eb32e5bf | ||
|
|
9b86cee9be | ||
|
|
f55ae7ddf8 | ||
|
|
deaf86c364 | ||
|
|
df35648920 | ||
|
|
5abac31c28 | ||
|
|
92c54feedc | ||
|
|
d5ebdec076 | ||
|
|
c21932389e | ||
|
|
b73e305cf3 | ||
|
|
927636ec8b | ||
|
|
0f76bcf59a | ||
|
|
23d4c3a320 | ||
|
|
43f459a024 | ||
|
|
50f9f8a6aa | ||
|
|
7db60e8839 | ||
|
|
5c9839926d | ||
|
|
83b5f1f5b6 | ||
|
|
ca59ba0f1f | ||
|
|
55a6774daf | ||
|
|
a85b99ceb6 | ||
|
|
b400042244 | ||
|
|
9e1b1aeefe | ||
|
|
e7c29f91f2 | ||
|
|
5c01d7d9aa | ||
|
|
acc610c2bf | ||
|
|
e8f56b42e6 | ||
|
|
5ad7f1bf77 | ||
|
|
3beb8aada2 | ||
|
|
dcf57273e2 | ||
|
|
664aa5c098 | ||
|
|
f88d4af146 | ||
|
|
53e9a44a5c | ||
|
|
e8b730b592 | ||
|
|
720ea0652a | ||
|
|
5a5a30a1d4 | ||
|
|
345ec99348 | ||
|
|
a44768c089 | ||
|
|
5623100e93 | ||
|
|
8df2d648dd | ||
|
|
48492d5238 | ||
|
|
0e8ba713a8 | ||
|
|
1833e94a10 | ||
|
|
3d015c4e80 | ||
|
|
492efe0f8a | ||
|
|
34ae532cdd | ||
|
|
f4e8fdc42f | ||
|
|
16a31335d0 | ||
|
|
d819a0f1e6 | ||
|
|
50de1c180d | ||
|
|
7bcdbe236a | ||
|
|
2c100d72b6 | ||
|
|
a36723a325 | ||
|
|
ea92a1a92b | ||
|
|
654bbda457 | ||
|
|
c483c7b8be | ||
|
|
2b0545afcf | ||
|
|
ab2e5cc4ce | ||
|
|
9e317ecf62 | ||
|
|
0aaaeb6b7f | ||
|
|
de6b8999b7 | ||
|
|
4d18a3a9a2 | ||
|
|
14c6fe76c5 | ||
|
|
80417cac06 | ||
|
|
2196149c79 | ||
|
|
9d9506e0cd | ||
|
|
0a7717910a | ||
|
|
baf673904c | ||
|
|
50985b52f1 | ||
|
|
dbb1842ef4 | ||
|
|
ec0cd1318b | ||
|
|
2c533fa52a | ||
|
|
2fd92bf82c | ||
|
|
d5f0175dc2 | ||
|
|
8c8ff11fd7 | ||
|
|
ad770a792a | ||
|
|
a51e0b6b31 | ||
|
|
8b1ec85f1d | ||
|
|
66a1146562 | ||
|
|
d108903174 | ||
|
|
4b3f00729f | ||
|
|
4db6de8bf5 | ||
|
|
92287c0a9a | ||
|
|
0fecd426f4 | ||
|
|
64d29e1241 | ||
|
|
12a853fa42 | ||
|
|
8824da3f15 | ||
|
|
720e5e3e34 | ||
|
|
674cd89cd7 | ||
|
|
70d62b2da3 | ||
|
|
79de6aa2a1 | ||
|
|
e4dd4ad4da | ||
|
|
dcf2a1200c | ||
|
|
364b4db200 | ||
|
|
271f2d1fb5 | ||
|
|
c7dec45abe | ||
|
|
e5dda8f9c3 | ||
|
|
4c20a83c89 | ||
|
|
10412876f1 | ||
|
|
aef4cbb656 | ||
|
|
28fcb3ea99 | ||
|
|
6f803245d4 | ||
|
|
3c4cfd7411 |
No files matched your search
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "dashboard",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dashboard:dev"],
|
||||||
|
"port": 5173
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+22
-44
@@ -1,57 +1,35 @@
|
|||||||
# Deploying RedditBot to Dokploy
|
# Dokploy deployment
|
||||||
|
|
||||||
This guide explains how to deploy the RedditBot to Dokploy without needing to manually transfer your local browser session.
|
RedditBot now uses extension dumps as its only account-authentication path. The container does not expose VNC, start Google Chrome, submit Reddit credentials, or sniff Matrix tokens.
|
||||||
|
|
||||||
## Automated Login Feature
|
## Environment
|
||||||
|
|
||||||
The bot has been updated to support automated login using environment variables. This means when you deploy to a fresh server (like Dokploy), the bot will detect it's not logged in and automatically authenticate using the credentials you provide.
|
Configure only the application secrets required by the Compose file:
|
||||||
|
|
||||||
## Deployment Steps
|
- `OPENROUTER_API_KEY`
|
||||||
|
- `WEB_DASHBOARD_TOKEN`
|
||||||
|
|
||||||
1. **Create a New Application in Dokploy**:
|
Optional agent credentials:
|
||||||
* Select "Docker Compose" as the deployment method (recommended) or "Dockerfile".
|
|
||||||
|
|
||||||
2. **Environment Variables**:
|
- `REDDIT_BOT_AGENT_READ_TOKEN` permits authenticated GET/SSE access but cannot mutate state.
|
||||||
* Go to the "Environment" tab of your application.
|
- `REDDIT_BOT_AGENT_CONTROL_TOKEN` additionally permits versioned control and sync-job operations.
|
||||||
* Add the following variables:
|
|
||||||
* `OPENROUTER_API_KEY`: Your OpenRouter API key.
|
|
||||||
* `REDDIT_USERNAME`: Your Reddit username.
|
|
||||||
* `REDDIT_PASSWORD`: Your Reddit password.
|
|
||||||
* `BROWSER_HEADLESS`: `false` (required for interactive noVNC login).
|
|
||||||
* `VNC_PASSWORD`: A strong password for the interactive VNC session.
|
|
||||||
* `NOVNC_PUBLIC_URL`: `http://<dokploy-host>:16080`.
|
|
||||||
|
|
||||||
3. **Volumes (Persistence)**:
|
Use separate long random values. Neither agent token can invoke legacy send,
|
||||||
* The `docker-compose.yml` is already configured to mount a `./data` volume.
|
session-import, account-activation, or configuration mutations. The dashboard
|
||||||
* Dokploy usually handles volumes automatically for Docker Compose projects, or you may need to define a persistent volume path if you want data to survive container recreations (which you do, to avoid logging in every time).
|
token remains the administrator credential.
|
||||||
* Make sure the `/app/data` directory is persisted.
|
|
||||||
|
|
||||||
4. **Deploy**:
|
Do not configure Reddit usernames, passwords, display servers, CDP endpoints, or remote-browser ports.
|
||||||
* Push your code to your Git repository connected to Dokploy.
|
|
||||||
* Click "Deploy".
|
|
||||||
|
|
||||||
## noVNC networking
|
## Account activation
|
||||||
|
|
||||||
The Compose configuration publishes noVNC directly on host port `16080`.
|
1. Deploy the service and open the dashboard.
|
||||||
Do not route it through a Dokploy/Traefik domain: the upstream proxy strips
|
2. Install the extension from `extensions/reddit-session-exporter` in your local Chrome profile.
|
||||||
the WebSocket Upgrade request that noVNC requires, producing `rfb.js:688` with
|
3. Sign in to Reddit locally, open Reddit Chat, and wait for the extension badge to show `OK`.
|
||||||
WebSocket code `1011` and “Failed to connect to downstream server”.
|
4. Export `redditbot-session.json` and upload it under **Settings > Access**.
|
||||||
|
5. Delete the local JSON file after the dashboard verifies it.
|
||||||
|
|
||||||
Set `NOVNC_PUBLIC_URL` to `http://<dokploy-host>:16080`, then open that URL in
|
The dump contains both Reddit cookies and a Matrix bearer token. Until both identities verify, the bot, response checks, chat, media sync, and dashboard start control remain paused. If either credential expires, import a new dump.
|
||||||
a separate tab from the dashboard. If the dashboard itself is HTTPS, browsers
|
|
||||||
will not embed this HTTP endpoint in its iframe; the separate-tab link is the
|
|
||||||
intended route. Restrict inbound TCP `16080` at the Dokploy host firewall to
|
|
||||||
your own IP or VPN subnet.
|
|
||||||
|
|
||||||
## How it works
|
## Persistence
|
||||||
|
|
||||||
1. On the first run, the bot will see the `data/browser-session` folder is empty/invalid.
|
The `reddit_bot_data` volume retains the database, the imported Playwright cookie profile, and the verified Matrix credential across deployments. Back up this volume as sensitive account data.
|
||||||
2. It will read `REDDIT_USERNAME` and `REDDIT_PASSWORD`.
|
|
||||||
3. It will launch a headless browser, navigate to Reddit, and log in.
|
|
||||||
4. The session cookies and storage will be saved to `data/browser-session`.
|
|
||||||
5. On subsequent restarts, it will use the saved session (as long as the `/app/data` volume is persisted).
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
* **Login Failed**: Check the application logs. If Reddit presents a captcha or 2FA, the automated login will fail. You may need to temporarily run locally to solve the captcha, then copy the session (though this feature avoids that for standard accounts).
|
|
||||||
* **Verification**: The logs will show "Credentials found in config/env. Attempting automated login..." followed by "Login successful!".
|
|
||||||
+5
-39
@@ -1,51 +1,17 @@
|
|||||||
# Use the official Playwright image which includes all browser dependencies
|
|
||||||
FROM mcr.microsoft.com/playwright:v1.57.0-jammy
|
FROM mcr.microsoft.com/playwright:v1.57.0-jammy
|
||||||
|
|
||||||
# Install Xvfb (virtual display), x11vnc (VNC server) and noVNC (web-based VNC client)
|
|
||||||
# so a human can log in to Reddit through the dashboard.
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive \
|
|
||||||
TZ=Etc/UTC
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
tzdata \
|
|
||||||
xvfb \
|
|
||||||
x11vnc \
|
|
||||||
x11-utils \
|
|
||||||
novnc \
|
|
||||||
websockify \
|
|
||||||
fluxbox \
|
|
||||||
procps \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
RUN apt-get update \
|
||||||
COPY package*.json ./
|
&& apt-get install -y --no-install-recommends build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install dependencies (including devDependencies for building)
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build the TypeScript project
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Create data directory
|
|
||||||
RUN mkdir -p data
|
RUN mkdir -p data
|
||||||
|
|
||||||
# Copy entrypoint
|
|
||||||
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
|
||||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
|
||||||
|
|
||||||
# Web dashboard
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
# noVNC (browser-accessible VNC for human login)
|
CMD ["node", "dist/index.js"]
|
||||||
EXPOSE 6080
|
|
||||||
|
|
||||||
ENV DISPLAY=:99 \
|
|
||||||
VNC_DISPLAY_NUM=99 \
|
|
||||||
VNC_PORT=5900 \
|
|
||||||
NOVNC_PORT=6080 \
|
|
||||||
SCREEN_GEOMETRY=1280x800x24
|
|
||||||
|
|
||||||
CMD ["/usr/local/bin/entrypoint.sh"]
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -19,20 +33,11 @@ Refactor code to improve maintainability, testability, and clarity. Break down l
|
|||||||
- **Zod Validation**: Use `zod` (already in deps) to validate external data (config files, API responses).
|
- **Zod Validation**: Use `zod` (already in deps) to validate external data (config files, API responses).
|
||||||
- **Async/Await**: properly handle promises. Use `p-queue` or `p-retry` for rate-limited operations.
|
- **Async/Await**: properly handle promises. Use `p-queue` or `p-retry` for rate-limited operations.
|
||||||
|
|
||||||
### 4. Test-Driven Development (TDD) — REQUIRED
|
### 4. Testing
|
||||||
All new functionality and bug fixes MUST follow the red-green-refactor cycle. This is non-negotiable.
|
- TDD is not required. Agents may implement changes before writing or updating tests.
|
||||||
|
- Add or update tests when they provide useful coverage for new functionality or bug fixes.
|
||||||
- **Red**: Write a failing test FIRST that describes the desired behavior. Run it and confirm it fails for the expected reason.
|
|
||||||
- **Green**: Write the minimum production code needed to make the test pass. No extra features, no speculative abstractions.
|
|
||||||
- **Refactor**: With tests green, clean up implementation and tests. Re-run the suite after each change.
|
|
||||||
|
|
||||||
**Rules**:
|
|
||||||
- Never commit production code without an accompanying test that fails without it.
|
|
||||||
- Tests live in `tests/` (mirroring `src/` structure) and run via `vitest` (`npm test`).
|
- Tests live in `tests/` (mirroring `src/` structure) and run via `vitest` (`npm test`).
|
||||||
- Use dependency injection so collaborators (DB, HTTP, browser, clock) can be faked — do not mock what you don't own; wrap third-party APIs in a thin interface and fake the interface.
|
|
||||||
- Prefer behavior-focused tests (inputs → outputs / observable side effects) over tests that pin internal structure.
|
- Prefer behavior-focused tests (inputs → outputs / observable side effects) over tests that pin internal structure.
|
||||||
- Bug fixes start with a regression test that reproduces the bug.
|
|
||||||
- If a change is genuinely untestable (e.g. a one-line config tweak), state that explicitly in the PR/commit rather than skipping silently.
|
|
||||||
|
|
||||||
### 5. Project Specifics
|
### 5. Project Specifics
|
||||||
- **Tech Stack**: Node.js, TypeScript, Playwright, Express, SQLite, Vitest.
|
- **Tech Stack**: Node.js, TypeScript, Playwright, Express, SQLite, Vitest.
|
||||||
@@ -44,11 +49,13 @@ All new functionality and bug fixes MUST follow the red-green-refactor cycle. Th
|
|||||||
- **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. Check if the change violates SRP. Identify the test(s) that will drive the change.
|
2. **Analyze**: Before writing code, read the file and understand the context.
|
||||||
3. **Write failing test (Red)**: Add a vitest spec that captures the new behavior or reproduces the bug. Run `npm test` and confirm it fails for the right reason.
|
3. **Plan**: Identify what needs to change and check if the change violates SRP.
|
||||||
4. **Implement (Green)**: Write the smallest change that makes the test pass. Create new small files when SRP calls for it.
|
4. **Implement**: Make the scoped change. Create new small files when SRP calls for it.
|
||||||
5. **Refactor**: Tidy the implementation and the test with the suite green. Re-run tests after each refactor step.
|
5. **Test**: Add or update useful tests as appropriate for the change.
|
||||||
6. **Verify**: Ensure the full suite passes (`npm test`) and types check (`npm run build` or `tsc --noEmit`).
|
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,10 +3,12 @@ import {
|
|||||||
botStatsSchema,
|
botStatsSchema,
|
||||||
controlResponseSchema,
|
controlResponseSchema,
|
||||||
timersSchema,
|
timersSchema,
|
||||||
|
type BotMode,
|
||||||
type BotStats,
|
type BotStats,
|
||||||
type ControlResponse,
|
type ControlResponse,
|
||||||
type Timers,
|
type Timers,
|
||||||
} from './contracts.js';
|
} from './contracts.js';
|
||||||
|
import { createDashboardRequest } from './request.js';
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -35,13 +37,11 @@ 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 {
|
||||||
function request(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
const request = createDashboardRequest(fetchImplementation);
|
||||||
return (fetchImplementation ?? globalThis.fetch)(input, init);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async getStats() {
|
async getStats() {
|
||||||
@@ -50,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,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export type DashboardRequest = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||||
|
|
||||||
|
function redirectToLogin(): void {
|
||||||
|
globalThis.location.assign('/auth/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDashboardRequest(
|
||||||
|
fetchImplementation: typeof fetch | undefined = undefined,
|
||||||
|
onUnauthorized: () => void = redirectToLogin,
|
||||||
|
): DashboardRequest {
|
||||||
|
return async (input, init) => {
|
||||||
|
const fetchRequest = fetchImplementation ?? globalThis.fetch;
|
||||||
|
const response = await fetchRequest(input, { credentials: 'same-origin', ...init });
|
||||||
|
if (response.status === 401) onUnauthorized();
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dashboardRequest = createDashboardRequest();
|
||||||
@@ -1,12 +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 { 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">
|
||||||
@@ -15,22 +17,29 @@ 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) => (
|
||||||
<NavLink
|
<div className="nav-group" key={group}>
|
||||||
aria-label={item.label}
|
<span className="nav-group-label">{group}</span>
|
||||||
className={({ isActive }) => (isActive ? 'nav-link is-active' : 'nav-link')}
|
{navigationItemsByGroup(group).map((item) => (
|
||||||
end={item.path === '/'}
|
<NavLink
|
||||||
key={item.path}
|
aria-label={item.label}
|
||||||
onClick={() => setIsNavigationOpen(false)}
|
className={({ isActive }) => (isActive ? 'nav-link is-active' : 'nav-link')}
|
||||||
to={item.path}
|
end={item.path === '/'}
|
||||||
>
|
key={item.path}
|
||||||
<span>{item.label}</span>
|
onClick={() => setIsNavigationOpen(false)}
|
||||||
<small>{item.description}</small>
|
to={item.path}
|
||||||
</NavLink>
|
>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
<small>{item.description}</small>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
<ShellStatusPanel />
|
||||||
</aside>
|
</aside>
|
||||||
{isNavigationOpen ? (
|
{isNavigationOpen ? (
|
||||||
<button
|
<button
|
||||||
@@ -43,21 +52,25 @@ 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>
|
||||||
<button
|
<div className="mobile-header-actions">
|
||||||
aria-controls="primary-navigation"
|
<NotificationBell />
|
||||||
aria-expanded={isNavigationOpen}
|
<button
|
||||||
aria-label={isNavigationOpen ? 'Close navigation' : 'Open navigation'}
|
aria-controls="primary-navigation"
|
||||||
className="icon-button"
|
aria-expanded={isNavigationOpen}
|
||||||
onClick={() => setIsNavigationOpen((current) => !current)}
|
aria-label={isNavigationOpen ? 'Close navigation' : 'Open navigation'}
|
||||||
type="button"
|
className="icon-button"
|
||||||
>
|
onClick={() => setIsNavigationOpen((current) => !current)}
|
||||||
<span aria-hidden="true">{isNavigationOpen ? '×' : '☰'}</span>
|
type="button"
|
||||||
</button>
|
>
|
||||||
|
<span aria-hidden="true">{isNavigationOpen ? '×' : '☰'}</span>
|
||||||
|
</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,30 @@ 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: 'Learning review', path: '/learning', description: 'Human-led examples and attribution', group: 'Analyse' },
|
||||||
|
{ label: 'Operations', path: '/operations', description: 'Jobs, events, and diagnostics', group: 'Analyse' },
|
||||||
|
{ label: 'Subreddits', path: '/subreddits', description: 'Where the bot posts and how it talks', group: 'Configure' },
|
||||||
|
{ label: 'Accounts', path: '/accounts', description: 'Reddit sessions and access', group: 'Configure' },
|
||||||
|
{ label: 'Account growth', path: '/growth', description: 'Build comment history on new accounts', group: 'Configure' },
|
||||||
|
{ label: 'Settings', path: '/settings', description: 'Pacing, safety, and notifications', group: 'Configure' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export function navigationItemsByGroup(group: NavigationGroup): readonly NavigationItem[] {
|
||||||
|
return navigationItems.filter((item) => item.group === group);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
.notification-bell {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
place-items: center;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
padding: 0;
|
||||||
|
color: #dfe9e3;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: .7rem;
|
||||||
|
}
|
||||||
|
.notification-bell:hover { color: var(--accent); border-color: #3d5a4c; }
|
||||||
|
.notification-bell:focus-visible, .notification-close:focus-visible, .notification-clear:focus-visible, .notification-entry:focus-visible, .notification-toast a:focus-visible, .notification-toast button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
.notification-bell svg, .notification-empty svg { width: 1.2rem; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.8; }
|
||||||
|
.notification-badge { position: absolute; top: -.35rem; right: -.35rem; display: grid; place-items: center; min-width: 1.15rem; height: 1.15rem; padding: 0 .25rem; color: #07130c; background: var(--accent); border: 2px solid #0c1511; border-radius: 999px; font-size: .62rem; font-weight: 900; line-height: 1; }
|
||||||
|
.brand { justify-content: space-between; }
|
||||||
|
.brand > div { margin-right: auto; }
|
||||||
|
.notification-backdrop { position: fixed; inset: 0; z-index: 69; width: 100%; padding: 0; background: rgba(0, 0, 0, .52); border: 0; }
|
||||||
|
.notification-centre { position: fixed; inset: 0 0 0 auto; z-index: 70; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; width: min(27rem, 100%); visibility: hidden; background: #0c1511; border-left: 1px solid var(--border); box-shadow: -1.5rem 0 4rem rgba(0, 0, 0, .38); transform: translateX(100%); transition: transform 220ms ease, visibility 220ms; }
|
||||||
|
.notification-centre.is-open { visibility: visible; transform: translateX(0); }
|
||||||
|
.notification-centre > header { display: flex; align-items: center; justify-content: space-between; min-height: 5.5rem; padding: 1.2rem 1.25rem; border-bottom: 1px solid var(--border); }
|
||||||
|
.notification-centre h2 { font-size: 1.35rem; letter-spacing: -.025em; }
|
||||||
|
.notification-centre .eyebrow { margin-bottom: .25rem; }
|
||||||
|
.notification-close { display: grid; place-items: center; width: 2.35rem; height: 2.35rem; padding: 0; color: var(--muted); background: transparent; border: 1px solid var(--border); border-radius: .6rem; font-size: 1.5rem; line-height: 1; }
|
||||||
|
.notification-list { min-height: 0; padding: 0; margin: 0; overflow-y: auto; list-style: none; }
|
||||||
|
.notification-list li { border-bottom: 1px solid var(--border); }
|
||||||
|
.notification-entry { display: grid; grid-template-columns: .65rem minmax(0, 1fr); gap: .8rem; padding: 1rem 1.25rem; color: inherit; text-decoration: none; }
|
||||||
|
a.notification-entry:hover { background: var(--surface); }
|
||||||
|
.notification-signal { width: .55rem; height: .55rem; margin-top: .3rem; background: #77a6d8; border-radius: 50%; box-shadow: 0 0 0 .3rem rgba(119, 166, 216, .1); }
|
||||||
|
.notification-signal.success { background: var(--accent); box-shadow: 0 0 0 .3rem rgba(117, 214, 157, .1); }
|
||||||
|
.notification-signal.warning { background: var(--warning); box-shadow: 0 0 0 .3rem rgba(243, 199, 107, .1); }
|
||||||
|
.notification-signal.error { background: var(--danger); box-shadow: 0 0 0 .3rem rgba(255, 138, 132, .1); }
|
||||||
|
.notification-copy { display: grid; gap: .28rem; min-width: 0; }
|
||||||
|
.notification-copy strong { color: #edf5f0; font-size: .9rem; line-height: 1.3; }
|
||||||
|
.notification-copy > span { overflow: hidden; color: var(--muted); font-size: .82rem; line-height: 1.45; text-overflow: ellipsis; }
|
||||||
|
.notification-copy time { color: #71857b; font-size: .7rem; }
|
||||||
|
.notification-centre > footer { display: flex; justify-content: flex-end; padding: .8rem 1.25rem; border-top: 1px solid var(--border); }
|
||||||
|
.notification-clear { padding: .4rem 0; color: var(--muted); background: transparent; border: 0; font-size: .78rem; font-weight: 700; }
|
||||||
|
.notification-clear:hover { color: #edf5f0; }
|
||||||
|
.notification-empty { display: grid; place-items: center; align-content: center; padding: 2rem; text-align: center; }
|
||||||
|
.notification-empty > span { display: grid; place-items: center; width: 3.25rem; height: 3.25rem; margin-bottom: .9rem; color: var(--accent); background: var(--accent-dark); border: 1px solid #28593d; border-radius: 1rem; }
|
||||||
|
.notification-empty strong { margin-bottom: .35rem; font-size: 1rem; }
|
||||||
|
.notification-empty p { max-width: 18rem; margin: 0; color: var(--muted); font-size: .82rem; line-height: 1.5; }
|
||||||
|
.toast-stack { position: fixed; right: 1rem; bottom: 1rem; z-index: 75; display: grid; gap: .65rem; width: min(24rem, calc(100% - 2rem)); max-height: calc(100vh - 2rem); overflow-y: auto; 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,332 @@
|
|||||||
|
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 null;
|
||||||
|
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':
|
||||||
|
if (event.payload.status !== 'stopped') return null;
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
occurredAt: event.occurredAt,
|
||||||
|
title: `Bot ${event.payload.status}`,
|
||||||
|
message: event.payload.reason ?? `The bot is now ${event.payload.status}.`,
|
||||||
|
tone: 'warning',
|
||||||
|
href: '/operations',
|
||||||
|
};
|
||||||
|
case 'sync.completed':
|
||||||
|
return null;
|
||||||
|
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':
|
||||||
|
if (!event.payload.limited) return null;
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
occurredAt: event.occurredAt,
|
||||||
|
title: 'Rate limit reached',
|
||||||
|
message: event.payload.reason ?? 'Sending will resume when the limit resets.',
|
||||||
|
tone: 'warning',
|
||||||
|
href: '/operations',
|
||||||
|
};
|
||||||
|
case 'reddit.session.changed':
|
||||||
|
if (!['expired', 'logged-out', 'error'].includes(event.payload.status)) return null;
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
occurredAt: event.occurredAt,
|
||||||
|
title: 'Reddit account needs attention',
|
||||||
|
message: event.payload.reason ?? (event.payload.username
|
||||||
|
? `The session for u/${event.payload.username} changed to ${event.payload.status}.`
|
||||||
|
: `Session status changed to ${event.payload.status}.`),
|
||||||
|
tone: '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]);
|
||||||
|
}), [realtime]);
|
||||||
|
|
||||||
|
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>New replies, received images, and important 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import type { PropsWithChildren } from 'react';
|
import type { PropsWithChildren } from 'react';
|
||||||
|
import { DashboardRealtimeProvider } from './realtime-provider.js';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -13,5 +14,5 @@ const queryClient = new QueryClient({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function AppProviders({ children }: PropsWithChildren) {
|
export function AppProviders({ children }: PropsWithChildren) {
|
||||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
return <QueryClientProvider client={queryClient}><DashboardRealtimeProvider>{children}</DashboardRealtimeProvider></QueryClientProvider>;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { createContext, useContext, useEffect, useMemo, useRef, useState, type PropsWithChildren } from 'react';
|
||||||
|
import type { DashboardEvent } from '../../../../packages/api-contracts/src/index.js';
|
||||||
|
import {
|
||||||
|
createBrowserEventSource, createDashboardRealtimeClient, queryKeysForDashboardEvent,
|
||||||
|
type RealtimeConnectionSnapshot,
|
||||||
|
} from '../features/operations/realtime-client.js';
|
||||||
|
import { browserPollingScheduler, browserVisibilitySource, createVisibilityAwarePollingFallback } from '../features/operations/realtime-fallback.js';
|
||||||
|
import { repairRealtimeSnapshots } from './realtime-repair.js';
|
||||||
|
|
||||||
|
type EventListener = (event: DashboardEvent) => void;
|
||||||
|
interface RealtimeContextValue { connection: RealtimeConnectionSnapshot; subscribe(listener: EventListener): () => void; }
|
||||||
|
const RealtimeContext = createContext<RealtimeContextValue | null>(null);
|
||||||
|
|
||||||
|
export function DashboardRealtimeProvider({ children }: PropsWithChildren) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const listeners = useRef(new Set<EventListener>());
|
||||||
|
const [connection, setConnection] = useState<RealtimeConnectionSnapshot>({ status: 'idle', isStale: true });
|
||||||
|
useEffect(() => {
|
||||||
|
const repair = () => { void repairRealtimeSnapshots(queryClient); };
|
||||||
|
const fallback = createVisibilityAwarePollingFallback({ intervalMs: 15_000, poll: repair, scheduler: browserPollingScheduler, visibility: browserVisibilitySource });
|
||||||
|
fallback.start();
|
||||||
|
if (typeof EventSource === 'undefined') { fallback.setStale(true); return () => fallback.stop(); }
|
||||||
|
const client = createDashboardRealtimeClient({
|
||||||
|
sourceFactory: createBrowserEventSource,
|
||||||
|
onConnected: repair,
|
||||||
|
onEvent(event) {
|
||||||
|
for (const queryKey of queryKeysForDashboardEvent(event)) void queryClient.invalidateQueries({ queryKey });
|
||||||
|
for (const listener of listeners.current) listener(event);
|
||||||
|
},
|
||||||
|
onStateChange(snapshot) { setConnection(snapshot); fallback.setStale(snapshot.isStale); },
|
||||||
|
});
|
||||||
|
client.start();
|
||||||
|
return () => { client.stop(); fallback.stop(); };
|
||||||
|
}, [queryClient]);
|
||||||
|
const value = useMemo<RealtimeContextValue>(() => ({
|
||||||
|
connection,
|
||||||
|
subscribe(listener) { listeners.current.add(listener); return () => listeners.current.delete(listener); },
|
||||||
|
}), [connection]);
|
||||||
|
return <RealtimeContext.Provider value={value}>{children}</RealtimeContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDashboardRealtime(): RealtimeContextValue {
|
||||||
|
return useContext(RealtimeContext) ?? { connection: { status: 'idle', isStale: true }, subscribe: () => () => undefined };
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { QueryKey } from '@tanstack/react-query';
|
||||||
|
import { dashboardQueryKeys } from '../api/queries.js';
|
||||||
|
import { operationsQueryKeys } from '../features/operations/operations-queries.js';
|
||||||
|
import { settingsQueryKeys } from '../features/settings/settings-queries.js';
|
||||||
|
|
||||||
|
export const realtimeSnapshotKeys: readonly QueryKey[] = [
|
||||||
|
dashboardQueryKeys.stats,
|
||||||
|
dashboardQueryKeys.timers,
|
||||||
|
settingsQueryKeys.loginStatus,
|
||||||
|
operationsQueryKeys.syncStatus,
|
||||||
|
['conversations'],
|
||||||
|
['media'],
|
||||||
|
['performance'],
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface RealtimeQueryRepairTarget {
|
||||||
|
invalidateQueries(filters: { queryKey: QueryKey; refetchType: 'none' }): Promise<unknown>;
|
||||||
|
refetchQueries(filters: { queryKey: QueryKey; type: 'active' }): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function repairRealtimeSnapshots(target: RealtimeQueryRepairTarget): Promise<void> {
|
||||||
|
await Promise.all(realtimeSnapshotKeys.map(async (queryKey) => {
|
||||||
|
await target.invalidateQueries({ queryKey, refetchType: 'none' });
|
||||||
|
await target.refetchQueries({ queryKey, type: 'active' });
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { isRouteErrorResponse, useRouteError } from 'react-router-dom';
|
||||||
|
|
||||||
|
export function RouteErrorPage() {
|
||||||
|
const error = useRouteError();
|
||||||
|
const message = isRouteErrorResponse(error) ? `${error.status} ${error.statusText}` : error instanceof Error ? error.message : 'Unexpected route failure';
|
||||||
|
return <main className="page-state error-state" role="alert"><strong>This page could not be loaded</strong><span>{message}</span><a className="button secondary" href="/">Return to overview</a></main>;
|
||||||
|
}
|
||||||
@@ -1,25 +1,41 @@
|
|||||||
import { createBrowserRouter, type RouteObject } from 'react-router-dom';
|
import { createBrowserRouter, type RouteObject } from 'react-router-dom';
|
||||||
import { AppShell } from './app-shell.js';
|
import { AppShell } from './app-shell.js';
|
||||||
import { OverviewPage } from '../features/overview/overview-page.js';
|
import { OverviewPage } from '../features/overview/overview-page.js';
|
||||||
import { PlaceholderPage } from '../features/placeholder-page.js';
|
|
||||||
import { ConversationRoute } from '../features/conversations/conversation-route.js';
|
import { ConversationRoute } from '../features/conversations/conversation-route.js';
|
||||||
import { MediaPage } from '../features/media/media-page.js';
|
import { MediaRoute } from '../features/media/media-route.js';
|
||||||
import { OperationsPage } from '../features/operations/operations-page.js';
|
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 { RouteErrorPage } from './route-error-page.js';
|
||||||
|
import { AttentionPage } from '../features/attention/attention-page.js';
|
||||||
|
import { OutreachPage } from '../features/outreach/outreach-page.js';
|
||||||
|
import { MediaLibraryPage } from '../features/media-library/media-library-page.js';
|
||||||
|
import { SubredditsPage } from '../features/subreddits/subreddits-page.js';
|
||||||
|
import { AccountsPage } from '../features/accounts/accounts-page.js';
|
||||||
|
import { GrowthPage } from '../features/growth/growth-page.js';
|
||||||
|
import { LearningPage } from '../features/learning/learning-page.js';
|
||||||
|
|
||||||
export function createDashboardRoutes(): RouteObject[] {
|
export function createDashboardRoutes(): RouteObject[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
element: <AppShell />,
|
element: <AppShell />,
|
||||||
|
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: <MediaPage /> },
|
{ path: 'media', element: <MediaRoute /> },
|
||||||
|
{ path: 'media-library', element: <MediaLibraryPage /> },
|
||||||
{ path: 'performance', element: <PerformancePage /> },
|
{ path: 'performance', element: <PerformancePage /> },
|
||||||
|
{ path: 'learning', element: <LearningPage /> },
|
||||||
{ path: 'operations', element: <OperationsPage /> },
|
{ path: 'operations', element: <OperationsPage /> },
|
||||||
{ path: 'settings', element: <PlaceholderPage title="Settings" description="Manage runtime behavior and Reddit access." /> },
|
{ path: 'subreddits', element: <SubredditsPage /> },
|
||||||
|
{ path: 'accounts', element: <AccountsPage /> },
|
||||||
|
{ path: 'growth', element: <GrowthPage /> },
|
||||||
|
{ path: 'settings', element: <SettingsRoute /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.shell-status { display: grid; gap: .45rem; margin: 1rem .5rem; padding: .8rem; color: var(--muted); background: var(--surface); border: 1px solid var(--border); border-radius: .75rem; font-size: .75rem; }
|
||||||
|
.shell-status strong { color: #e9f1ec; }
|
||||||
|
.shell-status .button { margin-top: .3rem; }
|
||||||
|
.shell-warning { color: var(--warning); }
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useStatsQuery, useSetModeMutation } from '../api/queries.js';
|
||||||
|
import type { BotMode } from '../api/contracts.js';
|
||||||
|
import { ConfirmationDialog } from '../components/confirmation-dialog.js';
|
||||||
|
import { useSyncStatusQuery } from '../features/operations/operations-queries.js';
|
||||||
|
import { useLoginStatusQuery } from '../features/settings/settings-queries.js';
|
||||||
|
import { useDashboardRealtime } from './realtime-provider.js';
|
||||||
|
import { deriveShellStatus } from './shell-status.js';
|
||||||
|
import './shell-status-panel.css';
|
||||||
|
|
||||||
|
export function ShellStatusPanel() {
|
||||||
|
const stats = useStatsQuery();
|
||||||
|
const reddit = useLoginStatusQuery();
|
||||||
|
const sync = useSyncStatusQuery();
|
||||||
|
const realtime = useDashboardRealtime();
|
||||||
|
const control = useSetModeMutation();
|
||||||
|
const [confirming, setConfirming] = useState<BotMode | null>(null);
|
||||||
|
const status = deriveShellStatus({ stats: stats.data, reddit: reddit.data, sync: sync.data, realtime: realtime.connection });
|
||||||
|
return <>
|
||||||
|
<section aria-label="Platform status" className="shell-status">
|
||||||
|
<strong>{status.bot}</strong><span>{status.reddit}</span><span>{status.realtime}</span><span>{status.sync}</span>
|
||||||
|
{status.cooldown ? <span className="shell-warning">Cooldown: {status.cooldown}</span> : null}
|
||||||
|
<button aria-label={`${status.controlAction === 'running' ? 'Start' : 'Stop'} bot from global status`} className={status.controlAction === 'running' ? 'button primary' : 'button danger'} disabled={status.controlDisabled || control.isPending} onClick={() => setConfirming(status.controlAction)} type="button">{status.controlAction === 'running' ? 'Start bot' : 'Stop bot'}</button>
|
||||||
|
</section>
|
||||||
|
{confirming ? <ConfirmationDialog action={confirming} isPending={control.isPending} onCancel={() => setConfirming(null)} onConfirm={() => control.mutate(confirming, { onSuccess: () => setConfirming(null) })} /> : null}
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { RealtimeConnectionSnapshot } from '../features/operations/realtime-client.js';
|
||||||
|
import type { BotMode } from '../api/contracts.js';
|
||||||
|
|
||||||
|
const MODE_LABEL: Record<BotMode, string> = {
|
||||||
|
running: 'Running',
|
||||||
|
scan: 'Scanning',
|
||||||
|
stopped: 'Stopped',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ShellStatusInput {
|
||||||
|
stats?: { mode: BotMode; dryRunMode: boolean; invitationCooldown: { reason: string } | null };
|
||||||
|
reddit?: { ready: boolean; loggedIn?: boolean; status?: string; error?: string };
|
||||||
|
realtime: RealtimeConnectionSnapshot;
|
||||||
|
sync?: { syncing: boolean; syncingKind?: string | null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShellStatus {
|
||||||
|
bot: string;
|
||||||
|
reddit: string;
|
||||||
|
realtime: string;
|
||||||
|
cooldown: string | null;
|
||||||
|
sync: string;
|
||||||
|
controlAction: BotMode;
|
||||||
|
controlDisabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sentence(value: string): string { return value.charAt(0).toUpperCase() + value.slice(1); }
|
||||||
|
|
||||||
|
export function deriveShellStatus(input: ShellStatusInput): ShellStatus {
|
||||||
|
const stats = input.stats;
|
||||||
|
const realtime = input.realtime.status === 'connected'
|
||||||
|
? 'Realtime connected'
|
||||||
|
: `Realtime ${input.realtime.status}${input.realtime.isStale ? ' (stale)' : ''}`;
|
||||||
|
const reddit = input.reddit?.ready
|
||||||
|
? input.reddit.loggedIn ? `Reddit ${input.reddit.status ?? 'authenticated'}` : 'Reddit login required'
|
||||||
|
: 'Reddit session unavailable';
|
||||||
|
return {
|
||||||
|
bot: stats ? `${MODE_LABEL[stats.mode]}${stats.dryRunMode ? ' (dry run)' : ''}` : 'Bot state unavailable',
|
||||||
|
reddit,
|
||||||
|
realtime,
|
||||||
|
cooldown: stats?.invitationCooldown?.reason ?? null,
|
||||||
|
sync: input.sync?.syncing ? `${sentence(input.sync.syncingKind ?? 'unknown')} sync active` : 'No active sync',
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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">{title}</h2>
|
||||||
<h2 id="confirmation-title">{isStarting ? 'Start bot?' : 'Stop bot?'}</h2>
|
<p>{body}</p>
|
||||||
<p>
|
{errorMessage ? <p className="dialog-error" role="alert">{errorMessage}</p> : null}
|
||||||
{isStarting
|
<div className="dialog-actions">
|
||||||
? 'The bot will resume automated processing.'
|
<button autoFocus className="button secondary" disabled={isPending} onClick={onCancel} type="button">
|
||||||
: 'The bot will pause after its current operation.'}
|
Cancel
|
||||||
</p>
|
</button>
|
||||||
<div className="dialog-actions">
|
<button className={tone} disabled={isPending} onClick={onConfirm} type="button">
|
||||||
<button className="button secondary" disabled={isPending} onClick={onCancel} ref={cancelButtonRef} type="button">
|
{isPending ? 'Applying…' : confirm}
|
||||||
Cancel
|
</button>
|
||||||
</button>
|
|
||||||
<button className={isStarting ? 'button primary' : 'button danger'} disabled={isPending} onClick={onConfirm} type="button">
|
|
||||||
{isPending ? 'Applying…' : `Confirm ${action}`}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface FieldShellProps {
|
||||||
|
error?: string;
|
||||||
|
/** Guidance shown when there is no error to show in its place. */
|
||||||
|
hint?: ReactNode;
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldIds(name: string) {
|
||||||
|
return { errorId: `${name}-error`, hintId: `${name}-hint` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldMessage({ error, hint, name }: FieldShellProps) {
|
||||||
|
const { errorId, hintId } = fieldIds(name);
|
||||||
|
if (error) return <small className="field-error" id={errorId}>{error}</small>;
|
||||||
|
if (hint) return <small className="field-hint" id={hintId}>{hint}</small>;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describedBy({ error, hint, name }: FieldShellProps): string | undefined {
|
||||||
|
const { errorId, hintId } = fieldIds(name);
|
||||||
|
if (error) return errorId;
|
||||||
|
return hint ? hintId : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NumberFieldProps extends FieldShellProps {
|
||||||
|
max?: number;
|
||||||
|
min?: number;
|
||||||
|
step?: number | 'any';
|
||||||
|
/** Rendered inside the input as a trailing unit, e.g. "min" or "/hr". */
|
||||||
|
suffix?: string;
|
||||||
|
value: string;
|
||||||
|
onChange(value: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NumberField({ error, hint, label, max, min, name, onChange, step = 1, suffix, value }: NumberFieldProps) {
|
||||||
|
const shell = { error, hint, label, name };
|
||||||
|
return (
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">{label}</span>
|
||||||
|
<span className={suffix ? 'field-input has-suffix' : 'field-input'}>
|
||||||
|
<input
|
||||||
|
aria-describedby={describedBy(shell)}
|
||||||
|
aria-invalid={error ? 'true' : undefined}
|
||||||
|
aria-label={label}
|
||||||
|
max={max}
|
||||||
|
min={min}
|
||||||
|
name={name}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
step={step}
|
||||||
|
type="number"
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
{suffix ? <span aria-hidden="true" className="field-suffix">{suffix}</span> : null}
|
||||||
|
</span>
|
||||||
|
<FieldMessage {...shell} />
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextFieldProps extends FieldShellProps {
|
||||||
|
placeholder?: string;
|
||||||
|
/** Rendered as a non-editable prefix inside the input, e.g. "r/". */
|
||||||
|
prefix?: string;
|
||||||
|
type?: 'text' | 'url';
|
||||||
|
value: string;
|
||||||
|
onChange(value: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextField({ error, hint, label, name, onChange, placeholder, prefix, type = 'text', value }: TextFieldProps) {
|
||||||
|
const shell = { error, hint, label, name };
|
||||||
|
return (
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">{label}</span>
|
||||||
|
<span className={prefix ? 'field-input has-prefix' : 'field-input'}>
|
||||||
|
{prefix ? <span aria-hidden="true" className="field-prefix">{prefix}</span> : null}
|
||||||
|
<input
|
||||||
|
aria-describedby={describedBy(shell)}
|
||||||
|
aria-invalid={error ? 'true' : undefined}
|
||||||
|
aria-label={label}
|
||||||
|
name={name}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
type={type}
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<FieldMessage {...shell} />
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectFieldProps<Value extends string> extends FieldShellProps {
|
||||||
|
options: ReadonlyArray<{ value: Value; label: string }>;
|
||||||
|
value: Value;
|
||||||
|
onChange(value: Value): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectField<Value extends string>({ error, hint, label, name, onChange, options, value }: SelectFieldProps<Value>) {
|
||||||
|
const shell = { error, hint, label, name };
|
||||||
|
return (
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">{label}</span>
|
||||||
|
<select
|
||||||
|
aria-describedby={describedBy(shell)}
|
||||||
|
aria-invalid={error ? 'true' : undefined}
|
||||||
|
aria-label={label}
|
||||||
|
name={name}
|
||||||
|
onChange={(event) => onChange(event.target.value as Value)}
|
||||||
|
value={value}
|
||||||
|
>
|
||||||
|
{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||||
|
</select>
|
||||||
|
<FieldMessage {...shell} />
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextAreaFieldProps extends FieldShellProps {
|
||||||
|
monospace?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
rows?: number;
|
||||||
|
value: string;
|
||||||
|
onChange(value: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextAreaField({ error, hint, label, monospace, name, onChange, placeholder, rows = 3, value }: TextAreaFieldProps) {
|
||||||
|
const shell = { error, hint, label, name };
|
||||||
|
return (
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">{label}</span>
|
||||||
|
<textarea
|
||||||
|
aria-describedby={describedBy(shell)}
|
||||||
|
aria-invalid={error ? 'true' : undefined}
|
||||||
|
aria-label={label}
|
||||||
|
className={monospace ? 'is-monospace' : undefined}
|
||||||
|
name={name}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rows={rows}
|
||||||
|
spellCheck={monospace ? false : undefined}
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
<FieldMessage {...shell} />
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwitchFieldProps {
|
||||||
|
checked: boolean;
|
||||||
|
description?: ReactNode;
|
||||||
|
label: string;
|
||||||
|
/** Emphasises the field as a consequential control. */
|
||||||
|
tone?: 'default' | 'critical';
|
||||||
|
onChange(checked: boolean): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SwitchField({ checked, description, label, onChange, tone = 'default' }: SwitchFieldProps) {
|
||||||
|
return (
|
||||||
|
<label className={tone === 'critical' ? 'switch-field is-critical' : 'switch-field'}>
|
||||||
|
<input aria-label={label} checked={checked} onChange={(event) => onChange(event.target.checked)} type="checkbox" />
|
||||||
|
<span className="switch-copy">
|
||||||
|
<strong>{label}</strong>
|
||||||
|
{description ? <small>{description}</small> : null}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RadioCardOption<Value extends string> {
|
||||||
|
value: Value;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
/** Extra controls revealed only while this option is selected. */
|
||||||
|
detail?: ReactNode;
|
||||||
|
/** Flags the option as operationally risky. */
|
||||||
|
isRisky?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RadioCardsProps<Value extends string> {
|
||||||
|
legend: string;
|
||||||
|
name: string;
|
||||||
|
options: ReadonlyArray<RadioCardOption<Value>>;
|
||||||
|
value: Value;
|
||||||
|
onChange(value: Value): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-of choice where each option's explanation is body copy rather than a
|
||||||
|
* footnote, and its dependent settings are nested inside the chosen card so
|
||||||
|
* revealing them does not reflow the options above.
|
||||||
|
*/
|
||||||
|
export function RadioCards<Value extends string>({ legend, name, onChange, options, value }: RadioCardsProps<Value>) {
|
||||||
|
return (
|
||||||
|
<fieldset className="radio-cards">
|
||||||
|
<legend>{legend}</legend>
|
||||||
|
{options.map((option) => {
|
||||||
|
const isSelected = option.value === value;
|
||||||
|
return (
|
||||||
|
<div className={isSelected ? 'radio-card is-selected' : 'radio-card'} key={option.value}>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
checked={isSelected}
|
||||||
|
name={name}
|
||||||
|
onChange={() => onChange(option.value)}
|
||||||
|
type="radio"
|
||||||
|
value={option.value}
|
||||||
|
/>
|
||||||
|
<span className="radio-copy">
|
||||||
|
<strong>
|
||||||
|
{option.label}
|
||||||
|
{option.isRisky ? <span className="pill warning">Autonomous</span> : null}
|
||||||
|
</strong>
|
||||||
|
<small>{option.description}</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{isSelected && option.detail ? <div className="radio-detail">{option.detail}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useEffect, useRef, useState, type PropsWithChildren, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
/** Class applied to the <dialog> element itself (it is the visible card). */
|
||||||
|
className?: string;
|
||||||
|
/** Blocks Escape/backdrop dismissal while a mutation is in flight. */
|
||||||
|
isDismissLocked?: boolean;
|
||||||
|
/** Close when the backdrop (the ::backdrop area of the dialog) is pressed. */
|
||||||
|
closeOnBackdrop?: boolean;
|
||||||
|
label?: string;
|
||||||
|
labelledBy?: string;
|
||||||
|
onClose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared modal built on native <dialog>.showModal(): the browser provides
|
||||||
|
* focus trapping, inert background, and Escape handling. Render conditionally;
|
||||||
|
* mounting opens the dialog and unmounting restores focus to the opener.
|
||||||
|
*/
|
||||||
|
export function Modal({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
closeOnBackdrop = false,
|
||||||
|
isDismissLocked = false,
|
||||||
|
label,
|
||||||
|
labelledBy,
|
||||||
|
onClose,
|
||||||
|
}: PropsWithChildren<ModalProps>): ReactNode {
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
const onCloseRef = useRef(onClose);
|
||||||
|
onCloseRef.current = onClose;
|
||||||
|
const dismissLockedRef = useRef(isDismissLocked);
|
||||||
|
dismissLockedRef.current = isDismissLocked;
|
||||||
|
|
||||||
|
// Captured at first render, before autoFocus moves focus into the dialog.
|
||||||
|
const [previouslyFocused] = useState(() => document.activeElement);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
if (!dialog.open) {
|
||||||
|
if (typeof dialog.showModal === 'function') dialog.showModal();
|
||||||
|
else dialog.setAttribute('open', '');
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (previouslyFocused instanceof HTMLElement && previouslyFocused.isConnected) {
|
||||||
|
previouslyFocused.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [previouslyFocused]);
|
||||||
|
|
||||||
|
function requestClose(): void {
|
||||||
|
if (!dismissLockedRef.current) onCloseRef.current();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog
|
||||||
|
aria-label={label}
|
||||||
|
aria-labelledby={labelledBy}
|
||||||
|
className={className ? `modal ${className}` : 'modal'}
|
||||||
|
onCancel={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
requestClose();
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
requestClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
if (closeOnBackdrop && event.target === event.currentTarget) requestClose();
|
||||||
|
}}
|
||||||
|
ref={dialogRef}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useState, type ReactNode } from 'react';
|
||||||
|
import { Modal } from './modal.js';
|
||||||
|
|
||||||
|
export interface OriginPost {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
subreddit?: string;
|
||||||
|
postedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPostAge(postedAt: number, now = Date.now()): string {
|
||||||
|
const elapsedSeconds = Math.max(0, Math.floor((now - postedAt) / 1000));
|
||||||
|
if (elapsedSeconds < 60) return 'now';
|
||||||
|
const elapsedMinutes = Math.floor(elapsedSeconds / 60);
|
||||||
|
if (elapsedMinutes < 60) return `${elapsedMinutes}m ago`;
|
||||||
|
const elapsedHours = Math.floor(elapsedMinutes / 60);
|
||||||
|
if (elapsedHours < 24) return `${elapsedHours}h ago`;
|
||||||
|
const elapsedDays = Math.floor(elapsedHours / 24);
|
||||||
|
if (elapsedDays < 30) return `${elapsedDays}d ago`;
|
||||||
|
const elapsedMonths = Math.floor(elapsedDays / 30);
|
||||||
|
if (elapsedMonths < 12) return `${elapsedMonths}mo ago`;
|
||||||
|
return `${Math.floor(elapsedMonths / 12)}y ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostOriginMeta({ post }: { post: OriginPost }) {
|
||||||
|
return (
|
||||||
|
<small>
|
||||||
|
Started from {post.subreddit ? `r/${post.subreddit}` : 'Reddit'}
|
||||||
|
{post.postedAt ? (
|
||||||
|
<> · <time dateTime={new Date(post.postedAt).toISOString()} title={new Date(post.postedAt).toLocaleString()}>{formatPostAge(post.postedAt)}</time></>
|
||||||
|
) : null}
|
||||||
|
</small>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OriginPostCard({ post }: { post: OriginPost }) {
|
||||||
|
const [isImageRevealed, setIsImageRevealed] = useState(false);
|
||||||
|
const [isImagePreviewOpen, setIsImagePreviewOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="origin-post-card">
|
||||||
|
{post.imageUrl ? (
|
||||||
|
<div className="origin-post-image">
|
||||||
|
<button
|
||||||
|
aria-label={isImageRevealed
|
||||||
|
? `View source image full screen: ${post.title}`
|
||||||
|
: `Reveal source image: ${post.title}`}
|
||||||
|
className="origin-post-image-preview-trigger"
|
||||||
|
onClick={() => {
|
||||||
|
if (isImageRevealed) setIsImagePreviewOpen(true);
|
||||||
|
else setIsImageRevealed(true);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<img alt={`Source post: ${post.title}`} className={isImageRevealed ? undefined : 'is-blurred'} loading="lazy" src={post.imageUrl} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-pressed={isImageRevealed}
|
||||||
|
className="origin-post-image-reveal"
|
||||||
|
onClick={() => setIsImageRevealed((current) => !current)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isImageRevealed ? 'Blur original image' : 'Reveal original image'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<a aria-label={post.title} className="origin-post-copy" href={post.url} rel="noreferrer" target="_blank">
|
||||||
|
<PostOriginMeta post={post} />
|
||||||
|
<strong>{post.title}</strong>
|
||||||
|
<span>Open original post <span aria-hidden="true">↗</span></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{isImagePreviewOpen && post.imageUrl ? (
|
||||||
|
<Modal
|
||||||
|
className="origin-image-preview"
|
||||||
|
closeOnBackdrop
|
||||||
|
label={`Source image: ${post.title}`}
|
||||||
|
onClose={() => setIsImagePreviewOpen(false)}
|
||||||
|
>
|
||||||
|
<div className="origin-image-preview-content">
|
||||||
|
<img alt={`Source post: ${post.title}`} src={post.imageUrl} />
|
||||||
|
<button aria-label="Close source image preview" autoFocus onClick={() => setIsImagePreviewOpen(false)} type="button">×</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OriginPostButton({ children, post }: { children: ReactNode; post: OriginPost }) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button className="origin-post-trigger" onClick={() => setIsOpen(true)} type="button">
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
{isOpen ? (
|
||||||
|
<Modal
|
||||||
|
className="origin-post-dialog"
|
||||||
|
closeOnBackdrop
|
||||||
|
labelledBy="origin-post-dialog-title"
|
||||||
|
onClose={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
|
<article>
|
||||||
|
<div className="origin-post-dialog-heading">
|
||||||
|
<span>Conversation origin</span>
|
||||||
|
<button aria-label="Close source post" autoFocus onClick={() => setIsOpen(false)} type="button">×</button>
|
||||||
|
</div>
|
||||||
|
{post.imageUrl ? <img alt={`Source post: ${post.title}`} loading="lazy" src={post.imageUrl} /> : null}
|
||||||
|
<div className="origin-post-dialog-copy">
|
||||||
|
<PostOriginMeta post={post} />
|
||||||
|
<h2 id="origin-post-dialog-title">{post.title}</h2>
|
||||||
|
<a className="button primary" href={post.url} rel="noreferrer" target="_blank">
|
||||||
|
Open Reddit post <span aria-hidden="true">↗</span><span className="sr-only">(opens in a new tab)</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</Modal>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Fragment } from 'react';
|
||||||
|
import { RedditUsernameLink } from './reddit-username-link.js';
|
||||||
|
|
||||||
|
const USER_MENTION = /(u\/[A-Za-z0-9_-]+|@?t2_[A-Za-z0-9]+(?::reddit\.com)?)/gi;
|
||||||
|
|
||||||
|
export function RedditUserText({ children }: { children: string }) {
|
||||||
|
return children.split(USER_MENTION).map((part, index) => {
|
||||||
|
if (/^u\/(?!t2_)[A-Za-z0-9_-]+$/i.test(part)) {
|
||||||
|
return <RedditUsernameLink key={`${part}-${index}`} username={part} />;
|
||||||
|
}
|
||||||
|
if (/^(?:@?t2_)/i.test(part) || /^u\/t2_/i.test(part)) {
|
||||||
|
return <Fragment key={`${part}-${index}`}>Reddit user</Fragment>;
|
||||||
|
}
|
||||||
|
return <Fragment key={`${part}-${index}`}>{part}</Fragment>;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
export interface RedditUsernameLinkProps {
|
||||||
|
username: string;
|
||||||
|
showPrefix?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRedditUsername(username: string): string {
|
||||||
|
return username.trim().replace(/^\/?u\//i, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDisplayableRedditUsername(username: string): boolean {
|
||||||
|
const normalized = normalizeRedditUsername(username);
|
||||||
|
return normalized.length > 0
|
||||||
|
&& !/^(?:me|you|unknown|unknown reddit user)$/i.test(normalized)
|
||||||
|
&& !/^t2_/i.test(normalized)
|
||||||
|
&& !/^@t2_/i.test(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redditUsernameLabel(username: string, showPrefix = true): string {
|
||||||
|
if (!isDisplayableRedditUsername(username)) return 'Unknown Reddit user';
|
||||||
|
const normalized = normalizeRedditUsername(username);
|
||||||
|
return showPrefix ? `u/${normalized}` : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RedditUsernameLink({ username, showPrefix = true }: RedditUsernameLinkProps) {
|
||||||
|
const normalizedUsername = normalizeRedditUsername(username);
|
||||||
|
const label = redditUsernameLabel(username, showPrefix);
|
||||||
|
|
||||||
|
if (!isDisplayableRedditUsername(username)) {
|
||||||
|
return <span className="reddit-username-link is-unresolved">{label}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className="reddit-username-link"
|
||||||
|
href={`https://www.reddit.com/u/${encodeURIComponent(normalizedUsername)}`}
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,401 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import type {
|
||||||
|
AccountEstablishmentSummary,
|
||||||
|
RedditAccountSummary,
|
||||||
|
} from '../../../../../packages/api-contracts/src/index.js';
|
||||||
|
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
||||||
|
import {
|
||||||
|
useImportRedditSessionMutation,
|
||||||
|
useLoginStatusQuery,
|
||||||
|
useRedditAccountsQuery,
|
||||||
|
useRemoveRedditAccountMutation,
|
||||||
|
} from '../settings/settings-queries.js';
|
||||||
|
import './accounts.css';
|
||||||
|
|
||||||
|
const CONFIRM_TIMEOUT_MS = 8_000;
|
||||||
|
|
||||||
|
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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removal is destructive — the account's messages, activity and sync state go
|
||||||
|
* with it — so the button arms a confirmation that disarms itself if ignored.
|
||||||
|
*/
|
||||||
|
function AccountRow({ account }: { account: RedditAccountSummary }) {
|
||||||
|
const removeMutation = useRemoveRedditAccountMutation();
|
||||||
|
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||||
|
const isRemoved = account.status === 'removed';
|
||||||
|
const isSuspended = account.status === 'suspended';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!confirmRemove) return;
|
||||||
|
const timer = globalThis.setTimeout(() => setConfirmRemove(false), CONFIRM_TIMEOUT_MS);
|
||||||
|
return () => globalThis.clearTimeout(timer);
|
||||||
|
}, [confirmRemove]);
|
||||||
|
|
||||||
|
const rowClass = isRemoved
|
||||||
|
? 'account-row is-removed'
|
||||||
|
: isSuspended ? 'account-row is-suspended'
|
||||||
|
: account.isRunning ? 'account-row is-active' : 'account-row';
|
||||||
|
return (
|
||||||
|
<div className={rowClass}>
|
||||||
|
<div>
|
||||||
|
<strong><RedditUsernameLink username={account.redditUsername} /></strong>
|
||||||
|
<span>
|
||||||
|
{isRemoved
|
||||||
|
? 'Removed — its conversations and media are kept, read-only'
|
||||||
|
: isSuspended
|
||||||
|
? 'Suspended by Reddit — all bot work is paused'
|
||||||
|
: account.isRunning ? 'Running independently' : 'Waiting for a refreshed session'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="account-row-pills">
|
||||||
|
{isRemoved || isSuspended ? null : establishmentPill(account.establishment)}
|
||||||
|
<span className={isRemoved ? 'pill neutral' : account.isRunning ? 'pill success' : 'pill warning'}>
|
||||||
|
{isRemoved ? 'Removed' : isSuspended ? 'Suspended' : account.isRunning ? 'Running' : 'Paused'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="account-row-actions">
|
||||||
|
{isRemoved ? (
|
||||||
|
<span className="field-hint">Import a session for this account to bring it back.</span>
|
||||||
|
) : confirmRemove ? (
|
||||||
|
<>
|
||||||
|
<span className="account-remove-warning">
|
||||||
|
Stops u/{account.redditUsername} and deletes its stored session. Conversations,
|
||||||
|
messages and media it collected are kept, but become read-only.
|
||||||
|
</span>
|
||||||
|
<button className="button secondary" onClick={() => setConfirmRemove(false)} type="button">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-busy={removeMutation.isPending || undefined}
|
||||||
|
className="button danger"
|
||||||
|
disabled={removeMutation.isPending}
|
||||||
|
onClick={() => removeMutation.mutate(account.accountId)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{removeMutation.isPending ? 'Removing…' : 'Confirm removal'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
aria-label={`Remove u/${account.redditUsername}`}
|
||||||
|
className="button secondary"
|
||||||
|
onClick={() => setConfirmRemove(true)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{removeMutation.isError ? (
|
||||||
|
<strong className="save-error" role="alert">{errorMessage(removeMutation.error)}</strong>
|
||||||
|
) : null}
|
||||||
|
{!isRemoved && account.establishment && account.establishment.state !== 'unknown' ? (
|
||||||
|
<EstablishmentDetail establishment={account.establishment} />
|
||||||
|
) : null}
|
||||||
|
{isSuspended ? (
|
||||||
|
<div className="account-suspension" role="alert">
|
||||||
|
<strong>Account suspended</strong>
|
||||||
|
<span>Reddit or Matrix confirmed this account is suspended. It cannot send, scan, or sync until you review it and import a newly verified session.</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ?? [];
|
||||||
|
// Removed accounts are not paused work — nothing about them needs attention.
|
||||||
|
const liveAccounts = accounts.filter((account) => account.status !== 'removed');
|
||||||
|
const suspendedAccounts = liveAccounts.filter((account) => account.status === 'suspended');
|
||||||
|
const pausedCount = liveAccounts.filter((account) => !account.isRunning && account.status !== 'suspended').length;
|
||||||
|
const heldAccounts = liveAccounts.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}
|
||||||
|
|
||||||
|
{suspendedAccounts.length > 0 ? (
|
||||||
|
<div className="state-banner is-error" role="alert">
|
||||||
|
<strong>
|
||||||
|
{suspendedAccounts.length === 1
|
||||||
|
? `u/${suspendedAccounts[0].redditUsername} is suspended by Reddit.`
|
||||||
|
: `${suspendedAccounts.length} accounts are suspended by Reddit.`}
|
||||||
|
</strong>
|
||||||
|
<span>All work is paused for these accounts. Review the suspension before importing any new session.</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) => (
|
||||||
|
<AccountRow account={account} key={account.accountId} />
|
||||||
|
))}
|
||||||
|
{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,26 @@
|
|||||||
|
.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.is-removed { opacity: .72; }
|
||||||
|
.account-row.is-suspended { border-color: var(--danger-border); }
|
||||||
|
.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; }
|
||||||
|
.account-row > .account-row-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: .4rem; margin-left: auto; }
|
||||||
|
.account-row .account-remove-warning { flex-basis: 100%; text-align: right; color: var(--danger-text, var(--muted)); font-size: .78rem; }
|
||||||
|
.account-suspension { flex-basis: 100%; display: grid; gap: .25rem; padding-top: .7rem; color: var(--danger-text); border-top: 1px solid var(--danger-border); }
|
||||||
|
.account-suspension span { color: inherit; }
|
||||||
|
|
||||||
|
/* 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,90 @@
|
|||||||
|
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-suspended':
|
||||||
|
return 'No linked conversation — review the account suspension.';
|
||||||
|
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,36 @@ import type { ZodType } from 'zod';
|
|||||||
import {
|
import {
|
||||||
ConversationsResponseSchema,
|
ConversationsResponseSchema,
|
||||||
ConversationDetailResponseSchema,
|
ConversationDetailResponseSchema,
|
||||||
|
ConversationMediaShareOptionsResponseSchema,
|
||||||
|
ConversationImageReplacementOptionsResponseSchema,
|
||||||
|
GenerateConversationImageReplacementRequestSchema,
|
||||||
|
GenerateConversationImageReplacementResponseSchema,
|
||||||
ErrorResponseSchema,
|
ErrorResponseSchema,
|
||||||
|
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 ConversationImageReplacementOptions,
|
||||||
|
type ConversationImageReplacementResult,
|
||||||
|
type ConversationTriageStatus,
|
||||||
|
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';
|
||||||
|
|
||||||
async function parseResponse<T>(response: Response, schema: ZodType<T>): Promise<T> {
|
async function parseResponse<T>(response: Response, schema: ZodType<T>): Promise<T> {
|
||||||
const payload: unknown = await response.json();
|
const payload: unknown = await response.json();
|
||||||
@@ -30,18 +50,38 @@ export interface ConversationApi {
|
|||||||
list(): Promise<ActiveChat[]>;
|
list(): Promise<ActiveChat[]>;
|
||||||
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>;
|
||||||
|
mediaShareOptions(id: string): Promise<ConversationMediaShareOptions>;
|
||||||
|
imageReplacementOptions(id: string): Promise<ConversationImageReplacementOptions>;
|
||||||
|
generateImageReplacement(id: string, sourceAssetId: string, targetId: string): Promise<ConversationImageReplacementResult>;
|
||||||
|
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 {
|
||||||
function request(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
const request = createDashboardRequest(fetchImplementation);
|
||||||
return (fetchImplementation ?? globalThis.fetch)(input, init);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async list() {
|
async list() {
|
||||||
return parseResponse(await request('/api/conversations'), ConversationsResponseSchema);
|
return parseResponse(
|
||||||
|
await request('/api/conversations?includeImageMetadata=true'),
|
||||||
|
ConversationsResponseSchema,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
async detail(id) {
|
async detail(id) {
|
||||||
return parseResponse(await request(conversationPath(id)), ConversationDetailResponseSchema);
|
return parseResponse(await request(conversationPath(id)), ConversationDetailResponseSchema);
|
||||||
@@ -53,6 +93,42 @@ export function createConversationApi(fetchImplementation?: typeof fetch): Conve
|
|||||||
);
|
);
|
||||||
return response.suggestedReply;
|
return response.suggestedReply;
|
||||||
},
|
},
|
||||||
|
async suggestMediaAsk(id) {
|
||||||
|
const response = await parseResponse(
|
||||||
|
await request(`${conversationPath(id)}/suggest-media-ask`, { method: 'POST' }),
|
||||||
|
SuggestedReplyResponseSchema,
|
||||||
|
);
|
||||||
|
return response.suggestedReply;
|
||||||
|
},
|
||||||
|
async mediaShareOptions(id) {
|
||||||
|
return parseResponse(
|
||||||
|
await request(`${conversationPath(id)}/shareable-media`),
|
||||||
|
ConversationMediaShareOptionsResponseSchema,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async imageReplacementOptions(id) {
|
||||||
|
return parseResponse(
|
||||||
|
await request(`${conversationPath(id)}/image-replacement-options`),
|
||||||
|
ConversationImageReplacementOptionsResponseSchema,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async generateImageReplacement(id, sourceAssetId, targetId) {
|
||||||
|
const body = GenerateConversationImageReplacementRequestSchema.parse({ sourceAssetId, targetId });
|
||||||
|
return parseResponse(
|
||||||
|
await request(`${conversationPath(id)}/generate-image-replacement`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
GenerateConversationImageReplacementResponseSchema,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async processedPosts(username) {
|
||||||
|
return parseResponse(
|
||||||
|
await request(`/api/posts/by-author/${encodeURIComponent(username.trim().replace(/^u\//i, ''))}`),
|
||||||
|
ProcessedPostsResponseSchema,
|
||||||
|
);
|
||||||
|
},
|
||||||
async sendReply(id, message) {
|
async sendReply(id, message) {
|
||||||
const body = SendReplyRequestSchema.parse({ message: message.trim() });
|
const body = SendReplyRequestSchema.parse({ message: message.trim() });
|
||||||
return parseResponse(
|
return parseResponse(
|
||||||
@@ -64,8 +140,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(),
|
||||||
});
|
});
|
||||||
@@ -79,6 +167,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,67 +1,321 @@
|
|||||||
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 {
|
||||||
import { useConversationDetailQuery, useSendReplyMutation, useSuggestReplyMutation } from './conversation-queries.js';
|
ChatMessage,
|
||||||
|
ConversationDetailResponse,
|
||||||
|
MediaLibraryAsset,
|
||||||
|
} from '../../../../../packages/api-contracts/src/index.js';
|
||||||
|
import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
|
||||||
|
import { OriginPostCard } from '../../components/origin-post.js';
|
||||||
|
import {
|
||||||
|
useConversationDetailQuery,
|
||||||
|
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';
|
||||||
|
import { ConversationImageReplacement } from './conversation-image-replacement.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;
|
||||||
|
/**
|
||||||
|
* Emoji offered by the composer picker. Deliberately a short hand-picked set
|
||||||
|
* rather than a full Unicode index — the operator wants the handful that come
|
||||||
|
* up in casual chat, not a searchable catalogue.
|
||||||
|
*/
|
||||||
|
export const COMPOSER_EMOJI = [
|
||||||
|
'😊', '😄', '😉', '😅', '😂', '🙃',
|
||||||
|
'😍', '🥰', '😘', '😏', '😈', '🥵',
|
||||||
|
'🙈', '👀', '🔥', '✨', '❤️', '💜',
|
||||||
|
'👋', '🙌', '🤔', '😴', '😇', '🥺',
|
||||||
|
] as const;
|
||||||
|
|
||||||
interface ConversationDetailProps {
|
interface ConversationDetailProps {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
onClose(): void;
|
onClose(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMessageTime(timestamp: number): string {
|
function mediaPath(localPath: string): string {
|
||||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp);
|
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) => {
|
||||||
<div>
|
const previous = index > 0 ? messages[index - 1] : null;
|
||||||
<strong>{message.isSelf ? 'You' : `u/${conversation.username}`}</strong>
|
const startsNewDay = previous === null || !isSameDay(previous.timestamp, message.timestamp);
|
||||||
<time dateTime={new Date(message.timestamp).toISOString()}>{formatMessageTime(message.timestamp)}</time>
|
const key = message.eventId ?? `${message.timestamp}-${message.sender}-${message.text}`;
|
||||||
</div>
|
|
||||||
<p>{message.text}</p>
|
return [
|
||||||
</li>
|
startsNewDay ? (
|
||||||
))}
|
<li aria-hidden="true" className="conversation-day-divider" key={`day-${key}`}>
|
||||||
</ol>
|
<span>{formatDayLabel(message.timestamp, now)}</span>
|
||||||
|
</li>
|
||||||
|
) : null,
|
||||||
|
index === unreadIndex ? (
|
||||||
|
<li className="conversation-unread-divider" key={`unread-${key}`} ref={dividerRef}>
|
||||||
|
<span>New messages</span>
|
||||||
|
</li>
|
||||||
|
) : null,
|
||||||
|
<li className={message.isSelf ? 'is-self' : 'is-recipient'} key={key}>
|
||||||
|
<div>
|
||||||
|
<strong>{message.isSelf
|
||||||
|
? `You${conversation.owner ? ` (u/${conversation.owner.redditUsername})` : ''}`
|
||||||
|
: <RedditUsernameLink username={conversation.username} />}</strong>
|
||||||
|
{message.isAi ? <span aria-label="Sent by AI" className="conversation-ai-badge">AI</span> : null}
|
||||||
|
<time dateTime={new Date(message.timestamp).toISOString()} title={formatExactTime(message.timestamp)}>
|
||||||
|
{formatTimeOfDay(message.timestamp)}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<p>{message.text}</p>
|
||||||
|
{message.media?.length ? (
|
||||||
|
<div className="conversation-message-images">
|
||||||
|
{message.media.map((media) => {
|
||||||
|
const mediaKey = `${message.timestamp}-${media.localPath}`;
|
||||||
|
const sender = message.isSelf ? 'sent by you' : `received from ${redditUsernameLabel(conversation.username)}`;
|
||||||
|
const isRevealed = revealedMedia.has(mediaKey);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="conversation-message-image" key={mediaKey}>
|
||||||
|
<img
|
||||||
|
alt={`Image ${sender}`}
|
||||||
|
className={isRevealed ? undefined : 'is-blurred'}
|
||||||
|
loading="lazy"
|
||||||
|
src={mediaPath(media.localPath)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
aria-pressed={isRevealed}
|
||||||
|
className="conversation-image-reveal"
|
||||||
|
onClick={() => setRevealedMedia((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(mediaKey)) next.delete(mediaKey);
|
||||||
|
else next.add(mediaKey);
|
||||||
|
return next;
|
||||||
|
})}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isRevealed ? 'Blur conversation image' : 'Reveal conversation image'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</li>,
|
||||||
|
];
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
{!isAtBottom ? (
|
||||||
|
<button className="conversation-jump-latest" onClick={scrollToBottom} type="button">
|
||||||
|
Jump to latest
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConversationComposer({ conversation }: { 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 [isEmojiPickerOpen, setIsEmojiPickerOpen] = 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 sendMutation = useSendReplyMutation(conversation.roomId ?? conversation.username);
|
const replyTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const suggestionMutation = useSuggestReplyMutation(conversationId);
|
||||||
|
const mediaAskMutation = useSuggestMediaAskMutation(conversationId);
|
||||||
|
const sendMutation = useSendReplyMutation(conversationId);
|
||||||
|
const mediaOptionsQuery = useConversationMediaShareOptionsQuery(conversationId, isMediaShareOpen);
|
||||||
|
const mediaShareMutation = useShareConversationMediaMutation(conversationId);
|
||||||
|
const mediaOptions = mediaOptionsQuery.data?.assets ?? [];
|
||||||
|
const selectedAsset: MediaLibraryAsset | null = mediaOptions.find((asset) => asset.id === selectedAssetId) ?? null;
|
||||||
const canSend = draft.trim().length > 0 && !sendMutation.isPending;
|
const 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splices an emoji in at the caret (replacing any selection) and leaves the
|
||||||
|
* caret after it, so the operator can keep typing without reaching for the
|
||||||
|
* mouse again. Silently no-ops when the draft is already at the length cap,
|
||||||
|
* which matches how the textarea itself refuses further typing.
|
||||||
|
*/
|
||||||
|
function insertEmoji(emoji: string) {
|
||||||
|
const textarea = replyTextareaRef.current;
|
||||||
|
const start = textarea?.selectionStart ?? draft.length;
|
||||||
|
const end = textarea?.selectionEnd ?? draft.length;
|
||||||
|
const next = draft.slice(0, start) + emoji + draft.slice(end);
|
||||||
|
if (next.length > REDDIT_CHAT_MAX_MESSAGE_LENGTH) return;
|
||||||
|
updateDraft(next);
|
||||||
|
setRestorableDraft(null);
|
||||||
|
setWasSent(false);
|
||||||
|
const caret = start + emoji.length;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
textarea?.focus();
|
||||||
|
textarea?.setSelectionRange(caret, caret);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function suggestReply() {
|
function suggestReply() {
|
||||||
setWasSent(false);
|
setWasSent(false);
|
||||||
suggestionMutation.mutate(undefined, { onSuccess: (suggestion) => setDraft(suggestion) });
|
suggestionMutation.mutate(undefined, { onSuccess: applySuggestion });
|
||||||
|
}
|
||||||
|
|
||||||
|
function suggestMediaAsk() {
|
||||||
|
setWasSent(false);
|
||||||
|
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">
|
||||||
@@ -69,25 +323,126 @@ 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 || !automationActive} onClick={suggestMediaAsk} type="button">
|
||||||
|
{mediaAskMutation.isPending ? 'Generating media ask…' : 'Suggest media ask'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<label className="sr-only" htmlFor="conversation-reply">Reply to u/{conversation.username}</label>
|
<label className="sr-only" htmlFor="conversation-reply">Reply to {redditUsernameLabel(conversation.username)}</label>
|
||||||
<textarea
|
<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"
|
||||||
|
ref={replyTextareaRef}
|
||||||
rows={5}
|
rows={5}
|
||||||
value={draft}
|
value={draft}
|
||||||
/>
|
/>
|
||||||
|
<div className="conversation-emoji">
|
||||||
|
<button
|
||||||
|
aria-expanded={isEmojiPickerOpen}
|
||||||
|
onClick={() => setIsEmojiPickerOpen((current) => !current)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isEmojiPickerOpen ? 'Hide emoji' : 'Add emoji'}
|
||||||
|
</button>
|
||||||
|
{isEmojiPickerOpen ? (
|
||||||
|
<div aria-label="Emoji" className="conversation-emoji-options" role="group">
|
||||||
|
{COMPOSER_EMOJI.map((emoji) => (
|
||||||
|
<button key={emoji} onClick={() => insertEmoji(emoji)} type="button">
|
||||||
|
{emoji}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{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}
|
||||||
{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 u/{conversation.username}</div>
|
<div aria-label="Reply sent" className="conversation-inline-success" role="status">Reply sent to <RedditUsernameLink username={conversation.username} /></div>
|
||||||
) : null}
|
) : null}
|
||||||
|
<ConversationImageReplacement conversationId={conversationId} />
|
||||||
|
<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"
|
||||||
@@ -107,12 +462,154 @@ 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 'engaged':
|
||||||
|
return 'Reply received. The engagement goal is complete and no automated response will be sent.';
|
||||||
|
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 || conversation.readOnly) return;
|
||||||
|
if (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>;
|
||||||
@@ -128,35 +625,72 @@ 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>u/{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 className="conversation-detail-controls">
|
||||||
|
{conversation.readOnly ? null : (
|
||||||
|
<ConversationTriageActions
|
||||||
|
conversation={conversation}
|
||||||
|
conversationId={conversationId}
|
||||||
|
onResolved={onClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button aria-label="Close conversation" onClick={onClose} type="button">Close</button>
|
||||||
</div>
|
</div>
|
||||||
<button aria-label="Close conversation" onClick={onClose} type="button">Close</button>
|
|
||||||
</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.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{conversation.triggeringPost ? (
|
{conversation.triggeringPost ? (
|
||||||
<a
|
<div className="triggering-post">
|
||||||
aria-label={conversation.triggeringPost.title ?? `Open triggering post ${conversation.triggeringPost.id}`}
|
<OriginPostCard post={{
|
||||||
className="triggering-post"
|
title: conversation.triggeringPost.title ?? conversation.triggeringPost.id,
|
||||||
href={conversation.triggeringPost.url}
|
url: conversation.triggeringPost.url,
|
||||||
rel="noreferrer"
|
imageUrl: conversation.triggeringPost.imageUrl,
|
||||||
target="_blank"
|
subreddit: conversation.triggeringPost.subreddit,
|
||||||
>
|
postedAt: conversation.triggeringPost.postedAt,
|
||||||
<span>Started from {conversation.triggeringPost.subreddit ? `r/${conversation.triggeringPost.subreddit}` : 'Reddit'}</span>
|
}} />
|
||||||
<strong>{conversation.triggeringPost.title ?? conversation.triggeringPost.id}</strong>
|
</div>
|
||||||
</a>
|
|
||||||
) : null}
|
) : null}
|
||||||
<MessageHistory conversation={conversation} />
|
<MessageHistory conversation={conversation} now={now} />
|
||||||
<ConversationComposer conversation={conversation} />
|
{conversation.readOnly ? (
|
||||||
|
<section aria-labelledby="read-only-heading" className="conversation-read-only" role="status">
|
||||||
|
<h3 id="read-only-heading">Read-only</h3>
|
||||||
|
<p>
|
||||||
|
{conversation.owner?.redditUsername
|
||||||
|
? `u/${conversation.owner.redditUsername} was removed from the app.`
|
||||||
|
: 'The account that owned this conversation was removed from the app.'}
|
||||||
|
{' '}Its history is kept here, but nothing can be sent or synced from it.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
useConversationImageReplacementOptionsQuery,
|
||||||
|
useGenerateConversationImageReplacementMutation,
|
||||||
|
} from './conversation-queries.js';
|
||||||
|
|
||||||
|
export function ConversationImageReplacement({ conversationId }: { conversationId: string }) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [areSourcesRevealed, setAreSourcesRevealed] = useState(false);
|
||||||
|
const [areTargetsRevealed, setAreTargetsRevealed] = useState(false);
|
||||||
|
const [isResultRevealed, setIsResultRevealed] = useState(false);
|
||||||
|
const [sourceAssetId, setSourceAssetId] = useState<string | null>(null);
|
||||||
|
const [targetId, setTargetId] = useState<string | null>(null);
|
||||||
|
const targetFieldsetRef = useRef<HTMLFieldSetElement>(null);
|
||||||
|
const optionsQuery = useConversationImageReplacementOptionsQuery(conversationId, isOpen);
|
||||||
|
const generation = useGenerateConversationImageReplacementMutation(conversationId);
|
||||||
|
const options = optionsQuery.data;
|
||||||
|
|
||||||
|
function clearResult() {
|
||||||
|
generation.reset();
|
||||||
|
setIsResultRevealed(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details className="conversation-image-replacement" open={isOpen}>
|
||||||
|
<summary
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsOpen((current) => !current);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create a green-screen image
|
||||||
|
</summary>
|
||||||
|
<p>
|
||||||
|
Pick a green-screen source from your exchange library, then place the original post or any conversation image into it. Generation only creates a private preview; it does not send anything.
|
||||||
|
</p>
|
||||||
|
{optionsQuery.isLoading ? <span role="status">Loading image choices…</span> : null}
|
||||||
|
{optionsQuery.isError ? <div className="conversation-inline-error" role="alert">{optionsQuery.error.message}</div> : null}
|
||||||
|
{options?.reason ? <div className="conversation-media-unavailable">{options.reason}</div> : null}
|
||||||
|
{options?.sources.length ? (
|
||||||
|
<fieldset>
|
||||||
|
<legend>1. Green-screen source</legend>
|
||||||
|
<button onClick={() => setAreSourcesRevealed((current) => !current)} type="button">
|
||||||
|
{areSourcesRevealed ? 'Blur source images' : 'Reveal source images'}
|
||||||
|
</button>
|
||||||
|
<div className="conversation-replacement-options">
|
||||||
|
{options.sources.map((source) => (
|
||||||
|
<label className={sourceAssetId === source.id ? 'is-selected' : ''} key={source.id}>
|
||||||
|
<input
|
||||||
|
aria-label={`Green-screen source ${source.fileName}`}
|
||||||
|
checked={sourceAssetId === source.id}
|
||||||
|
disabled={!areSourcesRevealed}
|
||||||
|
name="replacement-source"
|
||||||
|
onChange={() => {
|
||||||
|
setSourceAssetId(source.id);
|
||||||
|
clearResult();
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
targetFieldsetRef.current?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
type="radio"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
alt={`Green-screen source ${source.fileName}`}
|
||||||
|
className={areSourcesRevealed ? undefined : 'is-blurred'}
|
||||||
|
src={source.urlPath}
|
||||||
|
/>
|
||||||
|
<span>{source.fileName}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
) : null}
|
||||||
|
{options?.targets.length ? (
|
||||||
|
<fieldset ref={targetFieldsetRef}>
|
||||||
|
<legend>2. Image to place on the green screen</legend>
|
||||||
|
<button onClick={() => setAreTargetsRevealed((current) => !current)} type="button">
|
||||||
|
{areTargetsRevealed ? 'Blur conversation choices' : 'Reveal conversation choices'}
|
||||||
|
</button>
|
||||||
|
<div className="conversation-replacement-options">
|
||||||
|
{options.targets.map((target) => (
|
||||||
|
<label className={targetId === target.id ? 'is-selected' : ''} key={target.id}>
|
||||||
|
<input
|
||||||
|
aria-label={target.label}
|
||||||
|
checked={targetId === target.id}
|
||||||
|
disabled={!areTargetsRevealed}
|
||||||
|
name="replacement-target"
|
||||||
|
onChange={() => {
|
||||||
|
setTargetId(target.id);
|
||||||
|
clearResult();
|
||||||
|
}}
|
||||||
|
type="radio"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
alt={target.label}
|
||||||
|
className={areTargetsRevealed ? undefined : 'is-blurred'}
|
||||||
|
src={target.urlPath}
|
||||||
|
/>
|
||||||
|
<span>{target.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
className="conversation-primary"
|
||||||
|
disabled={!sourceAssetId || !targetId || generation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (sourceAssetId && targetId) generation.mutate({ sourceAssetId, targetId });
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{generation.isPending ? 'Creating image…' : 'Create image'}
|
||||||
|
</button>
|
||||||
|
{generation.isError ? <div className="conversation-inline-error" role="alert">{generation.error.message}</div> : null}
|
||||||
|
{generation.data ? (
|
||||||
|
<div className="conversation-replacement-result" role="status">
|
||||||
|
<strong>Generated preview</strong>
|
||||||
|
<span>{generation.data.width} × {generation.data.height} JPEG</span>
|
||||||
|
<div className="conversation-message-image">
|
||||||
|
<img
|
||||||
|
alt="Generated green-screen replacement"
|
||||||
|
className={isResultRevealed ? undefined : 'is-blurred'}
|
||||||
|
src={generation.data.imageDataUrl}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
aria-pressed={isResultRevealed}
|
||||||
|
className="conversation-image-reveal"
|
||||||
|
onClick={() => setIsResultRevealed((current) => !current)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isResultRevealed ? 'Blur generated image' : 'Reveal generated image'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<a download={generation.data.fileName} href={generation.data.imageDataUrl}>Download generated image</a>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,24 @@
|
|||||||
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,
|
||||||
|
mediaShareOptions: (id: string) => ['conversations', 'media-share-options', id] as const,
|
||||||
|
imageReplacementOptions: (id: string) => ['conversations', 'image-replacement-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) {
|
||||||
@@ -15,6 +26,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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,6 +34,41 @@ export function useSuggestReplyMutation(id: string) {
|
|||||||
return useMutation({ mutationFn: () => conversationApi.suggestReply(id) });
|
return useMutation({ mutationFn: () => conversationApi.suggestReply(id) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useSuggestMediaAskMutation(id: string) {
|
||||||
|
return useMutation({ mutationFn: () => conversationApi.suggestMediaAsk(id) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConversationMediaShareOptionsQuery(id: string, enabled: boolean) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: conversationQueryKeys.mediaShareOptions(id),
|
||||||
|
queryFn: () => conversationApi.mediaShareOptions(id),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConversationImageReplacementOptionsQuery(id: string, enabled: boolean) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: conversationQueryKeys.imageReplacementOptions(id),
|
||||||
|
queryFn: () => conversationApi.imageReplacementOptions(id),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGenerateConversationImageReplacementMutation(id: string) {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ sourceAssetId, targetId }: { sourceAssetId: string; targetId: string }) =>
|
||||||
|
conversationApi.generateImageReplacement(id, sourceAssetId, targetId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProcessedPostsQuery(username: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: conversationQueryKeys.processedPosts(username),
|
||||||
|
queryFn: () => conversationApi.processedPosts(username),
|
||||||
|
enabled: username.length > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useSendReplyMutation(id: string) {
|
export function useSendReplyMutation(id: string) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
@@ -35,11 +82,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,22 +1,55 @@
|
|||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||||
import { ConversationsPage } 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 filterValue = searchParams.get('filter');
|
||||||
|
const sortValue = searchParams.get('sort');
|
||||||
|
const filters: ConversationFilters = {
|
||||||
|
search: searchParams.get('q') ?? '',
|
||||||
|
unreadOnly: searchParams.get('unread') === 'true',
|
||||||
|
manualOnly: searchParams.get('manual') === 'true',
|
||||||
|
imagesOnly: searchParams.get('images') === 'true',
|
||||||
|
filter: isConversationFilter(filterValue) ? filterValue : DEFAULT_CONVERSATION_FILTERS.filter,
|
||||||
|
sort: isConversationSort(sortValue) ? sortValue : DEFAULT_CONVERSATION_FILTERS.sort,
|
||||||
|
account: searchParams.get('account') ?? DEFAULT_CONVERSATION_FILTERS.account,
|
||||||
|
};
|
||||||
|
|
||||||
function selectConversation(nextConversationId: string | null) {
|
function selectConversation(nextConversationId: string | null) {
|
||||||
|
const suffix = searchParams.size > 0 ? `?${searchParams.toString()}` : '';
|
||||||
void navigate(
|
void navigate(
|
||||||
nextConversationId
|
nextConversationId
|
||||||
? `/conversations/${encodeURIComponent(nextConversationId)}`
|
? `/conversations/${encodeURIComponent(nextConversationId)}${suffix}`
|
||||||
: '/conversations',
|
: `/conversations${suffix}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateFilters(next: ConversationFilters) {
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
if (next.search) params.set('q', next.search); else params.delete('q');
|
||||||
|
if (next.filter !== DEFAULT_CONVERSATION_FILTERS.filter) params.set('filter', next.filter); else params.delete('filter');
|
||||||
|
if (next.unreadOnly) params.set('unread', 'true'); else params.delete('unread');
|
||||||
|
if (next.manualOnly) params.set('manual', 'true'); else params.delete('manual');
|
||||||
|
if (next.imagesOnly) params.set('images', 'true'); else params.delete('images');
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConversationsPage
|
<ConversationsPage
|
||||||
selectedConversationId={conversationId ?? null}
|
selectedConversationId={conversationId ?? null}
|
||||||
onSelectedConversationChange={selectConversation}
|
onSelectedConversationChange={selectConversation}
|
||||||
|
filters={filters}
|
||||||
|
onFiltersChange={updateFilters}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import type { ActiveChat } from '../../../../../packages/api-contracts/src/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutually exclusive inbox workflow queues. Read state, manual ownership, and
|
||||||
|
* images are secondary filters so they cannot make a row appear to occupy two
|
||||||
|
* competing workflow states.
|
||||||
|
*/
|
||||||
|
export type ConversationFilter =
|
||||||
|
| 'needs-reply'
|
||||||
|
| 'awaiting-reply'
|
||||||
|
| 'sellers'
|
||||||
|
| 'account-deleted'
|
||||||
|
| 'archived'
|
||||||
|
| 'snoozed'
|
||||||
|
| 'done';
|
||||||
|
|
||||||
|
export type ConversationSort = 'recent' | 'oldest-unanswered';
|
||||||
|
|
||||||
|
export interface ConversationFilters {
|
||||||
|
search: string;
|
||||||
|
filter: ConversationFilter;
|
||||||
|
unreadOnly: boolean;
|
||||||
|
manualOnly: boolean;
|
||||||
|
imagesOnly: boolean;
|
||||||
|
sort: ConversationSort;
|
||||||
|
/** Reddit account id to narrow to, or `all`. */
|
||||||
|
account: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_CONVERSATION_FILTERS: ConversationFilters = {
|
||||||
|
search: '',
|
||||||
|
filter: 'needs-reply',
|
||||||
|
unreadOnly: false,
|
||||||
|
manualOnly: false,
|
||||||
|
imagesOnly: false,
|
||||||
|
sort: 'recent',
|
||||||
|
account: 'all',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CONVERSATION_FILTERS: ReadonlyArray<{ value: ConversationFilter; label: string }> = [
|
||||||
|
{ value: 'needs-reply', label: 'Needs attention' },
|
||||||
|
{ value: 'awaiting-reply', label: 'Waiting' },
|
||||||
|
{ value: 'sellers', label: 'Sellers' },
|
||||||
|
{ value: 'snoozed', label: 'Snoozed' },
|
||||||
|
{ value: 'done', label: 'Done' },
|
||||||
|
{ value: 'account-deleted', label: 'Deleted' },
|
||||||
|
{ value: 'archived', label: 'Archived' },
|
||||||
|
];
|
||||||
|
|
||||||
|
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 conversationQueue(conversation) === 'awaiting-reply';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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). Terminal
|
||||||
|
* engagement successes and killed conversations are completed, not manual.
|
||||||
|
*
|
||||||
|
* 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'
|
||||||
|
&& conversation.automationStatus !== 'engaged'
|
||||||
|
&& conversation.automationStatus !== 'killed';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 '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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One authoritative workflow queue per conversation. Data-ownership and
|
||||||
|
* terminal safety outcomes take precedence over message direction so archived,
|
||||||
|
* deleted, and seller rows can never leak into actionable queues.
|
||||||
|
*/
|
||||||
|
export function conversationQueue(conversation: ActiveChat): ConversationFilter {
|
||||||
|
if (conversation.readOnly) return 'archived';
|
||||||
|
if (conversation.recipientAccountStatus === 'deleted') return 'account-deleted';
|
||||||
|
if (conversation.sellerDetected === true || conversation.automationStatus === 'killed') return 'sellers';
|
||||||
|
const status = triageStatus(conversation);
|
||||||
|
if (status === 'snoozed') return 'snoozed';
|
||||||
|
if (status === 'done') return 'done';
|
||||||
|
return needsReply(conversation) ? 'needs-reply' : 'awaiting-reply';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesFilter(conversation: ActiveChat, filter: ConversationFilter): boolean {
|
||||||
|
return conversationQueue(conversation) === filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
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[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesSecondaryFilters(conversation: ActiveChat, filters: ConversationFilters): boolean {
|
||||||
|
return (!filters.unreadOnly || isUnread(conversation))
|
||||||
|
&& (!filters.manualOnly || isManualFlow(conversation))
|
||||||
|
&& (!filters.imagesOnly || conversation.hasReceivedImages === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `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 {
|
||||||
|
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;
|
||||||
|
return matchesSecondaryFilters(conversation, filters);
|
||||||
|
});
|
||||||
|
return { visible: visible.sort(compareConversations(filters.sort)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
&& matchesSecondaryFilters(conversation, filters));
|
||||||
|
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,72 +1,402 @@
|
|||||||
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 { 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 { 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,
|
||||||
|
conversationQueue,
|
||||||
|
filterCounts,
|
||||||
|
isUnread,
|
||||||
|
manualFlowLabel,
|
||||||
|
needsReply,
|
||||||
|
partitionConversations,
|
||||||
|
type ConversationFilter,
|
||||||
|
type ConversationFilters,
|
||||||
|
type ConversationSort,
|
||||||
|
} from './conversation-triage.js';
|
||||||
|
|
||||||
type ConversationFilter = 'all' | 'unread' | 'awaiting-reply';
|
export type { ConversationFilter, ConversationFilters, ConversationSort } from './conversation-triage.js';
|
||||||
|
|
||||||
|
const SMALL_VIEWPORT_QUERY = '(max-width: 51.9999rem)';
|
||||||
|
|
||||||
export interface ConversationsPageProps {
|
export interface ConversationsPageProps {
|
||||||
selectedConversationId?: string | null;
|
selectedConversationId?: string | null;
|
||||||
onSelectedConversationChange?(conversationId: string | null): void;
|
onSelectedConversationChange?(conversationId: string | null): void;
|
||||||
|
filters?: ConversationFilters;
|
||||||
|
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;
|
|
||||||
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(' · ')}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One workflow chip plus independent presentation/ownership badges. */
|
||||||
|
function conversationChips(conversation: ActiveChat, now: number): ConversationChip[] {
|
||||||
|
const chips: ConversationChip[] = [];
|
||||||
|
switch (conversationQueue(conversation)) {
|
||||||
|
case 'needs-reply': chips.push({ label: 'Needs attention' }); break;
|
||||||
|
case 'awaiting-reply': chips.push({ label: 'Waiting' }); break;
|
||||||
|
case 'sellers': chips.push({ label: 'Seller' }); break;
|
||||||
|
case 'snoozed': chips.push({ label: 'Snoozed' }); break;
|
||||||
|
case 'done': chips.push({ label: 'Done' }); break;
|
||||||
|
case 'account-deleted': chips.push({ label: 'Account deleted' }); break;
|
||||||
|
case 'archived': chips.push({ label: 'Archived' }); break;
|
||||||
|
}
|
||||||
|
if (isUnread(conversation)) chips.push({ label: 'Unread' });
|
||||||
|
const manualLabel = manualFlowLabel(conversation);
|
||||||
|
if (manualLabel) {
|
||||||
|
chips.push({
|
||||||
|
label: 'Manual',
|
||||||
|
hint: manualLabel,
|
||||||
|
tooltip: conversation.automationHandoffReason
|
||||||
|
? `${manualLabel} — ${conversation.automationHandoffReason}`
|
||||||
|
: manualLabel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (conversationQueue(conversation) === 'needs-reply'
|
||||||
|
&& 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');
|
||||||
|
if (conversation.readOnly) itemClasses.push('is-read-only');
|
||||||
|
|
||||||
|
const descriptors = [
|
||||||
|
redditUsernameLabel(conversation.username),
|
||||||
|
...chips.map((chip) => chip.hint ?? chip.label),
|
||||||
|
relativeTime,
|
||||||
|
...(conversation.hasReceivedImages ? ['Image received'] : []),
|
||||||
|
...(conversation.readOnly ? ['Removed account, read-only'] : []),
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li>
|
<li className={itemClasses.join(' ')}>
|
||||||
|
<label className="conversation-list-check">
|
||||||
|
<input
|
||||||
|
aria-label={`Select ${redditUsernameLabel(conversation.username)}`}
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={onToggleChecked}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
aria-label={`u/${conversation.username}, ${state}`}
|
aria-current={isSelected || undefined}
|
||||||
aria-pressed={isSelected}
|
aria-label={descriptors.join(', ')}
|
||||||
className={isSelected ? 'conversation-list-item is-selected' : 'conversation-list-item'}
|
className="conversation-list-open"
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
|
ref={registerRef}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span className="conversation-list-heading"><strong>u/{conversation.username}</strong><small>{state}</small></span>
|
<span className="conversation-list-heading">
|
||||||
<span>{conversation.lastMessageSnippet || 'No message preview'}</span>
|
<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>
|
</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.readOnly ? (
|
||||||
|
<span className="conversation-read-only-marker" title="The account that owned this conversation was removed. History is kept, but nothing can be sent.">
|
||||||
|
Read-only
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{conversation.hasReceivedImages ? <span className="conversation-image-marker">Image received</span> : null}
|
||||||
|
<span className="conversation-list-profile"><RedditUsernameLink username={conversation.username} /></span>
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConversationsPage({ selectedConversationId, onSelectedConversationChange }: 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 [search, setSearch] = useState('');
|
const [localFilters, setLocalFilters] = useState<ConversationFilters>(DEFAULT_CONVERSATION_FILTERS);
|
||||||
const [filter, setFilter] = useState<ConversationFilter>('all');
|
|
||||||
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 markRead = useMarkConversationReadMutation();
|
||||||
|
const markUnread = useMarkConversationUnreadMutation();
|
||||||
|
const setStatus = useSetConversationStatusMutation();
|
||||||
|
const bulkMarkRead = useBulkMarkConversationsReadMutation();
|
||||||
|
const sellerScan = useSellerScanMutation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = window.setInterval(() => setNow(Date.now()), 60_000);
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// On small viewports the detail panel sits below the list; bring it into
|
||||||
|
// view when a conversation is picked so the selection isn't invisible.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeConversationId) return;
|
||||||
|
if (typeof window.matchMedia !== 'function') return;
|
||||||
|
if (!window.matchMedia(SMALL_VIEWPORT_QUERY).matches) return;
|
||||||
|
detailPanelRef.current?.scrollIntoView?.({ behavior: 'smooth', block: 'start' });
|
||||||
|
}, [activeConversationId]);
|
||||||
|
|
||||||
|
const updateFilters = useCallback((next: ConversationFilters) => {
|
||||||
|
if (filters === undefined) setLocalFilters(next);
|
||||||
|
onFiltersChange?.(next);
|
||||||
|
}, [filters, onFiltersChange]);
|
||||||
|
|
||||||
|
const allConversations = conversationsQuery.data ?? [];
|
||||||
|
|
||||||
|
const { visible: liveConversations } = 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 = 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, filter);
|
(rank.get(ownedConversationId(left)) ?? 0) - (rank.get(ownedConversationId(right)) ?? 0));
|
||||||
});
|
}, [liveConversations, frozenOrder]);
|
||||||
}, [conversationsQuery.data, filter, search]);
|
|
||||||
|
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">
|
||||||
@@ -75,17 +405,53 @@ 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>
|
||||||
<button className="conversation-primary" onClick={() => setIsStartingConversation((current) => !current)} type="button">
|
<div className="conversations-header-actions">
|
||||||
{isStartingConversation ? 'Close new conversation' : 'New conversation'}
|
<div className="conversation-refresh">
|
||||||
</button>
|
<button
|
||||||
|
aria-busy={conversationsQuery.isFetching || undefined}
|
||||||
|
disabled={conversationsQuery.isFetching}
|
||||||
|
onClick={() => void conversationsQuery.refetch()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{conversationsQuery.isFetching ? 'Refreshing…' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
<span className="conversation-refresh-note">
|
||||||
|
{lastSyncedLabel ? `Updated ${lastSyncedLabel} · auto every 30s` : 'Auto-refreshes every 30s'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
aria-busy={sellerScan.isPending || undefined}
|
||||||
|
className="conversation-seller-scan"
|
||||||
|
disabled={sellerScan.isPending}
|
||||||
|
onClick={() => sellerScan.mutate()}
|
||||||
|
title="Re-check every active conversation for content sellers"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{sellerScan.isPending ? 'Scanning for sellers…' : 'Scan for sellers'}
|
||||||
|
</button>
|
||||||
|
<button className="conversation-primary" onClick={() => setIsStartingConversation((current) => !current)} type="button">
|
||||||
|
{isStartingConversation ? 'Close new conversation' : 'New conversation'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</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>
|
||||||
<StartConversationForm onStarted={(id) => selectConversation(id)} />
|
<StartConversationForm onStarted={(id) => selectConversation(id)} />
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
<ProcessedUserLookup />
|
||||||
|
|
||||||
<div className="conversation-workspace">
|
<div className="conversation-workspace">
|
||||||
<section aria-label="Conversation inbox" className="conversation-master">
|
<section aria-label="Conversation inbox" className="conversation-master">
|
||||||
@@ -93,21 +459,124 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
|
|||||||
<label className="sr-only" htmlFor="conversation-search">Search conversations</label>
|
<label className="sr-only" htmlFor="conversation-search">Search conversations</label>
|
||||||
<input
|
<input
|
||||||
id="conversation-search"
|
id="conversation-search"
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
onChange={(event) => updateFilters({ ...activeFilters, search: event.target.value })}
|
||||||
placeholder="Search conversations"
|
placeholder="Search conversations (/)"
|
||||||
|
ref={searchRef}
|
||||||
type="search"
|
type="search"
|
||||||
value={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) => setFilter(event.target.value as ConversationFilter)}
|
aria-label={`${option.label}, ${counts[option.value]}`}
|
||||||
value={filter}
|
aria-pressed={activeFilters.filter === option.value}
|
||||||
>
|
className="conversation-filter-pill"
|
||||||
<option value="all">All</option>
|
key={option.value}
|
||||||
<option value="unread">Unread</option>
|
onClick={() => updateFilters({ ...activeFilters, filter: option.value })}
|
||||||
<option value="awaiting-reply">Awaiting reply</option>
|
type="button"
|
||||||
</select>
|
>
|
||||||
|
{option.label}
|
||||||
|
<span className="conversation-filter-count">{counts[option.value]}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="conversation-toolbar-row">
|
||||||
|
<label className="conversation-sort">
|
||||||
|
<span>Sort</span>
|
||||||
|
<select
|
||||||
|
onChange={(event) => updateFilters({
|
||||||
|
...activeFilters,
|
||||||
|
sort: event.target.value as ConversationSort,
|
||||||
|
})}
|
||||||
|
value={activeFilters.sort}
|
||||||
|
>
|
||||||
|
{CONVERSATION_SORTS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{accounts.length > 1 ? (
|
||||||
|
<label className="conversation-account-filter">
|
||||||
|
<span>Account</span>
|
||||||
|
<select
|
||||||
|
onChange={(event) => updateFilters({ ...activeFilters, account: event.target.value })}
|
||||||
|
value={activeFilters.account}
|
||||||
|
>
|
||||||
|
<option value="all">All accounts</option>
|
||||||
|
{accounts.map((account) => (
|
||||||
|
<option key={account.accountId} value={account.accountId}>
|
||||||
|
u/{account.redditUsername}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<label className="conversation-seller-toggle">
|
||||||
|
<input
|
||||||
|
checked={activeFilters.unreadOnly}
|
||||||
|
onChange={(event) => updateFilters({ ...activeFilters, unreadOnly: event.target.checked })}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
Unread only
|
||||||
|
</label>
|
||||||
|
<label className="conversation-seller-toggle">
|
||||||
|
<input
|
||||||
|
checked={activeFilters.manualOnly}
|
||||||
|
onChange={(event) => updateFilters({ ...activeFilters, manualOnly: event.target.checked })}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
Manual only
|
||||||
|
</label>
|
||||||
|
<label className="conversation-seller-toggle">
|
||||||
|
<input
|
||||||
|
checked={activeFilters.imagesOnly}
|
||||||
|
onChange={(event) => updateFilters({ ...activeFilters, imagesOnly: event.target.checked })}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
Has images
|
||||||
|
</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>
|
||||||
@@ -120,22 +589,43 @@ 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>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="conversation-list">
|
<>
|
||||||
{visibleConversations.map((conversation) => (
|
<ul className="conversation-list">
|
||||||
<ConversationListItem
|
{visibleConversations.map((conversation) => {
|
||||||
conversation={conversation}
|
const id = ownedConversationId(conversation);
|
||||||
isSelected={conversationId(conversation) === activeConversationId}
|
return (
|
||||||
key={conversationId(conversation)}
|
<ConversationRow
|
||||||
onSelect={() => selectConversation(conversationId(conversation))}
|
conversation={conversation}
|
||||||
/>
|
isChecked={checkedIds.has(id)}
|
||||||
))}
|
isSelected={id === activeConversationId}
|
||||||
</ul>
|
key={id}
|
||||||
|
now={now}
|
||||||
|
onSelect={() => selectConversation(id)}
|
||||||
|
onToggleChecked={() => toggleChecked(id)}
|
||||||
|
showOwner={accounts.length > 1}
|
||||||
|
registerRef={(element) => {
|
||||||
|
if (element) rowRefs.current.set(id, element);
|
||||||
|
else rowRefs.current.delete(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<p className="conversation-list-footer">
|
||||||
|
<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,40 +42,161 @@
|
|||||||
.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 { display: grid; gap: .4rem; width: 100%; padding: .8rem !important; text-align: left; background: transparent !important; border-color: transparent !important; }
|
/*
|
||||||
.conversation-list-item:hover { background: var(--surface-raised) !important; }
|
* The row is a grid of real siblings: a selection checkbox, a primary open
|
||||||
.conversation-list-item.is-selected { background: var(--accent-dark) !important; border-color: #28593d !important; }
|
* button, and a chip strip. The previous full-bleed absolute overlay button made
|
||||||
.conversation-list-item > span:last-child { overflow: hidden; color: var(--muted); font-size: .82rem; text-overflow: ellipsis; white-space: nowrap; }
|
* every per-row control need a pointer-events escape hatch.
|
||||||
|
*/
|
||||||
|
.conversation-list-item { position: relative; display: grid; grid-template-columns: auto minmax(0, 1fr); gap: .15rem .5rem; width: 100%; padding: .6rem .7rem; background: transparent; border: 1px solid transparent; border-radius: .6rem; }
|
||||||
|
.conversation-list-item:hover { background: var(--surface-raised); }
|
||||||
|
.conversation-list-item.is-selected { background: var(--accent-dark); border-color: #28593d; }
|
||||||
|
.conversation-list-item.is-checked { border-color: var(--accent); }
|
||||||
|
.conversation-list-item.is-unread .conversation-list-heading strong { font-weight: 900; }
|
||||||
|
.conversation-list-item.is-unread .conversation-list-heading strong::before { content: ''; display: inline-block; width: .45rem; height: .45rem; margin-right: .45rem; vertical-align: middle; background: var(--accent); border-radius: 50%; }
|
||||||
|
.conversation-list-check { display: flex; align-items: start; padding-top: .3rem; }
|
||||||
|
.conversation-list-check input { accent-color: var(--accent); }
|
||||||
|
.conversation-list-open { display: grid; gap: .25rem; width: 100%; min-height: 0 !important; padding: 0 !important; text-align: left; background: transparent !important; border: 0 !important; }
|
||||||
|
.conversation-list-open:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
.conversation-list-snippet { overflow: hidden; color: var(--muted); font-size: .82rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.conversation-list-heading { display: flex; align-items: center; justify-content: space-between; gap: .5rem; }
|
.conversation-list-heading { display: flex; align-items: center; justify-content: space-between; gap: .5rem; }
|
||||||
.conversation-list-heading small { color: var(--accent); }
|
.conversation-list-heading time { color: var(--muted); font-size: .7rem; white-space: nowrap; }
|
||||||
|
.conversation-list-heading time.is-stale { color: var(--warning-text); font-weight: 700; }
|
||||||
|
.conversation-list-heading time.is-critical { color: var(--danger-text); font-weight: 800; }
|
||||||
|
.conversation-list-chips { grid-column: 2; display: flex; flex-wrap: wrap; align-items: center; gap: .3rem; }
|
||||||
|
.conversation-list-profile { font-size: .7rem; }
|
||||||
|
|
||||||
|
.conversation-chip { padding: .1rem .4rem; border: 1px solid var(--border); border-radius: 999px; background: var(--surface-raised); color: var(--muted); font-size: .68rem; font-weight: 750; }
|
||||||
|
.conversation-chip.is-unread { color: var(--accent); border-color: #28593d; background: var(--accent-dark); }
|
||||||
|
.conversation-chip.is-needs-reply { color: #cfe8ff; border-color: #315d85; background: #18324d; }
|
||||||
|
.conversation-chip.is-manual { color: #e7d8ff; border-color: #5a4285; background: #2c1f47; }
|
||||||
|
.conversation-chip.is-overdue { color: var(--warning-text); border-color: var(--warning-border); background: var(--warning-bg); }
|
||||||
|
.conversation-chip.is-snoozed, .conversation-chip.is-done, .conversation-chip.is-account-deleted { opacity: .85; }
|
||||||
|
|
||||||
|
.conversation-list-footer { display: flex; flex-wrap: wrap; justify-content: space-between; gap: .5rem; padding: .5rem .75rem .75rem; margin: 0; color: var(--muted); font-size: .72rem; }
|
||||||
|
.conversation-shortcut-hint { margin-left: auto; opacity: .8; }
|
||||||
|
.conversation-image-marker { justify-self: start; padding: .18rem .45rem; color: #d8e8ff !important; background: #18324d; border: 1px solid #315d85; border-radius: 999px; font-size: .68rem !important; font-weight: 750; }
|
||||||
|
.conversation-seller-marker { display: inline-flex; justify-self: start; padding: .18rem .45rem; margin-right: .35rem; color: #ffd6d0 !important; background: #4b1e1a; border: 1px solid #874139; border-radius: 999px; font-size: .68rem !important; font-weight: 800; }
|
||||||
|
.conversation-read-only-marker { display: inline-flex; justify-self: start; padding: .18rem .45rem; margin-right: .35rem; color: var(--muted) !important; background: var(--surface-raised); border: 1px solid var(--border); border-radius: 999px; font-size: .68rem !important; font-weight: 800; }
|
||||||
|
.conversation-list-item.is-read-only .conversation-list-snippet { opacity: .8; }
|
||||||
|
.conversation-read-only { padding: 1rem; margin: 0 1rem 1rem; color: var(--muted); background: var(--surface-raised); border: 1px dashed var(--border); border-radius: .65rem; }
|
||||||
|
.conversation-read-only h3 { margin: 0 0 .3rem; color: var(--text); font-size: .9rem; }
|
||||||
|
.conversation-read-only p { margin: 0; font-size: .82rem; }
|
||||||
.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 { display: grid; gap: .2rem; margin: 1rem; padding: .75rem; color: inherit; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .65rem; text-decoration: none; }
|
.triggering-post { margin: 1rem; padding: .75rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .65rem; }
|
||||||
.triggering-post span { color: var(--muted); font-size: .75rem; }
|
.conversation-messages-shell { position: relative; display: flex; flex: 1 1 auto; min-height: 0; overflow: hidden; }
|
||||||
.conversation-messages { display: grid; gap: .75rem; max-height: 30rem; padding: 1rem; margin: 0; overflow: auto; list-style: none; }
|
.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); }
|
||||||
.conversation-messages li > div { display: flex; justify-content: space-between; gap: 1rem; margin-bottom: .35rem; }
|
.conversation-messages li > div { display: flex; justify-content: space-between; gap: 1rem; margin-bottom: .35rem; }
|
||||||
.conversation-messages time { color: var(--muted); font-size: .7rem; }
|
.conversation-messages 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-image { position: relative; overflow: hidden; border-radius: .6rem; }
|
||||||
|
.conversation-message-images img { display: block; width: 100%; max-width: 28rem; max-height: 32rem; object-fit: contain; background: #08100c; border: 1px solid var(--border); border-radius: .6rem; transition: filter 160ms ease, transform 160ms ease; }
|
||||||
|
.conversation-message-images img.is-blurred { filter: blur(24px) brightness(.55); transform: scale(1.12); }
|
||||||
|
.conversation-image-reveal { position: absolute; bottom: .5rem; left: .5rem; min-height: 2rem !important; padding: .35rem .6rem !important; color: white !important; background: rgba(0, 0, 0, .76) !important; border-color: rgba(255, 255, 255, .45) !important; font-size: .75rem; font-weight: 800; }
|
||||||
|
.conversation-image-reveal:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
.conversation-empty-detail { padding: 2rem; color: var(--muted); text-align: center; }
|
.conversation-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 1 auto; display: grid; gap: .65rem; min-height: 0; max-height: 70%; padding: 1rem; overflow-y: auto; overscroll-behavior-y: contain; scrollbar-gutter: stable; 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-emoji { display: grid; gap: .5rem; justify-items: start; }
|
||||||
|
.conversation-emoji > button { min-height: 2rem !important; padding: .3rem .65rem !important; }
|
||||||
|
.conversation-emoji-options { display: flex; flex-wrap: wrap; gap: .3rem; padding: .5rem; background: #0b1410; border: 1px solid var(--border); border-radius: .65rem; }
|
||||||
|
.conversation-emoji-options button { min-height: 2.1rem !important; padding: .2rem .4rem !important; font-size: 1.1rem; line-height: 1; }
|
||||||
|
.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-image-replacement { display: grid; gap: .7rem; padding: .75rem; background: #0b1410; border: 1px solid var(--border); border-radius: .65rem; }
|
||||||
|
.conversation-image-replacement[open] > * + * { margin-top: .7rem; }
|
||||||
|
.conversation-image-replacement summary { color: #e9f1ec; font-weight: 800; cursor: pointer; }
|
||||||
|
.conversation-image-replacement > p { margin: 0; color: var(--muted); font-size: .8rem; }
|
||||||
|
.conversation-image-replacement fieldset { display: grid; gap: .55rem; min-width: 0; padding: .65rem; border: 1px solid var(--border); border-radius: .55rem; }
|
||||||
|
.conversation-image-replacement legend { padding: 0 .3rem; color: #e9f1ec; font-size: .8rem; font-weight: 800; }
|
||||||
|
.conversation-image-replacement fieldset > button { justify-self: start; }
|
||||||
|
.conversation-replacement-options { display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); gap: .65rem; max-height: 18rem; overflow-y: auto; overscroll-behavior-y: contain; scrollbar-gutter: stable; }
|
||||||
|
.conversation-replacement-options label { display: grid; gap: .35rem; padding: .35rem; overflow: hidden; border: 1px solid var(--border); border-radius: .55rem; cursor: pointer; }
|
||||||
|
.conversation-replacement-options label.is-selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
||||||
|
.conversation-replacement-options label:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
.conversation-replacement-options input { position: absolute; opacity: 0; pointer-events: none; }
|
||||||
|
.conversation-replacement-options img { display: block; width: 100%; height: 8rem; object-fit: contain; background: #050907; border-radius: .35rem; transition: filter 160ms ease, transform 160ms ease; }
|
||||||
|
.conversation-replacement-options img.is-blurred { filter: blur(22px) brightness(.55); transform: scale(1.1); }
|
||||||
|
.conversation-replacement-options span { color: var(--muted); font-size: .7rem; }
|
||||||
|
.conversation-replacement-result { display: grid; gap: .45rem; padding: .65rem; color: var(--muted); background: var(--surface-raised); border-radius: .55rem; }
|
||||||
|
.conversation-replacement-result > strong { color: var(--text); }
|
||||||
|
.conversation-replacement-result img { display: block; width: 100%; max-height: 55vh; object-fit: contain; background: #050907; border-radius: .55rem; transition: filter 160ms ease; }
|
||||||
|
.conversation-replacement-result img.is-blurred { filter: blur(24px) brightness(.55); }
|
||||||
|
.conversation-replacement-result > a { justify-self: start; color: var(--accent); font-weight: 800; }
|
||||||
|
.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; }
|
||||||
@@ -73,5 +205,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
||||||
|
import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
|
||||||
|
import { useProcessedPostsQuery } from './conversation-queries.js';
|
||||||
|
|
||||||
|
export function ProcessedUserLookup() {
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const postsQuery = useProcessedPostsQuery(username);
|
||||||
|
// Only show results that belong to the current lookup; switching the
|
||||||
|
// username changes the query key, which clears stale data automatically.
|
||||||
|
const showResults = postsQuery.data !== undefined && !postsQuery.isFetching;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details className="new-conversation-panel processed-user-lookup">
|
||||||
|
<summary>
|
||||||
|
<h2 id="processed-user-heading">Processed user lookup</h2>
|
||||||
|
</summary>
|
||||||
|
<form
|
||||||
|
className="start-conversation-form processed-user-form"
|
||||||
|
onSubmit={(event) => { event.preventDefault(); setUsername(input.trim().replace(/^u\//i, '')); }}
|
||||||
|
>
|
||||||
|
<div className="conversation-field">
|
||||||
|
<label htmlFor="processed-user">Lookup Reddit username</label>
|
||||||
|
<input autoComplete="off" id="processed-user" onChange={(event) => setInput(event.target.value)} value={input} />
|
||||||
|
</div>
|
||||||
|
<button className="conversation-primary" disabled={input.trim().length === 0} type="submit">Look up user</button>
|
||||||
|
</form>
|
||||||
|
{postsQuery.isFetching && username ? <p role="status">Looking up processed posts...</p> : null}
|
||||||
|
{postsQuery.isError ? <p className="conversation-inline-error" role="alert">{postsQuery.error.message}</p> : null}
|
||||||
|
{showResults && postsQuery.data ? (
|
||||||
|
postsQuery.data.length === 0 ? <p>No processed posts found for <RedditUsernameLink username={username} />.</p> : (
|
||||||
|
<ol aria-label={`Processed posts for ${redditUsernameLabel(username)}`}>
|
||||||
|
{postsQuery.data.map((post) => <li key={`${post.owner?.accountId ?? 'legacy'}:${post.id}`}><strong>{post.title}</strong><AccountOwnerBadge owner={post.owner} /><span>r/{post.subreddit} · {post.messageSent ? 'Message sent' : post.error ?? 'Processed'}</span></li>)}
|
||||||
|
</ol>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</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"
|
||||||
<span className="conversation-eyebrow">Confirm message</span>
|
isDismissLocked={isPending}
|
||||||
<h2 id="send-confirmation-title">Send this reply?</h2>
|
labelledBy="send-confirmation-title"
|
||||||
<p>This sends immediately through Reddit chat.</p>
|
onClose={onCancel}
|
||||||
<blockquote>{draft}</blockquote>
|
>
|
||||||
<div className="conversation-actions">
|
<span className="conversation-eyebrow">Confirm message</span>
|
||||||
<button disabled={isPending} onClick={onCancel} ref={cancelButtonRef} type="button">Cancel</button>
|
<h2 id="send-confirmation-title">Send this reply?</h2>
|
||||||
<button className="conversation-primary" disabled={isPending} onClick={onConfirm} type="button">
|
<p>This sends immediately through Reddit chat.</p>
|
||||||
{isPending ? 'Sending…' : 'Confirm send'}
|
<blockquote>{draft}</blockquote>
|
||||||
</button>
|
<div className="conversation-actions">
|
||||||
</div>
|
<button autoFocus disabled={isPending} onClick={onCancel} type="button">Cancel</button>
|
||||||
|
<button
|
||||||
|
aria-busy={isPending || undefined}
|
||||||
|
className="conversation-primary"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={onConfirm}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isPending ? 'Sending…' : 'Confirm send'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,5 +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 { 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;
|
||||||
@@ -8,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));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -30,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
|
||||||
@@ -53,7 +89,7 @@ export function StartConversationForm({ onStarted }: StartConversationFormProps)
|
|||||||
{mutation.isError ? <div className="conversation-inline-error" role="alert">{mutation.error.message}</div> : null}
|
{mutation.isError ? <div className="conversation-inline-error" role="alert">{mutation.error.message}</div> : null}
|
||||||
{successUsername ? (
|
{successUsername ? (
|
||||||
<div aria-label="Conversation started" className="conversation-inline-success" role="status">
|
<div aria-label="Conversation started" className="conversation-inline-success" role="status">
|
||||||
Conversation started with u/{successUsername}
|
Conversation started with <RedditUsernameLink username={successUsername} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<button className="conversation-primary" disabled={!canSubmit} type="submit">
|
<button className="conversation-primary" disabled={!canSubmit} type="submit">
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import type { ZodType } from 'zod';
|
||||||
|
import {
|
||||||
|
ErrorResponseSchema,
|
||||||
|
GrowthAccountResponseSchema,
|
||||||
|
GrowthOverviewResponseSchema,
|
||||||
|
type GrowthAccountDetail,
|
||||||
|
type GrowthAccountSummary,
|
||||||
|
type BrowsingSettingsUpdateRequest,
|
||||||
|
type GrowthSettingsUpdateRequest,
|
||||||
|
type GrowthSubredditCreateRequest,
|
||||||
|
type GrowthSubredditUpdateRequest,
|
||||||
|
} from '../../../../../packages/api-contracts/src/index.js';
|
||||||
|
import { dashboardRequest } from '../../api/request.js';
|
||||||
|
|
||||||
|
async function parseResponse<T>(response: Response, schema: ZodType<T>): Promise<T> {
|
||||||
|
const payload: unknown = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
const parsed = ErrorResponseSchema.safeParse(payload);
|
||||||
|
throw new Error(parsed.success ? parsed.data.error : `Request failed (${response.status})`);
|
||||||
|
}
|
||||||
|
return schema.parse(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Account IDs are Matrix-shaped (`@t2_x:reddit.com`), so they must be encoded. */
|
||||||
|
function accountPath(accountId: string): string {
|
||||||
|
return `/api/growth/${encodeURIComponent(accountId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchGrowthOverview(): Promise<GrowthAccountSummary[]> {
|
||||||
|
return dashboardRequest('/api/growth')
|
||||||
|
.then((response) => parseResponse(response, GrowthOverviewResponseSchema))
|
||||||
|
.then((payload) => payload.accounts);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchGrowthAccount(accountId: string): Promise<GrowthAccountDetail> {
|
||||||
|
return dashboardRequest(accountPath(accountId))
|
||||||
|
.then((response) => parseResponse(response, GrowthAccountResponseSchema))
|
||||||
|
.then((payload) => payload.account);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateGrowthSettings(
|
||||||
|
accountId: string,
|
||||||
|
update: GrowthSettingsUpdateRequest,
|
||||||
|
): Promise<GrowthAccountDetail> {
|
||||||
|
return dashboardRequest(accountPath(accountId), {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(update),
|
||||||
|
})
|
||||||
|
.then((response) => parseResponse(response, GrowthAccountResponseSchema))
|
||||||
|
.then((payload) => payload.account);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBrowsingSettings(
|
||||||
|
accountId: string,
|
||||||
|
update: BrowsingSettingsUpdateRequest,
|
||||||
|
): Promise<GrowthAccountDetail> {
|
||||||
|
return dashboardRequest(`${accountPath(accountId)}/browsing`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(update),
|
||||||
|
})
|
||||||
|
.then((response) => parseResponse(response, GrowthAccountResponseSchema))
|
||||||
|
.then((payload) => payload.account);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runBrowsingSimulation(accountId: string): Promise<GrowthAccountDetail> {
|
||||||
|
return dashboardRequest(`${accountPath(accountId)}/browsing/run`, { method: 'POST' })
|
||||||
|
.then((response) => parseResponse(response, GrowthAccountResponseSchema))
|
||||||
|
.then((payload) => payload.account);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pauseGrowth(accountId: string, reason?: string): Promise<GrowthAccountDetail> {
|
||||||
|
return dashboardRequest(`${accountPath(accountId)}/pause`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(reason ? { reason } : {}),
|
||||||
|
})
|
||||||
|
.then((response) => parseResponse(response, GrowthAccountResponseSchema))
|
||||||
|
.then((payload) => payload.account);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resumeGrowth(accountId: string): Promise<GrowthAccountDetail> {
|
||||||
|
return dashboardRequest(`${accountPath(accountId)}/resume`, { method: 'POST' })
|
||||||
|
.then((response) => parseResponse(response, GrowthAccountResponseSchema))
|
||||||
|
.then((payload) => payload.account);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addGrowthSubreddit(
|
||||||
|
accountId: string,
|
||||||
|
request: GrowthSubredditCreateRequest,
|
||||||
|
): Promise<GrowthAccountDetail> {
|
||||||
|
return dashboardRequest(`${accountPath(accountId)}/subreddits`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
})
|
||||||
|
.then((response) => parseResponse(response, GrowthAccountResponseSchema))
|
||||||
|
.then((payload) => payload.account);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateGrowthSubreddit(
|
||||||
|
accountId: string,
|
||||||
|
subreddit: string,
|
||||||
|
update: GrowthSubredditUpdateRequest,
|
||||||
|
): Promise<GrowthAccountDetail> {
|
||||||
|
return dashboardRequest(
|
||||||
|
`${accountPath(accountId)}/subreddits/${encodeURIComponent(subreddit)}`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(update),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.then((response) => parseResponse(response, GrowthAccountResponseSchema))
|
||||||
|
.then((payload) => payload.account);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeGrowthSubreddit(
|
||||||
|
accountId: string,
|
||||||
|
subreddit: string,
|
||||||
|
): Promise<GrowthAccountDetail> {
|
||||||
|
return dashboardRequest(
|
||||||
|
`${accountPath(accountId)}/subreddits/${encodeURIComponent(subreddit)}`,
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
)
|
||||||
|
.then((response) => parseResponse(response, GrowthAccountResponseSchema))
|
||||||
|
.then((payload) => payload.account);
|
||||||
|
}
|
||||||
@@ -0,0 +1,770 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type {
|
||||||
|
GrowthAccountDetail,
|
||||||
|
GrowthAccountSummary,
|
||||||
|
GrowthComment,
|
||||||
|
GrowthSubreddit,
|
||||||
|
BrowsingDecision,
|
||||||
|
} from '../../../../../packages/api-contracts/src/index.js';
|
||||||
|
import { NumberField, SwitchField, TextField } from '../../components/form-fields.js';
|
||||||
|
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
||||||
|
import {
|
||||||
|
useAddGrowthSubredditMutation,
|
||||||
|
useGrowthAccountQuery,
|
||||||
|
useGrowthOverviewQuery,
|
||||||
|
usePauseGrowthMutation,
|
||||||
|
useRemoveGrowthSubredditMutation,
|
||||||
|
useResumeGrowthMutation,
|
||||||
|
useRunBrowsingSimulationMutation,
|
||||||
|
useUpdateGrowthSettingsMutation,
|
||||||
|
useUpdateBrowsingSettingsMutation,
|
||||||
|
useUpdateGrowthSubredditMutation,
|
||||||
|
} from './growth-queries.js';
|
||||||
|
import './growth.css';
|
||||||
|
|
||||||
|
function errorMessage(error: Error | null): string {
|
||||||
|
return error?.message ?? 'Something went wrong.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function minutes(ms: number): string {
|
||||||
|
if (ms < 60_000) return `${Math.round(ms / 1_000)}s`;
|
||||||
|
const mins = Math.round(ms / 60_000);
|
||||||
|
if (mins < 60) return `${mins} min`;
|
||||||
|
const hours = (mins / 60).toFixed(mins % 60 === 0 ? 0 : 1);
|
||||||
|
return `${hours}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusPill(comment: GrowthComment) {
|
||||||
|
if (comment.removed) return <span className="pill critical">Removed</span>;
|
||||||
|
if (comment.shadowBanned) return <span className="pill critical">Not public</span>;
|
||||||
|
switch (comment.status) {
|
||||||
|
case 'posted':
|
||||||
|
case 'verified':
|
||||||
|
return <span className="pill success">Live</span>;
|
||||||
|
case 'rejected':
|
||||||
|
return <span className="pill neutral">Discarded</span>;
|
||||||
|
case 'retryable':
|
||||||
|
return <span className="pill warning">Retrying</span>;
|
||||||
|
default:
|
||||||
|
return <span className="pill neutral">Queued</span>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountRow({
|
||||||
|
account,
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
account: GrowthAccountSummary;
|
||||||
|
isSelected: boolean;
|
||||||
|
onSelect(): void;
|
||||||
|
}) {
|
||||||
|
const { settings, stats } = account;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={isSelected ? 'growth-account-row is-selected' : 'growth-account-row'}
|
||||||
|
onClick={onSelect}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<strong>u/{account.redditUsername}</strong>
|
||||||
|
<span>
|
||||||
|
{settings.enabled
|
||||||
|
? `${stats.postedLast24h}/${settings.dailyAllowance} comments today · ${account.activeSubredditCount} active communities`
|
||||||
|
: 'Growth is off'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="growth-account-pills">
|
||||||
|
{settings.pausedReason ? <span className="pill critical">Paused</span> : null}
|
||||||
|
{settings.enabled && !settings.pausedReason
|
||||||
|
? <span className="pill success">Growing</span>
|
||||||
|
: null}
|
||||||
|
{!settings.enabled ? <span className="pill neutral">Off</span> : null}
|
||||||
|
<span className="pill neutral">Level {settings.trustLevel}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubredditRow({
|
||||||
|
accountId,
|
||||||
|
subreddit,
|
||||||
|
dailyAllowance,
|
||||||
|
}: {
|
||||||
|
accountId: string;
|
||||||
|
subreddit: GrowthSubreddit;
|
||||||
|
/** The account-wide ceiling, which binds before any per-community cap. */
|
||||||
|
dailyAllowance: number;
|
||||||
|
}) {
|
||||||
|
const update = useUpdateGrowthSubredditMutation(accountId);
|
||||||
|
const remove = useRemoveGrowthSubredditMutation(accountId);
|
||||||
|
const [cap, setCap] = useState(String(subreddit.dailyCap));
|
||||||
|
|
||||||
|
useEffect(() => setCap(String(subreddit.dailyCap)), [subreddit.dailyCap]);
|
||||||
|
|
||||||
|
const coolingDown = subreddit.cooldownUntil !== null && subreddit.cooldownUntil > Date.now();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="growth-subreddit-row">
|
||||||
|
<div className="growth-subreddit-name">
|
||||||
|
<strong>r/{subreddit.subreddit}</strong>
|
||||||
|
<span>{subreddit.postedLast24h} of {subreddit.dailyCap} in the last 24h</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{subreddit.dailyCap > dailyAllowance ? (
|
||||||
|
<p className="growth-subreddit-note">
|
||||||
|
Only {dailyAllowance} of these can actually be used today — the account allowance is the
|
||||||
|
overall limit, and this cap only stops one community taking all of it.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{subreddit.disabledReason ? (
|
||||||
|
<p className="growth-subreddit-note">
|
||||||
|
Reddit refused this account here: {subreddit.disabledReason}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{coolingDown && !subreddit.disabledReason ? (
|
||||||
|
<p className="growth-subreddit-note">
|
||||||
|
Cooling down until {new Date(subreddit.cooldownUntil as number).toLocaleTimeString()}.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="growth-subreddit-controls">
|
||||||
|
<label className="growth-inline-field">
|
||||||
|
<span>Daily cap</span>
|
||||||
|
<input
|
||||||
|
aria-label={`Daily cap for r/${subreddit.subreddit}`}
|
||||||
|
max={50}
|
||||||
|
min={1}
|
||||||
|
onBlur={() => {
|
||||||
|
const parsed = Number.parseInt(cap, 10);
|
||||||
|
if (Number.isInteger(parsed) && parsed !== subreddit.dailyCap) {
|
||||||
|
update.mutate({ subreddit: subreddit.subreddit, update: { dailyCap: parsed } });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onChange={(event) => setCap(event.target.value)}
|
||||||
|
type="number"
|
||||||
|
value={cap}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="button secondary"
|
||||||
|
disabled={update.isPending}
|
||||||
|
onClick={() => update.mutate({
|
||||||
|
subreddit: subreddit.subreddit,
|
||||||
|
update: { enabled: !subreddit.enabled },
|
||||||
|
})}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{subreddit.enabled ? 'Disable' : 'Enable'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button secondary"
|
||||||
|
disabled={remove.isPending}
|
||||||
|
onClick={() => remove.mutate(subreddit.subreddit)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{update.isError ? <p className="save-error" role="alert">{errorMessage(update.error)}</p> : null}
|
||||||
|
{remove.isError ? <p className="save-error" role="alert">{errorMessage(remove.error)}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BrowsingSimulationSection({ account }: { account: GrowthAccountDetail }) {
|
||||||
|
const update = useUpdateBrowsingSettingsMutation(account.accountId);
|
||||||
|
const runNow = useRunBrowsingSimulationMutation(account.accountId);
|
||||||
|
const { settings, stats, todayTarget, recentDecisions } = account.browsing;
|
||||||
|
const selected = recentDecisions.filter((decision) => decision.decision === 'simulated-upvote');
|
||||||
|
const [dailyMinimum, setDailyMinimum] = useState(String(settings.dailyTargetMin));
|
||||||
|
const [dailyMaximum, setDailyMaximum] = useState(String(settings.dailyTargetMax));
|
||||||
|
const [gapMinutes, setGapMinutes] = useState(String(settings.minimumGapMs / 60_000));
|
||||||
|
const [selectionsPerSession, setSelectionsPerSession] = useState(String(settings.selectionsPerSession));
|
||||||
|
|
||||||
|
useEffect(() => setDailyMinimum(String(settings.dailyTargetMin)), [settings.dailyTargetMin]);
|
||||||
|
useEffect(() => setDailyMaximum(String(settings.dailyTargetMax)), [settings.dailyTargetMax]);
|
||||||
|
useEffect(() => setGapMinutes(String(settings.minimumGapMs / 60_000)), [settings.minimumGapMs]);
|
||||||
|
useEffect(() => setSelectionsPerSession(String(settings.selectionsPerSession)), [settings.selectionsPerSession]);
|
||||||
|
|
||||||
|
const parsedDailyMinimum = Number.parseInt(dailyMinimum, 10);
|
||||||
|
const parsedDailyMaximum = Number.parseInt(dailyMaximum, 10);
|
||||||
|
const dailyRangeValid = Number.isInteger(parsedDailyMinimum)
|
||||||
|
&& Number.isInteger(parsedDailyMaximum)
|
||||||
|
&& parsedDailyMinimum >= 1
|
||||||
|
&& parsedDailyMaximum >= 1
|
||||||
|
&& parsedDailyMinimum <= parsedDailyMaximum;
|
||||||
|
const dailyRangeError = dailyMinimum !== '' && dailyMaximum !== '' && !dailyRangeValid
|
||||||
|
? 'Use positive whole numbers, with the minimum no greater than the maximum.'
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-labelledby="browsing-simulation-heading" className="panel settings-card">
|
||||||
|
<div className="settings-card-heading">
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">Conservative account activity</span>
|
||||||
|
<h2 id="browsing-simulation-heading">Browsing sessions</h2>
|
||||||
|
<p>Selects conservatively; automatic upvotes require their own explicit switch.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="settings-card-body">
|
||||||
|
<div className="state-banner is-warning" role="status">
|
||||||
|
<strong>{account.browsing.noVotesSubmitted ? 'No Reddit votes will be submitted.' : 'Automatic upvotes are enabled.'}</strong>
|
||||||
|
<span>{account.browsing.noVotesSubmitted
|
||||||
|
? 'Selections are recorded only in RedditBot.'
|
||||||
|
: 'Votes run only in running mode with browser dry-run disabled.'}</span>
|
||||||
|
</div>
|
||||||
|
<SwitchField
|
||||||
|
checked={settings.enabled}
|
||||||
|
description={`Selects ${settings.dailyTargetMin}–${settings.dailyTargetMax} eligible posts or comments during waking hours.`}
|
||||||
|
label="Run browsing sessions"
|
||||||
|
onChange={(enabled) => update.mutate({ enabled })}
|
||||||
|
/>
|
||||||
|
<SwitchField
|
||||||
|
checked={settings.autoVoteEnabled}
|
||||||
|
description="Submit the selected upvote automatically. Scan mode and browser dry-run always remain simulation-only."
|
||||||
|
label="Submit selected upvotes"
|
||||||
|
onChange={(autoVoteEnabled) => update.mutate({ autoVoteEnabled })}
|
||||||
|
/>
|
||||||
|
<div className="growth-pacing">
|
||||||
|
<NumberField
|
||||||
|
error={dailyRangeError}
|
||||||
|
hint="The system chooses one stable target inside this range for each account and local day."
|
||||||
|
label="Daily minimum"
|
||||||
|
min={1}
|
||||||
|
name="browsing-daily-minimum"
|
||||||
|
onChange={setDailyMinimum}
|
||||||
|
suffix="/day"
|
||||||
|
value={dailyMinimum}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
error={dailyRangeError}
|
||||||
|
label="Daily maximum"
|
||||||
|
min={1}
|
||||||
|
name="browsing-daily-maximum"
|
||||||
|
onChange={setDailyMaximum}
|
||||||
|
suffix="/day"
|
||||||
|
value={dailyMaximum}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="button secondary"
|
||||||
|
disabled={update.isPending || !dailyRangeValid}
|
||||||
|
onClick={() => update.mutate({
|
||||||
|
dailyTargetMin: parsedDailyMinimum,
|
||||||
|
dailyTargetMax: parsedDailyMaximum,
|
||||||
|
})}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Save daily range
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="growth-pacing">
|
||||||
|
<NumberField
|
||||||
|
hint={`Shortest gap between sessions. The system adds a random 0–${minutes(settings.jitterMs)} on top.`}
|
||||||
|
label="Minimum session gap"
|
||||||
|
min={45}
|
||||||
|
max={1_440}
|
||||||
|
name="browsing-minimum-gap"
|
||||||
|
onChange={setGapMinutes}
|
||||||
|
suffix="min"
|
||||||
|
value={gapMinutes}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="button secondary"
|
||||||
|
disabled={update.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
const parsed = Number.parseInt(gapMinutes, 10);
|
||||||
|
if (Number.isInteger(parsed) && parsed >= 45 && parsed <= 1_440) {
|
||||||
|
update.mutate({ minimumGapMs: parsed * 60_000 });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Save session gap
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="growth-pacing">
|
||||||
|
<NumberField
|
||||||
|
hint="Maximum eligible posts or comments selected by each scheduled or manual session."
|
||||||
|
label="Selections per session"
|
||||||
|
min={1}
|
||||||
|
name="browsing-selections-per-session"
|
||||||
|
onChange={setSelectionsPerSession}
|
||||||
|
suffix="/session"
|
||||||
|
value={selectionsPerSession}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="button secondary"
|
||||||
|
disabled={update.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
const parsed = Number.parseInt(selectionsPerSession, 10);
|
||||||
|
if (Number.isSafeInteger(parsed) && parsed >= 1) {
|
||||||
|
update.mutate({ selectionsPerSession: parsed });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Save session size
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="button secondary"
|
||||||
|
disabled={!settings.enabled || runNow.isPending}
|
||||||
|
onClick={() => runNow.mutate()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{runNow.isPending ? 'Running session…' : 'Run session now'}
|
||||||
|
</button>
|
||||||
|
{!settings.enabled ? <p className="field-hint">Enable browsing sessions to run one now.</p> : null}
|
||||||
|
<dl className="growth-metrics">
|
||||||
|
<div><dt>Selected today</dt><dd>{stats.selected} / {todayTarget}</dd></div>
|
||||||
|
<div><dt>Posts</dt><dd>{stats.posts}</dd></div>
|
||||||
|
<div><dt>Comments</dt><dd>{stats.comments}</dd></div>
|
||||||
|
<div><dt>Rejected</dt><dd>{stats.rejected}</dd></div>
|
||||||
|
<div><dt>Skipped</dt><dd>{stats.skipped}</dd></div>
|
||||||
|
<div>
|
||||||
|
<dt>Next session</dt>
|
||||||
|
<dd>{settings.nextDueAt === null ? 'Not scheduled' : new Date(settings.nextDueAt).toLocaleString()}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{update.isError ? <p className="save-error" role="alert">{errorMessage(update.error)}</p> : null}
|
||||||
|
{runNow.isError ? <p className="save-error" role="alert">{errorMessage(runNow.error)}</p> : null}
|
||||||
|
{selected.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<strong>No browsing selections yet</strong>
|
||||||
|
<span>The first session is durably scheduled after this feature is enabled.</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="growth-comment-list">
|
||||||
|
{selected.map((decision: BrowsingDecision) => (
|
||||||
|
<li className="growth-comment" key={`${decision.thingFullname}-${decision.decidedAt}`}>
|
||||||
|
<div className="growth-comment-head">
|
||||||
|
<a href={`https://www.reddit.com${decision.permalink}`} rel="noreferrer noopener" target="_blank">
|
||||||
|
r/{decision.subreddit} — {decision.thingKind} by u/{decision.author}
|
||||||
|
</a>
|
||||||
|
<span className={`pill ${decision.voteStatus === 'submitted' ? 'success' : decision.voteStatus === 'failed' ? 'danger' : 'neutral'}`}>
|
||||||
|
{decision.voteStatus === 'pending' ? 'Pending upvote'
|
||||||
|
: decision.voteStatus === 'submitted' ? 'Upvote submitted'
|
||||||
|
: decision.voteStatus === 'failed' ? 'Upvote failed'
|
||||||
|
: 'Simulated selection'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="field-hint">{new Date(decision.decidedAt).toLocaleString()}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountDetail({ account }: { account: GrowthAccountDetail }) {
|
||||||
|
const { accountId, settings, stats } = account;
|
||||||
|
const updateSettings = useUpdateGrowthSettingsMutation(accountId);
|
||||||
|
const pause = usePauseGrowthMutation(accountId);
|
||||||
|
const resume = useResumeGrowthMutation(accountId);
|
||||||
|
const addSubreddit = useAddGrowthSubredditMutation(accountId);
|
||||||
|
const [newSubreddit, setNewSubreddit] = useState('');
|
||||||
|
const [delayMinutes, setDelayMinutes] = useState(String(Math.round(settings.minDelayMs / 60_000)));
|
||||||
|
const [dailyLimit, setDailyLimit] = useState(settings.dailyLimit === null ? '' : String(settings.dailyLimit));
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => setDelayMinutes(String(Math.round(settings.minDelayMs / 60_000))),
|
||||||
|
[settings.minDelayMs],
|
||||||
|
);
|
||||||
|
useEffect(
|
||||||
|
() => setDailyLimit(settings.dailyLimit === null ? '' : String(settings.dailyLimit)),
|
||||||
|
[settings.dailyLimit],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="growth-detail">
|
||||||
|
<section aria-labelledby="growth-state-heading" className="panel settings-card">
|
||||||
|
<div className="settings-card-heading">
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">Commenting as</span>
|
||||||
|
<h2 id="growth-state-heading"><RedditUsernameLink username={account.redditUsername} /></h2>
|
||||||
|
<p>
|
||||||
|
Comments on active threads in the communities below to build this account's history,
|
||||||
|
so Reddit stops treating it as new.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{settings.pausedReason ? (
|
||||||
|
<div className="state-banner is-error" role="alert">
|
||||||
|
<strong>Growth is paused for this account.</strong>
|
||||||
|
<span>{settings.pausedReason}</span>
|
||||||
|
<span>
|
||||||
|
Nothing will be posted until you resume it. Check the comment history below before
|
||||||
|
you do — a removal or a filtered comment usually means this account needs a rest,
|
||||||
|
not another attempt.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="settings-card-body">
|
||||||
|
<SwitchField
|
||||||
|
checked={settings.enabled}
|
||||||
|
description="When on, this account posts a small number of AI-written comments per day, without waiting for your approval."
|
||||||
|
label="Grow this account"
|
||||||
|
onChange={(enabled) => updateSettings.mutate({ enabled })}
|
||||||
|
tone="critical"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<dl className="growth-metrics">
|
||||||
|
<div>
|
||||||
|
<dt>Used in last 24h</dt>
|
||||||
|
<dd>{stats.postedLast24h} / {settings.dailyAllowance}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Daily limit</dt>
|
||||||
|
<dd>
|
||||||
|
{settings.dailyLimit === null
|
||||||
|
? `Automatic — ${settings.adaptiveAllowance}/day`
|
||||||
|
: `Set to ${settings.dailyLimit}/day`}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Trust level</dt>
|
||||||
|
<dd>{settings.trustLevel} of 4 → {settings.adaptiveAllowance}/day earned</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Comments live</dt>
|
||||||
|
<dd>{stats.postedTotal - stats.removedTotal}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Removed</dt>
|
||||||
|
<dd>{stats.removedTotal}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Median score</dt>
|
||||||
|
<dd>{stats.medianScore === null ? '—' : stats.medianScore}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Queued</dt>
|
||||||
|
<dd>{stats.queued}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<p className="field-hint">
|
||||||
|
Left automatic, the allowance follows the trust level: level 0 permits 2 comments a day,
|
||||||
|
then 3, 5, 8 and 12, rising while comments survive and earn upvotes and falling back the
|
||||||
|
moment one is removed or heavily downvoted. Set a daily limit to override that. Either
|
||||||
|
way it is a rolling 24-hour window rather than a midnight reset, so capacity frees up 24
|
||||||
|
hours after each comment, and two removals in a row or a comment the public cannot see
|
||||||
|
pauses the account regardless.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="growth-pacing">
|
||||||
|
<NumberField
|
||||||
|
hint={settings.dailyLimit === null
|
||||||
|
? `Leave blank to let the allowance follow the trust level, currently ${settings.adaptiveAllowance}/day.`
|
||||||
|
: `Overriding the earned allowance of ${settings.adaptiveAllowance}/day. Clear the field to go back to automatic.`}
|
||||||
|
label="Daily limit"
|
||||||
|
min={1}
|
||||||
|
max={50}
|
||||||
|
name="growth-daily-limit"
|
||||||
|
onChange={setDailyLimit}
|
||||||
|
suffix="/day"
|
||||||
|
value={dailyLimit}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="button secondary"
|
||||||
|
disabled={updateSettings.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
const raw = dailyLimit.trim();
|
||||||
|
if (raw === '') {
|
||||||
|
updateSettings.mutate({ dailyLimit: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = Number.parseInt(raw, 10);
|
||||||
|
if (Number.isInteger(parsed) && parsed >= 1 && parsed <= 50) {
|
||||||
|
updateSettings.mutate({ dailyLimit: parsed });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Save limit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{settings.dailyLimit !== null && settings.dailyLimit > settings.adaptiveAllowance ? (
|
||||||
|
<p className="field-hint">
|
||||||
|
This is above the {settings.adaptiveAllowance}/day this account has earned, so the
|
||||||
|
adaptive backoff can no longer slow it down after a bad comment. The pause on two
|
||||||
|
removals in a row, or a comment the public cannot see, still applies.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="growth-pacing">
|
||||||
|
<NumberField
|
||||||
|
hint="Shortest gap between two comments. A random extra delay is always added on top."
|
||||||
|
label="Minimum gap"
|
||||||
|
min={0}
|
||||||
|
name="growth-min-delay"
|
||||||
|
onChange={setDelayMinutes}
|
||||||
|
suffix="min"
|
||||||
|
value={delayMinutes}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="button secondary"
|
||||||
|
disabled={updateSettings.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
const parsed = Number.parseInt(delayMinutes, 10);
|
||||||
|
if (Number.isInteger(parsed) && parsed >= 0) {
|
||||||
|
updateSettings.mutate({ minDelayMs: parsed * 60_000 });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Save gap
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SwitchField
|
||||||
|
checked={settings.quietHours.enabled}
|
||||||
|
description={`No comments between ${settings.quietHours.startHour}:00 and ${settings.quietHours.endHour}:00 local time.`}
|
||||||
|
label="Quiet hours"
|
||||||
|
onChange={(enabled) => updateSettings.mutate({
|
||||||
|
quietHours: { ...settings.quietHours, enabled },
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<SwitchField
|
||||||
|
checked={settings.allowNsfw}
|
||||||
|
description="Off by default. NSFW threads are skipped entirely."
|
||||||
|
label="Comment on NSFW threads"
|
||||||
|
onChange={(allowNsfw) => updateSettings.mutate({ allowNsfw })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="settings-card-footer">
|
||||||
|
<div aria-live="polite" className="settings-card-status">
|
||||||
|
{updateSettings.isError
|
||||||
|
? <strong className="save-error" role="alert">{errorMessage(updateSettings.error)}</strong>
|
||||||
|
: null}
|
||||||
|
<span className="field-hint">Minimum gap now {minutes(settings.minDelayMs)}.</span>
|
||||||
|
</div>
|
||||||
|
<div className="header-actions">
|
||||||
|
{settings.pausedReason ? (
|
||||||
|
<button
|
||||||
|
className="button primary"
|
||||||
|
disabled={resume.isPending}
|
||||||
|
onClick={() => resume.mutate()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{resume.isPending ? 'Resuming…' : 'Resume growth'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="button secondary"
|
||||||
|
disabled={pause.isPending}
|
||||||
|
onClick={() => pause.mutate(undefined)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{pause.isPending ? 'Pausing…' : 'Pause growth'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="growth-communities-heading" className="panel settings-card">
|
||||||
|
<div className="settings-card-heading">
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">Where it comments</span>
|
||||||
|
<h2 id="growth-communities-heading">Communities</h2>
|
||||||
|
<p>Each community has its own daily cap so this account never floods one place.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="settings-card-body">
|
||||||
|
{account.subreddits.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<strong>No communities yet</strong>
|
||||||
|
<span>Add one below. Nothing is posted until at least one is on the list.</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="growth-subreddit-list">
|
||||||
|
{account.subreddits.map((subreddit) => (
|
||||||
|
<SubredditRow
|
||||||
|
accountId={accountId}
|
||||||
|
dailyAllowance={settings.dailyAllowance}
|
||||||
|
key={subreddit.subreddit}
|
||||||
|
subreddit={subreddit}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="growth-add-subreddit"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const name = newSubreddit.trim();
|
||||||
|
if (!name) return;
|
||||||
|
addSubreddit.mutate({ subreddit: name }, { onSuccess: () => setNewSubreddit('') });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
label="Add a community"
|
||||||
|
name="growth-new-subreddit"
|
||||||
|
onChange={setNewSubreddit}
|
||||||
|
placeholder="AskUK"
|
||||||
|
prefix="r/"
|
||||||
|
value={newSubreddit}
|
||||||
|
/>
|
||||||
|
<button className="button primary" disabled={addSubreddit.isPending} type="submit">
|
||||||
|
{addSubreddit.isPending ? 'Adding…' : 'Add community'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{addSubreddit.isError
|
||||||
|
? <p className="save-error" role="alert">{errorMessage(addSubreddit.error)}</p>
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<BrowsingSimulationSection account={account} />
|
||||||
|
|
||||||
|
<section aria-labelledby="growth-history-heading" className="panel settings-card">
|
||||||
|
<div className="settings-card-heading">
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">Audit trail</span>
|
||||||
|
<h2 id="growth-history-heading">Recent comments</h2>
|
||||||
|
<p>
|
||||||
|
Everything this account posted, and everything it decided not to post. Comments go
|
||||||
|
live without review, so this is the record of what was actually said.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="settings-card-body">
|
||||||
|
{account.recentComments.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<strong>Nothing yet</strong>
|
||||||
|
<span>Comments will appear here as they are prepared and posted.</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="growth-comment-list">
|
||||||
|
{account.recentComments.map((comment) => (
|
||||||
|
<li className="growth-comment" key={`${comment.postId}-${comment.discoveredAt}`}>
|
||||||
|
<div className="growth-comment-head">
|
||||||
|
<a href={comment.threadUrl} rel="noreferrer noopener" target="_blank">
|
||||||
|
r/{comment.subreddit} — {comment.threadTitle}
|
||||||
|
</a>
|
||||||
|
<div className="growth-comment-pills">
|
||||||
|
{typeof comment.score === 'number'
|
||||||
|
? <span className="pill neutral">{comment.score} points</span>
|
||||||
|
: null}
|
||||||
|
{statusPill(comment)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{comment.comment ? <p className="growth-comment-body">{comment.comment}</p> : null}
|
||||||
|
{comment.lastError ? <p className="field-hint">{comment.lastError}</p> : null}
|
||||||
|
{comment.commentPermalink ? (
|
||||||
|
<a
|
||||||
|
className="growth-comment-link"
|
||||||
|
href={`https://www.reddit.com${comment.commentPermalink}`}
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
View on Reddit
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GrowthPage() {
|
||||||
|
const overview = useGrowthOverviewQuery();
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const accounts = overview.data ?? [];
|
||||||
|
const activeId = selectedId ?? accounts[0]?.accountId ?? null;
|
||||||
|
const detail = useGrowthAccountQuery(activeId);
|
||||||
|
|
||||||
|
const pausedAccounts = accounts.filter((account) => account.settings.pausedReason);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page growth-page">
|
||||||
|
<header className="page-header">
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">Account health</span>
|
||||||
|
<h1>Account growth</h1>
|
||||||
|
<p>
|
||||||
|
Builds a comment history for a new account so Reddit lets it start chats. Comments are
|
||||||
|
written by AI and posted without review, so keep the volume low and check the audit
|
||||||
|
trail.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{pausedAccounts.length > 0 ? (
|
||||||
|
<div className="state-banner is-warning" role="status">
|
||||||
|
<strong>
|
||||||
|
{pausedAccounts.length === 1
|
||||||
|
? `Growth is paused for u/${pausedAccounts[0].redditUsername}.`
|
||||||
|
: `Growth is paused for ${pausedAccounts.length} accounts.`}
|
||||||
|
</strong>
|
||||||
|
<span>
|
||||||
|
Reddit removed or filtered recent comments. Review the history before resuming.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{overview.isPending ? (
|
||||||
|
<div aria-label="Loading growth" className="page-state" role="status">
|
||||||
|
<span className="spinner" />Loading account growth…
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{overview.isError ? (
|
||||||
|
<div className="page-state error-state" role="alert">
|
||||||
|
<strong>Could not load account growth</strong>
|
||||||
|
<span>{errorMessage(overview.error)}</span>
|
||||||
|
<button className="button secondary" onClick={() => void overview.refetch()} type="button">
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{overview.isSuccess && accounts.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<strong>No Reddit accounts yet</strong>
|
||||||
|
<span>Add an account on the Accounts page before setting up growth.</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{accounts.length > 0 ? (
|
||||||
|
<div className="growth-layout">
|
||||||
|
<nav aria-label="Accounts" className="growth-account-list">
|
||||||
|
{accounts.map((account) => (
|
||||||
|
<AccountRow
|
||||||
|
account={account}
|
||||||
|
isSelected={account.accountId === activeId}
|
||||||
|
key={account.accountId}
|
||||||
|
onSelect={() => setSelectedId(account.accountId)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{detail.isPending ? (
|
||||||
|
<div className="page-state" role="status"><span className="spinner" />Loading account…</div>
|
||||||
|
) : null}
|
||||||
|
{detail.isError ? (
|
||||||
|
<div className="page-state error-state" role="alert">
|
||||||
|
<strong>Could not load this account</strong>
|
||||||
|
<span>{errorMessage(detail.error)}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{detail.data ? <AccountDetail account={detail.data} /> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type {
|
||||||
|
GrowthAccountDetail,
|
||||||
|
BrowsingSettingsUpdateRequest,
|
||||||
|
GrowthSettingsUpdateRequest,
|
||||||
|
GrowthSubredditCreateRequest,
|
||||||
|
GrowthSubredditUpdateRequest,
|
||||||
|
} from '../../../../../packages/api-contracts/src/index.js';
|
||||||
|
import {
|
||||||
|
addGrowthSubreddit,
|
||||||
|
fetchGrowthAccount,
|
||||||
|
fetchGrowthOverview,
|
||||||
|
pauseGrowth,
|
||||||
|
removeGrowthSubreddit,
|
||||||
|
resumeGrowth,
|
||||||
|
runBrowsingSimulation,
|
||||||
|
updateGrowthSettings,
|
||||||
|
updateBrowsingSettings,
|
||||||
|
updateGrowthSubreddit,
|
||||||
|
} from './growth-api.js';
|
||||||
|
|
||||||
|
export const growthOverviewQueryKey = ['dashboard', 'growth'] as const;
|
||||||
|
export const growthAccountQueryKey = (accountId: string) =>
|
||||||
|
['dashboard', 'growth', accountId] as const;
|
||||||
|
|
||||||
|
export function useGrowthOverviewQuery() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: growthOverviewQueryKey,
|
||||||
|
queryFn: fetchGrowthOverview,
|
||||||
|
// Growth moves on the order of hours, so a slow refresh is plenty.
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGrowthAccountQuery(accountId: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: growthAccountQueryKey(accountId ?? ''),
|
||||||
|
queryFn: () => fetchGrowthAccount(accountId as string),
|
||||||
|
enabled: accountId !== null,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every mutation returns the whole updated account, so the detail cache is
|
||||||
|
* written directly and the overview is invalidated. HTTP stays authoritative.
|
||||||
|
*/
|
||||||
|
function useGrowthMutation<TVariables>(
|
||||||
|
accountId: string,
|
||||||
|
mutationFn: (variables: TVariables) => Promise<GrowthAccountDetail>,
|
||||||
|
) {
|
||||||
|
const client = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn,
|
||||||
|
onSuccess: (account) => {
|
||||||
|
client.setQueryData(growthAccountQueryKey(accountId), account);
|
||||||
|
void client.invalidateQueries({ queryKey: growthOverviewQueryKey });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateGrowthSettingsMutation(accountId: string) {
|
||||||
|
return useGrowthMutation(
|
||||||
|
accountId,
|
||||||
|
(update: GrowthSettingsUpdateRequest) => updateGrowthSettings(accountId, update),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateBrowsingSettingsMutation(accountId: string) {
|
||||||
|
return useGrowthMutation(
|
||||||
|
accountId,
|
||||||
|
(update: BrowsingSettingsUpdateRequest) => updateBrowsingSettings(accountId, update),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRunBrowsingSimulationMutation(accountId: string) {
|
||||||
|
return useGrowthMutation<void>(accountId, () => runBrowsingSimulation(accountId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePauseGrowthMutation(accountId: string) {
|
||||||
|
return useGrowthMutation(accountId, (reason?: string) => pauseGrowth(accountId, reason));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResumeGrowthMutation(accountId: string) {
|
||||||
|
return useGrowthMutation<void>(accountId, () => resumeGrowth(accountId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddGrowthSubredditMutation(accountId: string) {
|
||||||
|
return useGrowthMutation(
|
||||||
|
accountId,
|
||||||
|
(request: GrowthSubredditCreateRequest) => addGrowthSubreddit(accountId, request),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateGrowthSubredditMutation(accountId: string) {
|
||||||
|
return useGrowthMutation(
|
||||||
|
accountId,
|
||||||
|
({ subreddit, update }: { subreddit: string; update: GrowthSubredditUpdateRequest }) =>
|
||||||
|
updateGrowthSubreddit(accountId, subreddit, update),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRemoveGrowthSubredditMutation(accountId: string) {
|
||||||
|
return useGrowthMutation(
|
||||||
|
accountId,
|
||||||
|
(subreddit: string) => removeGrowthSubreddit(accountId, subreddit),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
.growth-page { max-width: 64rem; }
|
||||||
|
.growth-page .state-banner { margin-bottom: 1rem; }
|
||||||
|
|
||||||
|
.growth-layout { display: grid; gap: 1rem; }
|
||||||
|
|
||||||
|
/* Account switcher: a short list, so it stays inline rather than in a sidebar. */
|
||||||
|
.growth-account-list { display: grid; gap: .5rem; }
|
||||||
|
@media (min-width: 45rem) {
|
||||||
|
.growth-account-list { grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); }
|
||||||
|
}
|
||||||
|
.growth-account-row {
|
||||||
|
display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .75rem;
|
||||||
|
padding: .8rem; text-align: left; cursor: pointer;
|
||||||
|
background: var(--surface-raised); border: 1px solid var(--border); border-radius: .75rem;
|
||||||
|
color: inherit; font: inherit;
|
||||||
|
}
|
||||||
|
.growth-account-row:hover { border-color: var(--accent); }
|
||||||
|
.growth-account-row.is-selected { border-color: var(--accent); background: var(--surface-selected, var(--surface-raised)); }
|
||||||
|
.growth-account-row > div { display: grid; gap: .2rem; min-width: 0; }
|
||||||
|
.growth-account-row span { color: var(--muted); font-size: .82rem; }
|
||||||
|
.growth-account-row .pill { font-size: .7rem; }
|
||||||
|
.growth-account-pills { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; }
|
||||||
|
|
||||||
|
.growth-detail { display: grid; gap: 1rem; }
|
||||||
|
|
||||||
|
.growth-metrics { display: flex; flex-wrap: wrap; gap: .35rem 1.5rem; margin: 0; }
|
||||||
|
.growth-metrics > div { display: grid; gap: .1rem; }
|
||||||
|
.growth-metrics dt { color: var(--muted); font-size: .7rem; text-transform: uppercase; letter-spacing: .04em; }
|
||||||
|
.growth-metrics dd { margin: 0; font-size: .95rem; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
.growth-pacing { display: flex; flex-wrap: wrap; align-items: flex-end; gap: .75rem; }
|
||||||
|
.growth-pacing > :first-child { flex: 1 1 12rem; }
|
||||||
|
|
||||||
|
.growth-subreddit-list { display: grid; gap: .6rem; }
|
||||||
|
.growth-subreddit-row {
|
||||||
|
display: grid; gap: .5rem; padding: .75rem;
|
||||||
|
background: var(--surface-raised); border: 1px solid var(--border); border-radius: .75rem;
|
||||||
|
}
|
||||||
|
.growth-subreddit-name { display: flex; flex-wrap: wrap; align-items: baseline; gap: .6rem; }
|
||||||
|
.growth-subreddit-name span { color: var(--muted); font-size: .8rem; font-variant-numeric: tabular-nums; }
|
||||||
|
.growth-subreddit-note { margin: 0; color: var(--muted); font-size: .8rem; }
|
||||||
|
.growth-subreddit-controls { display: flex; flex-wrap: wrap; align-items: flex-end; gap: .5rem; }
|
||||||
|
.growth-inline-field { display: grid; gap: .2rem; }
|
||||||
|
.growth-inline-field span { color: var(--muted); font-size: .7rem; text-transform: uppercase; letter-spacing: .04em; }
|
||||||
|
.growth-inline-field input { width: 5rem; }
|
||||||
|
|
||||||
|
.growth-add-subreddit { display: flex; flex-wrap: wrap; align-items: flex-end; gap: .75rem; }
|
||||||
|
.growth-add-subreddit > :first-child { flex: 1 1 14rem; }
|
||||||
|
|
||||||
|
.growth-comment-list { display: grid; gap: .6rem; margin: 0; padding: 0; list-style: none; }
|
||||||
|
.growth-comment {
|
||||||
|
display: grid; gap: .4rem; padding: .75rem;
|
||||||
|
background: var(--surface-raised); border: 1px solid var(--border); border-radius: .75rem;
|
||||||
|
}
|
||||||
|
.growth-comment-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: .6rem; }
|
||||||
|
.growth-comment-head a { font-size: .85rem; font-weight: 600; }
|
||||||
|
.growth-comment-pills { display: flex; flex-wrap: wrap; gap: .35rem; }
|
||||||
|
.growth-comment-pills .pill { font-size: .7rem; }
|
||||||
|
/* The posted text is the record of what was actually said, so it reads as a quote. */
|
||||||
|
.growth-comment-body { margin: 0; padding-left: .6rem; border-left: 2px solid var(--border); font-size: .88rem; }
|
||||||
|
.growth-comment-link { font-size: .78rem; }
|
||||||
|
.growth-comment .field-hint { margin: 0; }
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import {
|
||||||
|
LearningOverviewResponseSchema,
|
||||||
|
LearningReviewResponseSchema,
|
||||||
|
LearningTrajectoryDetailResponseSchema,
|
||||||
|
type LearningOverviewResponse,
|
||||||
|
type LearningTrajectoryDetailResponse,
|
||||||
|
} from '../../../../../packages/api-contracts/src/index.js';
|
||||||
|
import { createDashboardRequest } from '../../api/request.js';
|
||||||
|
|
||||||
|
async function parse<T>(response: Response, schema: { parse(value: unknown): T }): Promise<T> {
|
||||||
|
const body: unknown = await response.json();
|
||||||
|
if (!response.ok) throw new Error(typeof body === 'object' && body && 'error' in body ? String(body.error) : `Request failed (${response.status})`);
|
||||||
|
return schema.parse(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = createDashboardRequest();
|
||||||
|
export const learningApi = {
|
||||||
|
getOverview(): Promise<LearningOverviewResponse> {
|
||||||
|
return request('/api/learning').then((response) => parse(response, LearningOverviewResponseSchema));
|
||||||
|
},
|
||||||
|
getTrajectory(id: string): Promise<LearningTrajectoryDetailResponse> {
|
||||||
|
return request(`/api/learning/trajectories/${encodeURIComponent(id)}`)
|
||||||
|
.then((response) => parse(response, LearningTrajectoryDetailResponseSchema));
|
||||||
|
},
|
||||||
|
review(id: string, status: 'eligible' | 'excluded'): Promise<{ success: true }> {
|
||||||
|
return request(`/api/learning/trajectories/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }),
|
||||||
|
}).then((response) => parse(response, LearningReviewResponseSchema));
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { createOwnedConversationId } from '../conversations/conversation-identity.js';
|
||||||
|
import { learningApi } from './learning-api.js';
|
||||||
|
import './learning.css';
|
||||||
|
|
||||||
|
const humanCredits = new Set(['human-created-success', 'human-improved-success', 'human-completed-ai-trajectory']);
|
||||||
|
|
||||||
|
function TrajectoryDetail({ id }: { id: string }) {
|
||||||
|
const detail = useQuery({ queryKey: ['learning', 'trajectory', id], queryFn: () => learningApi.getTrajectory(id) });
|
||||||
|
if (detail.isPending) return <div className="panel-loading" role="status"><span className="spinner" />Loading decisions…</div>;
|
||||||
|
if (detail.isError) return <div className="error-panel" role="alert">{detail.error.message}</div>;
|
||||||
|
const { trajectory, decisions } = detail.data;
|
||||||
|
return (
|
||||||
|
<div className="trajectory-detail">
|
||||||
|
<dl className="trajectory-facts">
|
||||||
|
<div>
|
||||||
|
<dt>Conversation</dt>
|
||||||
|
<dd>
|
||||||
|
{/* The conversations route resolves owned ids only, so the account
|
||||||
|
that produced the trajectory has to travel with the room id. */}
|
||||||
|
<Link
|
||||||
|
className="trajectory-conversation-link"
|
||||||
|
to={`/conversations/${encodeURIComponent(createOwnedConversationId(trajectory.accountId, trajectory.conversationId))}`}
|
||||||
|
>
|
||||||
|
{trajectory.conversationId}
|
||||||
|
</Link>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div><dt>Policy mode</dt><dd>{trajectory.policyMode ?? 'Unknown'}</dd></div>
|
||||||
|
<div><dt>Outcome</dt><dd>{trajectory.outcomeType.replaceAll('-', ' ')}</dd></div>
|
||||||
|
<div><dt>Matures</dt><dd>{new Date(trajectory.maturityAt).toLocaleString()}</dd></div>
|
||||||
|
{trajectory.invalidationReason ? <div><dt>Invalidated</dt><dd>{trajectory.invalidationReason}</dd></div> : null}
|
||||||
|
</dl>
|
||||||
|
{decisions.length === 0 ? <p className="feature-empty">No manual decisions were captured for this trajectory.</p> : (
|
||||||
|
<ol className="decision-list">
|
||||||
|
{decisions.map((decision) => (
|
||||||
|
<li key={decision.id} className="decision-card">
|
||||||
|
<div className="decision-heading">
|
||||||
|
<span className="decision-stage">{decision.stage.replaceAll('-', ' ')}</span>
|
||||||
|
{decision.isDirectMediaInvitation ? <span className="decision-flag">Direct media ask</span> : null}
|
||||||
|
<span className={`trajectory-status status-${decision.qualityStatus}`}>{decision.qualityStatus}</span>
|
||||||
|
<span className="decision-turns">{decision.turnsUntilOutcome} turn(s) to outcome</span>
|
||||||
|
</div>
|
||||||
|
{decision.context.length > 0 ? (
|
||||||
|
<div className="decision-context">
|
||||||
|
{decision.context.map((turn) => (
|
||||||
|
<p key={turn.eventId} className={`turn turn-${turn.role}`}>
|
||||||
|
<span className="turn-actor">{turn.role === 'recipient' ? 'Them' : turn.actor === 'human' ? 'You' : 'AI'}</span>
|
||||||
|
<span className="turn-text">{turn.text}</span>
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<p className="decision-chosen"><span className="turn-actor">Your message</span><span className="turn-text">{decision.chosenResponse}</span></p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
<p className="decision-note">Identifiers are redacted before storage. Text shown here is what generation would receive.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LearningPage() {
|
||||||
|
const client = useQueryClient();
|
||||||
|
const [expanded, setExpanded] = useState<string | null>(null);
|
||||||
|
const overview = useQuery({ queryKey: ['learning'], queryFn: learningApi.getOverview });
|
||||||
|
const review = useMutation({
|
||||||
|
mutationFn: ({ id, status }: { id: string; status: 'eligible' | 'excluded' }) => learningApi.review(id, status),
|
||||||
|
onSuccess: () => client.invalidateQueries({ queryKey: ['learning'] }),
|
||||||
|
});
|
||||||
|
if (overview.isPending) return <main className="page-shell"><div className="panel-loading" role="status"><span className="spinner" />Loading learning evidence…</div></main>;
|
||||||
|
if (overview.isError) return <main className="page-shell"><div className="error-panel" role="alert">{overview.error.message}</div></main>;
|
||||||
|
const { summary, trajectories } = overview.data;
|
||||||
|
const humanTotal = summary.humanCreated + summary.humanImproved + summary.humanCompletedAi;
|
||||||
|
return (
|
||||||
|
<main className="page-shell learning-page">
|
||||||
|
<header className="page-heading"><div><span className="eyebrow">Human-in-the-loop evidence</span><h1>Learning review</h1><p>Audit who made the successful decision before examples can influence generation.</p></div><span className={`learning-mode ${overview.data.generationEnabled ? 'live' : ''}`}>{overview.data.generationEnabled ? (overview.data.shadowMode ? 'Shadow only' : 'Injected') : 'Capture only'}</span></header>
|
||||||
|
<section className="metrics-grid learning-metrics" aria-label="Learning totals">
|
||||||
|
<article className="metric-card"><span>Human-led outcomes</span><strong>{humanTotal}</strong><small>{summary.humanCreated} created · {summary.humanImproved} improved · {summary.humanCompletedAi} completed</small></article>
|
||||||
|
<article className="metric-card"><span>Eligible examples</span><strong>{summary.eligible}</strong><small>{summary.provisional} still maturing</small></article>
|
||||||
|
<article className="metric-card"><span>Needs review</span><strong>{summary.needsReview}</strong><small>{summary.inferred} inferred attributions</small></article>
|
||||||
|
<article className="metric-card"><span>Revoked or excluded</span><strong>{summary.invalidated + summary.excluded}</strong><small>Never supplied to generation</small></article>
|
||||||
|
</section>
|
||||||
|
<section className="panel learning-panel" aria-labelledby="trajectory-heading">
|
||||||
|
<div className="panel-heading"><div><span className="eyebrow">Policy {overview.data.policyVersion}</span><h2 id="trajectory-heading">Attributed trajectories</h2></div><span className="sample-size">n = {summary.total}</span></div>
|
||||||
|
{trajectories.length === 0 ? <p className="feature-empty">No received-image trajectories have been extracted yet. A full chat sync will backfill authoritative histories.</p> : (
|
||||||
|
<div className="table-scroll"><table className="performance-table learning-table"><thead><tr><th><span className="sr-only">Expand</span></th><th>Credit</th><th>Community</th><th>Confidence</th><th>Status</th><th>Updated</th><th>Review</th></tr></thead><tbody>{trajectories.flatMap((item) => {
|
||||||
|
const isOpen = expanded === item.id;
|
||||||
|
return [
|
||||||
|
<tr key={item.id} className={humanCredits.has(item.creditClass) ? 'human-led' : ''}>
|
||||||
|
<td><button type="button" className="button secondary expand-toggle" aria-expanded={isOpen} aria-controls={`detail-${item.id}`} onClick={() => setExpanded(isOpen ? null : item.id)}>{isOpen ? '▾' : '▸'}<span className="sr-only"> Show conversation evidence</span></button></td>
|
||||||
|
<th scope="row">{item.creditClass.replaceAll('-', ' ')}</th><td>{item.subreddit ? `r/${item.subreddit}` : 'Unknown'}</td><td>{item.attributionConfidence}</td><td><span className={`trajectory-status status-${item.status}`}>{item.status}</span></td><td>{new Date(item.updatedAt).toLocaleString()}</td><td><div className="review-actions"><button className="button secondary" disabled={review.isPending || item.status === 'invalidated'} onClick={() => review.mutate({ id: item.id, status: 'eligible' })}>Include</button><button className="button secondary" disabled={review.isPending || item.status === 'invalidated'} onClick={() => review.mutate({ id: item.id, status: 'excluded' })}>Exclude</button></div></td>
|
||||||
|
</tr>,
|
||||||
|
...(isOpen ? [<tr key={`${item.id}-detail`} id={`detail-${item.id}`} className="detail-row"><td colSpan={7}><TrajectoryDetail id={item.id} /></td></tr>] : []),
|
||||||
|
];
|
||||||
|
})}</tbody></table></div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
.learning-page { display: grid; gap: 1rem; }
|
||||||
|
.learning-metrics { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||||
|
.learning-mode, .trajectory-status { border-radius: 999px; background: var(--surface-strong); color: var(--muted); font-size: .78rem; padding: .35rem .65rem; text-transform: capitalize; }
|
||||||
|
.learning-mode.live, .status-eligible { color: var(--success); }
|
||||||
|
.status-needs-review { color: var(--warning); }
|
||||||
|
.status-invalidated, .status-excluded { color: var(--danger); }
|
||||||
|
.learning-panel { padding: 1rem; }
|
||||||
|
.learning-table tr.human-led th:first-child { color: var(--accent); }
|
||||||
|
.review-actions { display: flex; gap: .4rem; }
|
||||||
|
.review-actions .button { padding: .4rem .6rem; }
|
||||||
|
.expand-toggle { padding: .3rem .5rem; line-height: 1; }
|
||||||
|
.detail-row > td { background: var(--surface-strong); padding: .85rem 1rem; }
|
||||||
|
.trajectory-detail { display: grid; gap: .85rem; }
|
||||||
|
.trajectory-facts { display: flex; flex-wrap: wrap; gap: .35rem 1.4rem; margin: 0; }
|
||||||
|
.trajectory-facts dt { color: var(--muted); font-size: .72rem; text-transform: uppercase; letter-spacing: .04em; }
|
||||||
|
.trajectory-facts dd { margin: 0; font-size: .85rem; word-break: break-all; }
|
||||||
|
.trajectory-conversation-link { color: var(--accent); text-decoration: underline; text-underline-offset: .15em; }
|
||||||
|
.decision-list { display: grid; gap: .75rem; list-style: none; margin: 0; padding: 0; }
|
||||||
|
.decision-card { border: 1px solid var(--border); border-radius: .6rem; display: grid; gap: .5rem; padding: .7rem .8rem; }
|
||||||
|
.decision-heading { align-items: center; display: flex; flex-wrap: wrap; gap: .5rem; }
|
||||||
|
.decision-stage { color: var(--accent); font-size: .78rem; text-transform: capitalize; }
|
||||||
|
.decision-flag { border-radius: 999px; background: var(--surface); color: var(--warning); font-size: .72rem; padding: .2rem .5rem; }
|
||||||
|
.decision-turns { color: var(--muted); font-size: .72rem; margin-left: auto; }
|
||||||
|
.decision-context { display: grid; gap: .3rem; }
|
||||||
|
.turn { display: grid; gap: .1rem; margin: 0; }
|
||||||
|
.turn-actor { color: var(--muted); font-size: .7rem; text-transform: uppercase; letter-spacing: .04em; }
|
||||||
|
.turn-text { font-size: .85rem; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.turn-recipient .turn-text { color: var(--muted); }
|
||||||
|
.decision-chosen { border-left: 2px solid var(--accent); display: grid; gap: .1rem; margin: 0; padding-left: .6rem; }
|
||||||
|
.decision-note { color: var(--muted); font-size: .72rem; margin: 0; }
|
||||||
|
@media (max-width: 900px) { .learning-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||||
|
@media (max-width: 600px) { .learning-metrics { grid-template-columns: 1fr; } }
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { ZodType } from 'zod';
|
||||||
|
import {
|
||||||
|
ErrorResponseSchema,
|
||||||
|
MediaLibraryAssetSchema,
|
||||||
|
MediaLibraryResponseSchema,
|
||||||
|
type MediaLibraryAsset,
|
||||||
|
type MediaLibraryUpdate,
|
||||||
|
} from '../../../../../packages/api-contracts/src/index.js';
|
||||||
|
import { dashboardRequest } from '../../api/request.js';
|
||||||
|
|
||||||
|
async function parseResponse<T>(response: Response, schema: ZodType<T>): Promise<T> {
|
||||||
|
const payload: unknown = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
const parsed = ErrorResponseSchema.safeParse(payload);
|
||||||
|
throw new Error(parsed.success ? parsed.data.error : `Request failed (${response.status})`);
|
||||||
|
}
|
||||||
|
return schema.parse(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchMediaLibrary(): Promise<MediaLibraryAsset[]> {
|
||||||
|
return dashboardRequest('/api/media-library').then((response) => parseResponse(response, MediaLibraryResponseSchema));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadMediaLibraryImage(file: File): Promise<MediaLibraryAsset> {
|
||||||
|
return dashboardRequest('/api/media-library', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': file.type,
|
||||||
|
'X-File-Name': encodeURIComponent(file.name),
|
||||||
|
'X-Consent-Confirmed': 'true',
|
||||||
|
},
|
||||||
|
body: file,
|
||||||
|
}).then((response) => parseResponse(response, MediaLibraryAssetSchema));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateMediaLibraryImage(id: string, update: MediaLibraryUpdate): Promise<MediaLibraryAsset> {
|
||||||
|
return dashboardRequest(`/api/media-library/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(update),
|
||||||
|
}).then((response) => parseResponse(response, MediaLibraryAssetSchema));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMediaLibraryImage(id: string): Promise<void> {
|
||||||
|
const response = await dashboardRequest(`/api/media-library/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||||
|
const payload: unknown = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
const parsed = ErrorResponseSchema.safeParse(payload);
|
||||||
|
throw new Error(parsed.success ? parsed.data.error : `Request failed (${response.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
import { useEffect, useRef, useState, type ChangeEvent } from 'react';
|
||||||
|
import type { MediaLibraryAsset, MediaLibraryTag, MediaLibraryUpdate } from '../../../../../packages/api-contracts/src/index.js';
|
||||||
|
import { MediaPreviewDialog } from '../media/media-preview-dialog.js';
|
||||||
|
import { useDeleteMediaLibraryMutation, useMediaLibraryQuery, useUpdateMediaLibraryMutation, useUploadMediaLibraryMutation } from './queries.js';
|
||||||
|
import './media-library.css';
|
||||||
|
|
||||||
|
const TAGS: readonly MediaLibraryTag[] = ['selfie', 'chest', 'topless', 'feet', 'penis'];
|
||||||
|
const MAX_UPLOAD_BYTES = 15 * 1024 * 1024;
|
||||||
|
const NOTICE_MS = 5_000;
|
||||||
|
|
||||||
|
export function MediaLibraryPage() {
|
||||||
|
const query = useMediaLibraryQuery();
|
||||||
|
const upload = useUploadMediaLibraryMutation();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [fileError, setFileError] = useState<string | null>(null);
|
||||||
|
const [consent, setConsent] = useState(false);
|
||||||
|
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!file || typeof URL.createObjectURL !== 'function') {
|
||||||
|
setThumbnailUrl(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
setThumbnailUrl(url);
|
||||||
|
return () => {
|
||||||
|
if (typeof URL.revokeObjectURL === 'function') URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
}, [file]);
|
||||||
|
|
||||||
|
const uploadSucceeded = upload.isSuccess;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!uploadSucceeded) return;
|
||||||
|
const timer = setTimeout(() => upload.reset(), NOTICE_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [uploadSucceeded]);
|
||||||
|
|
||||||
|
function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
|
||||||
|
const picked = event.target.files?.[0] ?? null;
|
||||||
|
if (!picked) {
|
||||||
|
setFile(null);
|
||||||
|
setFileError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!picked.type.startsWith('image/')) {
|
||||||
|
setFile(null);
|
||||||
|
setFileError('Only image files can be uploaded.');
|
||||||
|
event.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (picked.size > MAX_UPLOAD_BYTES) {
|
||||||
|
setFile(null);
|
||||||
|
setFileError('Images must be 15 MB or smaller.');
|
||||||
|
event.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFileError(null);
|
||||||
|
setFile(picked);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.isPending) return <div className="page-state" role="status"><span className="spinner" />Loading exchange library…</div>;
|
||||||
|
if (query.isError) {
|
||||||
|
return (
|
||||||
|
<div className="page-state error-state" role="alert">
|
||||||
|
<strong>Exchange library unavailable</strong>
|
||||||
|
<span>{query.error.message}</span>
|
||||||
|
<button
|
||||||
|
aria-busy={query.isFetching || undefined}
|
||||||
|
className="button secondary"
|
||||||
|
disabled={query.isFetching}
|
||||||
|
onClick={() => void query.refetch()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{query.isFetching ? 'Refreshing…' : 'Retry'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const assets = query.data ?? [];
|
||||||
|
return (
|
||||||
|
<div className="page exchange-library-page">
|
||||||
|
<header className="page-header">
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">Operator-owned media</span>
|
||||||
|
<h1>Exchange library</h1>
|
||||||
|
<p>Add consented images, review the automatic anatomy classification, and control what the bot may send after a request.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section aria-label="Nonspecific request progression" className="exchange-ladder">
|
||||||
|
<div><span>First ask</span><strong>Random SFW</strong><small>Selfie, chest, or feet</small></div>
|
||||||
|
<div><span>Asked again</span><strong>Broader reveal</strong><small>SFW or topless</small></div>
|
||||||
|
<div><span>Further ask</span><strong>Full library</strong><small>Still exact-match when named</small></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel library-upload-panel">
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">Add an image</span>
|
||||||
|
<h2>Strip metadata and classify</h2>
|
||||||
|
<p>JPEG, PNG, or WebP up to 15 MB. Location and camera metadata are removed before the cleaned image is sent to the configured OpenRouter model for classification.</p>
|
||||||
|
</div>
|
||||||
|
<label className="library-file-field">
|
||||||
|
<span>Choose image</span>
|
||||||
|
<input accept="image/jpeg,image/png,image/webp" onChange={handleFileChange} ref={fileInputRef} type="file" />
|
||||||
|
{thumbnailUrl ? <img alt={`Preview of ${file?.name ?? 'selected image'}`} className="library-upload-thumbnail" src={thumbnailUrl} /> : null}
|
||||||
|
<small>{file ? file.name : 'No image selected'}</small>
|
||||||
|
</label>
|
||||||
|
{fileError ? <div className="media-notice error" role="alert">{fileError}</div> : null}
|
||||||
|
<label className="library-consent-field">
|
||||||
|
<input checked={consent} onChange={(event) => setConsent(event.target.checked)} type="checkbox" />
|
||||||
|
<span>I confirm every depicted adult consented to this image being stored and sent by the bot.</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
aria-busy={upload.isPending || undefined}
|
||||||
|
className="button primary"
|
||||||
|
disabled={!file || !consent || upload.isPending}
|
||||||
|
onClick={() => file && upload.mutate(file, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setFile(null);
|
||||||
|
setConsent(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{upload.isPending ? 'Classifying…' : 'Upload and classify'}
|
||||||
|
</button>
|
||||||
|
{upload.isError ? <div className="media-notice error" role="alert">{upload.error.message}</div> : null}
|
||||||
|
{upload.isSuccess ? <div className="media-notice success" role="status">Image added. Review its tags before relying on it.</div> : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="library-section-heading">
|
||||||
|
<div><span className="eyebrow">Available inventory</span><h2>{assets.length} {assets.length === 1 ? 'image' : 'images'}</h2></div>
|
||||||
|
<small>Images remain private and blurred until revealed.</small>
|
||||||
|
</div>
|
||||||
|
{assets.length === 0 ? (
|
||||||
|
<section className="panel empty-state"><strong>No exchange images yet.</strong><span>Add the first consented image above.</span></section>
|
||||||
|
) : (
|
||||||
|
<section aria-label="Exchange library images" className="library-grid">
|
||||||
|
{assets.map((asset) => <LibraryAssetCard asset={asset} key={asset.id} />)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LibraryAssetCard({ asset }: { asset: MediaLibraryAsset }) {
|
||||||
|
const updateMutation = useUpdateMediaLibraryMutation();
|
||||||
|
const deleteMutation = useDeleteMediaLibraryMutation();
|
||||||
|
const [revealed, setRevealed] = useState(false);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
|
const [showSaved, setShowSaved] = useState(false);
|
||||||
|
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||||
|
const [form, setForm] = useState<MediaLibraryUpdate>(() => ({
|
||||||
|
tags: asset.tags,
|
||||||
|
safety: asset.safety === 'nsfw' ? 'nsfw' : 'sfw',
|
||||||
|
enabled: asset.enabled,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Refetches must not clobber a form the operator has touched.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDirty) return;
|
||||||
|
setForm({ tags: asset.tags, safety: asset.safety === 'nsfw' ? 'nsfw' : 'sfw', enabled: asset.enabled });
|
||||||
|
}, [asset, isDirty]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showSaved) return;
|
||||||
|
const timer = setTimeout(() => setShowSaved(false), NOTICE_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [showSaved]);
|
||||||
|
|
||||||
|
// Armed delete confirmation reverts to the safe state if left alone.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!confirmDelete) return;
|
||||||
|
const timer = setTimeout(() => setConfirmDelete(false), NOTICE_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [confirmDelete]);
|
||||||
|
|
||||||
|
function editForm(update: (current: MediaLibraryUpdate) => MediaLibraryUpdate) {
|
||||||
|
setIsDirty(true);
|
||||||
|
setForm(update);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTag(tag: MediaLibraryTag) {
|
||||||
|
editForm((current) => ({
|
||||||
|
...current,
|
||||||
|
tags: current.tags.includes(tag) ? current.tags.filter((item) => item !== tag) : [...current.tags, tag],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const alt = `Library image tagged ${asset.tags.join(', ') || 'unclassified'}`;
|
||||||
|
const needsReview = asset.classificationStatus === 'failed' || asset.safety === 'unknown' || (asset.confidence ?? 0) < 0.7;
|
||||||
|
return (
|
||||||
|
<article className={asset.enabled ? 'library-card is-enabled' : 'library-card'}>
|
||||||
|
<div className="library-image-frame">
|
||||||
|
<button
|
||||||
|
aria-label={revealed ? `View ${asset.fileName} larger` : `Reveal library image ${asset.fileName}`}
|
||||||
|
className="library-image-button"
|
||||||
|
onClick={() => {
|
||||||
|
if (revealed) setIsPreviewOpen(true);
|
||||||
|
else setRevealed(true);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<img alt={alt} className={revealed ? '' : 'is-blurred'} src={asset.urlPath} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label={revealed ? `Blur ${asset.fileName}` : `Reveal ${asset.fileName}`}
|
||||||
|
aria-pressed={revealed}
|
||||||
|
className="library-reveal"
|
||||||
|
onClick={() => setRevealed((value) => !value)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{revealed ? 'Blur' : 'Reveal'}
|
||||||
|
</button>
|
||||||
|
<span className={`library-safety is-${asset.safety}`}>{asset.safety.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="library-card-body">
|
||||||
|
<div className="library-card-title">
|
||||||
|
<strong>{asset.fileName}</strong>
|
||||||
|
<span className="library-card-pills">
|
||||||
|
{isDirty ? <span className="pill warning">Unsaved changes</span> : null}
|
||||||
|
<span className={asset.enabled ? 'pill success' : 'pill neutral'}>{asset.enabled ? 'Enabled' : 'Disabled'}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{asset.description ? <p>{asset.description}</p> : <p>AI classification was unavailable. Add tags manually.</p>}
|
||||||
|
<small>{asset.width} × {asset.height} · {formatBytes(asset.sizeBytes)}{asset.confidence === null ? '' : ` · ${Math.round(asset.confidence * 100)}% confidence`}</small>
|
||||||
|
<small>{asset.sendCount === 0 ? 'Never sent' : `Sent ${asset.sendCount} ${asset.sendCount === 1 ? 'time' : 'times'}${asset.lastSentAt ? ` · last ${formatDate(asset.lastSentAt)}` : ''}`}</small>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Anatomy tags</legend>
|
||||||
|
<div className="library-tag-grid">
|
||||||
|
{TAGS.map((tag) => <label key={tag}><input checked={form.tags.includes(tag)} onChange={() => toggleTag(tag)} type="checkbox" /><span>{tag}</span></label>)}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<div className="library-controls">
|
||||||
|
<label>Safety<select onChange={(event) => editForm((current) => ({ ...current, safety: event.target.value as 'sfw' | 'nsfw' }))} value={form.safety}><option value="sfw">SFW</option><option value="nsfw">NSFW</option></select></label>
|
||||||
|
<div className="library-enable-group">
|
||||||
|
<label className="library-enable"><input checked={form.enabled} onChange={(event) => editForm((current) => ({ ...current, enabled: event.target.checked }))} type="checkbox" /><span>Available to bot</span></label>
|
||||||
|
{needsReview ? <div className="library-review-note" role="status">Review the AI result before enabling this image.</div> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{updateMutation.isError ? <div className="media-notice error" role="alert">{updateMutation.error.message}</div> : null}
|
||||||
|
{deleteMutation.isError ? <div className="media-notice error" role="alert">{deleteMutation.error.message}</div> : null}
|
||||||
|
<div className="library-card-actions">
|
||||||
|
<button
|
||||||
|
aria-busy={updateMutation.isPending || undefined}
|
||||||
|
className="button primary"
|
||||||
|
disabled={form.tags.length === 0 || updateMutation.isPending}
|
||||||
|
onClick={() => updateMutation.mutate({ id: asset.id, update: form }, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsDirty(false);
|
||||||
|
setShowSaved(true);
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{updateMutation.isPending ? 'Saving…' : 'Save changes'}
|
||||||
|
</button>
|
||||||
|
{showSaved ? <span className="library-saved-notice" role="status">Saved</span> : null}
|
||||||
|
{confirmDelete ? (
|
||||||
|
<>
|
||||||
|
<button className="button secondary" onClick={() => setConfirmDelete(false)} type="button">Cancel</button>
|
||||||
|
<button
|
||||||
|
aria-busy={deleteMutation.isPending || undefined}
|
||||||
|
className="button danger"
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
onClick={() => deleteMutation.mutate(asset.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Confirm delete
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button className="button secondary" onClick={() => setConfirmDelete(true)} type="button">Delete</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{form.tags.length === 0 ? <small className="library-save-hint">Select at least one tag to save</small> : null}
|
||||||
|
</div>
|
||||||
|
{isPreviewOpen ? (
|
||||||
|
<MediaPreviewDialog
|
||||||
|
entries={[{
|
||||||
|
id: asset.id,
|
||||||
|
src: asset.urlPath,
|
||||||
|
alt,
|
||||||
|
label: `Preview of ${asset.fileName}`,
|
||||||
|
captionTitle: asset.fileName,
|
||||||
|
captionSubtitle: `${asset.width} × ${asset.height}`,
|
||||||
|
}]}
|
||||||
|
index={0}
|
||||||
|
onClose={() => setIsPreviewOpen(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(value: number): string {
|
||||||
|
return value < 1024 * 1024 ? `${Math.round(value / 1024)} KB` : `${(value / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: number): string {
|
||||||
|
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
.exchange-library-page { --library-cyan: #7dd8d0; --library-violet: #b7a0ff; }
|
||||||
|
.exchange-ladder { display: grid; gap: 1px; margin-bottom: 1rem; overflow: hidden; border: 1px solid var(--border); border-radius: .9rem; background: var(--border); }
|
||||||
|
.exchange-ladder > div { position: relative; display: grid; gap: .25rem; min-height: 8rem; padding: 1.15rem; background: linear-gradient(145deg, #111c18, #14211d); }
|
||||||
|
.exchange-ladder > div::after { content: ''; position: absolute; inset: auto 1.15rem 0; height: .2rem; background: var(--accent); }
|
||||||
|
.exchange-ladder > div:nth-child(2)::after { background: var(--library-cyan); }
|
||||||
|
.exchange-ladder > div:nth-child(3)::after { background: var(--library-violet); }
|
||||||
|
.exchange-ladder span { color: var(--muted); font-size: .72rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
||||||
|
.exchange-ladder strong { align-self: end; font-size: 1.2rem; }
|
||||||
|
.exchange-ladder small { color: var(--muted); }
|
||||||
|
.library-upload-panel { display: grid; gap: 1rem; margin-bottom: 2rem; }
|
||||||
|
.library-upload-panel p { margin: .35rem 0 0; color: var(--muted); }
|
||||||
|
.library-file-field { display: grid; gap: .4rem; padding: 1rem; border: 1px dashed #4d685d; border-radius: .75rem; background: #0c1511; }
|
||||||
|
.library-file-field span { font-weight: 750; }
|
||||||
|
.library-file-field small { color: var(--muted); }
|
||||||
|
.library-file-field input { color: var(--muted); }
|
||||||
|
.library-consent-field, .library-enable { display: flex; align-items: flex-start; gap: .65rem; color: var(--muted); }
|
||||||
|
.library-consent-field input, .library-enable input { flex: 0 0 auto; width: 1.1rem; height: 1.1rem; margin-top: .1rem; accent-color: var(--accent); }
|
||||||
|
.library-upload-panel > .button { justify-self: start; }
|
||||||
|
.library-section-heading { display: flex; align-items: end; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; }
|
||||||
|
.library-section-heading small { color: var(--muted); text-align: right; }
|
||||||
|
.library-grid { display: grid; gap: 1rem; }
|
||||||
|
.library-card { overflow: hidden; border: 1px solid var(--border); border-radius: .9rem; background: var(--surface); }
|
||||||
|
.library-card.is-enabled { border-color: #386d4d; box-shadow: inset 0 0 0 1px rgba(117, 214, 157, .08); }
|
||||||
|
.library-image-frame { position: relative; min-height: 17rem; overflow: hidden; background: #050907; }
|
||||||
|
.library-image-button { display: block; width: 100%; padding: 0; border: 0; background: transparent; cursor: zoom-in; }
|
||||||
|
.library-image-button:focus-visible { outline: 2px solid var(--accent); outline-offset: -4px; }
|
||||||
|
.library-image-frame img { display: block; width: 100%; height: 17rem; object-fit: contain; transition: filter 180ms ease, transform 180ms ease; }
|
||||||
|
.library-image-frame img.is-blurred { filter: blur(24px) brightness(.5); transform: scale(1.12); }
|
||||||
|
.library-reveal { position: absolute; right: .75rem; bottom: .75rem; padding: .45rem .65rem; color: white; border: 1px solid rgba(255,255,255,.35); border-radius: .55rem; background: rgba(0,0,0,.78); font-weight: 750; }
|
||||||
|
.library-safety { position: absolute; top: .75rem; left: .75rem; padding: .3rem .5rem; border-radius: 999px; color: #08110d; background: var(--muted); font-size: .68rem; font-weight: 900; letter-spacing: .08em; }
|
||||||
|
.library-safety.is-sfw { background: var(--accent); }
|
||||||
|
.library-safety.is-nsfw { color: #21132d; background: var(--library-violet); }
|
||||||
|
.library-safety.is-unknown { color: var(--warning-text); background: var(--warning-bg); border: 1px solid var(--warning-border); }
|
||||||
|
.library-card-body { display: grid; gap: .85rem; padding: 1rem; }
|
||||||
|
.library-card-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
|
||||||
|
.library-card-title strong { overflow-wrap: anywhere; }
|
||||||
|
.library-card-body p { margin: 0; color: var(--muted); line-height: 1.45; }
|
||||||
|
.library-card-body > small { color: var(--muted); }
|
||||||
|
.library-review-note { padding: .6rem .7rem; color: var(--warning-text); border: 1px solid var(--warning-border); border-radius: .55rem; background: var(--warning-bg); font-size: .82rem; }
|
||||||
|
.library-card-pills { display: flex; flex-wrap: wrap; gap: .35rem; justify-content: flex-end; }
|
||||||
|
.library-enable-group { display: grid; gap: .5rem; }
|
||||||
|
.library-save-hint { color: var(--warning-text); }
|
||||||
|
.library-saved-notice { align-self: center; color: var(--success-text); font-weight: 750; }
|
||||||
|
.library-upload-thumbnail { display: block; max-width: 8rem; max-height: 8rem; border: 1px solid var(--border); border-radius: .5rem; object-fit: cover; }
|
||||||
|
.library-card fieldset { padding: 0; margin: 0; border: 0; }
|
||||||
|
.library-card legend { margin-bottom: .55rem; color: var(--muted); font-size: .78rem; font-weight: 750; }
|
||||||
|
.library-tag-grid { display: flex; flex-wrap: wrap; gap: .45rem; }
|
||||||
|
.library-tag-grid label { position: relative; }
|
||||||
|
.library-tag-grid input { position: absolute; opacity: 0; pointer-events: none; }
|
||||||
|
.library-tag-grid span { display: block; padding: .35rem .6rem; border: 1px solid var(--border); border-radius: 999px; color: var(--muted); background: #0c1511; text-transform: capitalize; cursor: pointer; }
|
||||||
|
.library-tag-grid input:checked + span { color: #07130c; border-color: var(--accent); background: var(--accent); font-weight: 800; }
|
||||||
|
.library-tag-grid input:focus-visible + span { outline: 2px solid white; outline-offset: 2px; }
|
||||||
|
.library-controls { display: grid; gap: .8rem; }
|
||||||
|
.library-controls > label:first-child { display: grid; gap: .35rem; color: var(--muted); font-size: .8rem; font-weight: 750; }
|
||||||
|
.library-controls select { min-height: 2.5rem; padding: .45rem; color: white; border: 1px solid var(--border); border-radius: .55rem; background: var(--surface-raised); }
|
||||||
|
.library-card-actions { display: flex; flex-wrap: wrap; gap: .55rem; padding-top: .25rem; }
|
||||||
|
@media (min-width: 48rem) {
|
||||||
|
.exchange-ladder { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
.library-upload-panel { grid-template-columns: minmax(16rem, 1fr) minmax(18rem, 1.2fr); align-items: center; }
|
||||||
|
.library-upload-panel .library-consent-field, .library-upload-panel .media-notice { grid-column: 1 / -1; }
|
||||||
|
.library-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.library-controls { grid-template-columns: 1fr 1fr; align-items: end; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) { .library-image-frame img { transition: none; } }
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type { MediaLibraryUpdate } from '../../../../../packages/api-contracts/src/index.js';
|
||||||
|
import { deleteMediaLibraryImage, fetchMediaLibrary, updateMediaLibraryImage, uploadMediaLibraryImage } from './api.js';
|
||||||
|
|
||||||
|
export const mediaLibraryQueryKey = ['dashboard', 'media-library'] as const;
|
||||||
|
|
||||||
|
export function useMediaLibraryQuery() {
|
||||||
|
return useQuery({ queryKey: mediaLibraryQueryKey, queryFn: fetchMediaLibrary });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadMediaLibraryMutation() {
|
||||||
|
const client = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: uploadMediaLibraryImage,
|
||||||
|
onSuccess: () => client.invalidateQueries({ queryKey: mediaLibraryQueryKey }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateMediaLibraryMutation() {
|
||||||
|
const client = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, update }: { id: string; update: MediaLibraryUpdate }) => updateMediaLibraryImage(id, update),
|
||||||
|
onSuccess: () => client.invalidateQueries({ queryKey: mediaLibraryQueryKey }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteMediaLibraryMutation() {
|
||||||
|
const client = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: deleteMediaLibraryImage,
|
||||||
|
onSuccess: () => client.invalidateQueries({ queryKey: mediaLibraryQueryKey }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type MediaItem,
|
type MediaItem,
|
||||||
type MediaSyncStatus,
|
type MediaSyncStatus,
|
||||||
} from '../../../../../packages/api-contracts/src/index.js';
|
} from '../../../../../packages/api-contracts/src/index.js';
|
||||||
|
import { dashboardRequest } from '../../api/request.js';
|
||||||
|
|
||||||
async function parseResponse<T>(response: Response, schema: ZodType<T>): Promise<T> {
|
async function parseResponse<T>(response: Response, schema: ZodType<T>): Promise<T> {
|
||||||
const payload: unknown = await response.json();
|
const payload: unknown = await response.json();
|
||||||
@@ -18,7 +19,7 @@ async function parseResponse<T>(response: Response, schema: ZodType<T>): Promise
|
|||||||
}
|
}
|
||||||
|
|
||||||
function request(path: string, init?: RequestInit): Promise<Response> {
|
function request(path: string, init?: RequestInit): Promise<Response> {
|
||||||
return globalThis.fetch(path, { credentials: 'same-origin', ...init });
|
return dashboardRequest(path, init);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchMedia(): Promise<MediaItem[]> {
|
export async function fetchMedia(): Promise<MediaItem[]> {
|
||||||
|
|||||||
@@ -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
|
||||||
if (filters.conversation !== 'all' && mediaConversation(item) !== filters.conversation) return false;
|
.filter((item) => {
|
||||||
if (filters.sender !== 'all' && item.sender !== filters.sender) return false;
|
if (filters.conversation !== 'all' && mediaConversation(item) !== filters.conversation) return false;
|
||||||
if (filters.type !== 'all' && mediaType(item) !== filters.type) return false;
|
if (filters.sender !== 'all' && item.sender !== filters.sender) return false;
|
||||||
if (rangeMs !== null && item.timestamp < now - rangeMs) return false;
|
if (filters.type !== 'all' && mediaType(item) !== filters.type) return false;
|
||||||
return true;
|
if (rangeMs !== null && item.timestamp < now - rangeMs) return false;
|
||||||
});
|
return true;
|
||||||
|
})
|
||||||
|
.sort((left, right) => right.timestamp - left.timestamp);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { Modal } from '../../components/modal.js';
|
||||||
|
|
||||||
interface FullSyncDialogProps {
|
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"
|
||||||
<span className="eyebrow">Confirm expensive action</span>
|
isDismissLocked={isPending}
|
||||||
<h2 id="full-sync-title">Run full media sync?</h2>
|
labelledBy="full-sync-title"
|
||||||
<p>This scans every conversation and can take several minutes. Routine refreshes should use Sync new media.</p>
|
onClose={onCancel}
|
||||||
<div className="dialog-actions">
|
>
|
||||||
<button className="button secondary" disabled={isPending} onClick={onCancel} ref={cancelButtonRef} type="button">
|
<span className="eyebrow">Confirm expensive action</span>
|
||||||
Cancel
|
<h2 id="full-sync-title">Run full media sync?</h2>
|
||||||
</button>
|
<p>This scans every conversation and can take several minutes. Routine refreshes should use Sync new media.</p>
|
||||||
<button className="button danger" disabled={isPending} onClick={onConfirm} type="button">
|
<div className="dialog-actions">
|
||||||
{isPending ? 'Syncing…' : 'Run full sync'}
|
<button autoFocus className="button secondary" disabled={isPending} onClick={onCancel} type="button">
|
||||||
</button>
|
Cancel
|
||||||
</div>
|
</button>
|
||||||
|
<button aria-busy={isPending || undefined} className="button danger" disabled={isPending} onClick={onConfirm} type="button">
|
||||||
|
{isPending ? 'Syncing…' : 'Run full sync'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,38 +1,62 @@
|
|||||||
import type { MediaItem } from '../../../../../packages/api-contracts/src/index.js';
|
import type { MediaItem } from '../../../../../packages/api-contracts/src/index.js';
|
||||||
import { mediaConversation, mediaType } from './filters.js';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { RedditUsernameLink } from '../../components/reddit-username-link.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;
|
||||||
isRevealed: boolean;
|
isRevealed: boolean;
|
||||||
onToggleReveal(): void;
|
onToggleReveal(): void;
|
||||||
|
onOpenPreview(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMediaDate(timestamp: number): string {
|
function formatMediaDate(timestamp: number): string {
|
||||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp);
|
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MediaCard({ item, isRevealed, onToggleReveal }: 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">
|
||||||
<img
|
<button
|
||||||
alt={`Media from ${item.sender}`}
|
aria-label={isRevealed ? `View media from ${item.sender} larger` : `Reveal media from ${item.sender}`}
|
||||||
className={isRevealed ? 'media-image' : 'media-image is-blurred'}
|
className="media-preview-button"
|
||||||
loading="lazy"
|
onClick={() => {
|
||||||
src={item.urlPath}
|
if (isRevealed) onOpenPreview();
|
||||||
/>
|
else onToggleReveal();
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<img alt={mediaAlt(item)} className={isRevealed ? 'media-image' : 'media-image is-blurred'} loading="lazy" src={item.urlPath} />
|
||||||
|
</button>
|
||||||
{!isRevealed ? <span className="privacy-label">Private media hidden</span> : null}
|
{!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>{item.sender}</strong>
|
<strong><span className="media-link-prefix">From</span> <RedditUsernameLink showPrefix={false} username={item.sender} /></strong>
|
||||||
<span>{mediaConversation(item)}</span>
|
<span><span className="media-link-prefix">Conversation</span> {item.chatUsername
|
||||||
|
? <RedditUsernameLink showPrefix={false} username={item.chatUsername} />
|
||||||
|
: mediaConversation(item)}</span>
|
||||||
</div>
|
</div>
|
||||||
<dl>
|
<dl>
|
||||||
<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,17 +1,21 @@
|
|||||||
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, 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';
|
||||||
|
|
||||||
const initialFilters: MediaFilters = {
|
export const initialMediaFilters: MediaFilters = {
|
||||||
conversation: 'all',
|
conversation: 'all',
|
||||||
sender: 'all',
|
sender: 'all',
|
||||||
type: 'all',
|
type: 'all',
|
||||||
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.';
|
||||||
}
|
}
|
||||||
@@ -20,17 +24,26 @@ function formatLastSync(timestamp: number): string {
|
|||||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp);
|
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MediaPage() {
|
export interface MediaPageProps {
|
||||||
|
filters?: MediaFilters;
|
||||||
|
onFiltersChange?(filters: MediaFilters): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MediaPage({ filters: controlledFilters, onFiltersChange }: MediaPageProps) {
|
||||||
const mediaQuery = useMediaQuery();
|
const mediaQuery = useMediaQuery();
|
||||||
const statusQuery = useMediaSyncStatusQuery();
|
const statusQuery = useMediaSyncStatusQuery();
|
||||||
const syncMutation = useTriggerMediaSyncMutation();
|
const syncMutation = useTriggerMediaSyncMutation();
|
||||||
const [filters, setFilters] = useState<MediaFilters>(initialFilters);
|
const [localFilters, setLocalFilters] = useState<MediaFilters>(initialMediaFilters);
|
||||||
|
const filters = controlledFilters ?? localFilters;
|
||||||
const [revealedIds, setRevealedIds] = useState<Set<string>>(() => new Set());
|
const [revealedIds, setRevealedIds] = useState<Set<string>>(() => new Set());
|
||||||
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 [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],
|
||||||
@@ -40,9 +53,37 @@ export function MediaPage() {
|
|||||||
[items],
|
[items],
|
||||||
);
|
);
|
||||||
const isSyncBusy = statusQuery.data?.isSyncing === true || syncMutation.isPending;
|
const isSyncBusy = statusQuery.data?.isSyncing === true || syncMutation.isPending;
|
||||||
|
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],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isSyncSuccess = syncMutation.isSuccess;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSyncSuccess) return;
|
||||||
|
const timer = setTimeout(() => syncMutation.reset(), SUCCESS_NOTICE_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
// syncMutation is a fresh object every render; keying on isSuccess is intentional.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isSyncSuccess]);
|
||||||
|
|
||||||
|
function updateFilters(next: MediaFilters) {
|
||||||
|
if (controlledFilters === undefined) setLocalFilters(next);
|
||||||
|
onFiltersChange?.(next);
|
||||||
|
setVisibleLimit(PAGE_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
function updateFilter<Key extends keyof MediaFilters>(key: Key, value: MediaFilters[Key]) {
|
function updateFilter<Key extends keyof MediaFilters>(key: Key, value: MediaFilters[Key]) {
|
||||||
setFilters((current) => ({ ...current, [key]: value }));
|
updateFilters({ ...filters, [key]: value });
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleItem(itemId: string) {
|
function toggleItem(itemId: string) {
|
||||||
@@ -79,7 +120,15 @@ export function MediaPage() {
|
|||||||
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -95,11 +144,9 @@ export function MediaPage() {
|
|||||||
<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'}
|
||||||
@@ -116,7 +163,7 @@ export function MediaPage() {
|
|||||||
>
|
>
|
||||||
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>
|
||||||
@@ -127,7 +174,15 @@ export function MediaPage() {
|
|||||||
{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 ? (
|
||||||
@@ -177,21 +232,45 @@ export function MediaPage() {
|
|||||||
<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>
|
||||||
<section aria-label="Media gallery" className="media-grid">
|
<span>Try broadening one or more filters.</span>
|
||||||
{visibleItems.map((item) => (
|
<button className="button secondary" onClick={() => updateFilters(initialMediaFilters)} type="button">
|
||||||
<MediaCard
|
Clear filters
|
||||||
isRevealed={isRevealAll || revealedIds.has(item.id)}
|
</button>
|
||||||
item={item}
|
|
||||||
key={item.id}
|
|
||||||
onToggleReveal={() => toggleItem(item.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</section>
|
</section>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<section aria-label="Media gallery" className="media-grid">
|
||||||
|
{displayedItems.map((item) => (
|
||||||
|
<MediaCard
|
||||||
|
isRevealed={isRevealAll || revealedIds.has(item.id)}
|
||||||
|
item={item}
|
||||||
|
key={item.id}
|
||||||
|
onOpenPreview={() => setPreviewItemId(item.id)}
|
||||||
|
onToggleReveal={() => toggleItem(item.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
{visibleItems.length > displayedItems.length ? (
|
||||||
|
<div className="media-load-more">
|
||||||
|
<button className="button secondary" onClick={() => setVisibleLimit((current) => current + PAGE_SIZE)} type="button">
|
||||||
|
Load more ({visibleItems.length - displayedItems.length} remaining)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -203,6 +282,14 @@ export function MediaPage() {
|
|||||||
onConfirm={() => runSync(true)}
|
onConfirm={() => runSync(true)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{previewIndex >= 0 ? (
|
||||||
|
<MediaPreviewDialog
|
||||||
|
entries={previewEntries}
|
||||||
|
index={previewIndex}
|
||||||
|
onClose={() => setPreviewItemId(null)}
|
||||||
|
onNavigate={(nextIndex) => setPreviewItemId(visibleItems[nextIndex]?.id ?? null)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Modal } from '../../components/modal.js';
|
||||||
|
|
||||||
|
export interface MediaPreviewEntry {
|
||||||
|
id: string;
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
/** Accessible name for the dialog. */
|
||||||
|
label: string;
|
||||||
|
/** Caption bar: sender (or file name) plus optional date line. */
|
||||||
|
captionTitle: string;
|
||||||
|
captionSubtitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediaPreviewDialogProps {
|
||||||
|
entries: MediaPreviewEntry[];
|
||||||
|
index: number;
|
||||||
|
onNavigate?(index: number): void;
|
||||||
|
onClose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(() => {
|
||||||
|
setIsLoaded(false);
|
||||||
|
}, [entry?.src]);
|
||||||
|
|
||||||
|
const hasPrevious = index > 0;
|
||||||
|
const hasNext = index < entries.length - 1;
|
||||||
|
const canNavigate = entries.length > 1 && onNavigate !== undefined;
|
||||||
|
|
||||||
|
// Arrow keys navigate regardless of which control inside the modal holds
|
||||||
|
// focus (a nav button can become disabled, dropping focus).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canNavigate) return;
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'ArrowLeft' && hasPrevious) {
|
||||||
|
event.preventDefault();
|
||||||
|
onNavigate?.(index - 1);
|
||||||
|
} else if (event.key === 'ArrowRight' && hasNext) {
|
||||||
|
event.preventDefault();
|
||||||
|
onNavigate?.(index + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [canNavigate, hasPrevious, hasNext, index, onNavigate]);
|
||||||
|
|
||||||
|
if (!entry) return null;
|
||||||
|
|
||||||
|
function goPrevious() {
|
||||||
|
if (hasPrevious) onNavigate?.(index - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goNext() {
|
||||||
|
if (hasNext) onNavigate?.(index + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal className="media-preview-dialog" closeOnBackdrop label={entry.label} onClose={onClose}>
|
||||||
|
<div className="media-preview-dialog-content">
|
||||||
|
{!isLoaded ? (
|
||||||
|
<span className="media-preview-dialog-loading" role="status">
|
||||||
|
<span className="spinner" />
|
||||||
|
<span className="sr-only">Loading full-size media</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<img
|
||||||
|
alt={entry.alt}
|
||||||
|
className={isLoaded ? 'media-preview-dialog-image' : 'media-preview-dialog-image is-loading'}
|
||||||
|
onLoad={() => setIsLoaded(true)}
|
||||||
|
src={entry.src}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
aria-label="Close media preview"
|
||||||
|
autoFocus
|
||||||
|
className="media-preview-dialog-close"
|
||||||
|
onClick={onClose}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
{canNavigate ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
aria-label="Previous media"
|
||||||
|
className="media-preview-dialog-nav is-previous"
|
||||||
|
disabled={!hasPrevious}
|
||||||
|
onClick={goPrevious}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="Next media"
|
||||||
|
className="media-preview-dialog-nav is-next"
|
||||||
|
disabled={!hasNext}
|
||||||
|
onClick={goNext}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<div className="media-preview-dialog-caption">
|
||||||
|
<strong>{entry.captionTitle}</strong>
|
||||||
|
{entry.captionSubtitle ? <span>{entry.captionSubtitle}</span> : null}
|
||||||
|
{canNavigate ? <span>{index + 1} of {entries.length}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import type { MediaFilters } from './filters.js';
|
||||||
|
import { initialMediaFilters, MediaPage } from './media-page.js';
|
||||||
|
|
||||||
|
function parseType(value: string | null): MediaFilters['type'] {
|
||||||
|
return value === 'image' || value === 'gif' ? value : 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateRange(value: string | null): MediaFilters['dateRange'] {
|
||||||
|
return value === '24h' || value === '7d' || value === '30d' ? value : 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MediaRoute() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const filters: MediaFilters = {
|
||||||
|
conversation: searchParams.get('conversation') ?? initialMediaFilters.conversation,
|
||||||
|
sender: searchParams.get('sender') ?? initialMediaFilters.sender,
|
||||||
|
type: parseType(searchParams.get('type')),
|
||||||
|
dateRange: parseDateRange(searchParams.get('range')),
|
||||||
|
};
|
||||||
|
|
||||||
|
function updateFilters(next: MediaFilters) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (next.conversation !== 'all') params.set('conversation', next.conversation);
|
||||||
|
if (next.sender !== 'all') params.set('sender', next.sender);
|
||||||
|
if (next.type !== 'all') params.set('type', next.type);
|
||||||
|
if (next.dateRange !== 'all') params.set('range', next.dateRange);
|
||||||
|
setSearchParams(params, { replace: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return <MediaPage filters={filters} onFiltersChange={updateFilters} />;
|
||||||
|
}
|
||||||
@@ -3,27 +3,50 @@
|
|||||||
.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:focus-visible { outline: 2px solid var(--accent); outline-offset: -4px; }
|
||||||
.media-image { width: 100%; height: 100%; object-fit: cover; transition: filter 160ms ease, transform 160ms ease; }
|
.media-image { width: 100%; height: 100%; object-fit: cover; transition: filter 160ms ease, transform 160ms ease; }
|
||||||
.media-image.is-blurred { filter: blur(24px) brightness(.55); transform: scale(1.12); }
|
.media-image.is-blurred { filter: blur(24px) brightness(.55); transform: scale(1.12); }
|
||||||
.privacy-label { position: absolute; padding: .45rem .65rem; color: white; background: rgba(0, 0, 0, .72); border-radius: 999px; font-size: .75rem; font-weight: 800; }
|
.privacy-label { position: absolute; padding: .45rem .65rem; color: white; background: rgba(0, 0, 0, .72); border-radius: 999px; font-size: .75rem; font-weight: 800; }
|
||||||
.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%; }
|
||||||
|
|
||||||
|
/* Full-screen lightbox rendered through the shared <dialog> Modal. */
|
||||||
|
dialog.media-preview-dialog { width: 100vw; height: 100vh; max-width: 100vw; max-height: 100vh; padding: 1rem; border: 0; background: transparent; }
|
||||||
|
dialog.media-preview-dialog::backdrop { background: rgba(0, 0, 0, .9); }
|
||||||
|
.media-preview-dialog-content { position: relative; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
|
||||||
|
.media-preview-dialog-image { display: block; width: auto; height: auto; max-width: 100%; max-height: calc(100% - 3.6rem); object-fit: contain; }
|
||||||
|
.media-preview-dialog-image.is-loading { opacity: 0; }
|
||||||
|
.media-preview-dialog-loading { position: absolute; display: grid; place-items: center; color: white; }
|
||||||
|
.media-preview-dialog-close { position: absolute; top: .5rem; right: .5rem; width: 2.75rem; height: 2.75rem; padding: 0; border: 1px solid rgba(255, 255, 255, .5); border-radius: 50%; color: white; background: rgba(0, 0, 0, .7); font-size: 2rem; line-height: 1; cursor: pointer; }
|
||||||
|
.media-preview-dialog-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
||||||
|
.media-preview-dialog-nav { position: absolute; top: 50%; width: 2.75rem; height: 2.75rem; padding: 0; transform: translateY(-50%); border: 1px solid rgba(255, 255, 255, .5); border-radius: 50%; color: white; background: rgba(0, 0, 0, .7); font-size: 1.8rem; line-height: 1; cursor: pointer; }
|
||||||
|
.media-preview-dialog-nav:disabled { opacity: .35; }
|
||||||
|
.media-preview-dialog-nav.is-previous { left: .5rem; }
|
||||||
|
.media-preview-dialog-nav.is-next { right: .5rem; }
|
||||||
|
.media-preview-dialog-nav:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
||||||
|
.media-preview-dialog-caption { position: absolute; bottom: 0; left: 50%; display: flex; flex-wrap: wrap; align-items: baseline; justify-content: center; gap: .35rem .8rem; max-width: 100%; padding: .55rem .9rem; transform: translateX(-50%); color: white; background: rgba(0, 0, 0, .72); border-radius: .6rem .6rem 0 0; font-size: .82rem; }
|
||||||
|
.media-preview-dialog-caption span { color: rgba(255, 255, 255, .75); }
|
||||||
|
|
||||||
@media (min-width: 40rem) {
|
@media (min-width: 40rem) {
|
||||||
.media-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.media-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
.media-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.media-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ export type LiveConsoleAction =
|
|||||||
| { type: 'set-paused'; paused: boolean }
|
| { type: 'set-paused'; paused: boolean }
|
||||||
| { type: 'set-auto-scroll'; enabled: boolean }
|
| { type: 'set-auto-scroll'; enabled: boolean }
|
||||||
| { type: 'set-level'; level: ConsoleLevelFilter }
|
| { type: 'set-level'; level: ConsoleLevelFilter }
|
||||||
| { type: 'set-search'; search: string };
|
| { type: 'set-search'; search: string }
|
||||||
|
| { type: 'clear' };
|
||||||
|
|
||||||
function boundedAppend(
|
function boundedAppend(
|
||||||
entries: readonly ConsoleEntry[],
|
entries: readonly ConsoleEntry[],
|
||||||
@@ -89,6 +90,8 @@ export function reduceLiveConsoleState(
|
|||||||
return { ...state, level: action.level };
|
return { ...state, level: action.level };
|
||||||
case 'set-search':
|
case 'set-search':
|
||||||
return { ...state, search: action.search };
|
return { ...state, search: action.search };
|
||||||
|
case 'clear':
|
||||||
|
return { ...state, entries: [], pendingEntries: [] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import { z } from 'zod';
|
|||||||
import {
|
import {
|
||||||
ActivityResponseSchema,
|
ActivityResponseSchema,
|
||||||
ChatSyncStatusSchema,
|
ChatSyncStatusSchema,
|
||||||
|
ChatSyncTriggerResponseSchema,
|
||||||
|
FetchAllChatsResponseSchema,
|
||||||
} from '../../../../../packages/api-contracts/src/index.js';
|
} from '../../../../../packages/api-contracts/src/index.js';
|
||||||
|
import { createDashboardRequest } from '../../api/request.js';
|
||||||
|
|
||||||
export const RawLogEntrySchema = z.strictObject({
|
export const RawLogEntrySchema = z.strictObject({
|
||||||
timestamp: z.number(),
|
timestamp: z.number(),
|
||||||
@@ -32,9 +35,7 @@ async function parseResponse<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createOperationsApi(fetchImplementation?: typeof fetch) {
|
export function createOperationsApi(fetchImplementation?: typeof fetch) {
|
||||||
function request(path: string): Promise<Response> {
|
const request = createDashboardRequest(fetchImplementation);
|
||||||
return (fetchImplementation ?? globalThis.fetch)(path);
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
async getActivity() {
|
async getActivity() {
|
||||||
return parseResponse(await request('/api/activity'), ActivityResponseSchema);
|
return parseResponse(await request('/api/activity'), ActivityResponseSchema);
|
||||||
@@ -48,6 +49,18 @@ export function createOperationsApi(fetchImplementation?: typeof fetch) {
|
|||||||
async getSyncStatus() {
|
async getSyncStatus() {
|
||||||
return parseResponse(await request('/api/chats/sync'), ChatSyncStatusSchema);
|
return parseResponse(await request('/api/chats/sync'), ChatSyncStatusSchema);
|
||||||
},
|
},
|
||||||
|
async triggerSync(kind: 'delta' | 'full') {
|
||||||
|
const result = await parseResponse(
|
||||||
|
await request(`/api/chats/sync/${kind}`, { method: 'POST' }),
|
||||||
|
ChatSyncTriggerResponseSchema,
|
||||||
|
);
|
||||||
|
if (!('success' in result)) throw new Error(result.error);
|
||||||
|
if (!result.success) throw new Error(result.reason);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async fetchAllChats() {
|
||||||
|
return parseResponse(await request('/api/chats/fetch-all', { method: 'POST' }), FetchAllChatsResponseSchema);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { useEffect, useReducer, useRef, useState } from 'react';
|
import { useEffect, useReducer, useRef, useState } from 'react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import type { ActivityItem, ChatSyncStatus, DashboardEvent, RedditAccountOwner } from '../../../../../packages/api-contracts/src/index.js';
|
||||||
import type { ActivityItem, ChatSyncStatus, DashboardEvent } from '../../../../../packages/api-contracts/src/index.js';
|
import { useDashboardRealtime } from '../../app/realtime-provider.js';
|
||||||
|
import { Modal } from '../../components/modal.js';
|
||||||
|
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
||||||
|
import { RedditUserText } from '../../components/reddit-user-text.js';
|
||||||
|
import { OriginPostButton } from '../../components/origin-post.js';
|
||||||
|
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
||||||
import {
|
import {
|
||||||
createLiveConsoleState,
|
createLiveConsoleState,
|
||||||
reduceLiveConsoleState,
|
reduceLiveConsoleState,
|
||||||
@@ -8,13 +13,8 @@ import {
|
|||||||
type ConsoleEntry,
|
type ConsoleEntry,
|
||||||
type ConsoleLevelFilter,
|
type ConsoleLevelFilter,
|
||||||
} from './live-console-model.js';
|
} from './live-console-model.js';
|
||||||
import { useActivityQuery, useLogsQuery, useSyncStatusQuery } from './operations-queries.js';
|
import { useActivityQuery, useFetchAllChatsMutation, useLogsQuery, useSyncStatusQuery, useTriggerChatSyncMutation } from './operations-queries.js';
|
||||||
import {
|
import type { RealtimeConnectionSnapshot } from './realtime-client.js';
|
||||||
createBrowserEventSource,
|
|
||||||
createDashboardRealtimeClient,
|
|
||||||
queryKeysForDashboardEvent,
|
|
||||||
type RealtimeConnectionSnapshot,
|
|
||||||
} from './realtime-client.js';
|
|
||||||
import './operations.css';
|
import './operations.css';
|
||||||
|
|
||||||
function formatTimestamp(timestamp: number | string): string {
|
function formatTimestamp(timestamp: number | string): string {
|
||||||
@@ -29,10 +29,15 @@ 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" />
|
||||||
<div><strong>{item.message}</strong><small>{formatTimestamp(item.timestamp)}{item.recipient ? ` · u/${item.recipient}` : ''}</small></div>
|
<AccountOwnerBadge owner={item.owner} />
|
||||||
|
<div><strong>{item.postUrl && item.postTitle ? (
|
||||||
|
<OriginPostButton post={{ title: item.postTitle, url: item.postUrl, imageUrl: item.imageUrl }}>
|
||||||
|
{item.message}
|
||||||
|
</OriginPostButton>
|
||||||
|
) : <RedditUserText>{item.message}</RedditUserText>}</strong><small>{formatTimestamp(item.timestamp)}{item.recipient && !item.message.includes(`u/${item.recipient}`) ? <> · <RedditUsernameLink username={item.recipient} /></> : null}</small></div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
@@ -68,24 +73,72 @@ 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 queryClient = useQueryClient();
|
const realtime = useDashboardRealtime();
|
||||||
const activityQuery = useActivityQuery();
|
const activityQuery = useActivityQuery();
|
||||||
const logsQuery = useLogsQuery();
|
const logsQuery = useLogsQuery();
|
||||||
const syncQuery = useSyncStatusQuery();
|
const syncQuery = useSyncStatusQuery();
|
||||||
|
const syncMutation = useTriggerChatSyncMutation();
|
||||||
|
const fetchAllMutation = useFetchAllChatsMutation();
|
||||||
|
const [consoleClearNotice, setConsoleClearNotice] = useState(false);
|
||||||
|
const [pendingSyncConfirm, setPendingSyncConfirm] = useState<'full' | 'fetch-all' | null>(null);
|
||||||
|
const [pauseAnnouncement, setPauseAnnouncement] = useState('');
|
||||||
|
const [isPinnedToBottom, setIsPinnedToBottom] = useState(true);
|
||||||
const [consoleState, dispatchConsole] = useReducer(
|
const [consoleState, dispatchConsole] = useReducer(
|
||||||
reduceLiveConsoleState,
|
reduceLiveConsoleState,
|
||||||
createLiveConsoleState(500),
|
createLiveConsoleState(500),
|
||||||
);
|
);
|
||||||
const [connection, setConnection] = useState<RealtimeConnectionSnapshot>({
|
const connection = realtimeEnabled ? realtime.connection : { status: 'idle', isStale: true } as const;
|
||||||
status: 'idle',
|
|
||||||
isStale: true,
|
|
||||||
});
|
|
||||||
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) => {
|
||||||
@@ -103,10 +156,7 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps)
|
|||||||
}, [logsQuery.data]);
|
}, [logsQuery.data]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!realtimeEnabled || typeof EventSource === 'undefined') {
|
if (!realtimeEnabled) return;
|
||||||
setConnection({ status: 'idle', isStale: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
function onEvent(event: DashboardEvent): void {
|
function onEvent(event: DashboardEvent): void {
|
||||||
if (event.type === 'log.created') {
|
if (event.type === 'log.created') {
|
||||||
const entry: ConsoleEntry = {
|
const entry: ConsoleEntry = {
|
||||||
@@ -118,29 +168,61 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps)
|
|||||||
};
|
};
|
||||||
dispatchConsole({ type: 'append', entry });
|
dispatchConsole({ type: 'append', entry });
|
||||||
}
|
}
|
||||||
for (const queryKey of queryKeysForDashboardEvent(event)) {
|
|
||||||
void queryClient.invalidateQueries({ queryKey });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const client = createDashboardRealtimeClient({
|
return realtime.subscribe(onEvent);
|
||||||
sourceFactory: createBrowserEventSource,
|
}, [realtime, realtimeEnabled]);
|
||||||
onEvent,
|
|
||||||
onStateChange: setConnection,
|
|
||||||
});
|
|
||||||
client.start();
|
|
||||||
return () => client.stop();
|
|
||||||
}, [queryClient, realtimeEnabled]);
|
|
||||||
|
|
||||||
const visibleEntries = selectVisibleConsoleEntries(consoleState);
|
const visibleEntries = selectVisibleConsoleEntries(consoleState);
|
||||||
|
const syncBusy = syncQuery.data?.syncing === true || syncMutation.isPending || fetchAllMutation.isPending;
|
||||||
|
|
||||||
|
function runExpensiveSync(kind: 'full' | 'fetch-all') {
|
||||||
|
setPendingSyncConfirm(null);
|
||||||
|
if (kind === 'full') syncMutation.mutate('full'); else fetchAllMutation.mutate();
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetSyncMutation = syncMutation.reset;
|
||||||
|
const resetFetchAllMutation = fetchAllMutation.reset;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!syncMutation.isSuccess) return;
|
||||||
|
const timer = globalThis.setTimeout(() => resetSyncMutation(), NOTICE_DISMISS_MS);
|
||||||
|
return () => globalThis.clearTimeout(timer);
|
||||||
|
}, [resetSyncMutation, syncMutation.isSuccess]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fetchAllMutation.isSuccess) return;
|
||||||
|
const timer = globalThis.setTimeout(() => resetFetchAllMutation(), NOTICE_DISMISS_MS);
|
||||||
|
return () => globalThis.clearTimeout(timer);
|
||||||
|
}, [fetchAllMutation.isSuccess, resetFetchAllMutation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!consoleClearNotice) return;
|
||||||
|
const timer = globalThis.setTimeout(() => setConsoleClearNotice(false), NOTICE_DISMISS_MS);
|
||||||
|
return () => globalThis.clearTimeout(timer);
|
||||||
|
}, [consoleClearNotice]);
|
||||||
|
|
||||||
|
function handleConsoleScroll() {
|
||||||
|
const viewport = consoleViewport.current;
|
||||||
|
if (!viewport) return;
|
||||||
|
const atBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight <= 16;
|
||||||
|
setIsPinnedToBottom(atBottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpToLatest() {
|
||||||
|
setIsPinnedToBottom(true);
|
||||||
|
consoleEnd.current?.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
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">
|
||||||
@@ -156,24 +238,60 @@ 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} />}
|
{syncQuery.isPending ? <p role="status">Loading sync state…</p> : syncQuery.isError ? <p role="alert">{syncQuery.error.message}</p> : <><SyncSummary status={syncQuery.data} /><div className="header-actions"><button className="button secondary" disabled={syncBusy} onClick={() => syncMutation.mutate('delta')} type="button">Sync latest chats</button><button className="button secondary" disabled={syncBusy} onClick={() => setPendingSyncConfirm('full')} type="button">Full cache sync</button><button className="button secondary" disabled={syncBusy} onClick={() => setPendingSyncConfirm('fetch-all')} type="button">Fetch all chats</button></div></>}
|
||||||
|
{syncMutation.isSuccess ? <p role="status">{syncMutation.data.kind === 'delta' ? 'Delta' : 'Full'} sync started. Progress is shown from server status.</p> : null}
|
||||||
|
{fetchAllMutation.isSuccess ? <p role="status">Fetched {fetchAllMutation.data.count} chats.</p> : null}
|
||||||
|
{syncMutation.isError ? <p role="alert">{syncMutation.error.message}</p> : null}
|
||||||
|
{fetchAllMutation.isError ? <p role="alert">{fetchAllMutation.error.message}</p> : null}
|
||||||
</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>
|
||||||
<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>
|
||||||
<div aria-live="polite" className="live-console" role="log">
|
{consoleClearNotice ? <p role="status">Cleared from this browser view. Server logs were not deleted.</p> : null}
|
||||||
{logsQuery.isPending ? <p>Loading logs…</p> : visibleEntries.length === 0 ? <p>No logs match the current filters.</p> : visibleEntries.map((entry) => (
|
<p aria-live="polite" className="sr-only" role="status">{pauseAnnouncement}</p>
|
||||||
<article className={`console-entry ${entry.level}`} key={entry.id}><time dateTime={entry.occurredAt}>{formatTimestamp(entry.occurredAt)}</time><strong>{entry.level.toUpperCase()}</strong><span>{entry.message}</span></article>
|
<div className="live-console" onScroll={handleConsoleScroll} ref={consoleViewport} role="log">
|
||||||
))}
|
{logsQuery.isPending ? <p>Loading logs…</p> : visibleEntries.length === 0 ? <p>No logs match the current filters.</p> : visibleEntries.map((entry) => {
|
||||||
|
const owner = accountOwnerFromLogData(entry.data);
|
||||||
|
return (
|
||||||
|
<article className={`console-entry ${entry.level}`} key={entry.id}>
|
||||||
|
<time dateTime={entry.occurredAt}>{formatTimestamp(entry.occurredAt)}</time>
|
||||||
|
<strong>{entry.level.toUpperCase()}</strong>
|
||||||
|
<span className="console-account">{owner ? <RedditUsernameLink username={owner.redditUsername} /> : 'global'}</span>
|
||||||
|
<span><RedditUserText>{entry.message}</RedditUserText></span>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
<div ref={consoleEnd} />
|
<div 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,4 +1,4 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { operationsApi } from './operations-api.js';
|
import { operationsApi } from './operations-api.js';
|
||||||
|
|
||||||
export const operationsQueryKeys = {
|
export const operationsQueryKeys = {
|
||||||
@@ -16,5 +16,15 @@ export function useLogsQuery() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useSyncStatusQuery() {
|
export function useSyncStatusQuery() {
|
||||||
return useQuery({ queryKey: operationsQueryKeys.syncStatus, queryFn: operationsApi.getSyncStatus });
|
return useQuery({ queryKey: operationsQueryKeys.syncStatus, queryFn: operationsApi.getSyncStatus, refetchInterval: (query) => query.state.data?.syncing ? 2_000 : false });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTriggerChatSyncMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({ mutationFn: (kind: 'delta' | 'full') => operationsApi.triggerSync(kind), onSettled: () => queryClient.invalidateQueries({ queryKey: operationsQueryKeys.syncStatus }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFetchAllChatsMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({ mutationFn: operationsApi.fetchAllChats, onSettled: () => queryClient.invalidateQueries({ queryKey: operationsQueryKeys.syncStatus }) });
|
||||||
}
|
}
|
||||||
@@ -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',
|
||||||
@@ -40,6 +41,7 @@ export interface DashboardRealtimeClientOptions {
|
|||||||
url?: string;
|
url?: string;
|
||||||
sourceFactory(url: string): DashboardEventSource;
|
sourceFactory(url: string): DashboardEventSource;
|
||||||
onEvent(event: DashboardEvent): void;
|
onEvent(event: DashboardEvent): void;
|
||||||
|
onConnected?(context: { isReconnect: boolean }): void;
|
||||||
onStateChange?(snapshot: RealtimeConnectionSnapshot): void;
|
onStateChange?(snapshot: RealtimeConnectionSnapshot): void;
|
||||||
onInvalidEvent?(value: unknown): void;
|
onInvalidEvent?(value: unknown): void;
|
||||||
}
|
}
|
||||||
@@ -54,6 +56,7 @@ export function createDashboardRealtimeClient(
|
|||||||
): DashboardRealtimeClient {
|
): DashboardRealtimeClient {
|
||||||
let source: DashboardEventSource | undefined;
|
let source: DashboardEventSource | undefined;
|
||||||
let snapshot: RealtimeConnectionSnapshot = { status: 'idle', isStale: true };
|
let snapshot: RealtimeConnectionSnapshot = { status: 'idle', isStale: true };
|
||||||
|
let hasConnected = false;
|
||||||
|
|
||||||
function update(next: RealtimeConnectionSnapshot): void {
|
function update(next: RealtimeConnectionSnapshot): void {
|
||||||
snapshot = next;
|
snapshot = next;
|
||||||
@@ -89,6 +92,8 @@ export function createDashboardRealtimeClient(
|
|||||||
source = options.sourceFactory(options.url ?? '/api/events/stream');
|
source = options.sourceFactory(options.url ?? '/api/events/stream');
|
||||||
source.addEventListener('open', () => {
|
source.addEventListener('open', () => {
|
||||||
update({ ...snapshot, status: 'connected', isStale: false });
|
update({ ...snapshot, status: 'connected', isStale: false });
|
||||||
|
options.onConnected?.({ isReconnect: hasConnected });
|
||||||
|
hasConnected = true;
|
||||||
});
|
});
|
||||||
source.addEventListener('error', () => {
|
source.addEventListener('error', () => {
|
||||||
update({ ...snapshot, status: 'reconnecting', isStale: true });
|
update({ ...snapshot, status: 'reconnecting', isStale: true });
|
||||||
@@ -118,13 +123,17 @@ 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']];
|
||||||
case 'sync.completed':
|
case 'sync.completed':
|
||||||
case 'sync.failed':
|
|
||||||
return [['operations', 'sync-status'], ['conversations'], ['media']];
|
return [['operations', 'sync-status'], ['conversations'], ['media']];
|
||||||
|
case 'sync.failed':
|
||||||
|
return [['operations', 'sync-status']];
|
||||||
case 'reddit.session.changed':
|
case 'reddit.session.changed':
|
||||||
return [['reddit', 'session']];
|
return [['dashboard', 'login', 'status']];
|
||||||
case 'log.created':
|
case 'log.created':
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
export interface PollingScheduler {
|
||||||
|
setInterval(callback: () => void, intervalMs: number): unknown;
|
||||||
|
clearInterval(handle: unknown): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VisibilitySource {
|
||||||
|
isVisible(): boolean;
|
||||||
|
subscribe(listener: () => void): () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VisibilityAwarePollingFallback {
|
||||||
|
start(): void;
|
||||||
|
stop(): void;
|
||||||
|
setStale(stale: boolean): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createVisibilityAwarePollingFallback(options: {
|
||||||
|
intervalMs: number;
|
||||||
|
poll(): void;
|
||||||
|
scheduler: PollingScheduler;
|
||||||
|
visibility: VisibilitySource;
|
||||||
|
}): VisibilityAwarePollingFallback {
|
||||||
|
let stale = false;
|
||||||
|
let timer: unknown;
|
||||||
|
let unsubscribe: (() => void) | undefined;
|
||||||
|
const pollIfNeeded = () => { if (stale && options.visibility.isVisible()) options.poll(); };
|
||||||
|
return {
|
||||||
|
start() {
|
||||||
|
if (timer !== undefined) return;
|
||||||
|
timer = options.scheduler.setInterval(pollIfNeeded, options.intervalMs);
|
||||||
|
unsubscribe = options.visibility.subscribe(pollIfNeeded);
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
if (timer !== undefined) options.scheduler.clearInterval(timer);
|
||||||
|
timer = undefined;
|
||||||
|
unsubscribe?.();
|
||||||
|
unsubscribe = undefined;
|
||||||
|
},
|
||||||
|
setStale(value) { stale = value; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const browserVisibilitySource: VisibilitySource = {
|
||||||
|
isVisible: () => document.visibilityState === 'visible',
|
||||||
|
subscribe(listener) {
|
||||||
|
document.addEventListener('visibilitychange', listener);
|
||||||
|
return () => document.removeEventListener('visibilitychange', listener);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const browserPollingScheduler: PollingScheduler = {
|
||||||
|
setInterval: (callback, intervalMs) => window.setInterval(callback, intervalMs),
|
||||||
|
clearInterval: (handle) => { if (typeof handle === 'number') window.clearInterval(handle); },
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Modal } from '../../components/modal.js';
|
||||||
|
|
||||||
|
interface ClearFunnelDialogProps {
|
||||||
|
clearableCount: number;
|
||||||
|
sendingCount: number;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
isPending: boolean;
|
||||||
|
onCancel(): void;
|
||||||
|
onConfirm(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClearFunnelDialog({
|
||||||
|
clearableCount,
|
||||||
|
sendingCount,
|
||||||
|
errorMessage = null,
|
||||||
|
isPending,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: ClearFunnelDialogProps) {
|
||||||
|
return (
|
||||||
|
<Modal className="dialog-card" isDismissLocked={isPending} labelledBy="clear-funnel-title" onClose={onCancel}>
|
||||||
|
<span className="eyebrow">Confirm queue action</span>
|
||||||
|
<h2 id="clear-funnel-title">Clear the outreach funnel?</h2>
|
||||||
|
<p>
|
||||||
|
This will cancel {clearableCount} waiting, screening, prepared, or retrying
|
||||||
|
{clearableCount === 1 ? ' candidate' : ' candidates'}. Cleared candidates remain in the audit history and will
|
||||||
|
not be rediscovered.
|
||||||
|
</p>
|
||||||
|
{sendingCount > 0 ? (
|
||||||
|
<p><strong>{sendingCount} in-flight {sendingCount === 1 ? 'send' : 'sends'} will be left alone</strong> because delivery may already be crossing the remote-send boundary.</p>
|
||||||
|
) : null}
|
||||||
|
<p>If the bot is running, discovery may begin filling the funnel again immediately.</p>
|
||||||
|
{errorMessage ? <p className="dialog-error" role="alert">{errorMessage}</p> : null}
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<button autoFocus className="button secondary" disabled={isPending} onClick={onCancel} type="button">Cancel</button>
|
||||||
|
<button
|
||||||
|
aria-busy={isPending || undefined}
|
||||||
|
className="button danger"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={onConfirm}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isPending ? 'Clearing…' : 'Clear funnel'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { OutreachCandidate } from '../../../../../packages/api-contracts/src/index.js';
|
||||||
|
import { Modal } from '../../components/modal.js';
|
||||||
|
|
||||||
|
interface DeleteCandidateDialogProps {
|
||||||
|
candidate: OutreachCandidate;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
isPending: boolean;
|
||||||
|
onCancel(): void;
|
||||||
|
onConfirm(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteCandidateDialog({ candidate, errorMessage = null, isPending, onCancel, onConfirm }: DeleteCandidateDialogProps) {
|
||||||
|
return (
|
||||||
|
<Modal className="dialog-card" isDismissLocked={isPending} labelledBy="delete-candidate-title" onClose={onCancel}>
|
||||||
|
<span className="eyebrow">Confirm queue action</span>
|
||||||
|
<h2 id="delete-candidate-title">Remove u/{candidate.author} from the funnel?</h2>
|
||||||
|
<p>This candidate will be cancelled and retained in the audit history, so the same item will not be rediscovered.</p>
|
||||||
|
{errorMessage ? <p className="dialog-error" role="alert">{errorMessage}</p> : null}
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<button autoFocus className="button secondary" disabled={isPending} onClick={onCancel} type="button">Cancel</button>
|
||||||
|
<button className="button danger" disabled={isPending} onClick={onConfirm} type="button">
|
||||||
|
{isPending ? 'Removing…' : 'Remove candidate'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { OutreachCandidate } from '../../../../../packages/api-contracts/src/index.js';
|
||||||
|
import { TextAreaField } from '../../components/form-fields.js';
|
||||||
|
import { Modal } from '../../components/modal.js';
|
||||||
|
|
||||||
|
interface EditMessageDialogProps {
|
||||||
|
candidate: OutreachCandidate;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
isPending: boolean;
|
||||||
|
onCancel(): void;
|
||||||
|
onSave(message: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditMessageDialog({ candidate, errorMessage = null, isPending, onCancel, onSave }: EditMessageDialogProps) {
|
||||||
|
const [message, setMessage] = useState(candidate.preparation?.message ?? '');
|
||||||
|
const trimmed = message.trim();
|
||||||
|
const validationError = trimmed.length === 0
|
||||||
|
? 'Message cannot be empty.'
|
||||||
|
: trimmed.length > 2000 ? 'Message cannot exceed 2000 characters.' : undefined;
|
||||||
|
return (
|
||||||
|
<Modal className="dialog-card outreach-edit-dialog" isDismissLocked={isPending} labelledBy="edit-outreach-title" onClose={onCancel}>
|
||||||
|
<span className="eyebrow">Prepared outreach</span>
|
||||||
|
<h2 id="edit-outreach-title">Edit message for u/{candidate.author}</h2>
|
||||||
|
<p>The saved text will replace the generated opener and be marked as operator-edited for attribution.</p>
|
||||||
|
<TextAreaField
|
||||||
|
error={validationError}
|
||||||
|
label="Message"
|
||||||
|
name="outreach-message"
|
||||||
|
onChange={setMessage}
|
||||||
|
rows={7}
|
||||||
|
value={message}
|
||||||
|
/>
|
||||||
|
<small className="outreach-character-count">{message.length}/2000</small>
|
||||||
|
{errorMessage ? <p className="dialog-error" role="alert">{errorMessage}</p> : null}
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<button className="button secondary" disabled={isPending} onClick={onCancel} type="button">Cancel</button>
|
||||||
|
<button
|
||||||
|
className="button primary"
|
||||||
|
disabled={isPending || validationError !== undefined || trimmed === candidate.preparation?.message}
|
||||||
|
onClick={() => onSave(trimmed)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isPending ? 'Saving…' : 'Save message'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import {
|
||||||
|
OutreachClearResponseSchema,
|
||||||
|
OutreachCandidateDeleteResponseSchema,
|
||||||
|
OutreachFunnelResponseSchema,
|
||||||
|
OutreachMessageUpdateResponseSchema,
|
||||||
|
} 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);
|
||||||
|
},
|
||||||
|
async clearFunnel() {
|
||||||
|
const response = await request('/api/outreach', { method: 'DELETE' });
|
||||||
|
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
|
||||||
|
: `Could not clear outreach funnel (${response.status})`;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
return OutreachClearResponseSchema.parse(payload);
|
||||||
|
},
|
||||||
|
async updateMessage(postId: string, message: string) {
|
||||||
|
const response = await request(`/api/outreach/${encodeURIComponent(postId)}/message`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ message }),
|
||||||
|
});
|
||||||
|
const payload: unknown = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = typeof payload === 'object' && payload !== null && 'error' in payload
|
||||||
|
&& typeof payload.error === 'string' ? payload.error : `Could not edit message (${response.status})`;
|
||||||
|
throw new Error(detail);
|
||||||
|
}
|
||||||
|
return OutreachMessageUpdateResponseSchema.parse(payload);
|
||||||
|
},
|
||||||
|
async deleteCandidate(postId: string) {
|
||||||
|
const response = await request(`/api/outreach/${encodeURIComponent(postId)}`, { method: 'DELETE' });
|
||||||
|
const payload: unknown = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = typeof payload === 'object' && payload !== null && 'error' in payload
|
||||||
|
&& typeof payload.error === 'string' ? payload.error : `Could not remove candidate (${response.status})`;
|
||||||
|
throw new Error(detail);
|
||||||
|
}
|
||||||
|
return OutreachCandidateDeleteResponseSchema.parse(payload);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const outreachApi = createOutreachApi();
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type { OutreachCandidate, OutreachCandidateStatus, OutreachRejectedCandidate } 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 { ClearFunnelDialog } from './clear-funnel-dialog.js';
|
||||||
|
import { DeleteCandidateDialog } from './delete-candidate-dialog.js';
|
||||||
|
import { EditMessageDialog } from './edit-message-dialog.js';
|
||||||
|
import './outreach.css';
|
||||||
|
|
||||||
|
type OutreachTab = 'active' | 'reasons' | 'recent-rejections';
|
||||||
|
|
||||||
|
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.operatorEdited
|
||||||
|
? 'Operator edited'
|
||||||
|
: 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>
|
||||||
|
{candidate.targetComment ? (
|
||||||
|
<div className="candidate-source-comment">
|
||||||
|
<span>Comment that qualified this recipient</span>
|
||||||
|
<blockquote>{candidate.targetComment.body}</blockquote>
|
||||||
|
<a href={candidate.targetComment.permalink} rel="noreferrer" target="_blank">View comment on Reddit ↗</a>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<details className="candidate-analysis">
|
||||||
|
<summary>Show image analysis</summary>
|
||||||
|
<pre>{preparation.imageAnalysis}</pre>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CandidateCard({
|
||||||
|
candidate,
|
||||||
|
now,
|
||||||
|
onDelete,
|
||||||
|
onEdit,
|
||||||
|
}: {
|
||||||
|
candidate: OutreachCandidate;
|
||||||
|
now: number;
|
||||||
|
onDelete(candidate: OutreachCandidate): void;
|
||||||
|
onEdit(candidate: OutreachCandidate): void;
|
||||||
|
}) {
|
||||||
|
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.targetComment ? 'Commenter' : 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>
|
||||||
|
<div className="candidate-actions">
|
||||||
|
{candidate.preparation && (candidate.status === 'ready' || candidate.status === 'retryable') ? (
|
||||||
|
<button className="button secondary" onClick={() => onEdit(candidate)} type="button">Edit message</button>
|
||||||
|
) : null}
|
||||||
|
{candidate.status !== 'sending' ? (
|
||||||
|
<button className="button danger" onClick={() => onDelete(candidate)} type="button">Remove</button>
|
||||||
|
) : 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RejectedCandidateCard({ candidate, now }: { candidate: OutreachRejectedCandidate; now: number }) {
|
||||||
|
return (
|
||||||
|
<li className="rejected-candidate">
|
||||||
|
<div className="rejected-candidate-heading">
|
||||||
|
<div className="candidate-labels">
|
||||||
|
<span className="candidate-status status-rejected">Rejected</span>
|
||||||
|
<span>r/{candidate.subreddit}</span>
|
||||||
|
<span>{candidate.sourceType === 'image' ? 'Image post' : 'Chat post'}</span>
|
||||||
|
</div>
|
||||||
|
<time dateTime={new Date(candidate.rejectedAt).toISOString()} title={formatExactTime(candidate.rejectedAt)}>
|
||||||
|
{formatShortRelativeTime(candidate.rejectedAt, now)}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<a className="candidate-title" href={candidate.postUrl} rel="noreferrer" target="_blank">
|
||||||
|
{candidate.title} <span aria-hidden="true">↗</span>
|
||||||
|
</a>
|
||||||
|
<RedditUsernameLink username={candidate.author} />
|
||||||
|
{candidate.sourceContent ? <blockquote className="rejected-source-content">{candidate.sourceContent}</blockquote> : null}
|
||||||
|
<p className="rejection-reason"><strong>Rejected because</strong>{candidate.reason}</p>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OutreachPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['outreach', 'funnel'],
|
||||||
|
queryFn: outreachApi.getFunnel,
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
});
|
||||||
|
const clearMutation = useMutation({
|
||||||
|
mutationFn: outreachApi.clearFunnel,
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['outreach', 'funnel'] }),
|
||||||
|
});
|
||||||
|
const editMutation = useMutation({
|
||||||
|
mutationFn: ({ postId, message }: { postId: string; message: string }) => outreachApi.updateMessage(postId, message),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['outreach', 'funnel'] }),
|
||||||
|
});
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (postId: string) => outreachApi.deleteCandidate(postId),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['outreach', 'funnel'] }),
|
||||||
|
});
|
||||||
|
const [isConfirmingClear, setIsConfirmingClear] = useState(false);
|
||||||
|
const [editingCandidate, setEditingCandidate] = useState<OutreachCandidate | null>(null);
|
||||||
|
const [deletingCandidate, setDeletingCandidate] = useState<OutreachCandidate | null>(null);
|
||||||
|
const [actionNotice, setActionNotice] = useState<string | null>(null);
|
||||||
|
const [selectedTab, setSelectedTab] = useState<OutreachTab>('active');
|
||||||
|
const activeTabRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const reasonsTabRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const recentRejectionsTabRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
const selectTab = (tab: OutreachTab, focus = false) => {
|
||||||
|
setSelectedTab(tab);
|
||||||
|
if (focus) ({ active: activeTabRef, reasons: reasonsTabRef, 'recent-rejections': recentRejectionsTabRef }[tab]).current?.focus();
|
||||||
|
};
|
||||||
|
const handleTabKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||||
|
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight' && event.key !== 'Home' && event.key !== 'End') return;
|
||||||
|
event.preventDefault();
|
||||||
|
const tabs: OutreachTab[] = ['active', 'reasons', 'recent-rejections'];
|
||||||
|
const currentIndex = tabs.indexOf(selectedTab);
|
||||||
|
const nextIndex = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : (currentIndex + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
|
||||||
|
selectTab(tabs[nextIndex], true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Single client-side clock for every displayed offset: retry countdowns tick
|
||||||
|
// every second while visible, everything else every 10 seconds.
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
const hasVisibleCountdown = (query.data?.candidates ?? []).some((candidate) => candidate.availableAt > now);
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => setNow(Date.now()), hasVisibleCountdown ? 1_000 : 10_000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [hasVisibleCountdown]);
|
||||||
|
|
||||||
|
if (query.isPending) return <div className="page-state" role="status"><span className="spinner" />Loading outreach funnel…</div>;
|
||||||
|
if (query.isError) {
|
||||||
|
return (
|
||||||
|
<div className="page-state error-state" role="alert">
|
||||||
|
<strong>Outreach funnel unavailable</strong>
|
||||||
|
<span>{query.error.message}</span>
|
||||||
|
<button
|
||||||
|
aria-busy={query.isFetching || undefined}
|
||||||
|
className="button secondary"
|
||||||
|
disabled={query.isFetching}
|
||||||
|
onClick={() => void query.refetch()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{query.isFetching ? 'Refreshing…' : 'Retry'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = query;
|
||||||
|
const clearableCount = data.activeCount - data.stages.sending;
|
||||||
|
const clearResult = clearMutation.data;
|
||||||
|
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>
|
||||||
|
<div className="header-actions">
|
||||||
|
<button
|
||||||
|
className="button danger"
|
||||||
|
disabled={clearableCount === 0 || clearMutation.isPending}
|
||||||
|
onClick={() => { clearMutation.reset(); setActionNotice(null); setIsConfirmingClear(true); }}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Clear funnel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-busy={query.isFetching || undefined}
|
||||||
|
className="button secondary"
|
||||||
|
disabled={query.isFetching}
|
||||||
|
onClick={() => void query.refetch()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{query.isFetching ? 'Refreshing…' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{clearResult ? (
|
||||||
|
<div className="outreach-clear-notice" role="status">
|
||||||
|
Cleared {clearResult.cleared.total} unsent {clearResult.cleared.total === 1 ? 'candidate' : 'candidates'}.
|
||||||
|
{clearResult.retainedSending > 0
|
||||||
|
? ` ${clearResult.retainedSending} in-flight ${clearResult.retainedSending === 1 ? 'send was' : 'sends were'} retained.`
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{actionNotice ? <div className="outreach-clear-notice" role="status">{actionNotice}</div> : null}
|
||||||
|
|
||||||
|
<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> not sent</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 not-sent totals, including manually cleared candidates, cover the lifetime of this queue.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div aria-label="Outreach funnel views" className="outreach-tabs" role="tablist">
|
||||||
|
<button
|
||||||
|
aria-controls="outreach-active-panel"
|
||||||
|
aria-selected={selectedTab === 'active'}
|
||||||
|
className="outreach-tab"
|
||||||
|
id="outreach-active-tab"
|
||||||
|
onClick={() => selectTab('active')}
|
||||||
|
onKeyDown={handleTabKeyDown}
|
||||||
|
ref={activeTabRef}
|
||||||
|
role="tab"
|
||||||
|
tabIndex={selectedTab === 'active' ? 0 : -1}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Active queue <span>{data.activeCount}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-controls="outreach-rejections-panel"
|
||||||
|
aria-selected={selectedTab === 'reasons'}
|
||||||
|
className="outreach-tab"
|
||||||
|
id="outreach-reasons-tab"
|
||||||
|
onClick={() => selectTab('reasons')}
|
||||||
|
onKeyDown={handleTabKeyDown}
|
||||||
|
ref={reasonsTabRef}
|
||||||
|
role="tab"
|
||||||
|
tabIndex={selectedTab === 'reasons' ? 0 : -1}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Rejection reasons <span>{data.outcomes.rejected}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-controls="outreach-recent-rejections-panel"
|
||||||
|
aria-selected={selectedTab === 'recent-rejections'}
|
||||||
|
className="outreach-tab"
|
||||||
|
id="outreach-recent-rejections-tab"
|
||||||
|
onClick={() => selectTab('recent-rejections')}
|
||||||
|
onKeyDown={handleTabKeyDown}
|
||||||
|
ref={recentRejectionsTabRef}
|
||||||
|
role="tab"
|
||||||
|
tabIndex={selectedTab === 'recent-rejections' ? 0 : -1}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Recent rejections <span>{data.recentRejections.length}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedTab === 'active' ? (
|
||||||
|
<section aria-labelledby="outreach-active-tab" className="candidate-section" id="outreach-active-panel" role="tabpanel">
|
||||||
|
<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}
|
||||||
|
onDelete={(selected) => { clearMutation.reset(); deleteMutation.reset(); setActionNotice(null); setDeletingCandidate(selected); }}
|
||||||
|
onEdit={(selected) => { clearMutation.reset(); editMutation.reset(); setActionNotice(null); setEditingCandidate(selected); }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
) : selectedTab === 'reasons' ? (
|
||||||
|
<section aria-labelledby="outreach-reasons-tab" className="rejection-section" id="outreach-rejections-panel" role="tabpanel">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div><span className="eyebrow">Terminal screening outcomes</span><h2>Rejections</h2></div>
|
||||||
|
<span className="candidate-updated">Updated {formatShortRelativeTime(data.generatedAt, now)} · Auto-updates every 10s</span>
|
||||||
|
</div>
|
||||||
|
{data.rejectionReasons.length === 0 ? (
|
||||||
|
<div className="empty-state panel"><strong>No rejection reasons recorded.</strong><span>Rejected candidates will be grouped here as the funnel screens them.</span></div>
|
||||||
|
) : (
|
||||||
|
<ol aria-label="Outreach rejection reasons" className="rejection-reasons">
|
||||||
|
{data.rejectionReasons.map(({ reason, count }) => (
|
||||||
|
<li key={reason}><span>{reason}</span><strong>{count}</strong></li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section aria-labelledby="outreach-recent-rejections-tab" className="rejection-section" id="outreach-recent-rejections-panel" role="tabpanel">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div><span className="eyebrow">Latest 50 terminal outcomes</span><h2>Recent rejections</h2></div>
|
||||||
|
<span className="candidate-updated">Updated {formatShortRelativeTime(data.generatedAt, now)} · Auto-updates every 10s</span>
|
||||||
|
</div>
|
||||||
|
{data.recentRejections.length === 0 ? (
|
||||||
|
<div className="empty-state panel"><strong>No rejected candidates yet.</strong><span>The most recent 50 screening rejections will appear here with their source and reason.</span></div>
|
||||||
|
) : (
|
||||||
|
<ol aria-label="Recent rejected outreach candidates" className="recent-rejections">
|
||||||
|
{data.recentRejections.map((candidate) => <RejectedCandidateCard candidate={candidate} key={candidate.postId} now={now} />)}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{isConfirmingClear ? (
|
||||||
|
<ClearFunnelDialog
|
||||||
|
clearableCount={clearableCount}
|
||||||
|
errorMessage={clearMutation.isError ? clearMutation.error.message : null}
|
||||||
|
isPending={clearMutation.isPending}
|
||||||
|
onCancel={() => { setIsConfirmingClear(false); clearMutation.reset(); }}
|
||||||
|
onConfirm={() => clearMutation.mutate(undefined, { onSuccess: () => setIsConfirmingClear(false) })}
|
||||||
|
sendingCount={data.stages.sending}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{editingCandidate ? (
|
||||||
|
<EditMessageDialog
|
||||||
|
candidate={editingCandidate}
|
||||||
|
errorMessage={editMutation.isError ? editMutation.error.message : null}
|
||||||
|
isPending={editMutation.isPending}
|
||||||
|
onCancel={() => { setEditingCandidate(null); editMutation.reset(); }}
|
||||||
|
onSave={(message) => editMutation.mutate(
|
||||||
|
{ postId: editingCandidate.postId, message },
|
||||||
|
{ onSuccess: () => { setActionNotice(`Message for u/${editingCandidate.author} updated.`); setEditingCandidate(null); } },
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{deletingCandidate ? (
|
||||||
|
<DeleteCandidateDialog
|
||||||
|
candidate={deletingCandidate}
|
||||||
|
errorMessage={deleteMutation.isError ? deleteMutation.error.message : null}
|
||||||
|
isPending={deleteMutation.isPending}
|
||||||
|
onCancel={() => { setDeletingCandidate(null); deleteMutation.reset(); }}
|
||||||
|
onConfirm={() => deleteMutation.mutate(
|
||||||
|
deletingCandidate.postId,
|
||||||
|
{ onSuccess: () => { setActionNotice(`Removed u/${deletingCandidate.author} from the funnel.`); setDeletingCandidate(null); } },
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
.outreach-page { display: grid; gap: 1rem; }
|
||||||
|
.outreach-clear-notice { padding: .75rem 1rem; color: var(--accent); background: rgba(117, 214, 157, .08); border: 1px solid rgba(117, 214, 157, .3); border-radius: .7rem; }
|
||||||
|
.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; }
|
||||||
|
.outreach-tabs { display: flex; width: fit-content; max-width: 100%; overflow-x: auto; padding: .25rem; background: #101a16; border: 1px solid #355044; border-radius: .7rem; }
|
||||||
|
.outreach-tab { display: inline-flex; align-items: center; gap: .45rem; min-height: 2.3rem; padding: .45rem .7rem; color: var(--muted); background: transparent; border: 0; border-radius: .45rem; font: inherit; font-size: .8rem; font-weight: 750; white-space: nowrap; cursor: pointer; }
|
||||||
|
.outreach-tab[aria-selected="true"] { color: #eef6f1; background: #20362b; box-shadow: inset 0 0 0 1px #426655; }
|
||||||
|
.outreach-tab span { min-width: 1.45rem; padding: .08rem .35rem; color: #dce8e2; background: rgba(9, 17, 14, .7); border-radius: 999px; font-size: .7rem; text-align: center; }
|
||||||
|
.outreach-tab:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
.rejection-section { display: grid; gap: .75rem; }
|
||||||
|
.rejection-reasons { display: grid; gap: .55rem; padding: 0; margin: 0; list-style: none; }
|
||||||
|
.rejection-reasons li { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 1rem; padding: .8rem .9rem; color: #e7d9b1; background: #211b12; border: 1px solid #4c3b1d; border-left: 3px solid var(--warning); border-radius: .65rem; }
|
||||||
|
.rejection-reasons span { overflow-wrap: anywhere; }
|
||||||
|
.rejection-reasons strong { min-width: 2rem; color: var(--warning-text); font-size: 1.1rem; text-align: right; }
|
||||||
|
.recent-rejections { display: grid; gap: .7rem; padding: 0; margin: 0; list-style: none; }
|
||||||
|
.rejected-candidate { display: grid; gap: .65rem; padding: .9rem 1rem; background: var(--surface); border: 1px solid var(--border); border-left: 3px solid var(--warning); border-radius: .75rem; }
|
||||||
|
.rejected-candidate-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: .75rem; }
|
||||||
|
.rejected-candidate-heading time { flex: 0 0 auto; color: var(--muted); font-size: .74rem; }
|
||||||
|
.status-rejected { color: var(--warning-text); background: #42351b; }
|
||||||
|
.rejected-source-content { margin: 0; padding: .65rem .75rem; color: #dce8e2; background: #111915; border-left: 3px solid #426655; border-radius: .35rem; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.rejection-reason { display: grid; gap: .18rem; margin: 0; padding: .55rem .7rem; color: #e7d9b1; background: #211b12; border-left: 2px solid var(--warning); border-radius: .35rem; overflow-wrap: anywhere; }
|
||||||
|
.rejection-reason strong { color: var(--warning-text); font-size: .68rem; letter-spacing: .06em; text-transform: uppercase; }
|
||||||
|
.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-actions { display: flex; flex-wrap: wrap; gap: .55rem; }
|
||||||
|
.candidate-actions .button { min-height: 2.2rem; padding: .4rem .7rem; }
|
||||||
|
dialog.outreach-edit-dialog { width: min(38rem, calc(100% - 2rem)); }
|
||||||
|
.outreach-character-count { display: block; margin-top: .35rem; color: var(--muted); text-align: right; }
|
||||||
|
.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, .candidate-source-comment > 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-source-comment { display: grid; gap: .35rem; }
|
||||||
|
.candidate-source-comment blockquote { margin: 0; padding: .7rem .8rem; color: #dce8e2; background: #111915; border-left: 3px solid #86b7d7; border-radius: .35rem; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.candidate-source-comment a { width: fit-content; color: var(--accent); font-size: .75rem; }
|
||||||
|
.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,8 +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 { OriginPostButton } from '../../components/origin-post.js';
|
||||||
|
import { RecentMessagesPanel } from './recent-messages.js';
|
||||||
|
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
||||||
|
|
||||||
|
const MODE_ORDER: readonly BotMode[] = ['running', 'scan', 'stopped'];
|
||||||
|
|
||||||
|
const modeCopy: Record<BotMode, { label: string; action: string; dot: string; buttonTone: string }> = {
|
||||||
|
running: { label: 'Running', action: 'Start bot', dot: 'running', buttonTone: 'button primary' },
|
||||||
|
scan: { label: 'Scanning', action: 'Scan mode', dot: 'scanning', buttonTone: 'button secondary' },
|
||||||
|
stopped: { label: 'Stopped', action: 'Stop bot', dot: 'stopped', buttonTone: 'button danger' },
|
||||||
|
};
|
||||||
|
|
||||||
function errorMessage(error: Error | null): string {
|
function errorMessage(error: Error | null): string {
|
||||||
return error?.message ?? 'Something went wrong.';
|
return error?.message ?? 'Something went wrong.';
|
||||||
@@ -12,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">
|
||||||
@@ -23,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">
|
||||||
@@ -36,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>
|
||||||
@@ -51,6 +88,66 @@ 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.' },
|
||||||
|
'account-suspended': { label: 'Account suspended', tone: 'warning', detail: 'Reddit or Matrix suspended this account, so it has been paused.' },
|
||||||
|
'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">
|
||||||
@@ -67,10 +164,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.message}</strong><small>{formatTime(item.timestamp)}</small></div>
|
<div><strong>{item.postUrl && item.postTitle ? (
|
||||||
|
<OriginPostButton post={{ title: item.postTitle, url: item.postUrl, imageUrl: item.imageUrl }}>
|
||||||
|
{item.message}
|
||||||
|
</OriginPostButton>
|
||||||
|
) : <RedditUserText>{item.message}</RedditUserText>}</strong><AccountOwnerBadge owner={item.owner} /><small>{formatTime(item.timestamp)}</small></div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
@@ -83,8 +184,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() {
|
||||||
@@ -96,12 +197,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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +226,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">
|
||||||
@@ -128,6 +239,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}
|
||||||
@@ -136,21 +248,23 @@ export function OverviewPage() {
|
|||||||
>
|
>
|
||||||
{isRefreshing ? 'Refreshing…' : 'Refresh'}
|
{isRefreshing ? 'Refreshing…' : 'Refresh'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
{offeredModes.map((mode) => (
|
||||||
aria-label={`${action === 'start' ? 'Start' : 'Stop'} bot`}
|
<button
|
||||||
className={action === 'start' ? 'button primary' : 'button danger'}
|
className={modeCopy[mode].buttonTone}
|
||||||
onClick={() => setConfirmationAction(action)}
|
key={mode}
|
||||||
type="button"
|
onClick={() => setConfirmationAction(mode)}
|
||||||
>
|
type="button"
|
||||||
{action === 'start' ? 'Start bot' : 'Stop bot'}
|
>
|
||||||
</button>
|
{modeCopy[mode].action}
|
||||||
|
</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>
|
||||||
@@ -158,18 +272,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>
|
||||||
@@ -184,13 +302,14 @@ export function OverviewPage() {
|
|||||||
)}
|
)}
|
||||||
<ActivityPanel stats={stats} />
|
<ActivityPanel stats={stats} />
|
||||||
</div>
|
</div>
|
||||||
|
<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}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { MessagesResponseSchema, type SentMessage } from '../../../../../packages/api-contracts/src/index.js';
|
||||||
|
import { dashboardRequest } from '../../api/request.js';
|
||||||
|
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
||||||
|
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
||||||
|
|
||||||
|
async function loadRecentMessages(): Promise<SentMessage[]> {
|
||||||
|
const response = await dashboardRequest('/api/messages');
|
||||||
|
const payload: unknown = await response.json();
|
||||||
|
if (!response.ok) throw new Error(`Could not load recent messages (${response.status})`);
|
||||||
|
return MessagesResponseSchema.parse(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSentAt(timestamp: number): string {
|
||||||
|
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentMessagesPanel() {
|
||||||
|
const query = useQuery({ queryKey: ['overview', 'recent-messages'], queryFn: loadRecentMessages });
|
||||||
|
return (
|
||||||
|
<section aria-labelledby="recent-messages-heading" className="panel">
|
||||||
|
<div className="panel-heading"><div><span className="eyebrow">Durable records</span><h2 id="recent-messages-heading">Recent sent messages</h2></div></div>
|
||||||
|
{query.isPending ? <div className="panel-loading" role="status"><span className="spinner" />Loading recent messages…</div> : null}
|
||||||
|
{query.isError ? (
|
||||||
|
<div className="error-panel" role="alert">
|
||||||
|
<strong>Recent messages unavailable</strong>
|
||||||
|
<span>{query.error.message}</span>
|
||||||
|
<button className="button secondary" onClick={() => void query.refetch()} type="button">Retry messages</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{query.data?.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<strong>No sent messages recorded yet.</strong>
|
||||||
|
<span>Delivered messages will appear here.</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{query.data && query.data.length > 0 ? (
|
||||||
|
<ol aria-label="Recent sent messages" className="activity-list">
|
||||||
|
{query.data.map((message) => (
|
||||||
|
<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>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,13 +3,18 @@ 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';
|
||||||
|
|
||||||
type SubredditPerformanceResponse = z.infer<typeof SubredditPerformanceResponseSchema>;
|
type SubredditPerformanceResponse = z.infer<typeof SubredditPerformanceResponseSchema>;
|
||||||
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,
|
||||||
@@ -34,14 +39,14 @@ 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(
|
||||||
fetchImplementation?: typeof fetch,
|
fetchImplementation?: typeof fetch,
|
||||||
) {
|
) {
|
||||||
function request(path: string): Promise<Response> {
|
const request = createDashboardRequest(fetchImplementation);
|
||||||
return (fetchImplementation ?? globalThis.fetch)(path);
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
async getSubredditPerformance() {
|
async getSubredditPerformance() {
|
||||||
return parseResponse(
|
return parseResponse(
|
||||||
@@ -67,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">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,8 +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><SampleSize totalSent={row.totalSent} /></td>
|
<td>{row.totalImages}</td>
|
||||||
|
<td>{row.imageRate}%</td>
|
||||||
|
<td><SampleSize totalSent={row.totalSent} />{row.pendingSent ? <small className="interval">{row.pendingSent} pending</small> : null}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -59,14 +165,23 @@ function PerformanceTable({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Trend({ rows }: { rows: readonly DailyTrend[] }) {
|
function Trend({ rows, rangeDays }: { rows: readonly DailyTrend[]; rangeDays: number }) {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="empty-state">
|
||||||
|
<strong>No sends recorded yet.</strong>
|
||||||
|
<span>Daily results appear once messages have been sent.</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<ol aria-label="30 day response trend" className="trend-list">
|
<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>
|
||||||
<SampleSize totalSent={row.totalSent} />
|
<SampleSize totalSent={row.totalSent} />
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -82,7 +197,7 @@ function SuccessfulExamples({ examples }: { examples: readonly SuccessfulExample
|
|||||||
const source = example.subreddit ?? 'unknown source';
|
const source = example.subreddit ?? 'unknown source';
|
||||||
return (
|
return (
|
||||||
<article aria-label={`Successful example from ${source}`} className="successful-example" key={example.id}>
|
<article aria-label={`Successful example from ${source}`} className="successful-example" key={example.id}>
|
||||||
<div><span className="pill success">Response received</span><small>{source}</small></div>
|
<div><span className="pill success">Response received</span>{example.gotImage ? <span className="pill success">Image received</span> : null}<small>{source}</small></div>
|
||||||
{example.postTitle ? <h3>{example.postTitle}</h3> : null}
|
{example.postTitle ? <h3>{example.postTitle}</h3> : null}
|
||||||
<blockquote>{example.originalMessage}</blockquote>
|
<blockquote>{example.originalMessage}</blockquote>
|
||||||
{example.responseSnippet ? <p><strong>Reply:</strong> {example.responseSnippet}</p> : null}
|
{example.responseSnippet ? <p><strong>Reply:</strong> {example.responseSnippet}</p> : null}
|
||||||
@@ -93,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 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 aria-label="Date range" className="header-actions" role="group">
|
||||||
|
{TREND_RANGES.map((days) => (
|
||||||
|
<button
|
||||||
|
aria-pressed={rangeDays === days}
|
||||||
|
className={rangeDays === days ? 'button primary' : 'button secondary'}
|
||||||
|
key={days}
|
||||||
|
onClick={() => setRangeDays(days)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{days} days
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<section aria-labelledby="funnel-heading-anchor" className="panel feature-panel">
|
||||||
|
<span className="sr-only" id="funnel-heading-anchor">Outcome funnel</span>
|
||||||
|
<PanelBody label="outcome funnel" query={funnel}>{(data) => <FunnelPanelBody funnel={data} />}</PanelBody>
|
||||||
|
</section>
|
||||||
|
<section aria-labelledby="ai-usage-heading-anchor" className="panel feature-panel">
|
||||||
|
<span className="sr-only" id="ai-usage-heading-anchor">AI cost and reliability</span>
|
||||||
|
<PanelBody label="AI usage" query={aiUsage}>{(data) => <AiUsagePanelBody usage={data} />}</PanelBody>
|
||||||
|
</section>
|
||||||
|
<section aria-labelledby="trend-heading" className="panel feature-panel">
|
||||||
|
<div className="panel-heading"><div><span className="eyebrow">Last {rangeDays} days</span><h2 id="trend-heading">Response trend</h2></div></div>
|
||||||
|
<PanelBody label="response trend" query={trend}>{(data) => <Trend rangeDays={rangeDays} rows={data} />}</PanelBody>
|
||||||
|
</section>
|
||||||
<div className="performance-grid">
|
<div className="performance-grid">
|
||||||
<section aria-labelledby="subreddit-heading" className="panel feature-panel"><div className="panel-heading"><h2 id="subreddit-heading">By subreddit</h2></div><PerformanceTable label="Subreddit performance" rows={subreddits.data ?? []} rowLabel={(row) => 'subreddit' in row ? row.subreddit : row.strategyId} /></section>
|
<section aria-labelledby="subreddit-heading" className="panel feature-panel">
|
||||||
<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>
|
<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>
|
</div>
|
||||||
<section aria-labelledby="examples-heading" className="panel feature-panel"><div className="panel-heading"><div><span className="eyebrow">What worked</span><h2 id="examples-heading">Successful examples</h2></div></div><SuccessfulExamples examples={examples.data ?? []} /></section>
|
<section aria-labelledby="examples-heading" className="panel feature-panel">
|
||||||
|
<div className="panel-heading"><div><span className="eyebrow">What worked</span><h2 id="examples-heading">Successful examples</h2></div></div>
|
||||||
|
<PanelBody label="successful examples" query={examples}>{(data) => <SuccessfulExamples examples={data} />}</PanelBody>
|
||||||
|
</section>
|
||||||
</div>
|
</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 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,15 +0,0 @@
|
|||||||
interface PlaceholderPageProps {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PlaceholderPage({ title, description }: PlaceholderPageProps) {
|
|
||||||
return (
|
|
||||||
<div className="page">
|
|
||||||
<header className="page-header">
|
|
||||||
<div><span className="eyebrow">Workspace</span><h1>{title}</h1><p>{description}</p></div>
|
|
||||||
</header>
|
|
||||||
<section className="panel empty-state"><strong>{title} workspace</strong><span>This route is ready for its feature module.</span></section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Modal } from '../../components/modal.js';
|
||||||
|
|
||||||
|
interface RiskDialogProps {
|
||||||
|
reasons: string[];
|
||||||
|
isPending: boolean;
|
||||||
|
onCancel(): void;
|
||||||
|
onConfirm(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RiskDialog({ reasons, isPending, onCancel, onConfirm }: RiskDialogProps) {
|
||||||
|
return (
|
||||||
|
<Modal className="dialog-card" isDismissLocked={isPending} labelledBy="risk-settings-title" onClose={onCancel}>
|
||||||
|
<span className="eyebrow">Confirm operational risk</span>
|
||||||
|
<h2 id="risk-settings-title">Confirm risky settings</h2>
|
||||||
|
<ul className="risk-list">
|
||||||
|
{reasons.map((reason) => <li key={reason}>{reason}</li>)}
|
||||||
|
</ul>
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<button autoFocus className="button secondary" disabled={isPending} onClick={onCancel} type="button">Cancel</button>
|
||||||
|
<button className="button danger" disabled={isPending} onClick={onConfirm} type="button">
|
||||||
|
{isPending ? 'Applying…' : 'Apply risky settings'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
|
||||||
|
import { SwitchField } from '../../components/form-fields.js';
|
||||||
|
import { SettingsCard } from './settings-card.js';
|
||||||
|
import { createSafetyDraft, safetyRisks, validateSafetyDraft, type SafetyDraft } from './settings-model.js';
|
||||||
|
import { useConfigCard } from './use-config-card.js';
|
||||||
|
|
||||||
|
export function SafetyCard({ config }: { config: ConfigResponse }) {
|
||||||
|
const card = useConfigCard<SafetyDraft>(config, {
|
||||||
|
id: 'safety',
|
||||||
|
toDraft: createSafetyDraft,
|
||||||
|
validate: validateSafetyDraft,
|
||||||
|
risks: safetyRisks,
|
||||||
|
});
|
||||||
|
const isLive = !card.persisted.dryRun;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsCard
|
||||||
|
anchor="safety"
|
||||||
|
card={card}
|
||||||
|
description="The single switch that decides whether anything actually reaches Reddit."
|
||||||
|
eyebrow="Send guard"
|
||||||
|
saveLabel="Save safety"
|
||||||
|
status={<span className={isLive ? 'pill danger' : 'pill success'}>{isLive ? 'Sending live' : 'Dry run'}</span>}
|
||||||
|
title="Safety"
|
||||||
|
>
|
||||||
|
{/* State first: the operator should read what is happening now before
|
||||||
|
being shown the control that changes it. */}
|
||||||
|
<p className={isLive ? 'state-banner is-live' : 'state-banner is-safe'} role="status">
|
||||||
|
<strong>{isLive ? 'Messages are being sent to real people.' : 'No messages are being sent.'}</strong>
|
||||||
|
<span>
|
||||||
|
{isLive
|
||||||
|
? 'Every evaluated post can produce a real outbound message, subject to the pacing limits.'
|
||||||
|
: 'Posts are evaluated and replies are drafted, but nothing is delivered to Reddit.'}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<SwitchField
|
||||||
|
checked={card.draft.dryRun}
|
||||||
|
description="Evaluate posts and draft messages without delivering anything. Turn this off only when you intend to send."
|
||||||
|
label="Dry run mode"
|
||||||
|
onChange={(dryRun) => card.update({ ...card.draft, dryRun })}
|
||||||
|
tone="critical"
|
||||||
|
/>
|
||||||
|
</SettingsCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import type { ZodType } from 'zod';
|
||||||
|
import {
|
||||||
|
ConfigResponseSchema,
|
||||||
|
ConfigUpdateRequestSchema,
|
||||||
|
ConfigUpdateResponseSchema,
|
||||||
|
ErrorResponseSchema,
|
||||||
|
LoginStatusResponseSchema,
|
||||||
|
RedditAccountsResponseSchema,
|
||||||
|
RedditAccountRemovalResponseSchema,
|
||||||
|
RedditCookieImportRequestSchema,
|
||||||
|
RedditCookieImportResponseSchema,
|
||||||
|
type ConfigResponse,
|
||||||
|
type ConfigUpdateRequest,
|
||||||
|
type LoginStatusResponse,
|
||||||
|
type RedditAccountSummary,
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function request(path: string, init?: RequestInit): Promise<Response> {
|
||||||
|
return dashboardRequest(path, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchSettings(): Promise<ConfigResponse> {
|
||||||
|
return request('/api/config').then((response) => parseResponse(response, ConfigResponseSchema));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSettings(updates: ConfigUpdateRequest) {
|
||||||
|
const body = ConfigUpdateRequestSchema.parse(updates);
|
||||||
|
return parseResponse(
|
||||||
|
await request('/api/config', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
ConfigUpdateResponseSchema,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchLoginStatus(): Promise<LoginStatusResponse> {
|
||||||
|
return request('/api/login/status').then((response) => parseResponse(response, LoginStatusResponseSchema));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchRedditAccounts(): Promise<RedditAccountSummary[]> {
|
||||||
|
return request('/api/accounts').then((response) => parseResponse(response, RedditAccountsResponseSchema));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeRedditAccount(accountId: string): Promise<RedditAccountSummary> {
|
||||||
|
const response = await request(`/api/accounts/${encodeURIComponent(accountId)}`, { method: 'DELETE' });
|
||||||
|
// A refused removal carries its reason in the same envelope as a successful
|
||||||
|
// one, so parse the body before falling back to the bare status code.
|
||||||
|
const parsed = RedditAccountRemovalResponseSchema.safeParse(await response.json() as unknown);
|
||||||
|
if (!parsed.success) throw new Error(`Request failed (${response.status})`);
|
||||||
|
if (!parsed.data.ok) throw new Error(parsed.data.error);
|
||||||
|
return parsed.data.account;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importRedditSession(exported: unknown): Promise<RedditAccountSummary> {
|
||||||
|
const body = RedditCookieImportRequestSchema.parse(exported);
|
||||||
|
const response = await parseResponse(
|
||||||
|
await request('/api/login/session-import', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
RedditCookieImportResponseSchema,
|
||||||
|
);
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
ConfigUpdateRequestSchema,
|
||||||
|
type ConfigResponse,
|
||||||
|
} from '../../../../../packages/api-contracts/src/index.js';
|
||||||
|
import type { ConfigCardValidation } from './use-config-card.js';
|
||||||
|
|
||||||
|
/** Above this hourly ceiling a save is treated as operationally risky. */
|
||||||
|
export const RISKY_MESSAGES_PER_HOUR = 20;
|
||||||
|
|
||||||
|
export interface PacingDraft {
|
||||||
|
minDelayMinutes: string;
|
||||||
|
maxMessagesPerHour: string;
|
||||||
|
jitterSeconds: string;
|
||||||
|
prioritizeRecentPosts: boolean;
|
||||||
|
recentWindowMinutes: string;
|
||||||
|
maxReadyCandidates: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SafetyDraft {
|
||||||
|
dryRun: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TargetingDraft {
|
||||||
|
physiqueTargetingEnabled: boolean;
|
||||||
|
physiqueMinScore: string;
|
||||||
|
physiqueMaxScore: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdvancedDraft {
|
||||||
|
advancedConfigJson: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPacingDraft(config: ConfigResponse): PacingDraft {
|
||||||
|
return {
|
||||||
|
minDelayMinutes: String(config.rateLimit.minDelayBetweenMessagesMs / 60_000),
|
||||||
|
maxMessagesPerHour: String(config.rateLimit.maxMessagesPerHour),
|
||||||
|
jitterSeconds: String(config.rateLimit.jitterMs / 1_000),
|
||||||
|
prioritizeRecentPosts: config.reddit.prioritizeRecentPosts,
|
||||||
|
recentWindowMinutes: String(config.reddit.recentWindowMinutes),
|
||||||
|
maxReadyCandidates: String(config.reddit.maxReadyCandidates),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSafetyDraft(config: ConfigResponse): SafetyDraft {
|
||||||
|
return { dryRun: config.browser.dryRun };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTargetingDraft(config: ConfigResponse): TargetingDraft {
|
||||||
|
return {
|
||||||
|
physiqueTargetingEnabled: config.reddit.physiqueTargeting.enabled,
|
||||||
|
physiqueMinScore: String(config.reddit.physiqueTargeting.minScore),
|
||||||
|
physiqueMaxScore: String(config.reddit.physiqueTargeting.maxScore),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAdvancedDraft(config: ConfigResponse): AdvancedDraft {
|
||||||
|
return { advancedConfigJson: config.advanced ? JSON.stringify(config.advanced, null, 2) : '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const PacingSchema = z.strictObject({
|
||||||
|
minDelayMinutes: z.number().finite().min(0, 'Minimum delay cannot be negative.'),
|
||||||
|
maxMessagesPerHour: z.number().int('Hourly limit must be a whole number.').min(1, 'Hourly limit must be at least 1.'),
|
||||||
|
jitterSeconds: z.number().finite().min(0, 'Jitter cannot be negative.'),
|
||||||
|
prioritizeRecentPosts: z.boolean(),
|
||||||
|
recentWindowMinutes: z.number().finite().positive('Recent window must be greater than 0 minutes.'),
|
||||||
|
maxReadyCandidates: z.number()
|
||||||
|
.int('Ready-post limit must be a whole number.')
|
||||||
|
.min(1, 'Ready-post limit must be at least 1.')
|
||||||
|
.max(500, 'Ready-post limit cannot exceed 500.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const requiredPacingFields: ReadonlyArray<{ key: keyof PacingDraft; message: string }> = [
|
||||||
|
{ key: 'minDelayMinutes', message: 'Minimum delay is required.' },
|
||||||
|
{ key: 'maxMessagesPerHour', message: 'Hourly limit is required.' },
|
||||||
|
{ key: 'jitterSeconds', message: 'Jitter is required.' },
|
||||||
|
{ key: 'recentWindowMinutes', message: 'Recent window is required.' },
|
||||||
|
{ key: 'maxReadyCandidates', message: 'Ready-post limit is required.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function validatePacingDraft(draft: PacingDraft): ConfigCardValidation {
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
for (const field of requiredPacingFields) {
|
||||||
|
if (String(draft[field.key]).trim() === '') errors[field.key] = field.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = PacingSchema.safeParse({
|
||||||
|
minDelayMinutes: Number(draft.minDelayMinutes),
|
||||||
|
maxMessagesPerHour: Number(draft.maxMessagesPerHour),
|
||||||
|
jitterSeconds: Number(draft.jitterSeconds),
|
||||||
|
prioritizeRecentPosts: draft.prioritizeRecentPosts,
|
||||||
|
recentWindowMinutes: Number(draft.recentWindowMinutes),
|
||||||
|
maxReadyCandidates: Number(draft.maxReadyCandidates),
|
||||||
|
});
|
||||||
|
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 };
|
||||||
|
|
||||||
|
return {
|
||||||
|
payload: ConfigUpdateRequestSchema.parse({
|
||||||
|
rateLimit: {
|
||||||
|
minDelayBetweenMessagesMs: parsed.data.minDelayMinutes * 60_000,
|
||||||
|
maxMessagesPerHour: parsed.data.maxMessagesPerHour,
|
||||||
|
jitterMs: parsed.data.jitterSeconds * 1_000,
|
||||||
|
},
|
||||||
|
reddit: {
|
||||||
|
prioritizeRecentPosts: parsed.data.prioritizeRecentPosts,
|
||||||
|
recentWindowMinutes: parsed.data.recentWindowMinutes,
|
||||||
|
maxReadyCandidates: parsed.data.maxReadyCandidates,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
errors: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pacingRisks(draft: PacingDraft, persisted: PacingDraft): string[] {
|
||||||
|
const next = Number(draft.maxMessagesPerHour);
|
||||||
|
const current = Number(persisted.maxMessagesPerHour);
|
||||||
|
if (next > RISKY_MESSAGES_PER_HOUR && next > current) {
|
||||||
|
return [`The limit will allow ${next} messages per hour, which raises the risk of Reddit rate limiting or banning the account.`];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateSafetyDraft(draft: SafetyDraft): ConfigCardValidation {
|
||||||
|
return {
|
||||||
|
payload: ConfigUpdateRequestSchema.parse({ browser: { dryRun: draft.dryRun } }),
|
||||||
|
errors: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safetyRisks(draft: SafetyDraft, persisted: SafetyDraft): string[] {
|
||||||
|
if (persisted.dryRun && !draft.dryRun) {
|
||||||
|
return ['Dry run is being turned off. Automated messages will be sent to real Reddit users as soon as this is saved.'];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PhysiqueScoreSchema = z.number()
|
||||||
|
.int('Physique scores must be whole numbers.')
|
||||||
|
.min(1, 'Physique scores must be between 1 and 10.')
|
||||||
|
.max(10, 'Physique scores must be between 1 and 10.');
|
||||||
|
|
||||||
|
const TargetingSchema = z.strictObject({
|
||||||
|
physiqueTargetingEnabled: z.boolean(),
|
||||||
|
physiqueMinScore: PhysiqueScoreSchema,
|
||||||
|
physiqueMaxScore: PhysiqueScoreSchema,
|
||||||
|
}).superRefine((value, context) => {
|
||||||
|
if (value.physiqueMinScore > value.physiqueMaxScore) {
|
||||||
|
context.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['physiqueMinScore'],
|
||||||
|
message: 'Minimum physique score cannot exceed maximum physique score.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export function validateTargetingDraft(draft: TargetingDraft): ConfigCardValidation {
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
if (draft.physiqueMinScore.trim() === '') errors.physiqueMinScore = 'Minimum physique score is required.';
|
||||||
|
if (draft.physiqueMaxScore.trim() === '') errors.physiqueMaxScore = 'Maximum physique score is required.';
|
||||||
|
|
||||||
|
const parsed = TargetingSchema.safeParse({
|
||||||
|
physiqueTargetingEnabled: draft.physiqueTargetingEnabled,
|
||||||
|
physiqueMinScore: Number(draft.physiqueMinScore),
|
||||||
|
physiqueMaxScore: Number(draft.physiqueMaxScore),
|
||||||
|
});
|
||||||
|
if (!parsed.success) {
|
||||||
|
for (const issue of parsed.error.issues) {
|
||||||
|
const path = issue.path.join('.');
|
||||||
|
if (!(path in errors)) errors[path] = issue.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!parsed.success || Object.keys(errors).length > 0) return { errors };
|
||||||
|
|
||||||
|
// Only `physiqueTargeting` is sent: the route applies each `reddit` field
|
||||||
|
// independently, so the pacing card's values are left untouched.
|
||||||
|
return {
|
||||||
|
payload: ConfigUpdateRequestSchema.parse({
|
||||||
|
reddit: {
|
||||||
|
physiqueTargeting: {
|
||||||
|
enabled: parsed.data.physiqueTargetingEnabled,
|
||||||
|
minScore: parsed.data.physiqueMinScore,
|
||||||
|
maxScore: parsed.data.physiqueMaxScore,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
errors: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function targetingRisks(draft: TargetingDraft, persisted: TargetingDraft): string[] {
|
||||||
|
if (draft.physiqueTargetingEnabled && !persisted.physiqueTargetingEnabled) {
|
||||||
|
return [
|
||||||
|
`Image posts will be screened to a physique score of ${draft.physiqueMinScore}–${draft.physiqueMaxScore}, and posts without enough visible body evidence will be skipped entirely. This can sharply reduce how many posts qualify.`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateAdvancedDraft(draft: AdvancedDraft): ConfigCardValidation {
|
||||||
|
const raw = draft.advancedConfigJson.trim();
|
||||||
|
if (raw === '') return { errors: { advancedConfigJson: 'Advanced configuration cannot be emptied here.' } };
|
||||||
|
|
||||||
|
let value: unknown;
|
||||||
|
try {
|
||||||
|
value = JSON.parse(raw);
|
||||||
|
} catch (error) {
|
||||||
|
const detail = error instanceof Error ? error.message : 'Unparseable JSON.';
|
||||||
|
return { errors: { advancedConfigJson: `Not valid JSON — ${detail}` } };
|
||||||
|
}
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return { errors: { advancedConfigJson: 'Advanced configuration must be a JSON object, not an array or a bare value.' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
payload: ConfigUpdateRequestSchema.parse({ advanced: value as Record<string, unknown> }),
|
||||||
|
errors: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThroughputEstimate {
|
||||||
|
/** Messages per hour actually achievable, given both the cap and the delay. */
|
||||||
|
effectivePerHour: number;
|
||||||
|
/** True when the minimum delay, not the hourly cap, is the binding limit. */
|
||||||
|
isDelayBound: boolean;
|
||||||
|
/** Human-readable spacing between messages including average jitter. */
|
||||||
|
spacingLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hourly cap and the minimum delay both throttle sending, so the real
|
||||||
|
* ceiling is whichever binds first. Surfacing it stops operators from raising
|
||||||
|
* one number and seeing no change because the other one governs.
|
||||||
|
*/
|
||||||
|
export function estimateThroughput(draft: PacingDraft): ThroughputEstimate | null {
|
||||||
|
// A blank field coerces to 0, which would produce a confident but wrong
|
||||||
|
// readout, so treat anything unfilled as no estimate at all.
|
||||||
|
const inputs = [draft.maxMessagesPerHour, draft.minDelayMinutes, draft.jitterSeconds];
|
||||||
|
if (inputs.some((value) => value.trim() === '')) return null;
|
||||||
|
|
||||||
|
const perHourCap = Number(draft.maxMessagesPerHour);
|
||||||
|
const minDelayMinutes = Number(draft.minDelayMinutes);
|
||||||
|
const jitterSeconds = Number(draft.jitterSeconds);
|
||||||
|
if (!Number.isFinite(perHourCap) || !Number.isFinite(minDelayMinutes) || !Number.isFinite(jitterSeconds)) return null;
|
||||||
|
if (perHourCap < 1 || minDelayMinutes < 0 || jitterSeconds < 0) return null;
|
||||||
|
|
||||||
|
const averageSpacingMinutes = minDelayMinutes + jitterSeconds / 120;
|
||||||
|
const delayCap = averageSpacingMinutes > 0 ? 60 / averageSpacingMinutes : Number.POSITIVE_INFINITY;
|
||||||
|
const effectivePerHour = Math.min(perHourCap, delayCap);
|
||||||
|
|
||||||
|
return {
|
||||||
|
effectivePerHour: Math.round(effectivePerHour * 10) / 10,
|
||||||
|
isDelayBound: delayCap < perHourCap,
|
||||||
|
spacingLabel: averageSpacingMinutes === 0
|
||||||
|
? 'back to back'
|
||||||
|
: `about one every ${Math.round(averageSpacingMinutes * 10) / 10} min`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { UnsavedChangesProvider } from '../../components/unsaved-changes.js';
|
||||||
|
import { AdvancedCard } from './advanced-card.js';
|
||||||
|
import { PacingCard } from './pacing-card.js';
|
||||||
|
import { PushNotificationsCard } from './push-notifications-panel.js';
|
||||||
|
import { SafetyCard } from './safety-card.js';
|
||||||
|
import { TargetingCard } from './targeting-card.js';
|
||||||
|
import { useSettingsQuery } from './settings-queries.js';
|
||||||
|
import './settings.css';
|
||||||
|
|
||||||
|
/** In-page section anchors. Every section is on one page; these only jump. */
|
||||||
|
const sections: ReadonlyArray<{ anchor: string; label: string }> = [
|
||||||
|
{ anchor: 'safety', label: 'Safety' },
|
||||||
|
{ anchor: 'pacing', label: 'Pacing' },
|
||||||
|
{ anchor: 'targeting', label: 'Targeting' },
|
||||||
|
{ anchor: 'notifications', label: 'Notifications' },
|
||||||
|
{ anchor: 'advanced', label: 'Advanced' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function errorMessage(error: Error | null): string {
|
||||||
|
return error?.message ?? 'Something went wrong.';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsPageProps {
|
||||||
|
/** Section to scroll to on mount, from the legacy `?section=` link format. */
|
||||||
|
initialAnchor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsPage({ initialAnchor }: SettingsPageProps) {
|
||||||
|
const settingsQuery = useSettingsQuery();
|
||||||
|
const hasConfig = Boolean(settingsQuery.data);
|
||||||
|
|
||||||
|
// Deep links land before the cards exist, so wait for the config to render.
|
||||||
|
// Scrolling is a nicety: environments without scrollIntoView still land on
|
||||||
|
// the right page, just at the top.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialAnchor || !hasConfig) return;
|
||||||
|
const target = document.getElementById(initialAnchor);
|
||||||
|
if (typeof target?.scrollIntoView === 'function') target.scrollIntoView({ block: 'start' });
|
||||||
|
}, [initialAnchor, hasConfig]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UnsavedChangesProvider>
|
||||||
|
<div className="page settings-page">
|
||||||
|
<header className="page-header">
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">Platform configuration</span>
|
||||||
|
<h1>Settings</h1>
|
||||||
|
<p>Runtime behaviour for every account. Each section saves on its own, so one change never waits on another.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{hasConfig ? (
|
||||||
|
<nav aria-label="Settings sections" className="section-rail">
|
||||||
|
{sections.map((section) => (
|
||||||
|
<a href={`#${section.anchor}`} key={section.anchor}>{section.label}</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{settingsQuery.isPending ? (
|
||||||
|
<div aria-label="Loading settings" className="page-state" role="status">
|
||||||
|
<span className="spinner" />Loading settings…
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{settingsQuery.isError ? (
|
||||||
|
<div className="page-state error-state" role="alert">
|
||||||
|
<strong>Settings unavailable</strong>
|
||||||
|
<span>{errorMessage(settingsQuery.error)}</span>
|
||||||
|
<button className="button secondary" onClick={() => void settingsQuery.refetch()} type="button">Retry settings</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{settingsQuery.data ? (
|
||||||
|
<div className="settings-stack">
|
||||||
|
<SafetyCard config={settingsQuery.data} />
|
||||||
|
<PacingCard config={settingsQuery.data} />
|
||||||
|
<TargetingCard config={settingsQuery.data} />
|
||||||
|
<PushNotificationsCard />
|
||||||
|
<AdvancedCard config={settingsQuery.data} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</UnsavedChangesProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
|
||||||
|
import {
|
||||||
|
fetchLoginStatus,
|
||||||
|
fetchRedditAccounts,
|
||||||
|
fetchSettings,
|
||||||
|
importRedditSession,
|
||||||
|
removeRedditAccount,
|
||||||
|
saveSettings,
|
||||||
|
} from './settings-api.js';
|
||||||
|
import {
|
||||||
|
fetchPushNotifications,
|
||||||
|
savePushNotifications,
|
||||||
|
sendTestPushNotification,
|
||||||
|
} from './push-notifications-api.js';
|
||||||
|
|
||||||
|
export const settingsQueryKeys = {
|
||||||
|
config: ['dashboard', 'config'] as const,
|
||||||
|
loginStatus: ['dashboard', 'login', 'status'] as const,
|
||||||
|
accounts: ['dashboard', 'reddit-accounts'] as const,
|
||||||
|
pushNotifications: ['dashboard', 'push-notifications'] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function usePushNotificationsQuery() {
|
||||||
|
return useQuery({ queryKey: settingsQueryKeys.pushNotifications, queryFn: fetchPushNotifications });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSavePushNotificationsMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: savePushNotifications,
|
||||||
|
onSuccess(response) {
|
||||||
|
queryClient.setQueryData(settingsQueryKeys.pushNotifications, response);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTestPushNotificationMutation() {
|
||||||
|
return useMutation({ mutationFn: sendTestPushNotification });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSettingsQuery() {
|
||||||
|
return useQuery({ queryKey: settingsQueryKeys.config, queryFn: fetchSettings });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSaveSettingsMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: saveSettings,
|
||||||
|
onSuccess(response) {
|
||||||
|
queryClient.setQueryData<ConfigResponse>(settingsQueryKeys.config, (current) => ({
|
||||||
|
...response.config,
|
||||||
|
subreddits: response.subreddits,
|
||||||
|
tones: current?.tones ?? [],
|
||||||
|
advanced: response.advanced ?? current?.advanced,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLoginStatusQuery() {
|
||||||
|
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() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: importRedditSession,
|
||||||
|
onSuccess() {
|
||||||
|
invalidateAccountContent(queryClient);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRemoveRedditAccountMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: removeRedditAccount,
|
||||||
|
onSuccess() {
|
||||||
|
invalidateAccountContent(queryClient);
|
||||||
|
// Removal takes the account's outreach history with it, so every
|
||||||
|
// account-derived view on the dashboard is now stale.
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['dashboard', 'growth'] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['dashboard', 'stats'] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['performance'] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['outreach'] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['attention'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Navigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { SettingsPage } from './settings-page.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Former tab names that are now in-page anchors on the settings page. */
|
||||||
|
const anchorSections: Record<string, string> = {
|
||||||
|
monitoring: 'pacing',
|
||||||
|
'rate-limits': 'pacing',
|
||||||
|
notifications: 'notifications',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SettingsRoute() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const section = searchParams.get('section');
|
||||||
|
|
||||||
|
const relocated = section ? relocatedSections[section] : undefined;
|
||||||
|
if (relocated) return <Navigate replace to={relocated} />;
|
||||||
|
|
||||||
|
return <SettingsPage initialAnchor={section ? anchorSections[section] : undefined} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
.settings-page { max-width: 62rem; }
|
||||||
|
.settings-stack { display: grid; gap: 1rem; }
|
||||||
|
|
||||||
|
/* Effective-throughput readout under the pacing fields. */
|
||||||
|
.throughput-readout { display: grid; gap: .2rem; padding: .8rem 1rem; margin: 0; color: var(--success-text); background: var(--success-bg); border: 1px solid var(--success-border); border-radius: .7rem; }
|
||||||
|
.throughput-readout strong { font-size: 1.05rem; }
|
||||||
|
.throughput-readout span { color: inherit; font-size: .82rem; opacity: .85; }
|
||||||
|
.throughput-readout.is-unknown { color: var(--muted); background: var(--surface-raised); border-color: var(--border); font-size: .85rem; }
|
||||||
|
|
||||||
|
.advanced-keys { display: grid; gap: .45rem; }
|
||||||
|
.advanced-keys ul { display: flex; flex-wrap: wrap; gap: .4rem; padding: 0; margin: 0; list-style: none; }
|
||||||
|
.advanced-keys code { display: inline-block; padding: .2rem .45rem; color: var(--accent); background: var(--surface-raised); border: 1px solid var(--border); border-radius: .4rem; font-size: .78rem; }
|
||||||
Loaded 100 of 418 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user