193 Commits
Author SHA1 Message Date
jjbrunton 6a009a41b4 Expose suspended accounts in dashboard 2026-08-14 16:20:55 +01:00
jjbrunton 7d039265e7 Pause suspended Reddit accounts 2026-08-14 16:00:37 +01:00
jjbrunton 94049ed9c4 Add recent outreach rejections stream 2026-08-14 15:45:57 +01:00
jjbrunton 0c21202253 Add outreach rejection reasons tab 2026-08-14 15:18:46 +01:00
jjbrunton 7af7b211fa Add engagement-only conversation mode 2026-08-14 15:07:54 +01:00
jjbrunton b7fcc7db05 fix: keep conversation history visible 2026-08-14 15:00:45 +01:00
jjbrunton 712226cd43 Reject unsupported Reddit chat capabilities 2026-08-14 14:40:32 +01:00
jjbrunton 8d932fcf36 Persist comment source monitoring 2026-08-14 13:40:55 +01:00
jjbrunton 5e1fc5def9 Preserve commenter role in outreach targeting 2026-08-14 12:59:53 +01:00
jjbrunton 12e2dc56f1 Enforce commenter gender targeting 2026-08-14 12:57:00 +01:00
jjbrunton 8523873831 Add conversation image replacement 2026-08-14 12:56:00 +01:00
jjbrunton 2500f0a23c Require explicit commenter context in openers 2026-08-14 12:50:39 +01:00
jjbrunton f77157d303 Fix commenter-targeted outreach attribution 2026-08-14 12:45:16 +01:00
Jamie BruntonandClaude Opus 5 b5304e24e3 Allow sending emojis in the chat
Add an emoji picker to the dashboard reply composer and let generated
replies use emoji.

The picker offers a short hand-picked set rather than a full Unicode
index, and inserts at the caret so the operator can keep typing without
reaching for the mouse again. Insertion respects the Reddit length cap.

The chat reply system prompt now permits at most one or two emoji per
message, only where someone texting casually would use one. Scoped to
ongoing replies — openers build from configurable prompts, so hardcoding
guidance there would fight the operator's own settings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 09:45:22 +01:00
Jamie Brunton 2447563c60 Make conversation queues mutually exclusive 2026-08-14 09:25:45 +01:00
Jamie Brunton 3bb1dda6bb Keep routine events out of notifications 2026-08-14 09:13:36 +01:00
Jamie Brunton 79c508be2b Add outreach candidate editing controls 2026-08-14 09:12:54 +01:00
Jamie Brunton c75e71f26e Make dashboard event toasts persistent 2026-08-14 09:08:13 +01:00
Jamie Brunton 4798385d8b Add manual outreach funnel clearing 2026-08-14 09:02:18 +01:00
Jamie Brunton 6b2e1bc380 Fix NSFW reply refusals and media offers 2026-08-14 08:59:07 +01:00
Jamie Brunton e9a4c965ee Add commenter targeting mode 2026-08-14 08:51:27 +01:00
Jamie BruntonandClaude Opus 4.8 0dfdda61fd Stop asking users to show their face in image invitations
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 08:59:18 +01:00
Jamie BruntonandClaude Opus 5 a9cea7ed18 Retire removed accounts instead of deleting them
Removing an account had no path at all, so a dead session could only be
left running or hand-edited out of the database. The first cut deleted the
account and everything it owned, which threw away conversations and media
that are worth keeping.

Retire instead: keep the row and every row it produced, destroy the stored
cookies and Matrix token. Wiping the credentials is what makes sending and
syncing impossible rather than merely disabled — isEnabledAccount requires
a verified status, so no runtime ever starts, and no session survives for
anything to use. setActive, markLoginRequired and the background credential
refresh now skip retired accounts so none of them quietly revives one;
re-importing a session deliberately does.

Keeping the content visible needed more than keeping the rows. Conversations
and media are only served from running runtimes, so a retired account's
history would have disappeared from the dashboard regardless. Media already
reads the account content table for every listed account. Conversations now
have an archive path that reads the stored ActiveChat payloads directly.

Every write path refuses a retired conversation with a 409: send-reply,
both suggest endpoints, share-media, shareable-media and all read-state
writes. Lazy-loading detail is skipped too — the fallback chat service
belongs to a different account, so it would sync the wrong session and
write the wrong cache. Archived detail derives conversation state from the
transcript alone for the same reason.

Migration 32 rebuilds reddit_accounts to admit the new status, with foreign
keys off and a foreign_key_check before they are restored, as migration 11
does. Two migration tests rewound user_version by counting back from the
head, which silently breaks whenever a migration is appended; they now
target absolute versions.

Conversations never opened before removal keep only their summary. Message
history is lazy-loaded on first open and there is no session left to fetch
the rest with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 08:58:22 +01:00
Jamie BruntonandClaude Opus 5 3f60f7f665 Link learning trajectories to their conversation
The trajectory detail panel showed the conversation id as plain text, so
reviewing an example meant copying the room id and hunting for it in the
inbox — with the evidence panel open on one screen and the conversation on
another.

Render it as a link instead. The id has to travel as an owned id
(accountId~conversationId): once account runtimes exist, findConversation
rejects a bare room id outright, so a plain link would 404 for every
multi-account install.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 08:58:04 +01:00
Jamie BruntonandClaude Opus 5 142378df33 feat: show the conversation evidence behind each learning trajectory
Reviewers were asked to include or exclude learning trajectories while
only seeing metadata — credit class, confidence, status. The decisions
and their redacted context turns were already captured and stored, but
no endpoint returned them, so the evidence was unreachable from the UI.

Add GET /api/learning/trajectories/:id and an expandable row on the
learning page that fetches on demand.

Also widen SentMessageSchema to accept matrixEventId, imageOutcomeCredit
and imageOutcomeAt. The learning loop added these to SentMessageRecord
and they flow out of /api/messages, but the strict contract rejected
them, breaking the dashboard.

Enable generation with shadow mode off so eligible examples reach reply
prompts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 08:23:22 +01:00
jjbrunton 687abab82b Add human-led conversation learning loop 2026-08-11 23:04:25 +01:00
jjbrunton bc42c55555 Remove browsing selection ceilings 2026-08-11 22:54:48 +01:00
jjbrunton c028637556 Fix push notifications for account runtimes 2026-08-11 22:36:21 +01:00
jjbrunton a71ad56f3c Expose browsing daily target range 2026-08-11 22:22:09 +01:00
jjbrunton e890f74478 Configure browsing session pacing and size 2026-08-11 22:17:18 +01:00
jjbrunton 1fd154e1eb Track pending browsing upvotes 2026-08-11 22:05:54 +01:00
jjbrunton 540590f43e Run immediate browsing sessions with enabled votes 2026-08-11 21:59:13 +01:00
jjbrunton 643c775813 Handle unscheduled browsing simulation ticks 2026-08-11 21:53:40 +01:00
jjbrunton 7bf1aa9d24 Enable automatic browsing upvotes 2026-08-11 21:52:54 +01:00
jjbrunton 27012399ec Add account browsing simulation 2026-08-11 21:40:31 +01:00
jjbrunton e2d7dc4b82 Prioritize growth retries and randomize intake 2026-08-11 21:14:30 +01:00
jjbrunton b949b6ee37 Make dashboard sidebar scrollable 2026-08-11 21:09:06 +01:00
Jamie BruntonandClaude Opus 5 5deac87cf1 feat: let the operator set the daily comment limit per account
The allowance was derived from the adaptive trust level with no way to
change it, which made the dashboard's "1 / 2 today" unexplainable and
left no way to slow an account down or push one along.

`dailyLimit` is null by default, keeping today's adaptive behaviour. A
whole number between 1 and 50 overrides it and is enforced verbatim in
either direction — it can raise an account above what it has earned or
hold it below. The trust level keeps being tracked underneath and is
reported as `adaptiveAllowance`, so the earned figure stays visible next
to the override, and clearing the field hands pacing back to the ladder.

Raising the limit above the earned allowance does remove the adaptive
backoff after a bad comment, so the dashboard says exactly that where the
limit is set. It does not touch the pause on two consecutive removals, a
comment the public cannot see, or a captcha challenge: those are safety
stops rather than rate controls and no limit setting can disable them.

Also correct two things the page was saying badly. The allowance is now
labelled "used in last 24h" with the trust level shown as what it earns,
because it is a rolling window and not a midnight reset. And a
per-community cap above the account allowance now states how much of it
is actually reachable — "1 of 10 today" beside an account limit of 2 read
as far more headroom than existed, since caps distribute the allowance
rather than adding to it.

Verified against a local instance: override above and below the earned
allowance, unrelated saves preserving it, clearing it through the UI,
rejection of 0/51/2.5/non-numeric, and a limit failing to clear a pause.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:07:25 +01:00
Jamie BruntonandClaude Opus 5 f88db1ae7d fix: stop Reddit's block page reading as a signed-out session
Reddit answers a throttled client with a large HTML 403 interstitial. To
the comment client that was indistinguishable from a logged-out session:
the body would not parse, so no modhash was found, so it reported
"Reddit did not return a modhash for this session" and returned
session-invalid — which calls auth.invalidate() and so could disrupt
outreach and chat for an account whose session was perfectly healthy.

That matches what prod is doing. u/Saira_Makimad posted successfully 25
seconds after boot and every later attempt failed with that message,
which is the shape of Reddit starting to throttle rather than a session
going bad.

A response is now only treated as a verdict about the session when it is
actually JSON. Non-JSON, at any status, is Reddit refusing to serve us
and stays retryable. A genuine JSON 403, or JSON with no modhash, still
invalidates as before.

When Reddit does report a signed-out session, the log and the recorded
reason now name the cookies visible to the write origin — names and
domains only, never values. Cookies scoped to www.reddit.com rather than
.reddit.com would produce exactly this symptom, and this makes that
answerable from the logs instead of by guesswork.

Also clarify the allowance in the dashboard. It is derived from the trust
level rather than typed in, and it is a rolling 24-hour window, neither
of which the page said. A per-community cap above the account allowance
now says how much of it is actually reachable, because "1 of 10 today"
next to an account limit of 2 reads as far more headroom than exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:59:25 +01:00
Jamie BruntonandClaude Opus 5 f36e93d5be fix: park the growth page on Reddit's origin before fetching
Growth could never post. Its page is opened with newPage() and was never
navigated, so it sat on about:blank, whose opaque origin cannot fetch
Reddit at all — production logged "Failed to read the Reddit session
modhash: TypeError: Failed to fetch" on every attempt. Confirmed against
real network: the same fetch throws from about:blank and succeeds from
https://old.reddit.com.

The transport now parks the page on that origin before any request, and
re-parks it if the page wanders elsewhere. Being same-origin is also what
sends the session cookies, so the authenticated info.json lookup moved to
the same host rather than www; the signed-out visibility probe stays on
www because it runs from Node, where CORS does not apply.

The unit tests could not catch this: they stub the transport, and the
earlier browser check navigated the page by hand in the harness — the
production path was the one place that never did. The page fake now
starts on about:blank like the real thing and asserts the navigation.

Also stop a transport failure from invalidating the account session. A
modhash lookup that could not reach Reddit returned session-invalid,
which calls auth.invalidate() and so would disrupt outreach and chat for
that account over a network blip. Only Reddit actually reporting a
signed-out session (HTTP 401/403, or a response with no modhash) does
that now; anything else is retryable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:44:33 +01:00
Jamie BruntonandClaude Opus 5 1ac6ad22ad feat: grow new accounts by commenting on active threads
A new Reddit account cannot start chats until Reddit considers it
established, and nothing in the app previously did anything about that.
Account growth posts a small number of AI-written comments per day on
active threads in an operator-chosen list of communities, per account,
so the history that lifts the hold actually accumulates.

Growth is account-scoped rather than part of the shared outreach funnel:
karma, comment history and pacing all belong to one identity. It runs as
the lowest-priority work in the account runtime, after inbound replies,
and deliberately keeps running while an account is held out of outbound
messaging.

Comments publish without operator review, so the guards are the feature:

- Both screening stages fail closed. A deterministic gate rejects links,
  contact solicitation, user tags, filler and any text that reveals the
  generator; the classifier must actively approve with confidence, and
  an outage stalls growth rather than publishing unreviewed text.
- Thread text reaches the model only as fenced untrusted data, since
  Reddit posts do contain instructions aimed at automated readers.
- The daily allowance is adaptive, not configured: it starts at two per
  day and rises only while comments survive and earn upvotes.
- Posted comments are swept at ~15m/2h/24h with one batched info.json
  request, plus a single signed-out probe that detects a comment the
  public cannot see. Two consecutive removals, an invisible comment, or
  a captcha challenge pauses the account until an operator clears it. A
  failed probe never pauses anything.
- One comment per thread across every owned account, enforced by a
  unique index, because two of our accounts on one post is the easiest
  pattern to correlate.
- A comment interrupted mid-post is abandoned, never retried: we cannot
  know whether the write landed and a duplicate cannot be withdrawn.
- Reddit's refusals are authoritative — a ban disables that community
  for that account, a throttle backs off at the scope Reddit named, and
  a signed-out response invalidates the session.

Comments are submitted through the account's own browser session against
the old.reddit form endpoint, which returns machine-readable error codes
and needs no credentials beyond the cookies already imported.

API-first: /api/growth exposes state, settings, the community list and
pause/resume, all verified against a local instance. Route responses are
parsed with the shared contracts in tests so the server-side mirror
cannot drift from what the dashboard expects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:08:52 +01:00
Jamie BruntonandClaude Opus 5 14d92e14ee feat: add scan mode so conversations stay in sync without sending
Stopping the bot previously halted conversation syncing too. On the
multi-account path the run-flag check sat above both the reply-sync and
response-check branches, so a stop left only MediaSyncService's coarse
30-minute chat refresh. The legacy single-account path did the opposite
and kept syncing while stopped — one boolean meaning two things.

Replace the boolean with an explicit three-way mode:

  running — discover, screen, send, automate replies
  scan    — refresh chats, detect replies, check responses; send nothing
             and discover nothing; actionable replies surface as backlog
  stopped — everything halts, including background media sync

scan differs from browser.dryRun, which still discovers and pays for AI
screening before suppressing the send.

The mode persists in runtime_state.bot_mode; a migration backfills it
from bot_running, and rows written by an older build still read
correctly. Both loops now gate on the mode, the account runtime loop
reconciles background media sync on transitions only, and the dashboard
and agent API expose all three states.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 10:16:24 +01:00
Jamie BruntonandClaude Opus 5 3a40651930 fix: never let a guessed threshold hold an account out of sending
The profile check was allowed to set state 'unestablished', which made
isSendable() false, which made canAcceptPost() false — so a flagged
account never attempted an invitation and the functional probe could
never run. The one signal with real authority was gated behind the one
that is guessing, and because each check pushed retryAt forward by
another interval, accounts that send perfectly well were held
indefinitely.

Reddit does not publish the thresholds its chat gate applies, so ours are
a guess and are now advisory only. Sending is held solely by a live
rejection, tracked as sendingHeld on the account summary and keyed off by
the attention feed, the sending state and the Accounts page.

Reason strings now say "below the configured floor of N" rather than
"needs N", and the Accounts page distinguishes "Sending on hold" from
"Below thresholds", stating outright that the latter is our own number
and the account is still sending.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 09:49:40 +01:00
Jamie BruntonandClaude Opus 5 89eb4583f8 feat: flag accounts Reddit considers unestablished and re-test them
New accounts cannot start chats until Reddit considers them established.
Until now the bot would burn a candidate on every invitation Reddit was
always going to reject, with nothing in the dashboard explaining why the
account looked idle.

Two signals feed the verdict. A periodic profile check reads the
account's own age, karma and email verification from about.json and
compares them against configurable thresholds — that is what flags a
brand new account before it wastes anything. A rejected invitation is
authoritative: Reddit has told us directly, so the account is held out of
sending with a backoff that doubles per rejection, and a delivered
invitation clears the flag outright.

An unestablished account takes no new posts (canAcceptPost), raises an
attention item, reports an `unestablished` sending state, and shows its
age/karma evidence on the Accounts page. An unchecked account is always
allowed through, so a first run is never blocked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 09:14:47 +01:00
Jamie BruntonandClaude Opus 5 c43e534cf1 refactor(dashboard): rethink settings, split out subreddits and accounts
The settings page had accumulated five tabs whose names no longer matched
their contents, three different save models, and the whole subreddit
configuration buried under a tab named after a checkbox.

Structure:
- Subreddits becomes its own page: a list showing each community's mode,
  tone, computed share of polling, and live sent/reply stats, with one
  subreddit edited at a time in a drawer.
- Accounts becomes its own page. It is session lifecycle, not config.
- Settings collapses to one scrolling page: Safety, Pacing (Monitoring and
  Rate limits merged), Targeting, Notifications, Advanced.
- The sidebar is grouped, having gone from 9 to 11 destinations.

Saving:
- Every section owns its own draft, validation, risk gate and save via
  useConfigCard, submitting only the keys it owns. A syntax error in the
  advanced JSON can no longer block a rate-limit change, and enabling an
  autonomous policy is its own deliberate commit.
- UnsavedChangesProvider restores the leave-page guard across cards that no
  longer share a draft.

Subreddit editing:
- Errors are keyed by field name instead of array index, removing the
  defensive invalidation that existed because row indices shifted on add
  and remove. Confirming an autonomous mode can no longer be attributed to
  the wrong row.
- Weight is shown as the share it actually produces, live while editing.
- Bulk actions cover start/pause polling, tone, policy and removal, because
  production runs 30-40 subreddits and per-row editing does not scale.
  Bulk policy changes name every subreddit turning autonomous, and
  normalise the hybrid mode's turn budget, which has no form to catch it.

Physique targeting moves into its own Targeting card. It sends only
reddit.physiqueTargeting, which the config route applies independently, so
it cannot clobber the pacing card's fields. Turning screening on now warns
that it will reduce how many posts qualify.

Also surfaces the effective sending rate, since the hourly limit and the
minimum delay both throttle and only the binding one matters.

Legacy ?section= links redirect or resolve to anchors.

Fixes found while verifying in a browser: a radio-card rule was shrinking
nested number inputs; .share-preview had no styles; the throughput readout
reported a rate from a blank field; and the reconcile logic warned about a
server change whenever a sibling card saved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 09:07:31 +01:00
jjbrunton 11b6300113 fix: revalidate authentication before bot start 2026-08-10 21:10:12 +01:00
jjbrunton 8ea1cf16d4 fix: persist dedicated physique targeting settings 2026-08-10 18:44:17 +01:00
jjbrunton 5c57ed337b fix: keep outreach funnel response rollout-compatible 2026-08-10 18:38:39 +01:00
jjbrunton 1515b3e291 fix: enforce current physique range in funnel 2026-08-10 18:35:22 +01:00
jjbrunton d8d635e1ee fix: reconcile physique targeting for queued posts 2026-08-10 18:23:23 +01:00
jjbrunton d0a8bd0e2f feat: expand outreach targeting and messaging 2026-08-10 17:31:05 +01:00
Jamie BruntonandClaude Opus 5 7defff422d fix: re-derive conversation authorship from history
Rooms that Reddit returns without timeline events in /sync fell back to
isFromUs=false, so an unanswered opener of ours was projected as inbound
and surfaced in the dashboard's "Needs reply" queue. loadDetails already
paginates the authoritative history for lastMessageSnippet/Time, so take
isFromUs from the same newest message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 15:31:19 +01:00
Jamie BruntonandClaude Opus 5 38d175e4a1 feat: push selected events to a phone over ntfy
Adds a Notifications tab to Settings so an operator can pick which events
reach their phone, published to a single self-hosted ntfy topic.

The notifier subscribes to the realtime event hub the dashboard already
consumes, so every notifiable event keeps exactly one publisher. Inbound
media had no event, so `media.received` is added and published from the
existing durable `gotImage` gate in the chat service — one notification
per conversation, never repeated when the room re-syncs. It feeds the
in-app notification bell as well.

- Best-effort delivery: a rejecting or unreachable ntfy server is logged
  and never propagated into the path that produced the event.
- Events buffered before the notifier attaches are skipped, so a restart
  cannot replay a backlog to somebody's phone.
- Settings are applied field by field onto the live config object, so a
  save takes effect without a restart.
- The access token is environment-owned (NTFY_AUTH_TOKEN) like the other
  secrets: stripped before the settings record is written, never returned
  by the API, which reports only `hasAuthToken`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 13:55:00 +01:00
Jamie BruntonandClaude Opus 5 827eb3a1e0 feat: force a seller scan over active conversations from the inbox
Adds a "Scan for sellers" button to the conversations header, backed by
POST /api/conversations/seller-scan.

The sweep reuses the existing seller detection rather than inventing a
second one: the configured keyword/domain list, the seller-link funnel
patterns, and the aiReplyScreening confidences all decide the verdict, so
a swept conversation lands in the state the live reply loop would have
given it. Being operator-forced, it deliberately ignores filterSellers
and aiReplyScreening.enabled.

AI is the last resort. Conversations are skipped when already flagged or
killed, settled for free by the deterministic rules, or skipped against a
durable sellerScreenedKey watermark when nothing new has arrived since
the last clean sweep. Only the remainder reaches the model, batched eight
at a time through a new screenConversationsForSellers, so the system
prompt and round trip are paid per batch instead of per conversation. A
classifier outage degrades the sweep instead of failing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 13:42:55 +01:00
Jamie BruntonandClaude Opus 5 0a4e0064f2 feat: make the conversation kill controls settable from the dashboard
The `conversations` section was only reachable by editing config.yaml, which
production ignores: config is database-backed and the YAML is imported once,
when the database has not yet been seeded. `AdvancedConfigSchema` is also a
strict object, so a PUT carrying the section was rejected outright.

