feat: support concurrent Reddit accounts
This commit is contained in:
1 parent
d819a0f1e6
commit
16a31335d0
51 files changed
+3241
-250
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export interface ConversationApi {
|
||||
suggestMediaAsk(id: string): Promise<string>;
|
||||
processedPosts(username: string): Promise<ProcessedPost[]>;
|
||||
sendReply(id: string, message: string): Promise<SuccessResponse>;
|
||||
startConversation(username: string, message: string): Promise<ActiveChat>;
|
||||
startConversation(accountId: string, username: string, message: string): Promise<ActiveChat>;
|
||||
}
|
||||
|
||||
export function createConversationApi(fetchImplementation?: typeof fetch): ConversationApi {
|
||||
@@ -83,8 +83,9 @@ export function createConversationApi(fetchImplementation?: typeof fetch): Conve
|
||||
SuccessResponseSchema,
|
||||
);
|
||||
},
|
||||
async startConversation(username, message) {
|
||||
async startConversation(accountId, username, message) {
|
||||
const body = StartConversationRequestSchema.parse({
|
||||
accountId,
|
||||
username: username.trim().replace(/^u\//i, ''),
|
||||
message: message.trim(),
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit
|
||||
import { OriginPostCard } from '../../components/origin-post.js';
|
||||
import { useConversationDetailQuery, useSendReplyMutation, useSuggestMediaAskMutation, useSuggestReplyMutation } from './conversation-queries.js';
|
||||
import { SendConfirmationDialog } from './send-confirmation-dialog.js';
|
||||
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
||||
|
||||
interface ConversationDetailProps {
|
||||
conversationId: string;
|
||||
@@ -15,7 +16,7 @@ function formatMessageTime(timestamp: number): string {
|
||||
}
|
||||
|
||||
function mediaPath(localPath: string): string {
|
||||
return `/media/${localPath.split('/').map(encodeURIComponent).join('/')}`;
|
||||
return `/media/${localPath.split(/[\\/]/).map(encodeURIComponent).join('/')}`;
|
||||
}
|
||||
|
||||
function MessageHistory({ conversation }: { conversation: ConversationDetailResponse }) {
|
||||
@@ -28,7 +29,9 @@ function MessageHistory({ conversation }: { conversation: ConversationDetailResp
|
||||
{conversation.messages.map((message) => (
|
||||
<li className={message.isSelf ? 'is-self' : 'is-recipient'} key={`${message.timestamp}-${message.sender}-${message.text}`}>
|
||||
<div>
|
||||
<strong>{message.isSelf ? 'You' : <RedditUsernameLink username={conversation.username} />}</strong>
|
||||
<strong>{message.isSelf
|
||||
? `You${conversation.owner ? ` (u/${conversation.owner.redditUsername})` : ''}`
|
||||
: <RedditUsernameLink username={conversation.username} />}</strong>
|
||||
<time dateTime={new Date(message.timestamp).toISOString()}>{formatMessageTime(message.timestamp)}</time>
|
||||
</div>
|
||||
<p>{message.text}</p>
|
||||
@@ -50,14 +53,14 @@ function MessageHistory({ conversation }: { conversation: ConversationDetailResp
|
||||
);
|
||||
}
|
||||
|
||||
function ConversationComposer({ conversation }: { conversation: ConversationDetailResponse }) {
|
||||
function ConversationComposer({ conversation, conversationId }: { conversation: ConversationDetailResponse; conversationId: string }) {
|
||||
const [draft, setDraft] = useState('');
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
const [wasSent, setWasSent] = useState(false);
|
||||
const sendButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const suggestionMutation = useSuggestReplyMutation(conversation.roomId ?? conversation.username);
|
||||
const mediaAskMutation = useSuggestMediaAskMutation(conversation.roomId ?? conversation.username);
|
||||
const sendMutation = useSendReplyMutation(conversation.roomId ?? conversation.username);
|
||||
const suggestionMutation = useSuggestReplyMutation(conversationId);
|
||||
const mediaAskMutation = useSuggestMediaAskMutation(conversationId);
|
||||
const sendMutation = useSendReplyMutation(conversationId);
|
||||
const canSend = draft.trim().length > 0 && !sendMutation.isPending;
|
||||
|
||||
const closeConfirmation = useCallback(() => {
|
||||
@@ -162,6 +165,7 @@ export function ConversationDetail({ conversationId, onClose }: ConversationDeta
|
||||
<div>
|
||||
<span className="conversation-eyebrow">Conversation</span>
|
||||
<h2><RedditUsernameLink username={conversation.username} /></h2>
|
||||
<AccountOwnerBadge owner={conversation.owner} />
|
||||
{conversation.unread ? <span className="conversation-badge">Unread</span> : null}
|
||||
</div>
|
||||
<button aria-label="Close conversation" onClick={onClose} type="button">Close</button>
|
||||
@@ -182,7 +186,7 @@ export function ConversationDetail({ conversationId, onClose }: ConversationDeta
|
||||
</div>
|
||||
) : null}
|
||||
<MessageHistory conversation={conversation} />
|
||||
<ConversationComposer conversation={conversation} />
|
||||
<ConversationComposer conversation={conversation} conversationId={conversationId} />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ActiveChat } from '../../../../../packages/api-contracts/src/index.js';
|
||||
|
||||
export function ownedConversationId(conversation: ActiveChat): string {
|
||||
const conversationId = conversation.roomId ?? conversation.username;
|
||||
return conversation.owner
|
||||
? `${encodeURIComponent(conversation.owner.accountId)}~${encodeURIComponent(conversationId)}`
|
||||
: conversationId;
|
||||
}
|
||||
@@ -51,8 +51,8 @@ export function useSendReplyMutation(id: string) {
|
||||
export function useStartConversationMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ username, message }: { username: string; message: string }) =>
|
||||
conversationApi.startConversation(username, message),
|
||||
mutationFn: ({ accountId, username, message }: { accountId: string; username: string; message: string }) =>
|
||||
conversationApi.startConversation(accountId, username, message),
|
||||
async onSuccess() {
|
||||
await queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all });
|
||||
},
|
||||
|
||||
@@ -5,7 +5,9 @@ import { ConversationDetail } from './conversation-detail.js';
|
||||
import { useConversationsQuery } 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';
|
||||
|
||||
export type ConversationFilter = 'all' | 'unread' | 'awaiting-reply' | 'received-images';
|
||||
|
||||
@@ -21,10 +23,6 @@ export interface ConversationsPageProps {
|
||||
onFiltersChange?(filters: ConversationFilters): void;
|
||||
}
|
||||
|
||||
function conversationId(conversation: ActiveChat): string {
|
||||
return conversation.roomId ?? conversation.username;
|
||||
}
|
||||
|
||||
function matchesFilter(conversation: ActiveChat, filter: ConversationFilter): boolean {
|
||||
if (filter === 'unread') return conversation.unread;
|
||||
if (filter === 'awaiting-reply') return conversation.isFromUs;
|
||||
@@ -48,6 +46,7 @@ function ConversationListItem({ conversation, isSelected, onSelect }: {
|
||||
type="button"
|
||||
/>
|
||||
<span className="conversation-list-heading"><strong><RedditUsernameLink username={conversation.username} /></strong><small>{state}</small></span>
|
||||
<AccountOwnerBadge owner={conversation.owner} />
|
||||
{conversation.hasReceivedImages ? <span className="conversation-image-marker">Image received</span> : null}
|
||||
<span>{conversation.lastMessageSnippet || 'No message preview'}</span>
|
||||
</li>
|
||||
@@ -143,9 +142,9 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
|
||||
{visibleConversations.map((conversation) => (
|
||||
<ConversationListItem
|
||||
conversation={conversation}
|
||||
isSelected={conversationId(conversation) === activeConversationId}
|
||||
key={conversationId(conversation)}
|
||||
onSelect={() => selectConversation(conversationId(conversation))}
|
||||
isSelected={ownedConversationId(conversation) === activeConversationId}
|
||||
key={ownedConversationId(conversation)}
|
||||
onSelect={() => selectConversation(ownedConversationId(conversation))}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
|
||||
@@ -15,12 +16,12 @@ export function ProcessedUserLookup() {
|
||||
<input id="processed-user" onChange={(event) => setInput(event.target.value)} value={input} />
|
||||
<button disabled={input.trim().length === 0} type="submit">Look up user</button>
|
||||
</form>
|
||||
{postsQuery.isPending && username ? <p role="status">Looking up processed posts…</p> : null}
|
||||
{postsQuery.isPending && username ? <p role="status">Looking up processed posts...</p> : null}
|
||||
{postsQuery.isError ? <p role="alert">{postsQuery.error.message}</p> : null}
|
||||
{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.id}><strong>{post.title}</strong><span>r/{post.subreddit} · {post.messageSent ? 'Message sent' : post.error ?? 'Processed'}</span></li>)}
|
||||
{postsQuery.data.map((post) => <li key={`${post.owner?.accountId ?? 'legacy'}:${post.id}`}><strong>{post.title}</strong><AccountOwnerBadge owner={post.owner} /><span>r/{post.subreddit} · {post.messageSent ? 'Message sent' : post.error ?? 'Processed'}</span></li>)}
|
||||
</ol>
|
||||
)
|
||||
) : null}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
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';
|
||||
|
||||
interface StartConversationFormProps {
|
||||
onStarted(conversationId: string): void;
|
||||
@@ -9,21 +11,25 @@ interface StartConversationFormProps {
|
||||
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 = username.trim().length > 0 && message.trim().length > 0 && !mutation.isPending;
|
||||
const canSubmit = accountId.length > 0 && username.trim().length > 0 && message.trim().length > 0 && !mutation.isPending;
|
||||
|
||||
function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
mutation.mutate(
|
||||
{ username, message },
|
||||
{ accountId, username, message },
|
||||
{
|
||||
onSuccess(chat) {
|
||||
setSuccessUsername(chat.username);
|
||||
setUsername('');
|
||||
setMessage('');
|
||||
onStarted(chat.roomId ?? chat.username);
|
||||
onStarted(ownedConversationId(chat));
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -31,6 +37,12 @@ export function StartConversationForm({ onStarted }: StartConversationFormProps)
|
||||
|
||||
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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { MediaItem } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
||||
import { mediaConversation, mediaType } from './filters.js';
|
||||
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
||||
|
||||
interface MediaCardProps {
|
||||
item: MediaItem;
|
||||
@@ -29,6 +30,7 @@ export function MediaCard({ item, isRevealed, onToggleReveal, onOpenPreview }: M
|
||||
{!isRevealed ? <span className="privacy-label">Private media hidden</span> : null}
|
||||
</div>
|
||||
<div className="media-card-body">
|
||||
<AccountOwnerBadge owner={item.owner} />
|
||||
<div>
|
||||
<strong><RedditUsernameLink showPrefix={false} username={item.sender} /></strong>
|
||||
<span>{item.chatUsername
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useDashboardRealtime } from '../../app/realtime-provider.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,
|
||||
@@ -30,6 +31,7 @@ function ActivityView({ items }: { items: readonly ActivityItem[] }) {
|
||||
{items.map((item) => (
|
||||
<li key={`${item.timestamp}-${item.message}`}>
|
||||
<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}
|
||||
|
||||
@@ -6,6 +6,7 @@ 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';
|
||||
|
||||
function errorMessage(error: Error | null): string {
|
||||
return error?.message ?? 'Something went wrong.';
|
||||
@@ -77,7 +78,7 @@ function ActivityPanel({ stats }: { stats: BotStats }) {
|
||||
<OriginPostButton post={{ title: item.postTitle, url: item.postUrl, imageUrl: item.imageUrl }}>
|
||||
{item.message}
|
||||
</OriginPostButton>
|
||||
) : <RedditUserText>{item.message}</RedditUserText>}</strong><small>{formatTime(item.timestamp)}</small></div>
|
||||
) : <RedditUserText>{item.message}</RedditUserText>}</strong><AccountOwnerBadge owner={item.owner} /><small>{formatTime(item.timestamp)}</small></div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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[]> {
|
||||
@@ -19,13 +20,13 @@ export function RecentMessagesPanel() {
|
||||
return (
|
||||
<section aria-labelledby="recent-messages-heading" className="panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">Durable records</span><h2 id="recent-messages-heading">Recent sent messages</h2></div></div>
|
||||
{query.isPending ? <p role="status">Loading recent messages…</p> : null}
|
||||
{query.isPending ? <p role="status">Loading recent messages...</p> : null}
|
||||
{query.isError ? <div role="alert"><span>{query.error.message}</span><button className="button secondary" onClick={() => void query.refetch()} type="button">Retry messages</button></div> : null}
|
||||
{query.data?.length === 0 ? <p>No sent messages recorded yet.</p> : null}
|
||||
{query.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><span>{message.message}</span><small>{formatSentAt(message.sentAt)} · {message.gotResponse ? 'Responded' : 'No response recorded'}</small></div></li>
|
||||
<li key={message.id}><div><strong><RedditUsernameLink username={message.recipient} /></strong><AccountOwnerBadge owner={message.owner} /><span>{message.message}</span><small>{formatSentAt(message.sentAt)} · {message.gotResponse ? 'Responded' : 'No response recorded'}</small></div></li>
|
||||
))}
|
||||
</ol>
|
||||
) : null}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useRef } from 'react';
|
||||
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
||||
import { useImportRedditSessionMutation, useLoginStatusQuery } from './settings-queries.js';
|
||||
import {
|
||||
useImportRedditSessionMutation,
|
||||
useLoginStatusQuery,
|
||||
useRedditAccountsQuery,
|
||||
} from './settings-queries.js';
|
||||
|
||||
function errorMessage(error: Error | null): string {
|
||||
return error?.message ?? 'Something went wrong.';
|
||||
@@ -20,6 +24,7 @@ function sessionLabel(status: ReturnType<typeof useLoginStatusQuery>['data']): s
|
||||
|
||||
export function AccessPanel() {
|
||||
const statusQuery = useLoginStatusQuery();
|
||||
const accountsQuery = useRedditAccountsQuery();
|
||||
const importMutation = useImportRedditSessionMutation();
|
||||
const importInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -35,7 +40,7 @@ export function AccessPanel() {
|
||||
}
|
||||
|
||||
if (statusQuery.isPending) {
|
||||
return <section aria-label="Loading access" className="panel panel-loading" role="status"><span className="spinner" />Loading Reddit access…</section>;
|
||||
return <section aria-label="Loading access" className="panel panel-loading" role="status"><span className="spinner" />Loading Reddit access...</section>;
|
||||
}
|
||||
|
||||
if (statusQuery.isError) {
|
||||
@@ -51,16 +56,25 @@ export function AccessPanel() {
|
||||
const username = session.ready && 'loggedIn' in session && session.loggedIn ? session.username ?? null : null;
|
||||
return (
|
||||
<section aria-labelledby="access-heading" className="panel settings-section">
|
||||
<div className="panel-heading"><div><span className="eyebrow">Reddit session</span><h2 id="access-heading">Access</h2></div><span className={session.ready && 'status' in session && session.loggedIn ? 'pill success' : 'pill warning'}>{sessionLabel(session)}</span></div>
|
||||
<p className="settings-copy">When Reddit is logged out, account work is paused and waiting for a fresh session dump from the extension.</p>
|
||||
{username ? <p className="settings-copy">Signed in as <strong><RedditUsernameLink username={username} /></strong>.</p> : null}
|
||||
<div className="panel-heading"><div><span className="eyebrow">Reddit accounts</span><h2 id="access-heading">Access</h2></div><span className={session.ready && 'status' in session && session.loggedIn ? 'pill success' : 'pill warning'}>{sessionLabel(session)}</span></div>
|
||||
<p className="settings-copy">Add each Reddit account with its own extension session dump. Content and sync state stay owned by that account in the database. Any account with an expired login pauses while waiting for a fresh session dump; other accounts keep running.</p>
|
||||
{username ? <p className="settings-copy">Verified account: <strong><RedditUsernameLink username={username} /></strong>.</p> : null}
|
||||
<div aria-label="Reddit accounts" className="reddit-account-list">
|
||||
{(accountsQuery.data ?? []).map((account) => (
|
||||
<div className={account.isRunning ? 'reddit-account-row is-active' : 'reddit-account-row'} key={account.accountId}>
|
||||
<div><strong><RedditUsernameLink username={account.redditUsername} /></strong><span>{account.isRunning ? 'Running independently' : 'Waiting for a refreshed session'}</span></div>
|
||||
<span className={account.isRunning ? 'pill success' : 'pill warning'}>{account.isRunning ? 'Running' : 'Paused'}</span>
|
||||
</div>
|
||||
))}
|
||||
{accountsQuery.isSuccess && accountsQuery.data.length === 0 ? <p className="settings-copy">No Reddit accounts have been added yet.</p> : null}
|
||||
</div>
|
||||
<div className="access-actions">
|
||||
<input accept="application/json" aria-label="Reddit session export" className="sr-only" onChange={(event) => void importSession(event.target.files?.[0])} ref={importInput} type="file" />
|
||||
<button className="button primary" disabled={importMutation.isPending} onClick={() => importInput.current?.click()} type="button">{importMutation.isPending ? 'Importing session…' : 'Import Reddit session'}</button>
|
||||
<button className="button secondary" disabled={statusQuery.isFetching} onClick={() => void statusQuery.refetch()} type="button">{statusQuery.isFetching ? 'Checking session…' : 'Recheck session'}</button>
|
||||
<button className="button primary" disabled={importMutation.isPending} onClick={() => importInput.current?.click()} type="button">{importMutation.isPending ? 'Adding account...' : 'Add or refresh account'}</button>
|
||||
<button className="button secondary" disabled={statusQuery.isFetching} onClick={() => void statusQuery.refetch()} type="button">{statusQuery.isFetching ? 'Checking sessions...' : 'Recheck accounts'}</button>
|
||||
</div>
|
||||
<p className="settings-copy">Open Reddit Chat in Chrome, wait for the extension badge to show OK, then upload its JSON dump here. It contains login credentials; do not share it.</p>
|
||||
{importMutation.isSuccess ? <div className="settings-notice success" role="status">Reddit session imported and verified.</div> : null}
|
||||
<p className="settings-copy">Open Reddit Chat in Chrome for the account you want to add, wait for the extension badge to show OK, then upload its JSON dump. It contains login credentials; do not share it.</p>
|
||||
{importMutation.isSuccess ? <div className="settings-notice success" role="status"><span>Reddit session imported and verified.</span> <span>Account runtime is running independently.</span></div> : null}
|
||||
{importMutation.isError ? <div className="settings-notice error" role="alert">{errorMessage(importMutation.error)}</div> : null}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -5,11 +5,13 @@ import {
|
||||
ConfigUpdateResponseSchema,
|
||||
ErrorResponseSchema,
|
||||
LoginStatusResponseSchema,
|
||||
RedditAccountsResponseSchema,
|
||||
RedditCookieImportRequestSchema,
|
||||
RedditCookieImportResponseSchema,
|
||||
type ConfigResponse,
|
||||
type ConfigUpdateRequest,
|
||||
type LoginStatusResponse,
|
||||
type RedditAccountSummary,
|
||||
} from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { dashboardRequest } from '../../api/request.js';
|
||||
|
||||
@@ -46,7 +48,11 @@ export function fetchLoginStatus(): Promise<LoginStatusResponse> {
|
||||
return request('/api/login/status').then((response) => parseResponse(response, LoginStatusResponseSchema));
|
||||
}
|
||||
|
||||
export async function importRedditSession(exported: unknown): Promise<void> {
|
||||
export function fetchRedditAccounts(): Promise<RedditAccountSummary[]> {
|
||||
return request('/api/accounts').then((response) => parseResponse(response, RedditAccountsResponseSchema));
|
||||
}
|
||||
|
||||
export async function importRedditSession(exported: unknown): Promise<RedditAccountSummary> {
|
||||
const body = RedditCookieImportRequestSchema.parse(exported);
|
||||
const response = await parseResponse(
|
||||
await request('/api/login/session-import', {
|
||||
@@ -57,4 +63,5 @@ export async function importRedditSession(exported: unknown): Promise<void> {
|
||||
RedditCookieImportResponseSchema,
|
||||
);
|
||||
if (!response.ok) throw new Error(response.error);
|
||||
return response.account;
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { fetchLoginStatus, fetchSettings, importRedditSession, saveSettings } from './settings-api.js';
|
||||
import { fetchLoginStatus, fetchRedditAccounts, fetchSettings, importRedditSession, saveSettings } from './settings-api.js';
|
||||
|
||||
export const settingsQueryKeys = {
|
||||
config: ['dashboard', 'config'] as const,
|
||||
loginStatus: ['dashboard', 'login', 'status'] as const,
|
||||
accounts: ['dashboard', 'reddit-accounts'] as const,
|
||||
};
|
||||
|
||||
export function useSettingsQuery() {
|
||||
@@ -30,12 +31,23 @@ 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() {
|
||||
void queryClient.invalidateQueries({ queryKey: settingsQueryKeys.loginStatus });
|
||||
invalidateAccountContent(queryClient);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -46,3 +46,26 @@
|
||||
@media (min-width: 70rem) {
|
||||
.settings-navigation { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
}
|
||||
.reddit-account-list {
|
||||
display: grid;
|
||||
gap: .65rem;
|
||||
}
|
||||
|
||||
.reddit-account-row {
|
||||
align-items: center;
|
||||
border: 1px solid var(--border-color, #d9d9d9);
|
||||
border-radius: .75rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
padding: .75rem;
|
||||
}
|
||||
|
||||
.reddit-account-row.is-active {
|
||||
border-color: var(--accent, #ff6534);
|
||||
}
|
||||
|
||||
.reddit-account-row > div {
|
||||
display: grid;
|
||||
gap: .2rem;
|
||||
}
|
||||
@@ -119,6 +119,23 @@ h2 { margin-bottom: 0; font-size: 1.1rem; }
|
||||
.toast { position: fixed; right: 1rem; bottom: 1rem; max-width: 25rem; padding: .8rem 1rem; border-radius: .7rem; }
|
||||
.toast.error { color: #ffd1ce; background: #461b18; border: 1px solid #80352f; }
|
||||
|
||||
.account-owner-badge {
|
||||
align-items: center;
|
||||
background: color-mix(in srgb, var(--accent, #ff6534) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent, #ff6534) 35%, transparent);
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
font-size: .75rem;
|
||||
gap: .35rem;
|
||||
padding: .2rem .55rem;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.account-owner-badge.is-unassigned {
|
||||
background: color-mix(in srgb, #d99a24 12%, transparent);
|
||||
border-color: color-mix(in srgb, #d99a24 40%, transparent);
|
||||
}
|
||||
|
||||
@media (min-width: 48rem) {
|
||||
.sidebar { visibility: visible; transform: none; }
|
||||
.workspace { margin-left: 17.5rem; }
|
||||
|
||||
Reference in new issue
Block a user