402 lines
17 KiB
TypeScript
402 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|