- Add `conversations` to the advanced config schema and payload, so it appears
  in the dashboard's advanced JSON editor and survives a save.
- Apply the edited section field by field rather than replacing the objects,
  and read the flags through to the live config from the account runtime, so a
  save applies to a running account without a restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:57:56 +01:00
Jamie BruntonandClaude Opus 5 4404ae75ff feat: kill conversations on off-platform and seller pushes
Seller detection on replies was a case-insensitive substring match over the
configured keyword list, and every hit landed in `human-required` alongside
genuine prospects that need a person. Off-platform pushes were only checked
for r4r policy modes, as were the under-18 signals.

Three layers now:

- New `off-platform-signals` module matches on normalized text (unicode
  folding, zero-width stripping, leet mapping, separator removal), so
  `0nlyf4ns`, `t3l3gr4m` and `t e l e g r a m` are caught. Ambiguous terms
  need context: `snap` on its own stays a photo request, never a kill.
- New terminal `killed` automation status. It records the evidence, sets the
  durable seller flag, supersedes the pending claim, and resolves triage to
  `done`, so killed conversations leave every actionable queue. Off-platform
  kills are independent of `reddit.filterSellers`.
- Optional AI screening of inbound replies behind
  `conversations.aiReplyScreening` (off by default). Runs only after the
  deterministic gates pass, memoizes per inbound event, and fails open.

Under-18 and in-person/contact-detail signals now apply to every policy mode
rather than r4r only, and remain human handoffs rather than kills.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:53:00 +01:00
Jamie BruntonandClaude Opus 5 ac12a3146a feat: recover missing conversations, fix media attachment, add manual inbox tab
Missing conversations: recipient Reddit accounts that are deleted or suspended
left conversations unaccounted for. A new RecipientAccountStatusService polls
Reddit for the users behind cached conversations and records their account
status durably, with progressive backfill and delta-media fixes in ChatService
so history and images are no longer dropped mid-sync.

Media: attachMedia now surfaces download failures as MediaAttachmentError so the
sync cursor can be retried instead of silently losing an image, recognizes image
bytes when Matrix returns a generic content type, and keeps separate gallery
occurrences when the same asset appears in more than one event.

Manual inbox tab: the conversations list now carries the reply-loop status, so
the inbox can separate conversations the operator is driving by hand from the
ones automation is still handling. A Manual filter pill and a per-row chip
explain why each conversation was handed over.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 11:03:33 +01:00
Jamie BruntonandClaude Opus 5 725b5fc4e6 feat: application-level read state and inbox triage for conversations
Conversations were hard to manage because "unread" came straight from
Matrix's notification_count, and nothing in the app ever sends a read
receipt — so opening a conversation in the dashboard could never clear it
and the unread set only ever grew. The list also collapsed three
orthogonal facts into one word, labelling "they spoke last and we have
not replied" as "Up to date".

Read state is now ours:

- conversation_read_state table (migration 19) plus
  ConversationReadStateRepository, kept separate from conversation_states
  so a read write can never be lost to mergeConversationState. The
  watermark is monotonic, so a stale poll cannot resurrect an unread
  badge.
- hasUnreadInbound and needsReply are derived server-side and returned on
  every conversation. Matrix's `unread` stays on the contract but the
  dashboard no longer reads it.
- POST /conversations/{id}/read|unread|status and
  POST /conversations/bulk-read, with optimistic cache updates.

Inbox rework:

- List rows are a real grid (checkbox, open button, chip strip) instead of
  a full-bleed absolute overlay button, which previously forced a
  pointer-events escape hatch for anything interactive in a row. Rows use
  aria-current rather than aria-pressed. Not a listbox: options must not
  contain interactive descendants.
- Counted filter pills replace the select, independent state chips replace
  the collapsed status word, plus a sort control (including oldest
  unanswered), a per-account filter, age-coloured timestamps, and an
  explicit count of rows hidden by the seller filter.
- Keyboard: j/k/arrows, Enter, u, e, / and Escape. Shortcuts yield to any
  open dialog so Escape dismisses the modal, not the conversation behind
  it. Multi-select with bulk mark-read and mark-done.
- Detail panel: auto-mark-read on open, a "New messages" divider pinned at
  mount (deriving it from the live watermark made it vanish a second after
  opening), triage actions in the header, the automation banner moved out
  of the composer, day dividers with time-only stamps, and a flex column
  so history scrolls in its own region instead of painting over the
  composer.
- Row order freezes while a conversation is open, with a "N new — refresh"
  pill, so a background poll cannot reorder rows mid-triage.

Two database tests rewound user_version by an offset from the latest
version, which silently re-targets a different migration whenever one is
appended. They now pin absolute versions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 10:06:03 +01:00
Jamie BruntonandClaude Opus 4.8 c8689af04d feat: decouple manual image sharing from automation gating
The "Share a saved image" panel was wired to the automated exchange
engine, so an operator could only share an image right after an inbound
image request, limited to assets the auto-exchanger would itself pick.

Add a manual path that offers any enabled, unused library image at any
point (no request detection, tier, seller, or boundary gating) and sends
it with no reciprocal "your turn" text. Safety flags now surface as a
non-blocking warning instead of disabling the panel. The automated
auto-send path keeps all its guardrails; the shared image-delivery step
is extracted into a helper used by both paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 09:18:37 +01:00
Jamie BruntonandClaude Fable 5 e984bf20ac feat: dashboard UI/UX overhaul from full review
Shared foundation:
- New Modal component on native <dialog>.showModal() (focus trap, inert
  background, Escape, focus restore, scroll lock); all eight dialogs and
  both globalThis.confirm calls migrated to it
- Design tokens for success/warning/danger surfaces; pill danger variant;
  .sr-only utility; toast layering above dialog backdrops; not-allowed
  cursor for validity-disabled buttons

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 09:07:42 +01:00
Jamie BruntonandClaude Fable 5 82be197b79 feat: self-refresh Matrix tokens from the account browser session
Expired Matrix bearers no longer pause an account until a new extension
dump is uploaded. BrowserMatrixTokenRefresher loads chat.reddit.com in
the account's persistent browser profile (whose Reddit cookies stay
valid), lifts the fresh bearer off the first authenticated Matrix
request, verifies it via whoami, swaps it into the live MatrixClient
and persists it to the account row.

Wired into the per-account runtime at boot verification, the auth-gate
authenticator, and ChatService's 401 retry path. A dump is now only
needed when the Reddit cookies themselves no longer authenticate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 08:56:17 +01:00
jjbrunton f34129666e fix: show media from stopped accounts 2026-08-09 22:31:51 +01:00
jjbrunton b573b88b08 feat: add conversation image previews and sharing 2026-08-09 22:28:41 +01:00
jjbrunton 488145340d fix: hide outbound media from gallery 2026-08-09 22:18:10 +01:00
jjbrunton c0cc661a2f Fix false autonomous safety handoffs 2026-08-09 21:27:53 +01:00
jjbrunton 84a133451f Pause automation after manual Reddit replies 2026-08-09 19:30:16 +01:00
jjbrunton bf474dfe10 Add consent-gated media exchange library 2026-08-09 19:24:38 +01:00
jjbrunton b8e4a1bb88 Harden conversation replies and prioritize newest candidates 2026-08-09 18:16:57 +01:00
jjbrunton f98c5c732d Treat Telegram references as seller signals 2026-08-09 17:09:41 +01:00
jjbrunton dc3fc6373b Fix missed conversation replies 2026-08-09 16:56:25 +01:00
jjbrunton 70c156e1f7 Pace candidate funnel to send capacity 2026-08-09 16:28:12 +01:00
jjbrunton 910576921b Allow replies during room creation cooldowns 2026-08-09 16:22:48 +01:00
jjbrunton c630303b47 docs: add agent codebase guide 2026-08-09 16:21:57 +01:00
jjbrunton 742c06d0c4 Fix account cooldown stats contract 2026-08-09 16:16:28 +01:00
jjbrunton e9083281a8 feat: configure ready outreach funnel limit 2026-08-09 16:13:58 +01:00
jjbrunton 22eb32e5bf Show per-account delivery status 2026-08-09 16:12:05 +01:00
jjbrunton 9b86cee9be Prioritize conversation replies 2026-08-09 16:04:39 +01:00
jjbrunton f55ae7ddf8 Add active outreach funnel dashboard 2026-08-09 15:54:05 +01:00
jjbrunton deaf86c364 Fix cross-account candidate delivery 2026-08-09 15:33:18 +01:00
jjbrunton df35648920 Add durable candidate funnel 2026-08-09 15:08:06 +01:00
jjbrunton 5abac31c28 Accept post creation time in message contract 2026-08-09 14:43:43 +01:00
jjbrunton 92c54feedc Filter detected sellers from conversations 2026-08-09 14:38:03 +01:00
jjbrunton d5ebdec076 Prevent location matching in bot messages 2026-08-09 14:37:23 +01:00
jjbrunton c21932389e Fix conversation automation counter validation 2026-08-09 14:27:20 +01:00
jjbrunton b73e305cf3 Add hybrid R4R media conversation mode 2026-08-09 11:45:45 +01:00
jjbrunton 927636ec8b Add married disclosure chat experiment 2026-08-09 11:36:41 +01:00
jjbrunton 0f76bcf59a Add per-subreddit R4R conversation modes 2026-08-09 11:30:32 +01:00
jjbrunton 23d4c3a320 Add varied messaging experiments 2026-08-09 10:28:55 +01:00
jjbrunton 43f459a024 Ground image analysis and add explicit hooks 2026-08-09 09:58:30 +01:00
jjbrunton 50f9f8a6aa Show Reddit post age in conversations 2026-08-09 09:54:53 +01:00
jjbrunton 7db60e8839 Support Reddit gallery posts 2026-08-09 09:42:15 +01:00
jjbrunton 5c9839926d Screen all older fallback candidates 2026-08-09 09:14:52 +01:00
jjbrunton 83b5f1f5b6 Add attention inbox and AI usage observability 2026-08-09 08:52:32 +01:00
jjbrunton ca59ba0f1f Show pending performance groups before maturity 2026-08-09 08:29:22 +01:00
jjbrunton 55a6774daf Fix conversation outcome learning ingestion 2026-08-09 08:17:56 +01:00
jjbrunton a85b99ceb6 Add notification centre and realtime toasts 2026-08-09 08:08:44 +01:00
jjbrunton b400042244 Fix activity account badge sizing 2026-08-09 08:07:42 +01:00
jjbrunton 9e1b1aeefe Shorten strategy response window to 12 hours 2026-08-09 08:03:54 +01:00
jjbrunton e7c29f91f2 Blur conversation images by default 2026-08-09 07:55:51 +01:00
jjbrunton 5c01d7d9aa fix: prevent duplicate outreach and follow-ups 2026-08-09 07:48:39 +01:00
jjbrunton acc610c2bf feat: link media to conversations 2026-08-09 07:32:26 +01:00
jjbrunton e8f56b42e6 feat: hand off upsell conversations to humans 2026-08-09 07:26:04 +01:00
jjbrunton 5ad7f1bf77 feat: label AI-generated conversation messages 2026-08-08 23:23:26 +01:00
jjbrunton 3beb8aada2 feat: automate bounded image-sharing conversations 2026-08-08 23:10:52 +01:00
jjbrunton dcf57273e2 Disable unavailable subreddits after Reddit 404 2026-08-08 22:53:24 +01:00
jjbrunton 664aa5c098 fix: clarify deferred post listing logs 2026-08-08 22:44:00 +01:00
jjbrunton f88d4af146 feat: log subreddit screening outcomes 2026-08-08 22:39:15 +01:00
jjbrunton 53e9a44a5c fix: back off after Reddit access denials 2026-08-08 22:37:19 +01:00
jjbrunton e8b730b592 feat: add agent API and operational analytics 2026-08-08 22:08:14 +01:00
jjbrunton 720ea0652a Install native build tools in container 2026-08-08 21:12:30 +01:00
jjbrunton 5a5a30a1d4 Improve messaging and dashboard reliability 2026-08-08 21:09:20 +01:00
jjbrunton 345ec99348 Improve Reddit chat and media reliability 2026-08-08 20:37:25 +01:00
jjbrunton a44768c089 fix: separate Reddit and Matrix runtime identities 2026-08-08 20:25:06 +01:00
jjbrunton 5623100e93 fix: verify stored sessions from a Reddit page 2026-08-08 20:22:45 +01:00
jjbrunton 8df2d648dd fix: open the dedicated Reddit Chat host 2026-08-08 20:20:03 +01:00
jjbrunton 48492d5238 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	src/browser/client.ts
#	src/services/reddit-session-importer.ts
#	src/web/routes/conversations.ts
2026-08-08 20:17:19 +01:00
jjbrunton 0e8ba713a8 Merge concurrent Reddit account support 2026-08-08 20:15:22 +01:00
jjbrunton 1833e94a10 Reduce Reddit profile screening requests 2026-08-08 20:11:03 +01:00
jjbrunton 3d015c4e80 Sanitize realtime log metadata 2026-08-08 19:46:51 +01:00
jjbrunton 492efe0f8a Bind Reddit and Matrix session identities 2026-08-08 19:43:35 +01:00
jjbrunton 34ae532cdd Fix chat visibility and conversation sync 2026-08-08 19:29:40 +01:00
jjbrunton f4e8fdc42f Improve seller account filtering 2026-08-08 19:15:05 +01:00
jjbrunton 16a31335d0 feat: support concurrent Reddit accounts 2026-08-08 19:00:38 +01:00
jjbrunton d819a0f1e6 feat: track received image outcomes 2026-08-08 18:53:11 +01:00
jjbrunton 50de1c180d Respect Reddit rate limit delays 2026-08-08 18:34:37 +01:00
jjbrunton 7bcdbe236a Filter unattributed successful messages 2026-08-08 18:28:46 +01:00
jjbrunton 2c100d72b6 feat: persist settings and enrich conversation context 2026-08-08 18:18:28 +01:00
jjbrunton a36723a325 chore: remove retired native login paths 2026-08-08 16:55:04 +01:00
jjbrunton ea92a1a92b fix(auth): unify imported sessions with account gating 2026-08-08 16:53:55 +01:00
jjbrunton 654bbda457 feat(dashboard): add full-size media preview 2026-08-08 16:49:18 +01:00
jjbrunton c483c7b8be feat: replace browser login with imported Reddit sessions 2026-08-08 16:48:59 +01:00
jjbrunton 2b0545afcf Merge branch 'codex/react-auth-cutover' into main (superseded implementation already integrated) 2026-08-08 16:47:14 +01:00
jjbrunton ab2e5cc4ce Merge branch 'codex/react-realtime-complete' into main (superseded implementation already integrated) 2026-08-08 16:47:13 +01:00
jjbrunton 9e317ecf62 Merge branch 'codex/react-parity' into main (changes already integrated) 2026-08-08 16:46:44 +01:00
jjbrunton 0aaaeb6b7f Merge branch 'codex/react-docs' into main (changes already integrated) 2026-08-08 16:46:44 +01:00
jjbrunton de6b8999b7 Merge branch 'codex/react-settings' into main (changes already integrated) 2026-08-08 16:46:43 +01:00
jjbrunton 4d18a3a9a2 Merge branch 'codex/react-performance-ops' into main (changes already integrated) 2026-08-08 16:46:43 +01:00
jjbrunton 14c6fe76c5 Merge branch 'codex/react-media' into main (changes already integrated) 2026-08-08 16:46:42 +01:00
jjbrunton 80417cac06 Merge branch 'codex/react-conversations' into main (changes already integrated) 2026-08-08 16:46:42 +01:00
jjbrunton 2196149c79 Merge branch 'codex/react-sse-adapter' into main (changes already integrated) 2026-08-08 16:46:41 +01:00
jjbrunton 9d9506e0cd Merge branch 'codex/react-events' into main (changes already integrated) 2026-08-08 16:46:41 +01:00
jjbrunton 0a7717910a Merge branch 'codex/react-contracts' into main (changes already integrated) 2026-08-08 16:46:40 +01:00
jjbrunton baf673904c Merge branch 'codex/react-foundation' into main (changes already integrated) 2026-08-08 16:46:39 +01:00
jjbrunton 50985b52f1 Merge branch 'codex/native-login-handoff' into main
# Conflicts:
#	public/index.html
#	src/bootstrap.ts
#	src/services/chat/service.ts
#	tests/chat-service-username-persist.test.ts
2026-08-08 16:46:16 +01:00
jjbrunton dbb1842ef4 Use native Chrome for Reddit login handoff 2026-08-08 16:16:11 +01:00
jjbrunton ec0cd1318b feat(dashboard): complete React cutover 2026-08-08 16:11:57 +01:00
jjbrunton 2c533fa52a Verify Reddit identity before Matrix capture 2026-08-08 16:03:54 +01:00
jjbrunton 2fd92bf82c Gate bot logic on full account authentication 2026-08-08 16:01:06 +01:00
jjbrunton d5f0175dc2 feat(web): wire domain events into realtime stream 2026-08-08 16:00:17 +01:00
jjbrunton 8c8ff11fd7 feat(web): publish bot control transitions 2026-08-08 15:51:58 +01:00
jjbrunton ad770a792a feat: secure React dashboard cutover 2026-08-08 15:51:06 +01:00
jjbrunton a51e0b6b31 feat(web): share realtime hub across domain events 2026-08-08 15:46:25 +01:00
jjbrunton 8b1ec85f1d feat: secure React dashboard cutover 2026-08-08 15:46:11 +01:00
jjbrunton 66a1146562 feat(dashboard): activate routed media filters 2026-08-08 15:43:53 +01:00
jjbrunton d108903174 Harden dashboard realtime state and controls 2026-08-08 15:41:58 +01:00
jjbrunton 4b3f00729f feat(dashboard): close legacy parity gaps 2026-08-08 15:41:34 +01:00
jjbrunton 4db6de8bf5 Stop Matrix refresh loop when login is unavailable 2026-08-08 15:41:02 +01:00
jjbrunton 92287c0a9a Stop Matrix refresh loop when login is unavailable 2026-08-08 15:40:55 +01:00
jjbrunton 0fecd426f4 feat(dashboard): close legacy parity gaps 2026-08-08 15:40:08 +01:00
jjbrunton 64d29e1241 Harden dashboard realtime state and controls 2026-08-08 15:38:11 +01:00
jjbrunton 12a853fa42 docs: describe React dashboard migration state 2026-08-08 15:32:38 +01:00
jjbrunton 8824da3f15 docs: describe React dashboard migration state 2026-08-08 15:32:16 +01:00
jjbrunton 720e5e3e34 test: include shared contract suite in root gate 2026-08-08 15:31:44 +01:00
jjbrunton 674cd89cd7 Use full Chrome for interactive Reddit login 2026-08-08 15:30:24 +01:00
jjbrunton 70d62b2da3 feat(dashboard): route settings and access 2026-08-08 15:26:40 +01:00
jjbrunton 79de6aa2a1 feat: add settings and access dashboard slice 2026-08-08 15:25:34 +01:00
jjbrunton e4dd4ad4da feat: add settings and access dashboard slice 2026-08-08 15:24:47 +01:00
jjbrunton dcf2a1200c Clear stale Chromium profile locks on startup 2026-08-08 15:22:20 +01:00
jjbrunton 364b4db200 docs: document dashboard auth and typed events 2026-08-08 15:20:31 +01:00
jjbrunton 271f2d1fb5 Handle non-string image content types 2026-08-08 15:18:28 +01:00
jjbrunton 51438aa5db Keep x11vnc alive for remote login 2026-08-08 15:16:51 +01:00
jjbrunton 89f7376b1b fix(dashboard): deduplicate realtime log replay 2026-08-08 15:15:54 +01:00
jjbrunton e003b0d8b2 feat(dashboard): route performance and operations 2026-08-08 15:11:48 +01:00
jjbrunton 5917b63755 feat(dashboard): add performance and operations slices 2026-08-08 15:10:52 +01:00
jjbrunton c7dec45abe feat(dashboard): add performance and operations slices 2026-08-08 15:06:44 +01:00
jjbrunton 20fa83f245 feat(dashboard): route conversations and media 2026-08-08 15:06:19 +01:00
jjbrunton 768470204b feat: add private media dashboard slice 2026-08-08 15:05:30 +01:00
jjbrunton 8d4eae7ab5 feat(dashboard): add conversations workspace 2026-08-08 15:05:08 +01:00
jjbrunton e5dda8f9c3 feat: add private media dashboard slice 2026-08-08 15:04:25 +01:00
jjbrunton 4c20a83c89 feat(dashboard): add conversations workspace 2026-08-08 15:04:04 +01:00
jjbrunton 40e5f4c818 chore(deps): apply audited dependency fixes 2026-08-08 15:02:23 +01:00
jjbrunton 9919b12aab feat(web): bridge logs to typed event stream 2026-08-08 15:00:02 +01:00
jjbrunton d6d29badcd feat(dashboard): align contracts and production build 2026-08-08 14:58:27 +01:00
jjbrunton 69d560d5c5 feat(web): add Express SSE event adapter 2026-08-08 14:56:37 +01:00
jjbrunton 10412876f1 feat(web): add Express SSE event adapter 2026-08-08 14:56:04 +01:00
jjbrunton 9f4f198800 fix: align realtime event contracts 2026-08-08 14:54:09 +01:00
jjbrunton 51e3e28a9b feat(dashboard): add React foundation and overview 2026-08-08 14:53:53 +01:00
jjbrunton aef4cbb656 fix: align realtime event contracts 2026-08-08 14:53:47 +01:00
jjbrunton 28fcb3ea99 feat(dashboard): add React foundation and overview 2026-08-08 14:52:39 +01:00
jjbrunton 59c3a61a88 feat: add shared dashboard API contracts 2026-08-08 14:52:06 +01:00
jjbrunton 6f803245d4 feat: add shared dashboard API contracts 2026-08-08 14:50:58 +01:00
jjbrunton 939cf408a0 feat(web): add typed realtime event core 2026-08-08 14:50:15 +01:00
jjbrunton 3c4cfd7411 feat(web): add typed realtime event core 2026-08-08 14:49:38 +01:00
427 changed files with 68568 additions and 14305 deletions

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "dashboard",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dashboard:dev"],
"port": 5173
}
]
}
+22 -44
View File
@@ -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
View File
@@ -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"]
+26 -19
View File
@@ -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.
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0b1210" />
<title>Reddit Bot Console</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+66
View File
@@ -0,0 +1,66 @@
import type { ZodType } from 'zod';
import {
botStatsSchema,
controlResponseSchema,
timersSchema,
type BotMode,
type BotStats,
type ControlResponse,
type Timers,
} from './contracts.js';
import { createDashboardRequest } from './request.js';
export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
this.name = 'ApiError';
}
}
interface ErrorPayload {
error?: unknown;
}
async function parseResponse<T>(response: Response, schema: ZodType<T>): Promise<T> {
const payload: unknown = await response.json();
if (!response.ok) {
const errorPayload = payload as ErrorPayload;
const message = typeof errorPayload.error === 'string' ? errorPayload.error : `Request failed (${response.status})`;
throw new ApiError(message, response.status);
}
return schema.parse(payload);
}
export interface DashboardApi {
getStats(): Promise<BotStats>;
getTimers(): Promise<Timers>;
setMode(mode: BotMode): Promise<ControlResponse>;
}
export function createDashboardApi(fetchImplementation?: typeof fetch): DashboardApi {
const request = createDashboardRequest(fetchImplementation);
return {
async getStats() {
return parseResponse(await request('/api/stats'), botStatsSchema);
},
async getTimers() {
return parseResponse(await request('/api/timers'), timersSchema);
},
async setMode(mode) {
return parseResponse(
await request('/api/mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode }),
}),
controlResponseSchema,
);
},
};
}
export const dashboardApi = createDashboardApi();
+12
View File
@@ -0,0 +1,12 @@
export {
ControlResponseSchema as controlResponseSchema,
StatsResponseSchema as botStatsSchema,
TimersResponseSchema as timersSchema,
} from '../../../../packages/api-contracts/src/index.js';
export type {
BotMode,
ControlResponse,
StatsResponse as BotStats,
TimersResponse as Timers,
} from '../../../../packages/api-contracts/src/index.js';
+32
View File
@@ -0,0 +1,32 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { dashboardApi } from './client.js';
import type { BotStats } from './contracts.js';
export const dashboardQueryKeys = {
stats: ['dashboard', 'stats'] as const,
timers: ['dashboard', 'timers'] as const,
};
export function useStatsQuery() {
return useQuery({
queryKey: dashboardQueryKeys.stats,
queryFn: dashboardApi.getStats,
refetchInterval: 15_000,
});
}
export function useTimersQuery() {
return useQuery({ queryKey: dashboardQueryKeys.timers, queryFn: dashboardApi.getTimers, refetchInterval: 15_000 });
}
export function useSetModeMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: dashboardApi.setMode,
onSuccess(response) {
queryClient.setQueryData<BotStats>(dashboardQueryKeys.stats, (current) =>
current ? { ...current, isRunning: response.running, mode: response.mode } : current,
);
},
});
}
+19
View File
@@ -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();
+76
View File
@@ -0,0 +1,76 @@
import { useState } from 'react';
import { NavLink, Outlet } from 'react-router-dom';
import { navigationGroups, navigationItemsByGroup } from './navigation.js';
import { ShellStatusPanel } from './shell-status-panel.js';
import { NotificationBell, NotificationProvider, NotificationViewport } from './notifications.js';
export function AppShell() {
const [isNavigationOpen, setIsNavigationOpen] = useState(false);
return (
<NotificationProvider><div className="app-shell">
<a className="skip-link" href="#main-content">Skip to content</a>
<aside className={isNavigationOpen ? 'sidebar is-open' : 'sidebar'}>
<div className="brand">
<span className="brand-mark" aria-hidden="true">R</span>
<div>
<strong>Reddit Bot</strong>
<span>Operator console</span>
</div>
<NotificationBell />
</div>
<nav aria-label="Primary navigation" id="primary-navigation">
{navigationGroups.map((group) => (
<div className="nav-group" key={group}>
<span className="nav-group-label">{group}</span>
{navigationItemsByGroup(group).map((item) => (
<NavLink
aria-label={item.label}
className={({ isActive }) => (isActive ? 'nav-link is-active' : 'nav-link')}
end={item.path === '/'}
key={item.path}
onClick={() => setIsNavigationOpen(false)}
to={item.path}
>
<span>{item.label}</span>
<small>{item.description}</small>
</NavLink>
))}
</div>
))}
</nav>
<ShellStatusPanel />
</aside>
{isNavigationOpen ? (
<button
aria-label="Close navigation backdrop"
className="navigation-backdrop"
onClick={() => setIsNavigationOpen(false)}
type="button"
/>
) : null}
<div className="workspace">
<header className="mobile-header">
<span>Reddit Bot</span>
<div className="mobile-header-actions">
<NotificationBell />
<button
aria-controls="primary-navigation"
aria-expanded={isNavigationOpen}
aria-label={isNavigationOpen ? 'Close navigation' : 'Open navigation'}
className="icon-button"
onClick={() => setIsNavigationOpen((current) => !current)}
type="button"
>
<span aria-hidden="true">{isNavigationOpen ? '×' : '☰'}</span>
</button>
</div>
</header>
<main id="main-content" tabIndex={-1}>
<Outlet />
</main>
</div>
<NotificationViewport />
</div></NotificationProvider>
);
}
+31
View File
@@ -0,0 +1,31 @@
export interface NavigationItem {
label: string;
path: string;
description: string;
group: NavigationGroup;
}
export type NavigationGroup = 'Monitor' | 'Content' | 'Analyse' | 'Configure';
/** Render order for the sidebar's group headings. */
export const navigationGroups: readonly NavigationGroup[] = ['Monitor', 'Content', 'Analyse', 'Configure'];
export const navigationItems: readonly NavigationItem[] = [
{ label: 'Overview', path: '/', description: 'System health and current activity', group: 'Monitor' },
{ label: 'Needs attention', path: '/attention', description: 'Handoffs, failures, and account risks', group: 'Monitor' },
{ label: 'Outreach funnel', path: '/outreach', description: 'Active posts moving toward delivery', group: 'Monitor' },
{ label: 'Conversations', path: '/conversations', description: 'Inbox and message history', group: 'Monitor' },
{ label: 'Media', path: '/media', description: 'Private media and sync controls', group: 'Content' },
{ label: 'Exchange library', path: '/media-library', description: 'Images the bot may send when asked', group: 'Content' },
{ label: 'Performance', path: '/performance', description: 'Response and strategy trends', group: 'Analyse' },
{ label: 'Learning review', path: '/learning', description: 'Human-led examples and attribution', group: 'Analyse' },
{ label: 'Operations', path: '/operations', description: 'Jobs, events, and diagnostics', group: 'Analyse' },
{ label: 'Subreddits', path: '/subreddits', description: 'Where the bot posts and how it talks', group: 'Configure' },
{ label: 'Accounts', path: '/accounts', description: 'Reddit sessions and access', group: 'Configure' },
{ label: 'Account growth', path: '/growth', description: 'Build comment history on new accounts', group: 'Configure' },
{ label: 'Settings', path: '/settings', description: 'Pacing, safety, and notifications', group: 'Configure' },
];
export function navigationItemsByGroup(group: NavigationGroup): readonly NavigationItem[] {
return navigationItems.filter((item) => item.group === group);
}
+57
View File
@@ -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; } }
+332
View File
@@ -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>
</>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { PropsWithChildren } from 'react';
import { DashboardRealtimeProvider } from './realtime-provider.js';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 15_000,
retry: 1,
refetchOnWindowFocus: false,
},
mutations: { retry: false },
},
});
export function AppProviders({ children }: PropsWithChildren) {
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 };
}
+26
View File
@@ -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>;
}
+44
View File
@@ -0,0 +1,44 @@
import { createBrowserRouter, type RouteObject } from 'react-router-dom';
import { AppShell } from './app-shell.js';
import { OverviewPage } from '../features/overview/overview-page.js';
import { ConversationRoute } from '../features/conversations/conversation-route.js';
import { MediaRoute } from '../features/media/media-route.js';
import { OperationsPage } from '../features/operations/operations-page.js';
import { PerformancePage } from '../features/performance/performance-page.js';
import { SettingsRoute } from '../features/settings/settings-route.js';
import { RouteErrorPage } from './route-error-page.js';
import { AttentionPage } from '../features/attention/attention-page.js';
import { OutreachPage } from '../features/outreach/outreach-page.js';
import { MediaLibraryPage } from '../features/media-library/media-library-page.js';
import { SubredditsPage } from '../features/subreddits/subreddits-page.js';
import { AccountsPage } from '../features/accounts/accounts-page.js';
import { GrowthPage } from '../features/growth/growth-page.js';
import { LearningPage } from '../features/learning/learning-page.js';
export function createDashboardRoutes(): RouteObject[] {
return [
{
path: '/',
element: <AppShell />,
errorElement: <RouteErrorPage />,
children: [
{ index: true, element: <OverviewPage /> },
{ path: 'attention', element: <AttentionPage /> },
{ path: 'outreach', element: <OutreachPage /> },
{ path: 'conversations', element: <ConversationRoute /> },
{ path: 'conversations/:conversationId', element: <ConversationRoute /> },
{ path: 'media', element: <MediaRoute /> },
{ path: 'media-library', element: <MediaLibraryPage /> },
{ path: 'performance', element: <PerformancePage /> },
{ path: 'learning', element: <LearningPage /> },
{ path: 'operations', element: <OperationsPage /> },
{ path: 'subreddits', element: <SubredditsPage /> },
{ path: 'accounts', element: <AccountsPage /> },
{ path: 'growth', element: <GrowthPage /> },
{ path: 'settings', element: <SettingsRoute /> },
],
},
];
}
export const dashboardRouter = createBrowserRouter(createDashboardRoutes());
@@ -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}
</>;
}
+48
View File
@@ -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>
);
}
@@ -0,0 +1,52 @@
import type { BotMode } from '../api/contracts.js';
import { Modal } from './modal.js';
interface ConfirmationDialogProps {
action: BotMode;
errorMessage?: string | null;
isPending: boolean;
onCancel(): void;
onConfirm(): void;
}
const copy: Record<BotMode, { title: string; body: string; confirm: string; tone: string }> = {
running: {
title: 'Start bot?',
body: 'The bot will resume automated processing.',
confirm: 'Confirm start',
tone: 'button primary',
},
scan: {
title: 'Switch to scan mode?',
body: 'Conversations stay in sync and replies are flagged for you, but nothing is sent and no new candidates are discovered.',
confirm: 'Confirm scan mode',
tone: 'button secondary',
},
stopped: {
title: 'Stop bot?',
body: 'The bot will pause after its current operation. Conversations stop syncing too — use scan mode to keep them up to date.',
confirm: 'Confirm stop',
tone: 'button danger',
},
};
export function ConfirmationDialog({ action, errorMessage = null, isPending, onCancel, onConfirm }: ConfirmationDialogProps) {
const { title, body, confirm, tone } = copy[action];
return (
<Modal className="dialog-card" isDismissLocked={isPending} labelledBy="confirmation-title" onClose={onCancel}>
<span className="eyebrow">Confirm action</span>
<h2 id="confirmation-title">{title}</h2>
<p>{body}</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={tone} disabled={isPending} onClick={onConfirm} type="button">
{isPending ? 'Applying…' : confirm}
</button>
</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>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { useEffect, useRef, useState, type PropsWithChildren, type ReactNode } from 'react';
interface ModalProps {
/** Class applied to the <dialog> element itself (it is the visible card). */
className?: string;
/** Blocks Escape/backdrop dismissal while a mutation is in flight. */
isDismissLocked?: boolean;
/** Close when the backdrop (the ::backdrop area of the dialog) is pressed. */
closeOnBackdrop?: boolean;
label?: string;
labelledBy?: string;
onClose(): void;
}
/**
* Shared modal built on native <dialog>.showModal(): the browser provides
* focus trapping, inert background, and Escape handling. Render conditionally;
* mounting opens the dialog and unmounting restores focus to the opener.
*/
export function Modal({
children,
className,
closeOnBackdrop = false,
isDismissLocked = false,
label,
labelledBy,
onClose,
}: PropsWithChildren<ModalProps>): ReactNode {
const dialogRef = useRef<HTMLDialogElement>(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
const dismissLockedRef = useRef(isDismissLocked);
dismissLockedRef.current = isDismissLocked;
// Captured at first render, before autoFocus moves focus into the dialog.
const [previouslyFocused] = useState(() => document.activeElement);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (!dialog.open) {
if (typeof dialog.showModal === 'function') dialog.showModal();
else dialog.setAttribute('open', '');
}
return () => {
if (previouslyFocused instanceof HTMLElement && previouslyFocused.isConnected) {
previouslyFocused.focus();
}
};
}, [previouslyFocused]);
function requestClose(): void {
if (!dismissLockedRef.current) onCloseRef.current();
}
return (
<dialog
aria-label={label}
aria-labelledby={labelledBy}
className={className ? `modal ${className}` : 'modal'}
onCancel={(event) => {
event.preventDefault();
requestClose();
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
requestClose();
}
}}
onMouseDown={(event) => {
if (closeOnBackdrop && event.target === event.currentTarget) requestClose();
}}
ref={dialogRef}
>
{children}
</dialog>
);
}
@@ -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();
}
@@ -0,0 +1,213 @@
import type { ZodType } from 'zod';
import {
ConversationsResponseSchema,
ConversationDetailResponseSchema,
ConversationMediaShareOptionsResponseSchema,
ConversationImageReplacementOptionsResponseSchema,
GenerateConversationImageReplacementRequestSchema,
GenerateConversationImageReplacementResponseSchema,
ErrorResponseSchema,
ProcessedPostsResponseSchema,
SendReplyRequestSchema,
ShareConversationMediaRequestSchema,
StartConversationRequestSchema,
StartConversationResponseSchema,
SuccessResponseSchema,
SuggestedReplyResponseSchema,
BulkMarkConversationsReadRequestSchema,
BulkMarkConversationsReadResponseSchema,
SellerScanResponseSchema,
MarkConversationReadRequestSchema,
MarkConversationUnreadRequestSchema,
SetConversationStatusRequestSchema,
type ActiveChat,
type BulkMarkConversationsReadResponse,
type ConversationDetailResponse,
type ConversationMediaShareOptions,
type ConversationImageReplacementOptions,
type ConversationImageReplacementResult,
type ConversationTriageStatus,
type ProcessedPost,
type SellerScanResponse,
type SuccessResponse,
} from '../../../../../packages/api-contracts/src/index.js';
import { createDashboardRequest } 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 conversationPath(id: string): string {
return `/api/conversations/${encodeURIComponent(id)}`;
}
export interface ConversationApi {
list(): Promise<ActiveChat[]>;
detail(id: string): Promise<ConversationDetailResponse>;
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>;
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 {
const request = createDashboardRequest(fetchImplementation);
return {
async list() {
return parseResponse(
await request('/api/conversations?includeImageMetadata=true'),
ConversationsResponseSchema,
);
},
async detail(id) {
return parseResponse(await request(conversationPath(id)), ConversationDetailResponseSchema);
},
async suggestReply(id) {
const response = await parseResponse(
await request(`${conversationPath(id)}/suggest-reply`, { method: 'POST' }),
SuggestedReplyResponseSchema,
);
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) {
const body = SendReplyRequestSchema.parse({ message: message.trim() });
return parseResponse(
await request(`${conversationPath(id)}/send-reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
SuccessResponseSchema,
);
},
async shareMedia(id, assetId) {
const body = ShareConversationMediaRequestSchema.parse({ assetId });
return parseResponse(
await request(`${conversationPath(id)}/share-media`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
SuccessResponseSchema,
);
},
async startConversation(accountId, username, message) {
const body = StartConversationRequestSchema.parse({
accountId,
username: username.trim().replace(/^u\//i, ''),
message: message.trim(),
});
const response = await parseResponse(
await request('/api/conversations/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
StartConversationResponseSchema,
);
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,
);
},
};
}
export const conversationApi = createConversationApi();
@@ -0,0 +1,696 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type {
ChatMessage,
ConversationDetailResponse,
MediaLibraryAsset,
} from '../../../../../packages/api-contracts/src/index.js';
import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
import { OriginPostCard } from '../../components/origin-post.js';
import {
useConversationDetailQuery,
useConversationMediaShareOptionsQuery,
useMarkConversationReadMutation,
useMarkConversationUnreadMutation,
useSendReplyMutation,
useSetConversationStatusMutation,
useShareConversationMediaMutation,
useSuggestMediaAskMutation,
useSuggestReplyMutation,
} from './conversation-queries.js';
import { SendConfirmationDialog } from './send-confirmation-dialog.js';
import { MediaShareConfirmationDialog } from './media-share-confirmation-dialog.js';
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
import { formatDayLabel, formatExactTime, formatTimeOfDay, isSameDay } from './relative-time.js';
import { getStoredComposerDraft, storeComposerDraft } from './composer-drafts.js';
import { SNOOZE_OPTIONS, isUnread, triageStatus } from './conversation-triage.js';
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 {
conversationId: string;
onClose(): void;
}
function mediaPath(localPath: string): string {
return `/media/${localPath.split(/[\\/]/).map(encodeURIComponent).join('/')}`;
}
/** Auto-dismisses a success flag after a few seconds. */
function useAutoDismiss(isVisible: boolean, dismiss: () => void) {
useEffect(() => {
if (!isVisible) return;
const timer = window.setTimeout(dismiss, SUCCESS_NOTICE_DISMISS_MS);
return () => window.clearTimeout(timer);
}, [isVisible, dismiss]);
}
/**
* 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-messages-shell">
<ol aria-label="Message history" className="conversation-messages" onScroll={handleScroll} ref={listRef}>
{messages.map((message, index) => {
const previous = index > 0 ? messages[index - 1] : null;
const startsNewDay = previous === null || !isSameDay(previous.timestamp, message.timestamp);
const key = message.eventId ?? `${message.timestamp}-${message.sender}-${message.text}`;
return [
startsNewDay ? (
<li aria-hidden="true" className="conversation-day-divider" key={`day-${key}`}>
<span>{formatDayLabel(message.timestamp, now)}</span>
</li>
) : null,
index === unreadIndex ? (
<li className="conversation-unread-divider" key={`unread-${key}`} ref={dividerRef}>
<span>New messages</span>
</li>
) : null,
<li className={message.isSelf ? 'is-self' : 'is-recipient'} key={key}>
<div>
<strong>{message.isSelf
? `You${conversation.owner ? ` (u/${conversation.owner.redditUsername})` : ''}`
: <RedditUsernameLink username={conversation.username} />}</strong>
{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, conversationId }: { conversation: ConversationDetailResponse; conversationId: string }) {
const [draft, setDraft] = useState(() => getStoredComposerDraft(conversationId));
const [restorableDraft, setRestorableDraft] = useState<string | null>(null);
const [isConfirming, setIsConfirming] = useState(false);
const [wasSent, setWasSent] = useState(false);
const [selectedAssetId, setSelectedAssetId] = useState<string | null>(null);
const [areSavedImagesRevealed, setAreSavedImagesRevealed] = useState(false);
const [isMediaShareOpen, setIsMediaShareOpen] = useState(false);
const [isConfirmingMedia, setIsConfirmingMedia] = useState(false);
const [wasMediaShared, setWasMediaShared] = useState(false);
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false);
const sendButtonRef = useRef<HTMLButtonElement>(null);
const mediaShareSummaryRef = useRef<HTMLElement>(null);
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 automationActive = conversation.automation.status === 'active' && conversation.automation.remainingTurns > 0;
const isNearCharacterLimit = draft.length >= REDDIT_CHAT_MAX_MESSAGE_LENGTH - CHARACTER_WARNING_MARGIN;
const updateDraft = useCallback((next: string) => {
setDraft(next);
storeComposerDraft(conversationId, next);
}, [conversationId]);
const dismissSent = useCallback(() => setWasSent(false), []);
const dismissMediaShared = useCallback(() => setWasMediaShared(false), []);
useAutoDismiss(wasSent, dismissSent);
useAutoDismiss(wasMediaShared, dismissMediaShared);
const closeConfirmation = useCallback(() => {
setIsConfirming(false);
queueMicrotask(() => sendButtonRef.current?.focus());
}, []);
const closeMediaConfirmation = useCallback(() => {
setIsConfirmingMedia(false);
queueMicrotask(() => mediaShareSummaryRef.current?.focus());
}, []);
function applySuggestion(suggestion: string) {
const priorDraft = draft;
updateDraft(suggestion);
if (priorDraft.trim().length > 0 && priorDraft !== suggestion) setRestorableDraft(priorDraft);
else setRestorableDraft(null);
}
/**
* 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() {
setWasSent(false);
suggestionMutation.mutate(undefined, { onSuccess: applySuggestion });
}
function suggestMediaAsk() {
setWasSent(false);
mediaAskMutation.mutate(undefined, { onSuccess: applySuggestion });
}
function sendReply() {
if (!canSend) return;
sendMutation.mutate(draft, {
onSuccess() {
updateDraft('');
setRestorableDraft(null);
setWasSent(true);
},
onSettled: closeConfirmation,
});
}
function shareMedia() {
if (!selectedAsset) return;
mediaShareMutation.mutate(selectedAsset.id, {
onSuccess() {
setSelectedAssetId(null);
setWasMediaShared(true);
},
onSettled: closeMediaConfirmation,
});
}
return (
<section aria-labelledby="reply-composer-heading" className="conversation-composer">
<div className="conversation-section-heading">
<h3 id="reply-composer-heading">Reply</h3>
<button disabled={suggestionMutation.isPending} onClick={suggestReply} type="button">
{suggestionMutation.isPending ? 'Generating…' : 'Suggest reply'}
</button>
<button disabled={mediaAskMutation.isPending || !automationActive} onClick={suggestMediaAsk} type="button">
{mediaAskMutation.isPending ? 'Generating media ask…' : 'Suggest media ask'}
</button>
</div>
<label className="sr-only" htmlFor="conversation-reply">Reply to {redditUsernameLabel(conversation.username)}</label>
<textarea
id="conversation-reply"
maxLength={REDDIT_CHAT_MAX_MESSAGE_LENGTH}
onChange={(event) => {
updateDraft(event.target.value);
setRestorableDraft(null);
setWasSent(false);
}}
placeholder="Write or generate a reply"
ref={replyTextareaRef}
rows={5}
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}
{mediaAskMutation.isError ? <div className="conversation-inline-error" role="alert">{mediaAskMutation.error.message}</div> : null}
{sendMutation.isError ? <div className="conversation-inline-error" role="alert">{sendMutation.error.message}</div> : null}
{wasSent ? (
<div aria-label="Reply sent" className="conversation-inline-success" role="status">Reply sent to <RedditUsernameLink username={conversation.username} /></div>
) : null}
<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">
<span className={isNearCharacterLimit ? 'conversation-char-counter is-warning' : 'conversation-char-counter'}>
{draft.length} / {REDDIT_CHAT_MAX_MESSAGE_LENGTH}
</span>
<button
aria-label="Review and send reply"
className="conversation-primary"
disabled={!canSend}
onClick={() => setIsConfirming(true)}
ref={sendButtonRef}
type="button"
>
Review and send
</button>
</div>
{isConfirming ? (
<SendConfirmationDialog
draft={draft.trim()}
isPending={sendMutation.isPending}
onCancel={closeConfirmation}
onConfirm={sendReply}
/>
) : null}
{isConfirmingMedia && selectedAsset ? (
<MediaShareConfirmationDialog
areImagesRevealed={areSavedImagesRevealed}
asset={selectedAsset}
isPending={mediaShareMutation.isPending}
onCancel={closeMediaConfirmation}
onConfirm={shareMedia}
/>
) : null}
</section>
);
}
function automationStatusMessage(conversation: ConversationDetailResponse): string {
switch (conversation.automation.status) {
case '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) {
const detailQuery = useConversationDetailQuery(conversationId);
const [now] = useState(() => Date.now());
useAutoMarkRead(conversationId, detailQuery.data);
if (detailQuery.isPending) {
return <div aria-label="Loading conversation" className="conversation-detail-state" role="status">Loading conversation…</div>;
}
if (detailQuery.isError) {
return (
<div className="conversation-detail-state conversation-inline-error" role="alert">
<strong>Conversation unavailable</strong>
<span>{detailQuery.error.message}</span>
<button onClick={() => void detailQuery.refetch()} type="button">Retry conversation</button>
</div>
);
}
const conversation = detailQuery.data;
const status = triageStatus(conversation);
return (
<article className="conversation-detail">
<header>
<div className="conversation-detail-identity">
<span className="conversation-eyebrow">Conversation</span>
<h2><RedditUsernameLink username={conversation.username} /></h2>
<span className="conversation-detail-badges">
<AccountOwnerBadge owner={conversation.owner} />
{conversation.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>
</header>
<div
className={`conversation-automation-status is-${conversation.automation.status}`}
role="status"
>
<strong>{conversation.automation.remainingTurns} automated {conversation.automation.remainingTurns === 1 ? 'turn' : 'turns'} remaining</strong>
<span>{automationStatusMessage(conversation)}</span>
</div>
{conversation.syncInProgress ? (
<div className="conversation-sync-notice" role="status">
A {conversation.syncInProgress.kind ?? 'chat'} sync is in progress. Messages may be stale.
</div>
) : null}
{conversation.triggeringPost ? (
<div className="triggering-post">
<OriginPostCard post={{
title: conversation.triggeringPost.title ?? conversation.triggeringPost.id,
url: conversation.triggeringPost.url,
imageUrl: conversation.triggeringPost.imageUrl,
subreddit: conversation.triggeringPost.subreddit,
postedAt: conversation.triggeringPost.postedAt,
}} />
</div>
) : null}
<MessageHistory conversation={conversation} now={now} />
{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>
);
}
@@ -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>
);
}
@@ -0,0 +1,224 @@
import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query';
import type { ActiveChat, ConversationTriageStatus } from '../../../../../packages/api-contracts/src/index.js';
import { conversationApi } from './conversation-api.js';
import { ownedConversationId } from './conversation-identity.js';
export const conversationQueryKeys = {
all: ['conversations'] as const,
detail: (id: string) => ['conversations', 'detail', id] as const,
processedPosts: (username: string) => ['conversations', 'processed-posts', username] as const,
mediaShareOptions: (id: string) => ['conversations', 'media-share-options', id] as const,
imageReplacementOptions: (id: string) => ['conversations', 'image-replacement-options', id] as const,
};
export const CONVERSATION_REFRESH_INTERVAL_MS = 30_000;
export function useConversationsQuery() {
return useQuery({
queryKey: conversationQueryKeys.all,
queryFn: conversationApi.list,
refetchInterval: CONVERSATION_REFRESH_INTERVAL_MS,
});
}
export function useConversationDetailQuery(id: string | null) {
return useQuery({
queryKey: conversationQueryKeys.detail(id ?? ''),
queryFn: () => conversationApi.detail(id ?? ''),
enabled: id !== null,
refetchInterval: CONVERSATION_REFRESH_INTERVAL_MS,
});
}
export function useSuggestReplyMutation(id: string) {
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) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (message: string) => conversationApi.sendReply(id, message),
async onSuccess() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all }),
queryClient.invalidateQueries({ queryKey: conversationQueryKeys.detail(id) }),
]);
},
});
}
export function useShareConversationMediaMutation(id: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (assetId: string) => conversationApi.shareMedia(id, assetId),
async onSuccess() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all }),
queryClient.invalidateQueries({ queryKey: conversationQueryKeys.detail(id) }),
queryClient.invalidateQueries({ queryKey: conversationQueryKeys.mediaShareOptions(id) }),
]);
},
});
}
/**
* Applies a triage patch to the cached list and detail entries for one
* conversation. Read-state writes are optimistic because the list only polls
* every 30s — waiting for a refetch would make marking read feel broken.
*/
function patchCachedConversation(
queryClient: QueryClient,
id: string,
patch: Partial<ActiveChat>,
): void {
queryClient.setQueryData<ActiveChat[]>(conversationQueryKeys.all, (current) =>
current?.map((conversation) => (ownedConversationId(conversation) === id
? { ...conversation, ...patch }
: conversation)));
queryClient.setQueryData<ActiveChat>(
conversationQueryKeys.detail(id),
(current) => (current ? { ...current, ...patch } : current),
);
}
function cachedConversation(queryClient: QueryClient, id: string): ActiveChat | undefined {
return queryClient.getQueryData<ActiveChat[]>(conversationQueryKeys.all)
?.find((conversation) => ownedConversationId(conversation) === id);
}
export function useMarkConversationReadMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, throughTimestamp }: { id: string; throughTimestamp?: number }) =>
conversationApi.markRead(id, throughTimestamp),
onMutate({ id, throughTimestamp }) {
const watermark = throughTimestamp ?? cachedConversation(queryClient, id)?.lastMessageTime ?? 0;
patchCachedConversation(queryClient, id, { hasUnreadInbound: false, lastReadMessageTs: watermark });
},
async onSettled() {
await queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all });
},
});
}
export function useMarkConversationUnreadMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, beforeTimestamp }: { id: string; beforeTimestamp?: number }) =>
conversationApi.markUnread(id, beforeTimestamp),
onMutate({ id, beforeTimestamp }) {
const boundary = beforeTimestamp ?? cachedConversation(queryClient, id)?.lastMessageTime ?? 0;
patchCachedConversation(queryClient, id, {
hasUnreadInbound: true,
triageStatus: 'open',
snoozedUntil: null,
lastReadMessageTs: Math.max(0, boundary - 1),
});
},
async onSettled() {
await queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all });
},
});
}
export function useSetConversationStatusMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, status, snoozeUntil }: {
id: string;
status: ConversationTriageStatus;
snoozeUntil?: number | null;
}) => conversationApi.setStatus(id, status, snoozeUntil),
onMutate({ id, status, snoozeUntil }) {
const conversation = cachedConversation(queryClient, id);
patchCachedConversation(queryClient, id, {
triageStatus: status,
snoozedUntil: status === 'snoozed' ? snoozeUntil ?? null : null,
// Resolving a conversation implies you have read it.
...(status === 'done'
? { hasUnreadInbound: false, lastReadMessageTs: conversation?.lastMessageTime ?? 0 }
: {}),
needsReply: status === 'open' && conversation?.isFromUs === false,
});
},
async onSettled() {
await queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all });
},
});
}
export function useBulkMarkConversationsReadMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (ids: string[]) => conversationApi.bulkMarkRead(ids),
onMutate(ids) {
for (const id of ids) {
const watermark = cachedConversation(queryClient, id)?.lastMessageTime ?? 0;
patchCachedConversation(queryClient, id, { hasUnreadInbound: false, lastReadMessageTs: watermark });
}
},
async onSettled() {
await queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all });
},
});
}
/**
* Forces a seller pass over every active conversation. The list is refetched
* on completion because a sweep can flag, kill and resolve conversations.
*/
export function useSellerScanMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => conversationApi.sellerScan(),
async onSuccess() {
await queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all });
},
});
}
export function useStartConversationMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ accountId, username, message }: { accountId: string; username: string; message: string }) =>
conversationApi.startConversation(accountId, username, message),
async onSuccess() {
await queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all });
},
});
}
@@ -0,0 +1,55 @@
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { ConversationsPage } from './conversations-page.js';
import {
DEFAULT_CONVERSATION_FILTERS,
isConversationFilter,
isConversationSort,
type ConversationFilters,
} from './conversation-triage.js';
export function ConversationRoute() {
const navigate = useNavigate();
const { conversationId } = useParams<{ conversationId: string }>();
const [searchParams, setSearchParams] = useSearchParams();
const filterValue = searchParams.get('filter');
const sortValue = searchParams.get('sort');
const filters: ConversationFilters = {
search: searchParams.get('q') ?? '',
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) {
const suffix = searchParams.size > 0 ? `?${searchParams.toString()}` : '';
void navigate(
nextConversationId
? `/conversations/${encodeURIComponent(nextConversationId)}${suffix}`
: `/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 (
<ConversationsPage
selectedConversationId={conversationId ?? null}
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 },
];
@@ -0,0 +1,641 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ActiveChat, SellerScanResponse } from '../../../../../packages/api-contracts/src/index.js';
import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
import { ConversationDetail } from './conversation-detail.js';
import {
useBulkMarkConversationsReadMutation,
useConversationsQuery,
useMarkConversationReadMutation,
useMarkConversationUnreadMutation,
useSellerScanMutation,
useSetConversationStatusMutation,
} from './conversation-queries.js';
import { StartConversationForm } from './start-conversation-form.js';
import { ProcessedUserLookup } from './processed-user-lookup.js';
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
import './conversations.css';
import { ownedConversationId } from './conversation-identity.js';
import { conversationAge, formatExactTime, formatShortRelativeTime } from './relative-time.js';
import {
CONVERSATION_FILTERS,
CONVERSATION_SORTS,
DEFAULT_CONVERSATION_FILTERS,
accountOptions,
conversationQueue,
filterCounts,
isUnread,
manualFlowLabel,
needsReply,
partitionConversations,
type ConversationFilter,
type ConversationFilters,
type ConversationSort,
} from './conversation-triage.js';
export type { ConversationFilter, ConversationFilters, ConversationSort } from './conversation-triage.js';
const SMALL_VIEWPORT_QUERY = '(max-width: 51.9999rem)';
export interface ConversationsPageProps {
selectedConversationId?: string | null;
onSelectedConversationChange?(conversationId: string | null): void;
filters?: ConversationFilters;
onFiltersChange?(filters: ConversationFilters): void;
}
interface ConversationRowProps {
conversation: ActiveChat;
isSelected: boolean;
isChecked: boolean;
/** Only worth the row space when more than one account is in play. */
showOwner: boolean;
now: number;
onSelect(): void;
onToggleChecked(): void;
registerRef(element: HTMLButtonElement | null): void;
}
/**
* A chip's visible label, plus the longer form used for screen readers and the
* hover tooltip when the label alone does not say why the chip is there.
*/
interface ConversationChip {
label: string;
hint?: string;
tooltip?: string;
}
/**
* Plain-English outcome of a forced seller sweep. The AI counts are shown
* because the sweep is deliberately frugal with them — most conversations are
* settled by keyword rules or skipped as unchanged, and the operator should be
* able to see that.
*/
function sellerScanSummary(result: SellerScanResponse): string {
const flagged = result.flagged.length;
const killed = result.flagged.filter((flag) => flag.killed).length;
const parts = [
`Scanned ${result.scanned} ${result.scanned === 1 ? 'conversation' : 'conversations'}`,
flagged === 0 ? 'no new sellers found' : `${flagged} flagged as sellers${killed > 0 ? ` (${killed} ended)` : ''}`,
result.aiScreened === 0
? 'no AI needed'
: `${result.aiScreened} needed AI over ${result.aiRequests} ${result.aiRequests === 1 ? 'request' : 'requests'}`,
];
if (result.unchanged > 0) parts.push(`${result.unchanged} unchanged since the last scan`);
if (result.budgetSkipped > 0) parts.push(`${result.budgetSkipped} left for the next scan`);
if (result.degraded) parts.push('some conversations were skipped because screening failed');
return `${parts.join(' · ')}.`;
}
/** 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 (
<li className={itemClasses.join(' ')}>
<label className="conversation-list-check">
<input
aria-label={`Select ${redditUsernameLabel(conversation.username)}`}
checked={isChecked}
onChange={onToggleChecked}
type="checkbox"
/>
</label>
<button
aria-current={isSelected || undefined}
aria-label={descriptors.join(', ')}
className="conversation-list-open"
onClick={onSelect}
ref={registerRef}
type="button"
>
<span className="conversation-list-heading">
<strong>{redditUsernameLabel(conversation.username)}</strong>
<time
className={overdue === 'fresh' ? undefined : `is-${overdue}`}
dateTime={new Date(conversation.lastMessageTime).toISOString()}
title={formatExactTime(conversation.lastMessageTime)}
>
{relativeTime}
</time>
</span>
<span className="conversation-list-snippet">
{conversation.lastMessageSnippet || 'No message preview'}
</span>
</button>
<span className="conversation-list-chips">
{chips.map((chip) => (
<span
className={`conversation-chip is-${chip.label.toLowerCase().replaceAll(' ', '-')}`}
key={chip.label}
title={chip.tooltip}
>
{chip.label}
</span>
))}
{showOwner ? <AccountOwnerBadge owner={conversation.owner} /> : null}
{conversation.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>
);
}
export function ConversationsPage({
selectedConversationId, onSelectedConversationChange, filters, onFiltersChange,
}: ConversationsPageProps) {
const conversationsQuery = useConversationsQuery();
const [internalSelectedId, setInternalSelectedId] = useState<string | null>(null);
const [localFilters, setLocalFilters] = useState<ConversationFilters>(DEFAULT_CONVERSATION_FILTERS);
const [isStartingConversation, setIsStartingConversation] = useState(false);
const [now, setNow] = useState(() => Date.now());
const [checkedIds, setCheckedIds] = useState<Set<string>>(() => new Set());
const detailPanelRef = useRef<HTMLElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const rowRefs = useRef(new Map<string, HTMLButtonElement>());
const activeConversationId = selectedConversationId !== undefined ? selectedConversationId : internalSelectedId;
const activeFilters = filters ?? localFilters;
const markRead = useMarkConversationReadMutation();
const markUnread = useMarkConversationUnreadMutation();
const setStatus = useSetConversationStatusMutation();
const bulkMarkRead = useBulkMarkConversationsReadMutation();
const sellerScan = useSellerScanMutation();
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(() => {
if (!frozenOrder) return liveConversations;
const rank = new Map(frozenOrder.map((id, index) => [id, index]));
return liveConversations
.filter((conversation) => rank.has(ownedConversationId(conversation)))
.sort((left, right) =>
(rank.get(ownedConversationId(left)) ?? 0) - (rank.get(ownedConversationId(right)) ?? 0));
}, [liveConversations, frozenOrder]);
const pendingCount = liveConversations.length - visibleConversations.length;
const visibleIds = useMemo(
() => visibleConversations.map((conversation) => ownedConversationId(conversation)),
[visibleConversations],
);
function selectConversation(id: string | null) {
if (selectedConversationId === undefined) setInternalSelectedId(id);
onSelectedConversationChange?.(id);
}
function releaseFrozenOrder() {
setFrozenOrder(activeConversationId ? liveIds : null);
}
function toggleChecked(id: string) {
setCheckedIds((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
const checkedVisibleIds = visibleIds.filter((id) => checkedIds.has(id));
const allVisibleChecked = visibleIds.length > 0 && checkedVisibleIds.length === visibleIds.length;
function toggleAllVisible() {
setCheckedIds(allVisibleChecked ? new Set() : new Set(visibleIds));
}
function moveFocus(offset: number) {
if (visibleIds.length === 0) return;
const focused = document.activeElement;
let index = visibleIds.findIndex((id) => rowRefs.current.get(id) === focused);
if (index < 0) index = visibleIds.findIndex((id) => id === activeConversationId);
if (index < 0) {
// Entering the list for the first time: land on the near end rather than
// stepping past it.
rowRefs.current.get(visibleIds[offset > 0 ? 0 : visibleIds.length - 1])?.focus();
return;
}
const nextIndex = Math.min(visibleIds.length - 1, Math.max(0, index + offset));
rowRefs.current.get(visibleIds[nextIndex])?.focus();
}
function focusedConversationId(): string | null {
const focused = document.activeElement;
const focusedId = visibleIds.find((id) => rowRefs.current.get(id) === focused);
return focusedId ?? activeConversationId ?? null;
}
function isTypingTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
return target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName);
}
/**
* Inbox keyboard shortcuts. Suppressed whenever focus is inside a text field
* so typing a reply or a search term never triggers an action.
*/
function handleShortcut(event: KeyboardEvent) {
if (event.metaKey || event.ctrlKey || event.altKey) return;
// A modal owns the keyboard while it is open — including Escape, which must
// dismiss the dialog rather than the conversation behind it.
if (event.defaultPrevented || document.querySelector('dialog[open]')) return;
const typing = isTypingTarget(event.target);
if (event.key === '/' && !typing) {
event.preventDefault();
searchRef.current?.focus();
return;
}
if (event.key === 'Escape') {
if (typing) (event.target as HTMLElement).blur();
else if (activeConversationId) selectConversation(null);
return;
}
if (typing) return;
switch (event.key) {
case 'j':
case 'ArrowDown':
event.preventDefault();
moveFocus(1);
break;
case 'k':
case 'ArrowUp':
event.preventDefault();
moveFocus(-1);
break;
case 'u': {
const id = focusedConversationId();
const conversation = allConversations.find((item) => ownedConversationId(item) === id);
if (!id || !conversation) return;
event.preventDefault();
if (isUnread(conversation)) markRead.mutate({ id });
else markUnread.mutate({ id });
break;
}
case 'e': {
const id = focusedConversationId();
if (!id) return;
event.preventDefault();
setStatus.mutate({ id, status: 'done' });
if (id === activeConversationId) selectConversation(null);
break;
}
default:
break;
}
}
// The handler closes over per-render state, so it is kept in a ref and the
// listener is registered once rather than re-bound on every render.
const shortcutRef = useRef(handleShortcut);
shortcutRef.current = handleShortcut;
useEffect(() => {
const listener = (event: KeyboardEvent) => shortcutRef.current(event);
window.addEventListener('keydown', listener);
return () => window.removeEventListener('keydown', listener);
}, []);
const lastSyncedLabel = conversationsQuery.dataUpdatedAt
? formatShortRelativeTime(conversationsQuery.dataUpdatedAt, now)
: null;
return (
<div className="conversations-page">
<header className="conversations-header">
<div>
<span className="conversation-eyebrow">Inbox</span>
<h1>Conversations</h1>
<p>Review replies, draft carefully, and send only after confirmation.</p>
</div>
<div className="conversations-header-actions">
<div className="conversation-refresh">
<button
aria-busy={conversationsQuery.isFetching || undefined}
disabled={conversationsQuery.isFetching}
onClick={() => void conversationsQuery.refetch()}
type="button"
>
{conversationsQuery.isFetching ? 'Refreshing…' : 'Refresh'}
</button>
<span className="conversation-refresh-note">
{lastSyncedLabel ? `Updated ${lastSyncedLabel} · auto every 30s` : 'Auto-refreshes every 30s'}
</span>
</div>
<button
aria-busy={sellerScan.isPending || undefined}
className="conversation-seller-scan"
disabled={sellerScan.isPending}
onClick={() => sellerScan.mutate()}
title="Re-check every active conversation for content sellers"
type="button"
>
{sellerScan.isPending ? 'Scanning for sellers…' : 'Scan for sellers'}
</button>
<button className="conversation-primary" onClick={() => setIsStartingConversation((current) => !current)} type="button">
{isStartingConversation ? 'Close new conversation' : 'New conversation'}
</button>
</div>
</header>
{sellerScan.isError ? (
<p className="conversation-seller-scan-result is-error" role="alert">
Seller scan failed: {sellerScan.error.message}
</p>
) : sellerScan.data ? (
<p className="conversation-seller-scan-result" role="status">
{sellerScanSummary(sellerScan.data)}
</p>
) : null}
{isStartingConversation ? (
<section aria-labelledby="new-conversation-heading" className="new-conversation-panel">
<h2 id="new-conversation-heading">Start a conversation</h2>
<StartConversationForm onStarted={(id) => selectConversation(id)} />
</section>
) : null}
<ProcessedUserLookup />
<div className="conversation-workspace">
<section aria-label="Conversation inbox" className="conversation-master">
<div className="conversation-toolbar">
<label className="sr-only" htmlFor="conversation-search">Search conversations</label>
<input
id="conversation-search"
onChange={(event) => updateFilters({ ...activeFilters, search: event.target.value })}
placeholder="Search conversations (/)"
ref={searchRef}
type="search"
value={activeFilters.search}
/>
<div aria-label="Filter conversations" className="conversation-filter-pills" role="group">
{CONVERSATION_FILTERS.map((option) => (
<button
aria-label={`${option.label}, ${counts[option.value]}`}
aria-pressed={activeFilters.filter === option.value}
className="conversation-filter-pill"
key={option.value}
onClick={() => updateFilters({ ...activeFilters, filter: option.value })}
type="button"
>
{option.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>
{conversationsQuery.isPending ? (
<div aria-label="Loading conversations" className="conversation-list-state" role="status">Loading conversations…</div>
) : conversationsQuery.isError ? (
<div className="conversation-list-state conversation-inline-error" role="alert">
<strong>Could not load conversations</strong>
<span>{conversationsQuery.error.message}</span>
<button onClick={() => void conversationsQuery.refetch()} type="button">Retry conversations</button>
</div>
) : conversationsQuery.data.length === 0 ? (
<div className="conversation-list-state"><strong>No conversations yet.</strong><span>Start one when you are ready.</span></div>
) : visibleConversations.length === 0 ? (
<div className="conversation-list-state">
<strong>No matching conversations.</strong>
<span>Try a different search or filter.</span>
</div>
) : (
<>
<ul className="conversation-list">
{visibleConversations.map((conversation) => {
const id = ownedConversationId(conversation);
return (
<ConversationRow
conversation={conversation}
isChecked={checkedIds.has(id)}
isSelected={id === activeConversationId}
key={id}
now={now}
onSelect={() => selectConversation(id)}
onToggleChecked={() => toggleChecked(id)}
showOwner={accounts.length > 1}
registerRef={(element) => {
if (element) rowRefs.current.set(id, element);
else rowRefs.current.delete(id);
}}
/>
);
})}
</ul>
<p className="conversation-list-footer">
<span className="conversation-shortcut-hint">
j/k move · Enter opens · u toggles unread · e marks done · / searches
</span>
</p>
</>
)}
</section>
<section aria-label="Selected conversation" className="conversation-detail-panel" ref={detailPanelRef}>
{activeConversationId ? (
<ConversationDetail conversationId={activeConversationId} key={activeConversationId} onClose={() => selectConversation(null)} />
) : (
<div className="conversation-detail-state">
<strong>Select a conversation</strong>
<span>Message history and reply tools will appear here.</span>
</div>
)}
</section>
</div>
</div>
);
}
@@ -0,0 +1,261 @@
.conversations-page {
width: min(86rem, 100%);
margin: 0 auto;
padding: 1.5rem 1rem 4rem;
}
.conversations-header {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.conversations-header h1 { margin-bottom: .4rem; }
.conversations-header-actions { display: flex; flex-wrap: wrap; align-items: center; gap: .75rem; }
.conversation-refresh { display: grid; gap: .25rem; justify-items: end; }
.conversation-refresh-note { color: var(--muted); font-size: .72rem; }
.conversations-header p { margin-bottom: 0; color: var(--muted); }
.conversation-eyebrow { display: block; margin-bottom: .4rem; color: var(--accent); font-size: .72rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }
.conversation-primary { border-color: transparent !important; color: #06140b !important; background: var(--accent) !important; font-weight: 750; }
.conversation-seller-scan-result { margin: 0 0 1rem; color: var(--muted); font-size: .8rem; }
.conversation-seller-scan-result.is-error { color: var(--danger, #ff8f8f); }
.conversations-page button, .conversations-page input, .conversations-page select, .conversations-page textarea { font: inherit; }
.conversations-page button { min-height: 2.5rem; padding: .5rem .8rem; border: 1px solid var(--border); border-radius: .6rem; color: #e9f1ec; background: var(--surface-raised); }
.conversations-page button:disabled { cursor: wait; opacity: .58; }
.new-conversation-panel { padding: 1rem; margin-bottom: 1rem; background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; }
.new-conversation-panel h2 { margin-bottom: 1rem; }
.processed-user-lookup summary { cursor: pointer; }
.processed-user-lookup summary h2 { display: inline-block; margin: 0; vertical-align: middle; }
.processed-user-lookup[open] summary { margin-bottom: 1rem; }
.processed-user-lookup form + p, .processed-user-lookup ol { margin-top: .8rem; }
.conversation-form-hint { margin: 0; color: var(--muted); }
.conversation-form-hint a { color: var(--accent); }
.start-conversation-form { display: grid; gap: .8rem; }
.conversation-field { display: grid; gap: .35rem; }
.conversation-field label { color: var(--muted); font-size: .8rem; font-weight: 700; }
.conversation-field input, .conversation-field textarea, .conversation-toolbar input, .conversation-toolbar select, .conversation-composer textarea {
width: 100%; padding: .7rem .75rem; color: #e9f1ec; background: #0b1410; border: 1px solid var(--border); border-radius: .55rem;
}
.conversation-field input:focus, .conversation-field textarea:focus, .conversation-toolbar input:focus, .conversation-toolbar select:focus, .conversation-composer textarea:focus { outline: 2px solid var(--accent); outline-offset: 2px; }
.conversation-workspace { display: grid; gap: .75rem; }
.conversation-master, .conversation-detail-panel { min-width: 0; background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; overflow: hidden; }
.conversation-toolbar { display: grid; 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; }
/*
* The row is a grid of real siblings: a selection checkbox, a primary open
* button, and a chip strip. The previous full-bleed absolute overlay button made
* every per-row control need a pointer-events escape hatch.
*/
.conversation-list-item { position: relative; display: grid; grid-template-columns: auto minmax(0, 1fr); gap: .15rem .5rem; width: 100%; padding: .6rem .7rem; background: transparent; border: 1px solid transparent; border-radius: .6rem; }
.conversation-list-item:hover { background: var(--surface-raised); }
.conversation-list-item.is-selected { background: var(--accent-dark); border-color: #28593d; }
.conversation-list-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 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 strong, .conversation-detail-state strong { color: #e9f1ec; }
/* Column layout: history takes the slack, the composer stays reachable. */
.conversation-detail { display: flex; flex-direction: column; min-height: 32rem; max-height: min(48rem, calc(100vh - 6rem)); }
.conversation-detail > header { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: .75rem; padding: 1rem; border-bottom: 1px solid var(--border); }
.conversation-detail h2 { display: inline; margin-right: .6rem; }
.conversation-detail-identity { min-width: 0; }
.conversation-detail-badges { display: inline-flex; flex-wrap: wrap; align-items: center; gap: .35rem; }
.conversation-detail-controls { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; }
.conversation-triage-actions { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; }
.conversation-triage-actions button, .conversation-detail-controls > button { min-height: 2.1rem !important; padding: .35rem .6rem !important; font-size: .8rem; }
.conversation-snooze { position: relative; }
.conversation-snooze-menu { position: absolute; right: 0; z-index: 3; display: grid; gap: .2rem; min-width: 9rem; padding: .35rem; margin-top: .25rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .55rem; box-shadow: 0 .5rem 1.5rem rgba(0, 0, 0, .45); }
.conversation-snooze-menu button { width: 100%; text-align: left; }
.conversation-badge { display: inline-flex; padding: .2rem .45rem; color: #aaf0c4; background: #163a26; border-radius: 999px; font-size: .7rem; font-weight: 800; }
.conversation-badge.is-snoozed { color: var(--warning-text); background: var(--warning-bg); }
.conversation-badge.is-done { color: var(--muted); background: var(--surface-raised); }
.conversation-sync-notice { padding: .75rem 1rem; color: #ffe1a0; background: #2a2111; border-bottom: 1px solid #59451e; }
.triggering-post { margin: 1rem; padding: .75rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .65rem; }
.conversation-messages-shell { position: relative; display: flex; flex: 1 1 auto; min-height: 0; overflow: hidden; }
.conversation-jump-latest { position: absolute; right: 1rem; bottom: .75rem; z-index: 2; padding: .35rem .7rem !important; min-height: 2rem !important; color: #06140b !important; background: var(--accent) !important; border-color: transparent !important; border-radius: 999px !important; font-size: .78rem; font-weight: 800; box-shadow: 0 .4rem 1.2rem rgba(0, 0, 0, .35); }
.conversation-messages { display: grid; flex: 1 1 auto; align-content: start; gap: .75rem; min-height: 0; padding: 1rem; margin: 0; overflow-y: auto; list-style: none; }
/* Day dividers let the reader see silences; the unread rule marks where to resume. */
.conversation-day-divider, .conversation-unread-divider { display: flex; align-items: center; gap: .6rem; justify-self: stretch; max-width: none !important; padding: 0 !important; background: none !important; }
.conversation-day-divider::before, .conversation-day-divider::after, .conversation-unread-divider::before, .conversation-unread-divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
.conversation-day-divider span { color: var(--muted); font-size: .7rem; font-weight: 750; text-transform: uppercase; letter-spacing: .06em; }
.conversation-unread-divider::before, .conversation-unread-divider::after { background: var(--accent); opacity: .6; }
.conversation-unread-divider span { padding: .1rem .5rem; border-radius: 999px; background: var(--accent); color: #06140b; font-size: .68rem; font-weight: 800; text-transform: uppercase; letter-spacing: .06em; }
.conversation-messages li { max-width: 85%; padding: .7rem .8rem; border-radius: .8rem; }
.conversation-messages li.is-self { justify-self: end; background: var(--accent-dark); }
.conversation-messages li.is-recipient { justify-self: start; background: var(--surface-raised); }
.conversation-messages li > div { display: flex; justify-content: space-between; gap: 1rem; margin-bottom: .35rem; }
.conversation-messages time { color: var(--muted); font-size: .7rem; }
.conversation-messages p { margin: 0; line-height: 1.45; white-space: pre-wrap; }
.conversation-message-images { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(12rem, 100%), 1fr)); gap: .5rem; margin-top: .65rem; }
.conversation-message-image { position: relative; overflow: hidden; border-radius: .6rem; }
.conversation-message-images img { display: block; width: 100%; max-width: 28rem; max-height: 32rem; object-fit: contain; background: #08100c; border: 1px solid var(--border); border-radius: .6rem; transition: filter 160ms ease, transform 160ms ease; }
.conversation-message-images img.is-blurred { filter: blur(24px) brightness(.55); transform: scale(1.12); }
.conversation-image-reveal { position: absolute; bottom: .5rem; left: .5rem; min-height: 2rem !important; padding: .35rem .6rem !important; color: white !important; background: rgba(0, 0, 0, .76) !important; border-color: rgba(255, 255, 255, .45) !important; font-size: .75rem; font-weight: 800; }
.conversation-image-reveal:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.conversation-empty-detail { padding: 2rem; color: var(--muted); text-align: center; }
.conversation-composer { 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 h3 { margin: 0; }
.conversation-actions > span { color: var(--muted); font-size: .75rem; }
.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-success { color: #b9f3ce; background: #163a26; border: 1px solid #28593d; }
.conversation-media-share { display: grid; gap: .7rem; padding: .75rem; background: #0b1410; border: 1px solid var(--border); border-radius: .65rem; }
.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 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; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (min-width: 52rem) {
.conversations-page { padding: 3rem 2rem 5rem; }
.start-conversation-form { grid-template-columns: minmax(12rem, .5fr) minmax(18rem, 1fr) auto; align-items: end; }
.conversation-workspace { grid-template-columns: minmax(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';
}
@@ -0,0 +1,36 @@
import { Modal } from '../../components/modal.js';
interface SendConfirmationDialogProps {
draft: string;
isPending: boolean;
onCancel(): void;
onConfirm(): void;
}
export function SendConfirmationDialog({ draft, isPending, onCancel, onConfirm }: SendConfirmationDialogProps) {
return (
<Modal
className="dialog-card conversation-dialog"
isDismissLocked={isPending}
labelledBy="send-confirmation-title"
onClose={onCancel}
>
<span className="conversation-eyebrow">Confirm message</span>
<h2 id="send-confirmation-title">Send this reply?</h2>
<p>This sends immediately through Reddit chat.</p>
<blockquote>{draft}</blockquote>
<div className="conversation-actions">
<button autoFocus disabled={isPending} onClick={onCancel} type="button">Cancel</button>
<button
aria-busy={isPending || undefined}
className="conversation-primary"
disabled={isPending}
onClick={onConfirm}
type="button"
>
{isPending ? 'Sending…' : 'Confirm send'}
</button>
</div>
</Modal>
);
}
@@ -0,0 +1,100 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
import { useStartConversationMutation } from './conversation-queries.js';
import { useRedditAccountsQuery } from '../settings/settings-queries.js';
import { ownedConversationId } from './conversation-identity.js';
const SUCCESS_NOTICE_DISMISS_MS = 5_000;
interface StartConversationFormProps {
onStarted(conversationId: string): void;
}
export function StartConversationForm({ onStarted }: StartConversationFormProps) {
const [username, setUsername] = 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 mutation = useStartConversationMutation();
const canSubmit = accountId.length > 0 && username.trim().length > 0 && message.trim().length > 0 && !mutation.isPending;
useEffect(() => {
if (successUsername === null) return;
const timer = window.setTimeout(() => setSuccessUsername(null), SUCCESS_NOTICE_DISMISS_MS);
return () => window.clearTimeout(timer);
}, [successUsername]);
if (accountsQuery.isPending) {
return <p className="conversation-form-hint" role="status">Loading Reddit accounts…</p>;
}
if (accountsQuery.isError) {
return <div className="conversation-inline-error" role="alert">{accountsQuery.error.message}</div>;
}
if (runningAccounts.length === 0) {
return (
<p className="conversation-form-hint">
No running accounts — start one in <Link to="/accounts">Accounts</Link>.
</p>
);
}
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!canSubmit) return;
mutation.mutate(
{ accountId, username, message },
{
onSuccess(chat) {
setSuccessUsername(chat.username);
setUsername('');
setMessage('');
onStarted(ownedConversationId(chat));
},
},
);
}
return (
<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">
<label htmlFor="new-conversation-username">Reddit username</label>
<input
autoComplete="off"
id="new-conversation-username"
onChange={(event) => setUsername(event.target.value)}
placeholder="username"
value={username}
/>
</div>
<div className="conversation-field">
<label htmlFor="new-conversation-message">Opening message</label>
<textarea
id="new-conversation-message"
onChange={(event) => setMessage(event.target.value)}
placeholder="Write a personal opening message"
rows={4}
value={message}
/>
</div>
{mutation.isError ? <div className="conversation-inline-error" role="alert">{mutation.error.message}</div> : null}
{successUsername ? (
<div aria-label="Conversation started" className="conversation-inline-success" role="status">
Conversation started with <RedditUsernameLink username={successUsername} />
</div>
) : null}
<button className="conversation-primary" disabled={!canSubmit} type="submit">
{mutation.isPending ? 'Starting…' : 'Start conversation'}
</button>
</form>
);
}
@@ -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 }),
});
}
+44
View File
@@ -0,0 +1,44 @@
import type { ZodType } from 'zod';
import {
ErrorResponseSchema,
MediaResponseSchema,
MediaSyncStatusSchema,
MediaSyncTriggerResponseSchema,
type MediaItem,
type MediaSyncStatus,
} 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 async function fetchMedia(): Promise<MediaItem[]> {
return parseResponse(await request('/api/media'), MediaResponseSchema);
}
export async function fetchMediaSyncStatus(): Promise<MediaSyncStatus> {
return parseResponse(await request('/api/media/sync'), MediaSyncStatusSchema);
}
export async function triggerMediaSync(isFull: boolean): Promise<void> {
const path = isFull ? '/api/media/sync/full' : '/api/media/sync';
const response = await parseResponse(
await request(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: isFull ? undefined : JSON.stringify({ full: false }),
}),
MediaSyncTriggerResponseSchema,
);
if (!response.success) throw new Error(response.reason);
}
@@ -0,0 +1,41 @@
import type { MediaItem } from '../../../../../packages/api-contracts/src/index.js';
export interface MediaFilters {
conversation: string;
sender: string;
type: 'all' | 'image' | 'gif';
dateRange: 'all' | '24h' | '7d' | '30d';
}
export function mediaConversation(item: MediaItem): string {
return item.chatUsername ?? item.chatRoomId ?? 'unknown';
}
export function mediaType(item: MediaItem): 'image' | 'gif' {
return item.fileName.toLowerCase().endsWith('.gif') ? 'gif' : 'image';
}
export function mediaAlt(item: MediaItem): string {
const kind = mediaType(item) === 'gif' ? 'GIF' : 'Image';
const date = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short' }).format(item.timestamp);
return `${kind} from u/${item.sender}, ${date}`;
}
export function filterMedia(items: MediaItem[], filters: MediaFilters, now: number): MediaItem[] {
const rangeMs = {
all: null,
'24h': 86_400_000,
'7d': 7 * 86_400_000,
'30d': 30 * 86_400_000,
}[filters.dateRange];
return items
.filter((item) => {
if (filters.conversation !== 'all' && mediaConversation(item) !== filters.conversation) return false;
if (filters.sender !== 'all' && item.sender !== filters.sender) return false;
if (filters.type !== 'all' && mediaType(item) !== filters.type) return false;
if (rangeMs !== null && item.timestamp < now - rangeMs) return false;
return true;
})
.sort((left, right) => right.timestamp - left.timestamp);
}
@@ -0,0 +1,30 @@
import { Modal } from '../../components/modal.js';
interface FullSyncDialogProps {
isPending: boolean;
onCancel(): void;
onConfirm(): void;
}
export function FullSyncDialog({ isPending, onCancel, onConfirm }: FullSyncDialogProps) {
return (
<Modal
className="dialog-card"
isDismissLocked={isPending}
labelledBy="full-sync-title"
onClose={onCancel}
>
<span className="eyebrow">Confirm expensive action</span>
<h2 id="full-sync-title">Run full media sync?</h2>
<p>This scans every conversation and can take several minutes. Routine refreshes should use Sync new media.</p>
<div className="dialog-actions">
<button autoFocus className="button secondary" disabled={isPending} onClick={onCancel} type="button">
Cancel
</button>
<button aria-busy={isPending || undefined} className="button danger" disabled={isPending} onClick={onConfirm} type="button">
{isPending ? 'Syncing…' : 'Run full sync'}
</button>
</div>
</Modal>
);
}
@@ -0,0 +1,69 @@
import type { MediaItem } from '../../../../../packages/api-contracts/src/index.js';
import { Link } from 'react-router-dom';
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
import { createOwnedConversationId } from '../conversations/conversation-identity.js';
import { mediaAlt, mediaConversation, mediaType } from './filters.js';
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
interface MediaCardProps {
item: MediaItem;
isRevealed: boolean;
onToggleReveal(): void;
onOpenPreview(): void;
}
function formatMediaDate(timestamp: number): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp);
}
export function MediaCard({ item, isRevealed, onToggleReveal, onOpenPreview }: MediaCardProps) {
const conversationId = item.chatRoomId ?? item.chatUsername;
const conversationPath = conversationId
? `/conversations/${encodeURIComponent(createOwnedConversationId(item.owner?.accountId, conversationId))}`
: null;
return (
<article className="media-card">
<div className="media-preview">
<button
aria-label={isRevealed ? `View media from ${item.sender} larger` : `Reveal media from ${item.sender}`}
className="media-preview-button"
onClick={() => {
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}
</div>
<div className="media-card-body">
<AccountOwnerBadge owner={item.owner} />
<div>
<strong><span className="media-link-prefix">From</span> <RedditUsernameLink showPrefix={false} username={item.sender} /></strong>
<span><span className="media-link-prefix">Conversation</span> {item.chatUsername
? <RedditUsernameLink showPrefix={false} username={item.chatUsername} />
: mediaConversation(item)}</span>
</div>
<dl>
<div><dt>Type</dt><dd>{mediaType(item) === 'gif' ? 'GIF' : 'Image'}</dd></div>
<div><dt>Received</dt><dd>{formatMediaDate(item.timestamp)}</dd></div>
</dl>
{conversationPath ? (
<Link className="button secondary media-conversation-link" to={conversationPath}>
Open conversation
</Link>
) : null}
<button
aria-pressed={isRevealed}
className="button secondary media-reveal"
onClick={onToggleReveal}
type="button"
>
{isRevealed ? `Hide media from ${item.sender}` : `Reveal media from ${item.sender}`}
</button>
</div>
</article>
);
}
@@ -0,0 +1,295 @@
import { useEffect, useMemo, useState } from 'react';
import { filterMedia, mediaAlt, mediaConversation, type MediaFilters } from './filters.js';
import { FullSyncDialog } from './full-sync-dialog.js';
import { MediaCard } from './media-card.js';
import { MediaPreviewDialog, type MediaPreviewEntry } from './media-preview-dialog.js';
import { useMediaQuery, useMediaSyncStatusQuery, useTriggerMediaSyncMutation } from './queries.js';
import './media.css';
export const initialMediaFilters: MediaFilters = {
conversation: 'all',
sender: 'all',
type: 'all',
dateRange: 'all',
};
const PAGE_SIZE = 48;
const SUCCESS_NOTICE_MS = 5_000;
function errorMessage(error: Error | null): string {
return error?.message ?? 'Something went wrong.';
}
function formatLastSync(timestamp: number): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp);
}
export interface MediaPageProps {
filters?: MediaFilters;
onFiltersChange?(filters: MediaFilters): void;
}
export function MediaPage({ filters: controlledFilters, onFiltersChange }: MediaPageProps) {
const mediaQuery = useMediaQuery();
const statusQuery = useMediaSyncStatusQuery();
const syncMutation = useTriggerMediaSyncMutation();
const [localFilters, setLocalFilters] = useState<MediaFilters>(initialMediaFilters);
const filters = controlledFilters ?? localFilters;
const [revealedIds, setRevealedIds] = useState<Set<string>>(() => new Set());
const [isRevealAll, setIsRevealAll] = useState(false);
const [isFullSyncDialogOpen, setIsFullSyncDialogOpen] = useState(false);
const [previewItemId, setPreviewItemId] = useState<string | null>(null);
const [visibleLimit, setVisibleLimit] = useState(PAGE_SIZE);
const items = mediaQuery.data ?? [];
const visibleItems = useMemo(() => filterMedia(items, filters, Date.now()), [items, filters]);
const displayedItems = visibleItems.slice(0, visibleLimit);
const conversations = useMemo(
() => [...new Set(items.map(mediaConversation))].sort((left, right) => left.localeCompare(right)),
[items],
);
const senders = useMemo(
() => [...new Set(items.map((item) => item.sender))].sort((left, right) => left.localeCompare(right)),
[items],
);
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]) {
updateFilters({ ...filters, [key]: value });
}
function toggleItem(itemId: string) {
if (isRevealAll) {
setIsRevealAll(false);
setRevealedIds(new Set(items.filter((item) => item.id !== itemId).map((item) => item.id)));
return;
}
setRevealedIds((current) => {
const next = new Set(current);
if (next.has(itemId)) next.delete(itemId);
else next.add(itemId);
return next;
});
}
function runSync(isFull: boolean) {
syncMutation.mutate(isFull, {
onSuccess: () => {
if (isFull) setIsFullSyncDialogOpen(false);
},
onError: () => {
if (isFull) setIsFullSyncDialogOpen(false);
},
});
}
if (mediaQuery.isPending) {
return <div aria-label="Loading media" className="page-state" role="status"><span className="spinner" />Loading media…</div>;
}
if (mediaQuery.isError) {
return (
<div className="page-state error-state" role="alert">
<strong>Media unavailable</strong>
<span>{errorMessage(mediaQuery.error)}</span>
<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>
);
}
return (
<div className="page media-page">
<header className="page-header">
<div>
<span className="eyebrow">Private gallery</span>
<h1>Media</h1>
<p>Browse authenticated conversation media. Everything stays blurred until you choose to reveal it.</p>
</div>
<div className="header-actions">
{items.length > 0 ? (
<button
aria-pressed={isRevealAll}
className="button secondary"
onClick={() => setIsRevealAll((current) => !current)}
type="button"
>
{isRevealAll ? 'Blur all media' : 'Reveal all media'}
</button>
) : null}
<button
className="button secondary"
disabled={isSyncBusy}
onClick={() => {
syncMutation.reset();
setIsFullSyncDialogOpen(true);
}}
type="button"
>
Full sync
</button>
<button aria-busy={isSyncBusy || undefined} className="button primary" disabled={isSyncBusy} onClick={() => runSync(false)} type="button">
{isSyncBusy ? 'Syncing…' : 'Sync new media'}
</button>
</div>
</header>
<section aria-label="Media sync status" className="status-strip media-sync-status">
{statusQuery.isPending ? <span role="status">Loading sync status…</span> : null}
{statusQuery.isError ? (
<div role="alert">
<span>{errorMessage(statusQuery.error)}</span>
<button
aria-busy={statusQuery.isFetching || undefined}
className="button secondary"
disabled={statusQuery.isFetching}
onClick={() => void statusQuery.refetch()}
type="button"
>
{statusQuery.isFetching ? 'Refreshing…' : 'Retry sync status'}
</button>
</div>
) : null}
{statusQuery.data ? (
statusQuery.data.isSyncing ? (
<div role="status"><span className="spinner" /><strong>Media sync in progress</strong></div>
) : (
<div>
<span className="status-dot running" aria-hidden="true" />
<div>
<small>Sync status</small>
<strong>{statusQuery.data.lastSyncChats === null ? 'No completed sync yet' : `Last synced ${statusQuery.data.lastSyncChats} conversations`}</strong>
</div>
</div>
)
) : null}
{statusQuery.data?.lastSyncAt ? <span className="status-note">{formatLastSync(statusQuery.data.lastSyncAt)}</span> : null}
</section>
{statusQuery.data?.lastError ? <div className="cooldown-banner" role="alert"><strong>Last sync failed</strong><span>{statusQuery.data.lastError}</span></div> : null}
{syncMutation.isSuccess ? <div className="media-notice success" role="status">{syncMutation.variables ? 'Full media sync completed.' : 'Media sync completed.'}</div> : null}
{syncMutation.isError ? <div className="media-notice error" role="alert">{errorMessage(syncMutation.error)}</div> : null}
{items.length === 0 ? (
<section className="panel empty-state"><strong>No media yet.</strong><span>Use Sync new media to check recent conversations.</span></section>
) : (
<>
<section aria-label="Media filters" className="panel media-filters">
<label>Conversation
<select value={filters.conversation} onChange={(event) => updateFilter('conversation', event.target.value)}>
<option value="all">All conversations</option>
{conversations.map((conversation) => <option key={conversation} value={conversation}>{conversation}</option>)}
</select>
</label>
<label>Sender
<select value={filters.sender} onChange={(event) => updateFilter('sender', event.target.value)}>
<option value="all">All senders</option>
{senders.map((sender) => <option key={sender} value={sender}>{sender}</option>)}
</select>
</label>
<label>Media type
<select value={filters.type} onChange={(event) => updateFilter('type', event.target.value as MediaFilters['type'])}>
<option value="all">All types</option><option value="image">Images</option><option value="gif">GIFs</option>
</select>
</label>
<label>Date range
<select value={filters.dateRange} onChange={(event) => updateFilter('dateRange', event.target.value as MediaFilters['dateRange'])}>
<option value="all">Any time</option><option value="24h">Past 24 hours</option><option value="7d">Past 7 days</option><option value="30d">Past 30 days</option>
</select>
</label>
<div className="media-filter-summary">
<span role="status">Showing {displayedItems.length} of {items.length} items</span>
{isFiltered ? (
<button className="button secondary" onClick={() => updateFilters(initialMediaFilters)} type="button">
Clear filters
</button>
) : null}
</div>
</section>
{visibleItems.length === 0 ? (
<section className="panel empty-state">
<strong>No media matches these filters.</strong>
<span>Try broadening one or more filters.</span>
<button className="button secondary" onClick={() => updateFilters(initialMediaFilters)} type="button">
Clear filters
</button>
</section>
) : (
<>
<section aria-label="Media gallery" className="media-grid">
{displayedItems.map((item) => (
<MediaCard
isRevealed={isRevealAll || revealedIds.has(item.id)}
item={item}
key={item.id}
onOpenPreview={() => setPreviewItemId(item.id)}
onToggleReveal={() => toggleItem(item.id)}
/>
))}
</section>
{visibleItems.length > displayedItems.length ? (
<div className="media-load-more">
<button className="button secondary" onClick={() => setVisibleLimit((current) => current + PAGE_SIZE)} type="button">
Load more ({visibleItems.length - displayedItems.length} remaining)
</button>
</div>
) : null}
</>
)}
</>
)}
{isFullSyncDialogOpen ? (
<FullSyncDialog
isPending={syncMutation.isPending}
onCancel={() => setIsFullSyncDialogOpen(false)}
onConfirm={() => runSync(true)}
/>
) : null}
{previewIndex >= 0 ? (
<MediaPreviewDialog
entries={previewEntries}
index={previewIndex}
onClose={() => setPreviewItemId(null)}
onNavigate={(nextIndex) => setPreviewItemId(visibleItems[nextIndex]?.id ?? null)}
/>
) : null}
</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} />;
}
@@ -0,0 +1,58 @@
.media-page { max-width: 86rem; }
.media-sync-status { min-height: 4.4rem; }
.media-sync-status > div[role="alert"] { width: 100%; justify-content: space-between; }
.media-sync-status .spinner { width: 1rem; height: 1rem; }
.media-notice { padding: .8rem 1rem; margin-bottom: 1rem; border-radius: .75rem; font-weight: 700; }
.media-notice.success { color: var(--success-text); background: var(--success-bg); border: 1px solid var(--success-border); }
.media-notice.error { color: var(--danger-text); background: var(--danger-bg); border: 1px solid var(--danger-border); }
.media-filters { display: grid; grid-template-columns: 1fr; gap: .8rem; margin-bottom: 1rem; }
.media-filters label { display: grid; gap: .35rem; color: var(--muted); font-size: .78rem; font-weight: 750; }
.media-filters select { width: 100%; min-height: 2.6rem; padding: .5rem .7rem; color: #e9f1ec; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .6rem; }
.media-filters select:focus-visible, .media-reveal:focus-visible, .media-conversation-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.media-filter-summary { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .6rem; grid-column: 1 / -1; color: var(--muted); font-size: .82rem; }
.media-grid { display: grid; grid-template-columns: 1fr; gap: 1rem; }
.media-load-more { display: flex; justify-content: center; margin-top: 1rem; }
.media-card { min-width: 0; overflow: hidden; background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; }
.media-preview { position: relative; display: grid; place-items: center; aspect-ratio: 4 / 3; overflow: hidden; background: #050907; }
.media-preview-button { width: 100%; height: 100%; padding: 0; border: 0; background: transparent; cursor: zoom-in; }
.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.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; }
.media-card-body { display: grid; gap: .8rem; padding: .9rem; }
.media-card-body > div:first-child { display: flex; justify-content: space-between; gap: .75rem; }
.media-card-body > div:first-child span { color: var(--muted); }
.media-link-prefix { color: var(--muted); font-size: .72rem; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; }
.media-card-body dl { display: grid; gap: .4rem; margin: 0; }
.media-card-body dl div { display: flex; justify-content: space-between; gap: .75rem; }
.media-card-body dt { color: var(--muted); }
.media-card-body dd { margin: 0; text-align: right; }
.media-conversation-link { display: flex; align-items: center; justify-content: center; width: 100%; text-decoration: none; }
.media-reveal { width: 100%; }
/* 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-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.media-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (min-width: 72rem) {
.media-filters { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.media-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@@ -0,0 +1,41 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { fetchMedia, fetchMediaSyncStatus, triggerMediaSync } from './api.js';
export const mediaQueryKeys = {
items: ['dashboard', 'media'] as const,
syncStatus: ['dashboard', 'media', 'sync-status'] as const,
};
export function useMediaQuery() {
return useQuery({ queryKey: mediaQueryKeys.items, queryFn: fetchMedia });
}
export function useMediaSyncStatusQuery() {
return useQuery({
queryKey: mediaQueryKeys.syncStatus,
queryFn: fetchMediaSyncStatus,
refetchInterval(query) {
return query.state.data?.isSyncing ? 1_000 : 15_000;
},
});
}
export function useTriggerMediaSyncMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn(isFull: boolean) {
const syncRequest = triggerMediaSync(isFull);
void queryClient.invalidateQueries({ queryKey: mediaQueryKeys.syncStatus });
return syncRequest;
},
async onSuccess() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: mediaQueryKeys.items }),
queryClient.invalidateQueries({ queryKey: mediaQueryKeys.syncStatus }),
]);
},
onError() {
void queryClient.invalidateQueries({ queryKey: mediaQueryKeys.syncStatus });
},
});
}
@@ -0,0 +1,118 @@
export type ConsoleLevel = 'debug' | 'info' | 'warn' | 'error';
export type ConsoleLevelFilter = 'all' | ConsoleLevel;
export interface ConsoleEntry {
id: string;
occurredAt: string;
level: ConsoleLevel;
message: string;
data?: unknown;
}
export interface LiveConsoleState {
capacity: number;
entries: readonly ConsoleEntry[];
pendingEntries: readonly ConsoleEntry[];
isPaused: boolean;
isAutoScrollEnabled: boolean;
level: ConsoleLevelFilter;
search: string;
}
export type LiveConsoleAction =
| { type: 'append'; entry: ConsoleEntry }
| { type: 'set-paused'; paused: boolean }
| { type: 'set-auto-scroll'; enabled: boolean }
| { type: 'set-level'; level: ConsoleLevelFilter }
| { type: 'set-search'; search: string }
| { type: 'clear' };
function boundedAppend(
entries: readonly ConsoleEntry[],
entry: ConsoleEntry,
capacity: number,
): readonly ConsoleEntry[] {
if (entries.some((candidate) => isSameEntry(candidate, entry))) return entries;
return [...entries, entry].slice(-capacity);
}
function isSameEntry(left: ConsoleEntry, right: ConsoleEntry): boolean {
return left.id === right.id || (
left.occurredAt === right.occurredAt &&
left.level === right.level &&
left.message === right.message &&
searchableData(left.data) === searchableData(right.data)
);
}
export function createLiveConsoleState(capacity = 500): LiveConsoleState {
if (!Number.isInteger(capacity) || capacity <= 0) {
throw new RangeError('capacity must be a positive integer');
}
return {
capacity,
entries: [],
pendingEntries: [],
isPaused: false,
isAutoScrollEnabled: true,
level: 'all',
search: '',
};
}
export function reduceLiveConsoleState(
state: LiveConsoleState,
action: LiveConsoleAction,
): LiveConsoleState {
switch (action.type) {
case 'append': {
if (
state.entries.some((entry) => isSameEntry(entry, action.entry)) ||
state.pendingEntries.some((entry) => isSameEntry(entry, action.entry))
) return state;
return state.isPaused
? { ...state, pendingEntries: boundedAppend(state.pendingEntries, action.entry, state.capacity) }
: { ...state, entries: boundedAppend(state.entries, action.entry, state.capacity) };
}
case 'set-paused':
if (action.paused === state.isPaused) return state;
return action.paused
? { ...state, isPaused: true }
: {
...state,
isPaused: false,
entries: [...state.entries, ...state.pendingEntries].slice(-state.capacity),
pendingEntries: [],
};
case 'set-auto-scroll':
return { ...state, isAutoScrollEnabled: action.enabled };
case 'set-level':
return { ...state, level: action.level };
case 'set-search':
return { ...state, search: action.search };
case 'clear':
return { ...state, entries: [], pendingEntries: [] };
}
}
function searchableData(data: unknown): string {
if (data === undefined) return '';
try {
return JSON.stringify(data);
} catch {
return '';
}
}
export function selectVisibleConsoleEntries(
state: LiveConsoleState,
): readonly ConsoleEntry[] {
const search = state.search.trim().toLocaleLowerCase();
return state.entries.filter((entry) => {
if (state.level !== 'all' && entry.level !== state.level) return false;
if (search.length === 0) return true;
return `${entry.message} ${searchableData(entry.data)}`
.toLocaleLowerCase()
.includes(search);
});
}
@@ -0,0 +1,67 @@
import { z } from 'zod';
import {
ActivityResponseSchema,
ChatSyncStatusSchema,
ChatSyncTriggerResponseSchema,
FetchAllChatsResponseSchema,
} from '../../../../../packages/api-contracts/src/index.js';
import { createDashboardRequest } from '../../api/request.js';
export const RawLogEntrySchema = z.strictObject({
timestamp: z.number(),
level: z.union([z.literal('debug'), z.literal('info'), z.literal('warn'), z.literal('error')]),
message: z.string(),
data: z.unknown().optional(),
});
export const RawLogsResponseSchema = z.array(RawLogEntrySchema);
export type RawLogEntry = z.infer<typeof RawLogEntrySchema>;
async function parseResponse<T>(
response: Response,
schema: { parse(value: unknown): T },
): Promise<T> {
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
: `Request failed (${response.status})`;
throw new Error(message);
}
return schema.parse(payload);
}
export function createOperationsApi(fetchImplementation?: typeof fetch) {
const request = createDashboardRequest(fetchImplementation);
return {
async getActivity() {
return parseResponse(await request('/api/activity'), ActivityResponseSchema);
},
async getLogs(limit = 200) {
return parseResponse(
await request(`/api/logs?limit=${limit}`),
RawLogsResponseSchema,
);
},
async getSyncStatus() {
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);
},
};
}
export const operationsApi = createOperationsApi();
@@ -0,0 +1,297 @@
import { useEffect, useReducer, useRef, useState } from 'react';
import type { ActivityItem, ChatSyncStatus, DashboardEvent, RedditAccountOwner } from '../../../../../packages/api-contracts/src/index.js';
import { useDashboardRealtime } from '../../app/realtime-provider.js';
import { Modal } from '../../components/modal.js';
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
import { RedditUserText } from '../../components/reddit-user-text.js';
import { OriginPostButton } from '../../components/origin-post.js';
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
import {
createLiveConsoleState,
reduceLiveConsoleState,
selectVisibleConsoleEntries,
type ConsoleEntry,
type ConsoleLevelFilter,
} from './live-console-model.js';
import { useActivityQuery, useFetchAllChatsMutation, useLogsQuery, useSyncStatusQuery, useTriggerChatSyncMutation } from './operations-queries.js';
import type { RealtimeConnectionSnapshot } from './realtime-client.js';
import './operations.css';
function formatTimestamp(timestamp: number | string): string {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'medium',
}).format(typeof timestamp === 'number' ? timestamp : new Date(timestamp));
}
function ActivityView({ items }: { items: readonly ActivityItem[] }) {
return items.length === 0 ? (
<p className="feature-empty">No recent operator activity.</p>
) : (
<ol className="operations-activity-list">
{items.map((item, index) => (
<li key={`${item.timestamp}-${index}`}>
<span className={`activity-marker ${item.type}`} aria-hidden="true" />
<AccountOwnerBadge owner={item.owner} />
<div><strong>{item.postUrl && item.postTitle ? (
<OriginPostButton post={{ title: item.postTitle, url: item.postUrl, imageUrl: item.imageUrl }}>
{item.message}
</OriginPostButton>
) : <RedditUserText>{item.message}</RedditUserText>}</strong><small>{formatTimestamp(item.timestamp)}{item.recipient && !item.message.includes(`u/${item.recipient}`) ? <> · <RedditUsernameLink username={item.recipient} /></> : null}</small></div>
</li>
))}
</ol>
);
}
function SyncSummary({ status }: { status: ChatSyncStatus }) {
const progress = status.progress;
return (
<div className="sync-summary">
<div><span className={`pill ${status.syncing ? 'warning' : 'success'}`}>{status.syncing ? `${status.syncingKind ?? 'Unknown'} sync running` : 'Idle'}</span><strong>{status.cachedChats} cached conversations</strong></div>
{progress ? (
<div>
<div className="sync-progress-label"><span>{progress.label ?? progress.phase}</span><strong>{progress.current} of {progress.total}</strong></div>
<progress aria-label="Sync progress" max={Math.max(1, progress.total)} value={progress.current} />
</div>
) : null}
<dl className="sync-history">
<div><dt>Last delta sync</dt><dd>{status.lastDeltaSyncAt ? formatTimestamp(status.lastDeltaSyncAt) : 'Never'}</dd></div>
<div><dt>Last full sync</dt><dd>{status.lastFullSyncAt ? formatTimestamp(status.lastFullSyncAt) : 'Never'}</dd></div>
</dl>
</div>
);
}
function connectionLabel(snapshot: RealtimeConnectionSnapshot): string {
switch (snapshot.status) {
case 'connected': return 'Realtime connected';
case 'connecting': return 'Connecting to realtime updates';
case 'reconnecting': return 'Realtime reconnecting';
case 'closed': return 'Realtime closed';
case 'idle': return 'Realtime unavailable';
}
}
export function accountOwnerFromLogData(data: unknown): RedditAccountOwner | undefined {
if (!data || typeof data !== 'object' || Array.isArray(data)) return undefined;
const accountId = Reflect.get(data, 'accountId');
const redditUsername = Reflect.get(data, 'redditUsername');
return typeof accountId === 'string' && accountId.length > 0
&& typeof redditUsername === 'string' && redditUsername.length > 0
? { accountId, redditUsername }
: undefined;
}
export interface OperationsPageProps {
realtimeEnabled?: boolean;
}
const NOTICE_DISMISS_MS = 5_000;
const expensiveSyncCopy = {
full: {
title: 'Run a full cache sync?',
body: 'This rebuilds the entire chat cache and can take significantly longer than a delta sync.',
confirmLabel: 'Run full sync',
},
'fetch-all': {
title: 'Fetch every chat now?',
body: 'This is a legacy blocking operation that fetches every chat and may take a while.',
confirmLabel: 'Start fetching',
},
} as const;
function ConfirmSyncDialog({ kind, onCancel, onConfirm }: {
kind: 'full' | 'fetch-all';
onCancel(): void;
onConfirm(): void;
}) {
const copy = expensiveSyncCopy[kind];
return (
<Modal className="dialog-card" labelledBy="confirm-sync-title" onClose={onCancel}>
<span className="eyebrow">Confirm action</span>
<h2 id="confirm-sync-title">{copy.title}</h2>
<p>{copy.body}</p>
<div className="dialog-actions">
<button autoFocus className="button secondary" onClick={onCancel} type="button">Cancel</button>
<button className="button primary" onClick={onConfirm} type="button">{copy.confirmLabel}</button>
</div>
</Modal>
);
}
export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps) {
const realtime = useDashboardRealtime();
const activityQuery = useActivityQuery();
const logsQuery = useLogsQuery();
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(
reduceLiveConsoleState,
createLiveConsoleState(500),
);
const connection = realtimeEnabled ? realtime.connection : { status: 'idle', isStale: true } as const;
const consoleEnd = useRef<HTMLDivElement>(null);
const consoleViewport = useRef<HTMLDivElement>(null);
useEffect(() => {
logsQuery.data?.forEach((entry, index) => {
dispatchConsole({
type: 'append',
entry: {
id: `initial-${entry.timestamp}-${index}`,
occurredAt: new Date(entry.timestamp).toISOString(),
level: entry.level,
message: entry.message,
data: entry.data,
},
});
});
}, [logsQuery.data]);
useEffect(() => {
if (!realtimeEnabled) return;
function onEvent(event: DashboardEvent): void {
if (event.type === 'log.created') {
const entry: ConsoleEntry = {
id: event.id,
occurredAt: event.occurredAt,
level: event.payload.level,
message: event.payload.message,
data: event.payload.data,
};
dispatchConsole({ type: 'append', entry });
}
}
return realtime.subscribe(onEvent);
}, [realtime, realtimeEnabled]);
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(() => {
if (
consoleState.isAutoScrollEnabled &&
isPinnedToBottom &&
!consoleState.isPaused &&
typeof consoleEnd.current?.scrollIntoView === 'function'
) {
consoleEnd.current.scrollIntoView({ block: 'nearest' });
}
}, [consoleState.entries, consoleState.isAutoScrollEnabled, consoleState.isPaused, isPinnedToBottom]);
return (
<div className="page operations-page">
<header className="page-header"><div><span className="eyebrow">Observability</span><h1>Operations</h1><p>Human-readable activity, background work, and raw diagnostic output in distinct views.</p></div></header>
<div aria-label="Realtime connection" className={`connection-banner ${connection.isStale ? 'stale' : ''}`} role="status">
<strong>{connectionLabel(connection)}</strong>
{connection.isStale ? <span>State may be stale</span> : <span>Events are updating live</span>}
</div>
<div className="operations-summary-grid">
<section aria-labelledby="operations-activity-heading" className="panel feature-panel" role="region">
<div className="panel-heading"><div><span className="eyebrow">Readable history</span><h2 id="operations-activity-heading">Activity</h2></div></div>
{activityQuery.isPending ? <p role="status">Loading activity…</p> : activityQuery.isError ? <p role="alert">{activityQuery.error.message}</p> : <ActivityView items={activityQuery.data} />}
</section>
<section aria-labelledby="sync-jobs-heading" className="panel feature-panel" role="region">
<div className="panel-heading"><div><span className="eyebrow">Background work</span><h2 id="sync-jobs-heading">Sync jobs</h2></div></div>
{syncQuery.isPending ? <p role="status">Loading sync state…</p> : syncQuery.isError ? <p role="alert">{syncQuery.error.message}</p> : <><SyncSummary status={syncQuery.data} /><div className="header-actions"><button className="button secondary" disabled={syncBusy} onClick={() => syncMutation.mutate('delta')} type="button">Sync latest chats</button><button className="button secondary" disabled={syncBusy} onClick={() => 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>
</div>
<section aria-labelledby="live-console-heading" className="panel console-panel" role="region">
<div className="panel-heading"><div><span className="eyebrow">Raw diagnostics</span><h2 id="live-console-heading">Live console</h2></div><span className="console-count">{visibleEntries.length === consoleState.entries.length
? `${consoleState.entries.length} retained`
: `${visibleEntries.length} shown · ${consoleState.entries.length} retained`}</span></div>
<div className="console-controls">
<button
aria-pressed={consoleState.isPaused}
className="button secondary"
onClick={() => {
const paused = !consoleState.isPaused;
dispatchConsole({ type: 'set-paused', paused });
setPauseAnnouncement(paused ? 'Console paused.' : 'Console resumed.');
}}
type="button"
>{consoleState.isPaused ? `Resume console (${consoleState.pendingEntries.length})` : 'Pause console'}</button>
<button className="button secondary" onClick={() => { dispatchConsole({ type: 'clear' }); setConsoleClearNotice(true); }} type="button">Clear local console</button>
<label>Log level<select aria-label="Log level" onChange={(event) => dispatchConsole({ type: 'set-level', level: event.target.value as ConsoleLevelFilter })} value={consoleState.level}><option value="all">All levels</option><option value="debug">Debug</option><option value="info">Info</option><option value="warn">Warning</option><option value="error">Error</option></select></label>
<label>Search logs<input aria-label="Search logs" onChange={(event) => dispatchConsole({ type: 'set-search', search: event.target.value })} placeholder="Message or metadata" type="search" value={consoleState.search} /></label>
<label className="checkbox-control"><input checked={consoleState.isAutoScrollEnabled} onChange={(event) => dispatchConsole({ type: 'set-auto-scroll', enabled: event.target.checked })} type="checkbox" />Auto-scroll</label>
</div>
{consoleClearNotice ? <p role="status">Cleared from this browser view. Server logs were not deleted.</p> : null}
<p aria-live="polite" className="sr-only" role="status">{pauseAnnouncement}</p>
<div className="live-console" onScroll={handleConsoleScroll} ref={consoleViewport} role="log">
{logsQuery.isPending ? <p>Loading logs…</p> : visibleEntries.length === 0 ? <p>No logs match the current filters.</p> : visibleEntries.map((entry) => {
const owner = accountOwnerFromLogData(entry.data);
return (
<article className={`console-entry ${entry.level}`} key={entry.id}>
<time dateTime={entry.occurredAt}>{formatTimestamp(entry.occurredAt)}</time>
<strong>{entry.level.toUpperCase()}</strong>
<span className="console-account">{owner ? <RedditUsernameLink username={owner.redditUsername} /> : 'global'}</span>
<span><RedditUserText>{entry.message}</RedditUserText></span>
</article>
);
})}
<div ref={consoleEnd} />
</div>
{!isPinnedToBottom ? (
<button className="button secondary console-jump" onClick={jumpToLatest} type="button">Jump to latest</button>
) : null}
</section>
{pendingSyncConfirm ? (
<ConfirmSyncDialog
kind={pendingSyncConfirm}
onCancel={() => setPendingSyncConfirm(null)}
onConfirm={() => runExpensiveSync(pendingSyncConfirm)}
/>
) : null}
</div>
);
}
@@ -0,0 +1,30 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { operationsApi } from './operations-api.js';
export const operationsQueryKeys = {
activity: ['operations', 'activity'] as const,
logs: ['operations', 'logs'] as const,
syncStatus: ['operations', 'sync-status'] as const,
};
export function useActivityQuery() {
return useQuery({ queryKey: operationsQueryKeys.activity, queryFn: operationsApi.getActivity });
}
export function useLogsQuery() {
return useQuery({ queryKey: operationsQueryKeys.logs, queryFn: () => operationsApi.getLogs(200) });
}
export function useSyncStatusQuery() {
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 }) });
}
@@ -0,0 +1,37 @@
.operations-page { display: grid; gap: .8rem; }
.connection-banner { display: flex; justify-content: space-between; gap: 1rem; padding: .8rem 1rem; color: var(--success-text); background: var(--success-bg); border: 1px solid var(--success-border); border-radius: .75rem; }
.connection-banner.stale { color: var(--warning-text); background: var(--warning-bg); border-color: var(--warning-border); }
.operations-summary-grid { display: grid; gap: .8rem; }
.operations-activity-list { display: grid; gap: .8rem; padding: 0; margin: 0; list-style: none; }
.operations-activity-list li { display: flex; gap: .75rem; }
.operations-activity-list li div { display: grid; gap: .2rem; }
.operations-activity-list small { color: var(--muted); }
.sync-summary { display: grid; gap: 1rem; }
.sync-summary > div:first-child { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
.sync-progress-label { display: flex; justify-content: space-between; gap: .75rem; margin-bottom: .45rem; color: var(--muted); }
.sync-summary progress { width: 100%; accent-color: var(--accent); }
.sync-history { display: grid; gap: .5rem; margin: 0; }
.sync-history div { display: flex; justify-content: space-between; gap: 1rem; padding-top: .6rem; border-top: 1px solid var(--border); }
.sync-history dt { color: var(--muted); }
.sync-history dd { margin: 0; }
.console-count { color: var(--muted); font-size: .8rem; }
.console-controls { display: grid; gap: .65rem; margin-bottom: .8rem; }
.console-controls label { display: grid; gap: .3rem; color: var(--muted); font-size: .78rem; font-weight: 700; }
.console-controls input, .console-controls select { min-height: 2.5rem; padding: .5rem .65rem; color: #e9f1ec; background: #09110e; border: 1px solid var(--border); border-radius: .55rem; }
.console-controls .checkbox-control { display: flex; flex-direction: row; align-items: center; }
.console-controls .checkbox-control input { min-height: auto; }
.live-console { height: min(30rem, 55vh); overflow: auto; padding: .65rem; color: #cbd8d1; background: #050906; border: 1px solid var(--border); border-radius: .6rem; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .78rem; }
.console-entry { display: grid; grid-template-columns: 11rem 3.5rem minmax(6rem, auto) minmax(0, 1fr); gap: .65rem; padding: .35rem; border-bottom: 1px solid #142019; }
.console-entry time { color: #708178; }
.console-entry strong { color: #8da49a; }
.console-account { color: #708178; }
.console-account a { color: var(--accent); font-weight: 700; }
.console-entry.warn strong { color: var(--warning); }
.console-entry.error strong { color: var(--danger); }
.live-console > p { color: var(--muted); text-align: center; }
.console-panel { position: relative; }
.console-jump { position: absolute; right: 1.6rem; bottom: 1.6rem; box-shadow: 0 .5rem 1.5rem rgba(0, 0, 0, .4); }
@media (min-width: 56rem) {
.operations-summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.console-controls { grid-template-columns: auto minmax(8rem, .45fr) minmax(14rem, 1fr) auto; align-items: end; }
}
@@ -0,0 +1,152 @@
import type { QueryKey } from '@tanstack/react-query';
import {
DashboardEventSchema,
type DashboardEvent,
} from '../../../../../packages/api-contracts/src/index.js';
export const DASHBOARD_EVENT_TYPES = [
'bot.status.changed',
'message.sent',
'message.failed',
'conversation.updated',
'reply.received',
'media.received',
'sync.started',
'sync.progress',
'sync.completed',
'sync.failed',
'rate-limit.changed',
'reddit.session.changed',
'log.created',
] as const;
export interface DashboardEventSource {
addEventListener(type: string, listener: (event: unknown) => void): void;
close(): void;
}
export interface RealtimeConnectionSnapshot {
status: 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'closed';
isStale: boolean;
lastEventId?: string;
}
export interface DashboardRealtimeClient {
start(): void;
stop(): void;
getSnapshot(): RealtimeConnectionSnapshot;
}
export interface DashboardRealtimeClientOptions {
url?: string;
sourceFactory(url: string): DashboardEventSource;
onEvent(event: DashboardEvent): void;
onConnected?(context: { isReconnect: boolean }): void;
onStateChange?(snapshot: RealtimeConnectionSnapshot): void;
onInvalidEvent?(value: unknown): void;
}
function eventData(value: unknown): string | undefined {
if (typeof value !== 'object' || value === null || !('data' in value)) return undefined;
return typeof value.data === 'string' ? value.data : undefined;
}
export function createDashboardRealtimeClient(
options: DashboardRealtimeClientOptions,
): DashboardRealtimeClient {
let source: DashboardEventSource | undefined;
let snapshot: RealtimeConnectionSnapshot = { status: 'idle', isStale: true };
let hasConnected = false;
function update(next: RealtimeConnectionSnapshot): void {
snapshot = next;
options.onStateChange?.(snapshot);
}
function receive(value: unknown): void {
const data = eventData(value);
if (data === undefined) {
options.onInvalidEvent?.(value);
return;
}
let decoded: unknown;
try {
decoded = JSON.parse(data);
} catch {
options.onInvalidEvent?.(data);
return;
}
const result = DashboardEventSchema.safeParse(decoded);
if (!result.success) {
options.onInvalidEvent?.(decoded);
return;
}
snapshot = { ...snapshot, lastEventId: result.data.id };
options.onEvent(result.data);
}
return {
start() {
if (source !== undefined) return;
update({ ...snapshot, status: 'connecting', isStale: true });
source = options.sourceFactory(options.url ?? '/api/events/stream');
source.addEventListener('open', () => {
update({ ...snapshot, status: 'connected', isStale: false });
options.onConnected?.({ isReconnect: hasConnected });
hasConnected = true;
});
source.addEventListener('error', () => {
update({ ...snapshot, status: 'reconnecting', isStale: true });
});
for (const type of DASHBOARD_EVENT_TYPES) source.addEventListener(type, receive);
},
stop() {
source?.close();
source = undefined;
update({ ...snapshot, status: 'closed', isStale: true });
},
getSnapshot() {
return snapshot;
},
};
}
export function queryKeysForDashboardEvent(event: DashboardEvent): readonly QueryKey[] {
switch (event.type) {
case 'bot.status.changed':
return [['dashboard', 'stats']];
case 'rate-limit.changed':
return [['dashboard', 'stats'], ['dashboard', 'timers']];
case 'message.sent':
case 'message.failed':
return [['conversations'], ['performance']];
case 'conversation.updated':
case 'reply.received':
return [['conversations'], ['operations', 'activity']];
case 'media.received':
return [['conversations'], ['media'], ['operations', 'activity']];
case 'sync.started':
case 'sync.progress':
return [['operations', 'sync-status']];
case 'sync.completed':
return [['operations', 'sync-status'], ['conversations'], ['media']];
case 'sync.failed':
return [['operations', 'sync-status']];
case 'reddit.session.changed':
return [['dashboard', 'login', 'status']];
case 'log.created':
return [];
}
}
export function createBrowserEventSource(url: string): DashboardEventSource {
const source = new EventSource(url);
return {
addEventListener(type, listener) {
source.addEventListener(type, (event) => listener(event));
},
close() {
source.close();
},
};
}
@@ -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; } }
}
@@ -0,0 +1,318 @@
import { useEffect, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { dashboardQueryKeys, useSetModeMutation, useStatsQuery, useTimersQuery } from '../../api/queries.js';
import type { BotMode, BotStats, Timers } from '../../api/contracts.js';
import type { AccountStatus } from '../../../../../packages/api-contracts/src/index.js';
import { ConfirmationDialog } from '../../components/confirmation-dialog.js';
import { RedditUserText } from '../../components/reddit-user-text.js';
import { OriginPostButton } from '../../components/origin-post.js';
import { RecentMessagesPanel } from './recent-messages.js';
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
const MODE_ORDER: readonly BotMode[] = ['running', 'scan', 'stopped'];
const modeCopy: Record<BotMode, { label: string; action: string; dot: string; buttonTone: string }> = {
running: { label: 'Running', action: 'Start bot', dot: 'running', buttonTone: 'button primary' },
scan: { label: 'Scanning', action: 'Scan mode', dot: 'scanning', buttonTone: 'button secondary' },
stopped: { label: 'Stopped', action: 'Stop bot', dot: 'stopped', buttonTone: 'button danger' },
};
function errorMessage(error: Error | null): string {
return error?.message ?? 'Something went wrong.';
}
function formatTime(timestamp: number): string {
return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(timestamp);
}
function formatMetricValue(value: number): string {
return value.toLocaleString();
}
function formatCountdown(remainingMs: number): string {
const totalSeconds = Math.max(0, Math.ceil(remainingMs / 1_000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
}
/** Ticks a client clock so time-derived UI flips without a refetch. */
function useNow(intervalMs = 1_000): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = globalThis.setInterval(() => setNow(Date.now()), intervalMs);
return () => globalThis.clearInterval(timer);
}, [intervalMs]);
return now;
}
function MetricCard({ label, value, detail, metric }: { label: string; value: string | number; detail: string; metric: string }) {
return (
<article className="metric-card">
<span>{label}</span>
<strong data-metric={metric}>{value}</strong>
<small>{detail}</small>
</article>
);
}
function TimersPanel({ timers }: { timers: Timers }) {
const now = useNow();
const isReady = timers.nextMessageAvailableAt === null || timers.nextMessageAvailableAt <= now;
return (
<section aria-labelledby="timers-heading" className="panel">
<div className="panel-heading">
<div>
<span className="eyebrow">Rate limits</span>
<h2 id="timers-heading">Sending window</h2>
</div>
<span className={isReady ? 'pill success' : 'pill warning'}>{isReady ? 'Ready' : 'Cooling down'}</span>
</div>
<dl className="definition-list">
<div>
<dt>Next message</dt>
<dd>{isReady
? 'Available now'
: `Ready in ${formatCountdown((timers.nextMessageAvailableAt ?? now) - now)} (${formatTime(timers.nextMessageAvailableAt ?? now)})`}</dd>
</div>
<div>
<dt>Hourly allowance</dt>
<dd>{timers.messagesRemaining} messages remaining</dd>
</div>
<div>
<dt>Allowance resets</dt>
<dd>{formatTime(timers.hourlyResetAt)}</dd>
</div>
</dl>
</section>
);
}
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 }) {
return (
<section aria-labelledby="activity-heading" className="panel activity-panel">
<div className="panel-heading">
<div>
<span className="eyebrow">Latest signals</span>
<h2 id="activity-heading">Recent activity</h2>
</div>
</div>
{stats.recentActivity.length === 0 ? (
<div className="empty-state">
<strong>No recent activity yet.</strong>
<span>New bot events will appear here.</span>
</div>
) : (
<ol className="activity-list">
{stats.recentActivity.map((item, index) => (
<li key={`${item.timestamp}-${index}`}>
<span className={`activity-marker ${item.type}`} aria-hidden="true" />
<div><strong>{item.postUrl && item.postTitle ? (
<OriginPostButton post={{ title: item.postTitle, url: item.postUrl, imageUrl: item.imageUrl }}>
{item.message}
</OriginPostButton>
) : <RedditUserText>{item.message}</RedditUserText>}</strong><AccountOwnerBadge owner={item.owner} /><small>{formatTime(item.timestamp)}</small></div>
</li>
))}
</ol>
)}
</section>
);
}
export function OverviewPage() {
const queryClient = useQueryClient();
const statsQuery = useStatsQuery();
const timersQuery = useTimersQuery();
const controlMutation = useSetModeMutation();
const [confirmationAction, setConfirmationAction] = useState<BotMode | null>(null);
const isRefreshing = (statsQuery.isFetching || timersQuery.isFetching) && !statsQuery.isPending;
async function refreshOverview() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: dashboardQueryKeys.stats }),
queryClient.invalidateQueries({ queryKey: dashboardQueryKeys.timers }),
]);
}
function confirmControl() {
if (!confirmationAction) return;
controlMutation.mutate(confirmationAction, {
onSuccess: () => setConfirmationAction(null),
});
}
if (statsQuery.isPending) {
if (statsQuery.isPaused) {
return (
<div className="page-state error-state" role="alert">
<strong>You appear to be offline</strong>
<span>The overview will load once the connection returns.</span>
<button className="button secondary" onClick={() => void statsQuery.refetch()} type="button">Retry overview</button>
</div>
);
}
return <div aria-label="Loading overview" className="page-state" role="status"><span className="spinner" />Loading overview…</div>;
}
if (statsQuery.isError) {
return (
<div className="page-state error-state" role="alert">
<strong>Overview unavailable</strong>
<span>{errorMessage(statsQuery.error)}</span>
<button className="button secondary" onClick={() => void statsQuery.refetch()} type="button">Retry overview</button>
</div>
);
}
const stats = statsQuery.data;
// Offer the two modes the bot is not currently in, in a stable order.
const offeredModes = MODE_ORDER.filter((mode) => mode !== stats.mode);
return (
<div className="page">
<header className="page-header">
<div>
<span className="eyebrow">Control room</span>
<h1>Overview</h1>
<p>System health, sending limits, and the signals that need attention.</p>
</div>
<div className="header-actions">
<button
aria-busy={isRefreshing ? 'true' : undefined}
aria-label={isRefreshing ? 'Refreshing overview' : 'Refresh overview'}
className="button secondary"
disabled={isRefreshing}
onClick={() => void refreshOverview()}
type="button"
>
{isRefreshing ? 'Refreshing…' : 'Refresh'}
</button>
{offeredModes.map((mode) => (
<button
className={modeCopy[mode].buttonTone}
key={mode}
onClick={() => setConfirmationAction(mode)}
type="button"
>
{modeCopy[mode].action}
</button>
))}
</div>
</header>
<section aria-label="Bot state" className="status-strip">
<div>
<span className={`status-dot ${modeCopy[stats.mode].dot}`} aria-hidden="true" />
<div><small>Bot status</small><strong data-bot-status>{modeCopy[stats.mode].label}</strong></div>
</div>
{stats.dryRunMode ? <span className="pill neutral">Dry run</span> : null}
<span className="status-note">{stats.messagesThisHour} of {stats.maxMessagesPerHour} messages used this hour</span>
</section>
{stats.invitationCooldown ? (
<div className="cooldown-banner" role="status">
<strong>{stats.invitationCooldown.redditUsername
? `u/${stats.invitationCooldown.redditUsername} invitation cooldown`
: 'Reddit invitation cooldown'}</strong>
<span>{stats.invitationCooldown.reason}</span>
</div>
) : null}
<section aria-label="Key metrics" className="metrics-grid">
<MetricCard detail="All recorded sends" label="Messages sent" metric="messages-sent" value={formatMetricValue(stats.totalMessagesSent)} />
<MetricCard detail={`${stats.responseRate}% response rate`} label="Responses" metric="responses" value={formatMetricValue(stats.totalResponses)} />
<MetricCard detail="Posts evaluated" label="Processed" metric="processed" value={formatMetricValue(stats.totalProcessed)} />
<MetricCard detail={`Limit ${formatMetricValue(stats.maxMessagesPerHour)}`} label="This hour" metric="messages-hour" value={formatMetricValue(stats.messagesThisHour)} />
</section>
<AccountStatusPanel accounts={stats.accounts} />
<div className="overview-grid">
{timersQuery.isPending ? (
<section aria-label="Loading timers" className="panel panel-loading" role="status"><span className="spinner" />Loading sending window…</section>
) : timersQuery.isError ? (
<section className="panel error-panel" role="alert">
<strong>Sending window unavailable</strong>
<span>{errorMessage(timersQuery.error)}</span>
<button className="button secondary" onClick={() => void timersQuery.refetch()} type="button">Retry timers</button>
</section>
) : (
<TimersPanel timers={timersQuery.data} />
)}
<ActivityPanel stats={stats} />
</div>
<RecentMessagesPanel />
{confirmationAction ? (
<ConfirmationDialog
action={confirmationAction}
errorMessage={controlMutation.isError ? errorMessage(controlMutation.error) : null}
isPending={controlMutation.isPending}
onCancel={() => { setConfirmationAction(null); controlMutation.reset(); }}
onConfirm={confirmControl}
/>
) : null}
</div>
);
}
@@ -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>
);
}
@@ -0,0 +1,90 @@
import {
DailyTrendResponseSchema,
StrategyPerformanceResponseSchema,
SubredditPerformanceResponseSchema,
SuccessfulExamplesResponseSchema,
FunnelResponseSchema,
AiUsageSummaryResponseSchema,
} from '../../../../../packages/api-contracts/src/index.js';
import type { z } from 'zod';
import { createDashboardRequest } from '../../api/request.js';
type SubredditPerformanceResponse = z.infer<typeof SubredditPerformanceResponseSchema>;
type StrategyPerformanceResponse = z.infer<typeof StrategyPerformanceResponseSchema>;
type DailyTrendResponse = z.infer<typeof DailyTrendResponseSchema>;
type SuccessfulExamplesResponse = z.infer<typeof SuccessfulExamplesResponseSchema>;
type FunnelResponse = z.infer<typeof FunnelResponseSchema>;
type AiUsageSummaryResponse = z.infer<typeof AiUsageSummaryResponseSchema>;
async function parseResponse<T>(
response: Response,
schema: { parse(value: unknown): T },
): Promise<T> {
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
: `Request failed (${response.status})`;
throw new Error(message);
}
return schema.parse(payload);
}
export interface PerformanceApi {
getSubredditPerformance(): Promise<SubredditPerformanceResponse>;
getStrategyPerformance(): Promise<StrategyPerformanceResponse>;
getDailyTrend(days: number): Promise<DailyTrendResponse>;
getSuccessfulExamples(): Promise<SuccessfulExamplesResponse>;
getFunnel(): Promise<FunnelResponse>;
getAiUsage(days: number): Promise<AiUsageSummaryResponse>;
}
export function createPerformanceApi(
fetchImplementation?: typeof fetch,
) {
const request = createDashboardRequest(fetchImplementation);
return {
async getSubredditPerformance() {
return parseResponse(
await request('/api/analytics/by-subreddit'),
SubredditPerformanceResponseSchema,
);
},
async getStrategyPerformance() {
return parseResponse(
await request('/api/analytics/by-strategy'),
StrategyPerformanceResponseSchema,
);
},
async getDailyTrend(days: number) {
return parseResponse(
await request(`/api/analytics/trend?days=${days}`),
DailyTrendResponseSchema,
);
},
async getSuccessfulExamples() {
return parseResponse(
await request('/api/successful'),
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;
}
export const performanceApi = createPerformanceApi();
@@ -0,0 +1,273 @@
import { useState, type ReactNode } from 'react';
import type { UseQueryResult } from '@tanstack/react-query';
import type { z } from 'zod';
import type {
DailyTrendResponseSchema,
StrategyPerformanceResponseSchema,
SubredditPerformanceResponseSchema,
SuccessfulExample,
FunnelResponseSchema,
AiUsageSummaryResponseSchema,
} from '../../../../../packages/api-contracts/src/index.js';
import {
useDailyTrendQuery,
useStrategyPerformanceQuery,
useSubredditPerformanceQuery,
useSuccessfulExamplesQuery,
useFunnelQuery,
useAiUsageQuery,
} from './performance-queries.js';
import './performance.css';
const LOW_CONFIDENCE_SAMPLE_SIZE = 20;
type SubredditPerformance = z.infer<typeof SubredditPerformanceResponseSchema>[number];
type StrategyPerformance = z.infer<typeof StrategyPerformanceResponseSchema>[number];
type DailyTrend = z.infer<typeof DailyTrendResponseSchema>[number];
type Funnel = z.infer<typeof FunnelResponseSchema>;
type AiUsageSummary = z.infer<typeof AiUsageSummaryResponseSchema>;
function formatTrendDate(isoDate: string): string {
const parsed = new Date(`${isoDate}T00:00:00`);
if (Number.isNaN(parsed.getTime())) return isoDate;
return new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short' }).format(parsed);
}
/** Renders one panel's own loading, error, and data states independently of its siblings. */
function PanelBody<T>({ query, label, children }: {
query: UseQueryResult<T, Error>;
label: string;
children: (data: T) => ReactNode;
}) {
if (query.isPending) {
return <div className="panel-loading" role="status"><span className="spinner" />Loading {label}…</div>;
}
if (query.isError) {
return (
<div className="error-panel" role="alert">
<strong>{label} unavailable</strong>
<span>{query.error.message}</span>
<button className="button secondary" onClick={() => void query.refetch()} type="button">Retry {label}</button>
</div>
);
}
return <>{children(query.data)}</>;
}
function formatUsd(value: number): string {
return new Intl.NumberFormat(undefined, {
style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 4,
}).format(value);
}
function AiUsagePanelBody({ usage }: { usage: AiUsageSummary }) {
const successRate = usage.requests > 0 ? Math.round((usage.successes / usage.requests) * 100) : 0;
return (
<>
<div className="panel-heading"><div><span className="eyebrow">OpenRouter · last {usage.days} days</span><h2 id="ai-usage-heading">AI cost and reliability</h2></div><span className="sample-size">{usage.requests} requests</span></div>
<div aria-label="AI usage totals" className="metrics-grid performance-metrics">
<article className="metric-card"><span>Reported cost</span><strong>{formatUsd(usage.costUsd)}</strong><small>OpenRouter usage accounting</small></article>
<article className="metric-card"><span>Tokens</span><strong>{usage.totalTokens.toLocaleString()}</strong><small>{usage.promptTokens.toLocaleString()} prompt · {usage.completionTokens.toLocaleString()} completion</small></article>
<article className="metric-card"><span>Success rate</span><strong>{successRate}%</strong><small>{usage.refusals} refusals · {usage.errors} errors/empty</small></article>
<article className="metric-card"><span>Average latency</span><strong>{usage.averageLatencyMs.toLocaleString()} ms</strong><small>All recorded attempts</small></article>
</div>
{usage.models.length === 0 ? <p className="feature-empty">No AI usage has been recorded yet. New requests will appear here.</p> : (
<div className="table-scroll"><table aria-label="AI usage by model" className="performance-table"><thead><tr><th scope="col">Model</th><th scope="col">Requests</th><th scope="col">Success</th><th scope="col">Refusals</th><th scope="col">Errors</th><th scope="col">Tokens</th><th scope="col">Cost</th><th scope="col">Latency</th></tr></thead><tbody>{usage.models.map((model) => (
<tr key={model.model}><th scope="row">{model.model}</th><td>{model.requests}</td><td>{model.successes}</td><td>{model.refusals}</td><td>{model.errors}</td><td>{model.totalTokens.toLocaleString()}</td><td>{formatUsd(model.costUsd)}</td><td>{model.averageLatencyMs.toLocaleString()} ms</td></tr>
))}</tbody></table></div>
)}
</>
);
}
function SampleSize({ totalSent }: { totalSent: number }) {
const isLowConfidence = totalSent < LOW_CONFIDENCE_SAMPLE_SIZE;
return (
<span className="sample-size">
n = {totalSent}
{isLowConfidence ? <span className="confidence-warning">Low confidence</span> : null}
</span>
);
}
function FunnelPanelBody({ funnel }: { funnel: Funnel }) {
const stages = [
['Processed', funnel.processed],
['Sent', funnel.sent],
[`Mature (${funnel.responseWindowHours}h)`, funnel.mature],
['Responded', funnel.responded],
['Engaged', funnel.engaged],
['Images', funnel.images],
] as const;
return (
<>
<div className="panel-heading"><div><span className="eyebrow">Qualified engagement</span><h2 id="funnel-heading">Outcome funnel</h2></div><span className="sample-size">{funnel.pending} awaiting maturity</span></div>
<ol className="funnel-list">
{stages.map(([label, value], index) => {
const previousValue = index > 0 ? stages[index - 1][1] : null;
const conversion = previousValue !== null && previousValue > 0
? `${Math.round((value / previousValue) * 100)}% of previous`
: null;
return (
<li key={label}>
<span>{label}</span>
<span className="funnel-value"><strong>{value.toLocaleString()}</strong>{conversion ? <small className="interval">{conversion}</small> : null}</span>
</li>
);
})}
</ol>
<div className="guardrail-list" aria-label="Guardrail outcomes">
<span>Declined <strong>{funnel.declined}</strong></span>
<span>Boundary <strong>{funnel.boundary}</strong></span>
<span>Upsell <strong>{funnel.upsell}</strong></span>
</div>
</>
);
}
function PerformanceTable({
label,
rows,
rowLabel,
}: {
label: string;
rows: readonly (SubredditPerformance | StrategyPerformance)[];
rowLabel: (row: SubredditPerformance | StrategyPerformance) => string;
}) {
if (rows.length === 0) {
return (
<div className="empty-state">
<strong>No sends recorded yet.</strong>
<span>Rows appear once messages have been sent.</span>
</div>
);
}
return (
<div className="table-scroll">
<table aria-label={label} className="performance-table">
<thead><tr><th scope="col">Name</th><th scope="col">Responses</th><th scope="col">Mature response rate</th><th scope="col">Images</th><th scope="col">Image rate</th><th scope="col">Sample</th></tr></thead>
<tbody>
{rows.map((row) => (
<tr key={rowLabel(row)}>
<th scope="row">
{rowLabel(row)}
{'configured' in row && row.configured ? <span className="configured-label">Configured</span> : null}
</th>
<td>{row.totalResponses}</td>
<td>{row.responseRate}%{row.responseRateLow !== undefined && row.responseRateHigh !== undefined ? <small className="interval">95% CI {row.responseRateLow}–{row.responseRateHigh}%</small> : null}</td>
<td>{row.totalImages}</td>
<td>{row.imageRate}%</td>
<td><SampleSize totalSent={row.totalSent} />{row.pendingSent ? <small className="interval">{row.pendingSent} pending</small> : null}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function Trend({ rows, rangeDays }: { rows: readonly DailyTrend[]; rangeDays: number }) {
if (rows.length === 0) {
return (
<div className="empty-state">
<strong>No sends recorded yet.</strong>
<span>Daily results appear once messages have been sent.</span>
</div>
);
}
return (
<ol aria-label={`${rangeDays} day response trend`} className="trend-list">
{rows.map((row) => (
<li key={row.date}>
<time dateTime={row.date}>{formatTrendDate(row.date)}</time>
<span className="trend-track" aria-hidden="true"><span style={{ width: `${Math.max(2, Math.min(100, row.responseRate))}%` }} /></span>
<strong>{row.responseRate}%</strong>
<span>{row.totalImages} images ({row.imageRate}%)</span>
<SampleSize totalSent={row.totalSent} />
</li>
))}
</ol>
);
}
function SuccessfulExamples({ examples }: { examples: readonly SuccessfulExample[] }) {
if (examples.length === 0) return <p className="feature-empty">No successful examples have been recorded yet.</p>;
return (
<div className="example-grid">
{examples.map((example) => {
const source = example.subreddit ?? 'unknown source';
return (
<article aria-label={`Successful example from ${source}`} className="successful-example" key={example.id}>
<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}
<blockquote>{example.originalMessage}</blockquote>
{example.responseSnippet ? <p><strong>Reply:</strong> {example.responseSnippet}</p> : null}
</article>
);
})}
</div>
);
}
const TREND_RANGES = [7, 30, 90] as const;
type TrendRange = (typeof TREND_RANGES)[number];
export function PerformancePage() {
const [rangeDays, setRangeDays] = useState<TrendRange>(30);
const subreddits = useSubredditPerformanceQuery();
const strategies = useStrategyPerformanceQuery();
const trend = useDailyTrendQuery(rangeDays);
const examples = useSuccessfulExamplesQuery();
const funnel = useFunnelQuery();
const aiUsage = useAiUsageQuery(rangeDays);
return (
<div className="page performance-page">
<header className="page-header">
<div><span className="eyebrow">Decision support</span><h1>Performance</h1><p>Compare response and received-image outcomes without hiding the sample sizes behind the percentages.</p></div>
<div aria-label="Date range" className="header-actions" role="group">
{TREND_RANGES.map((days) => (
<button
aria-pressed={rangeDays === days}
className={rangeDays === days ? 'button primary' : 'button secondary'}
key={days}
onClick={() => setRangeDays(days)}
type="button"
>
{days} days
</button>
))}
</div>
</header>
<section aria-labelledby="funnel-heading-anchor" className="panel feature-panel">
<span className="sr-only" id="funnel-heading-anchor">Outcome funnel</span>
<PanelBody label="outcome funnel" query={funnel}>{(data) => <FunnelPanelBody funnel={data} />}</PanelBody>
</section>
<section aria-labelledby="ai-usage-heading-anchor" className="panel feature-panel">
<span className="sr-only" id="ai-usage-heading-anchor">AI cost and reliability</span>
<PanelBody label="AI usage" query={aiUsage}>{(data) => <AiUsagePanelBody usage={data} />}</PanelBody>
</section>
<section aria-labelledby="trend-heading" className="panel feature-panel">
<div className="panel-heading"><div><span className="eyebrow">Last {rangeDays} days</span><h2 id="trend-heading">Response trend</h2></div></div>
<PanelBody label="response trend" query={trend}>{(data) => <Trend rangeDays={rangeDays} rows={data} />}</PanelBody>
</section>
<div className="performance-grid">
<section aria-labelledby="subreddit-heading" className="panel feature-panel">
<div className="panel-heading"><h2 id="subreddit-heading">By subreddit</h2></div>
<PanelBody label="subreddit performance" query={subreddits}>
{(data) => <PerformanceTable label="Subreddit performance" rowLabel={(row) => 'subreddit' in row ? row.subreddit : row.strategyId} rows={data} />}
</PanelBody>
</section>
<section aria-labelledby="strategy-heading" className="panel feature-panel">
<div className="panel-heading"><h2 id="strategy-heading">By strategy</h2></div>
<PanelBody label="strategy performance" query={strategies}>
{(data) => <PerformanceTable label="Strategy performance" rowLabel={(row) => 'strategyId' in row ? row.strategyId : row.subreddit} rows={data} />}
</PanelBody>
</section>
</div>
<section aria-labelledby="examples-heading" className="panel feature-panel">
<div className="panel-heading"><div><span className="eyebrow">What worked</span><h2 id="examples-heading">Successful examples</h2></div></div>
<PanelBody label="successful examples" query={examples}>{(data) => <SuccessfulExamples examples={data} />}</PanelBody>
</section>
</div>
);
}
@@ -0,0 +1,53 @@
import { useQuery } from '@tanstack/react-query';
import { performanceApi } from './performance-api.js';
export const performanceQueryKeys = {
subreddits: ['performance', 'subreddits'] as const,
strategies: ['performance', 'strategies'] as const,
trend: (days: number) => ['performance', 'trend', days] as const,
successfulExamples: ['performance', 'successful-examples'] as const,
funnel: ['performance', 'funnel'] as const,
aiUsage: (days: number) => ['performance', 'ai-usage', days] as const,
};
export function useSubredditPerformanceQuery() {
return useQuery({
queryKey: performanceQueryKeys.subreddits,
queryFn: performanceApi.getSubredditPerformance,
});
}
export function useStrategyPerformanceQuery() {
return useQuery({
queryKey: performanceQueryKeys.strategies,
queryFn: performanceApi.getStrategyPerformance,
});
}
export function useDailyTrendQuery(days: number) {
return useQuery({
queryKey: performanceQueryKeys.trend(days),
queryFn: () => performanceApi.getDailyTrend(days),
});
}
export function useSuccessfulExamplesQuery() {
return useQuery({
queryKey: performanceQueryKeys.successfulExamples,
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),
});
}
@@ -0,0 +1,36 @@
.performance-page { display: grid; gap: .8rem; }
.feature-panel { min-width: 0; }
.performance-grid { display: grid; gap: .8rem; }
.table-scroll { overflow-x: auto; }
.performance-table { width: 100%; border-collapse: collapse; text-align: left; }
.performance-table th, .performance-table td { padding: .7rem .5rem; border-top: 1px solid var(--border); }
.performance-table thead th { border-top: 0; color: var(--muted); font-size: .75rem; text-transform: uppercase; }
.performance-table tbody th { min-width: 9rem; }
.configured-label, .confidence-warning { display: block; margin-top: .2rem; color: var(--accent); font-size: .68rem; font-weight: 750; }
.confidence-warning { color: var(--warning); }
.sample-size { white-space: nowrap; color: var(--muted); }
.interval { display: block; margin-top: .2rem; color: var(--muted); white-space: nowrap; }
.funnel-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .6rem; padding: 0; margin: 0; list-style: none; }
.funnel-list li { display: flex; justify-content: space-between; gap: 1rem; padding: .8rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .65rem; }
.funnel-value { display: grid; gap: .1rem; text-align: right; }
.guardrail-list { display: flex; flex-wrap: wrap; gap: .6rem; margin-top: .7rem; color: var(--muted); }
.guardrail-list span { padding: .45rem .65rem; border: 1px solid var(--border); border-radius: 999px; }
.trend-list { display: grid; gap: .65rem; padding: 0; margin: 0; list-style: none; }
/* Narrow screens: wrap each day into two rows instead of forcing a wide grid. */
.trend-list li { display: grid; grid-template-columns: 4.5rem minmax(4rem, 1fr) 3rem; align-items: center; gap: .4rem .75rem; }
.trend-list li > span:not(.trend-track), .trend-list li .sample-size { grid-column: span 3; font-size: .78rem; }
@media (min-width: 48rem) {
.trend-list li { grid-template-columns: 6.5rem minmax(5rem, 1fr) 3rem 8rem 6rem; gap: .75rem; }
.trend-list li > span:not(.trend-track), .trend-list li .sample-size { grid-column: auto; font-size: inherit; }
}
.trend-track { height: .5rem; overflow: hidden; background: var(--border); border-radius: 999px; }
.trend-track span { display: block; height: 100%; background: var(--accent); border-radius: inherit; }
.example-grid { display: grid; gap: .75rem; }
.successful-example { padding: 1rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .75rem; }
.successful-example > div { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
.successful-example small { color: var(--muted); }
.successful-example h3 { margin-bottom: .5rem; }
.successful-example blockquote { margin: 1rem 0; padding-left: 1rem; border-left: 2px solid var(--accent); }
.successful-example p { margin-bottom: 0; color: var(--muted); }
.feature-empty { color: var(--muted); }
@media (min-width: 64rem) { .performance-grid, .example-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .funnel-list { grid-template-columns: repeat(6, minmax(0, 1fr)); } }
@@ -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} />;
}
Loaded 100 of 427 files, more files were not shown because too many files have changed in this diff. Show more