Enable automatic browsing upvotes
This commit is contained in:
1 parent
27012399ec
commit
7bf1aa9d24
26 files changed
+339
-57
No files matched your search
@@ -15,6 +15,7 @@ export interface AccountBrowsingSettingsRecord {
|
||||
minimumGapMs: number;
|
||||
jitterMs: number;
|
||||
allowNsfw: boolean;
|
||||
autoVoteEnabled: boolean;
|
||||
nextDueAt: number | null;
|
||||
updatedAt: number;
|
||||
}
|
||||
@@ -29,6 +30,7 @@ export interface AccountBrowsingSettingsPatch {
|
||||
minimumGapMs?: number;
|
||||
jitterMs?: number;
|
||||
allowNsfw?: boolean;
|
||||
autoVoteEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface BrowsingDecisionRecord {
|
||||
@@ -44,6 +46,7 @@ export interface BrowsingDecisionRecord {
|
||||
discoveredAt: number;
|
||||
decidedAt: number;
|
||||
scheduledSessionId: string;
|
||||
voteSubmitted: boolean;
|
||||
}
|
||||
|
||||
export interface BrowsingDecisionCounts {
|
||||
@@ -74,6 +77,7 @@ export class AccountBrowsingSimulationRepository {
|
||||
minimumGapMs: 45 * 60_000,
|
||||
jitterMs: 15 * 60_000,
|
||||
allowNsfw: false,
|
||||
autoVoteEnabled: false,
|
||||
nextDueAt: null,
|
||||
updatedAt: now,
|
||||
};
|
||||
@@ -95,9 +99,9 @@ export class AccountBrowsingSimulationRepository {
|
||||
this.db.prepare(`
|
||||
INSERT INTO account_browsing_settings (
|
||||
account_id, enabled, daily_target_min, daily_target_max, post_weight,
|
||||
comment_weight, quiet_hours_json, minimum_gap_ms, jitter_ms, allow_nsfw,
|
||||
comment_weight, quiet_hours_json, minimum_gap_ms, jitter_ms, allow_nsfw, auto_vote_enabled,
|
||||
next_due_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
enabled = excluded.enabled,
|
||||
daily_target_min = excluded.daily_target_min,
|
||||
@@ -108,11 +112,12 @@ export class AccountBrowsingSimulationRepository {
|
||||
minimum_gap_ms = excluded.minimum_gap_ms,
|
||||
jitter_ms = excluded.jitter_ms,
|
||||
allow_nsfw = excluded.allow_nsfw,
|
||||
auto_vote_enabled = excluded.auto_vote_enabled,
|
||||
updated_at = excluded.updated_at
|
||||
`).run(
|
||||
accountId, merged.enabled ? 1 : 0, merged.dailyTargetMin, merged.dailyTargetMax,
|
||||
merged.postWeight, merged.commentWeight, JSON.stringify(merged.quietHours),
|
||||
merged.minimumGapMs, merged.jitterMs, merged.allowNsfw ? 1 : 0,
|
||||
merged.minimumGapMs, merged.jitterMs, merged.allowNsfw ? 1 : 0, merged.autoVoteEnabled ? 1 : 0,
|
||||
merged.nextDueAt, now,
|
||||
);
|
||||
return this.getSettings(accountId, now);
|
||||
@@ -131,12 +136,13 @@ export class AccountBrowsingSimulationRepository {
|
||||
return this.db.prepare(`
|
||||
INSERT OR IGNORE INTO account_browsing_decisions (
|
||||
account_id, thing_fullname, thing_kind, post_id, subreddit, author,
|
||||
permalink, decision, reason, discovered_at, decided_at, scheduled_session_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
permalink, decision, reason, discovered_at, decided_at, scheduled_session_id, vote_submitted
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
decision.accountId, decision.thingFullname, decision.thingKind, decision.postId,
|
||||
decision.subreddit, decision.author, decision.permalink, decision.decision,
|
||||
decision.reason, decision.discoveredAt, decision.decidedAt, decision.scheduledSessionId,
|
||||
decision.voteSubmitted ? 1 : 0,
|
||||
).changes === 1;
|
||||
}
|
||||
|
||||
@@ -146,6 +152,13 @@ export class AccountBrowsingSimulationRepository {
|
||||
}
|
||||
}
|
||||
|
||||
markVoteSubmitted(accountId: string, thingFullname: string): boolean {
|
||||
return this.db.prepare(`
|
||||
UPDATE account_browsing_decisions SET vote_submitted = 1
|
||||
WHERE account_id = ? AND thing_fullname = ? AND vote_submitted = 0
|
||||
`).run(accountId, thingFullname).changes === 1;
|
||||
}
|
||||
|
||||
hasAccountDecision(accountId: string, thingFullname: string): boolean {
|
||||
return this.db.prepare(`
|
||||
SELECT 1 FROM account_browsing_decisions WHERE account_id = ? AND thing_fullname = ?
|
||||
@@ -271,6 +284,7 @@ function mapSettings(row: Record<string, unknown>): AccountBrowsingSettingsRecor
|
||||
minimumGapMs: row.minimum_gap_ms as number,
|
||||
jitterMs: row.jitter_ms as number,
|
||||
allowNsfw: row.allow_nsfw === 1,
|
||||
autoVoteEnabled: row.auto_vote_enabled === 1,
|
||||
nextDueAt: (row.next_due_at as number | null) ?? null,
|
||||
updatedAt: row.updated_at as number,
|
||||
};
|
||||
@@ -290,5 +304,6 @@ function mapDecision(row: Record<string, unknown>): BrowsingDecisionRecord {
|
||||
discoveredAt: row.discovered_at as number,
|
||||
decidedAt: row.decided_at as number,
|
||||
scheduledSessionId: row.scheduled_session_id as string,
|
||||
voteSubmitted: row.vote_submitted === 1,
|
||||
};
|
||||
}
|
||||
@@ -608,6 +608,10 @@ export function runMigrations(db: Database.Database): void {
|
||||
CREATE INDEX IF NOT EXISTS idx_account_browsing_decisions_day
|
||||
ON account_browsing_decisions(account_id, decision, decided_at);
|
||||
`),
|
||||
() => {
|
||||
addColumnIfNotExists(db, 'account_browsing_settings', 'auto_vote_enabled', 'INTEGER NOT NULL DEFAULT 0 CHECK(auto_vote_enabled IN (0, 1))');
|
||||
addColumnIfNotExists(db, 'account_browsing_decisions', 'vote_submitted', 'INTEGER NOT NULL DEFAULT 0 CHECK(vote_submitted IN (0, 1))');
|
||||
},
|
||||
];
|
||||
|
||||
let currentVersion = db.pragma('user_version', { simple: true }) as number;
|
||||
|
||||
@@ -31,6 +31,14 @@ export type CommentPostResult =
|
||||
/** Anything else: transient, safe to retry later. */
|
||||
| { status: 'failed'; reason: string };
|
||||
|
||||
/** Result of a single upvote. A failed/uncertain request is never retried. */
|
||||
export type VoteResult =
|
||||
| { status: 'submitted' }
|
||||
| { status: 'rate-limited'; retryAfterMs: number; reason: string }
|
||||
| { status: 'session-invalid'; reason: string }
|
||||
| { status: 'blocked'; reason: string }
|
||||
| { status: 'failed'; reason: string };
|
||||
|
||||
/** Why a modhash lookup failed, so the caller can pick a proportionate response. */
|
||||
type ModhashResult =
|
||||
| { ok: true; modhash: string }
|
||||
@@ -206,6 +214,50 @@ export class RedditCommentClient {
|
||||
return { status: 'posted', fullname: created.fullname, permalink: created.permalink };
|
||||
}
|
||||
|
||||
/** Submit one upvote through the same authenticated browser session as comments. */
|
||||
async upvote(thingFullname: string): Promise<VoteResult> {
|
||||
if (!/^t[13]_[a-z0-9]+$/i.test(thingFullname)) {
|
||||
return { status: 'failed', reason: `"${thingFullname}" is not a post or comment fullname` };
|
||||
}
|
||||
const modhash = await this.ensureModhash();
|
||||
if (!modhash.ok) return modhash.kind === 'signed-out'
|
||||
? { status: 'session-invalid', reason: modhash.reason }
|
||||
: { status: 'failed', reason: modhash.reason };
|
||||
|
||||
let response: HttpTextResponse;
|
||||
try {
|
||||
response = await this.scheduler.schedule(() => this.transport.postForm(
|
||||
`${REDDIT_WRITE_ORIGIN}/api/vote`,
|
||||
{ id: thingFullname, dir: '1', api_type: 'json', uh: modhash.modhash },
|
||||
{ 'X-Modhash': modhash.modhash, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
));
|
||||
} catch (error) {
|
||||
return { status: 'failed', reason: errorMessage(error) };
|
||||
}
|
||||
if (response.status === 429) {
|
||||
const retryAfterMs = 10 * 60_000;
|
||||
this.scheduler.extendCooldown(retryAfterMs);
|
||||
return { status: 'rate-limited', retryAfterMs, reason: 'Reddit returned HTTP 429 for the vote' };
|
||||
}
|
||||
if (response.status === 403) {
|
||||
if (!looksLikeJson(response.body)) return { status: 'blocked', reason: blockPageReason(response) };
|
||||
this.modhash = null;
|
||||
return { status: 'session-invalid', reason: 'Reddit rejected the vote with HTTP 403' };
|
||||
}
|
||||
if (response.status >= 500) return { status: 'failed', reason: `Reddit returned HTTP ${response.status}` };
|
||||
const payload = parseJson(response.body);
|
||||
if (!payload) return { status: 'failed', reason: `Reddit returned an unreadable vote response (HTTP ${response.status})` };
|
||||
const errors = readErrors(payload);
|
||||
if (errors.length > 0) {
|
||||
const classified = this.classifyError(errors[0], 'the selected community');
|
||||
if (classified.status === 'rate-limited') return { status: 'rate-limited', retryAfterMs: classified.retryAfterMs, reason: classified.reason };
|
||||
if (classified.status === 'session-invalid' || classified.status === 'blocked') return classified;
|
||||
return { status: 'failed', reason: 'reason' in classified ? classified.reason : 'Reddit rejected the vote' };
|
||||
}
|
||||
this.logger.info(`Submitted Reddit upvote for ${thingFullname}`);
|
||||
return { status: 'submitted' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Current score and removal state for previously posted comments, from the
|
||||
* account's own authenticated view. Batched because Reddit accepts up to 100
|
||||
|
||||
@@ -17,6 +17,10 @@ export interface BrowsingDecisionSink {
|
||||
recordSimulation(decision: BrowsingDecisionRecord): Promise<void>;
|
||||
}
|
||||
|
||||
export interface BrowsingVoter {
|
||||
upvote(thingFullname: string): Promise<{ status: 'submitted' | 'rate-limited' | 'session-invalid' | 'blocked' | 'failed'; reason?: string }>;
|
||||
}
|
||||
|
||||
export interface AccountBrowsingSimulationServiceOptions {
|
||||
accountId: string;
|
||||
repository: AccountBrowsingSimulationRepository;
|
||||
@@ -26,6 +30,7 @@ export interface AccountBrowsingSimulationServiceOptions {
|
||||
getBrowsingComments(subreddit: string, postId: string, limit: number): Promise<BrowsingThing[]>;
|
||||
};
|
||||
sink: BrowsingDecisionSink;
|
||||
voter?: BrowsingVoter;
|
||||
logger: Logger;
|
||||
now?: () => number;
|
||||
random?: () => number;
|
||||
@@ -50,19 +55,27 @@ export class AccountBrowsingSimulationService {
|
||||
this.random = options.random ?? Math.random;
|
||||
}
|
||||
|
||||
async tick(signal?: AbortSignal): Promise<BrowsingSimulationOutcome> {
|
||||
/** Runs one operator-requested, simulation-only selection without waiting for the schedule. */
|
||||
runNow(signal?: AbortSignal): Promise<BrowsingSimulationOutcome> {
|
||||
return this.tick({ force: true }, signal);
|
||||
}
|
||||
|
||||
async tick(
|
||||
{ allowVoting = false, force = false }: { allowVoting?: boolean; force?: boolean } = {},
|
||||
signal?: AbortSignal,
|
||||
): Promise<BrowsingSimulationOutcome> {
|
||||
if (signal?.aborted) throw new DOMException('Operation aborted', 'AbortError');
|
||||
const now = this.now();
|
||||
const settings = this.options.repository.getSettings(this.options.accountId, now);
|
||||
if (!settings.enabled) return { kind: 'idle', reason: 'Browsing simulation is disabled' };
|
||||
|
||||
if (settings.nextDueAt === null) {
|
||||
if (settings.nextDueAt === null && !force) {
|
||||
const nextDueAt = this.scheduleNext(settings, now);
|
||||
this.options.repository.setNextDueAt(this.options.accountId, nextDueAt, now);
|
||||
return { kind: 'scheduled', nextDueAt };
|
||||
}
|
||||
if (now < settings.nextDueAt) return { kind: 'idle', reason: 'Browsing simulation is not due' };
|
||||
if (isQuietTime(new Date(now), settings)) {
|
||||
if (!force && now < settings.nextDueAt) return { kind: 'idle', reason: 'Browsing simulation is not due' };
|
||||
if (!force && isQuietTime(new Date(now), settings)) {
|
||||
const nextDueAt = moveOutsideQuietHours(now, settings);
|
||||
this.options.repository.setNextDueAt(this.options.accountId, nextDueAt, now);
|
||||
return { kind: 'scheduled', nextDueAt };
|
||||
@@ -70,7 +83,7 @@ export class AccountBrowsingSimulationService {
|
||||
|
||||
const today = startOfLocalDay(now);
|
||||
const target = dailyTarget(this.options.accountId, today, settings);
|
||||
if (this.options.repository.countSelectedSince(this.options.accountId, today) >= target) {
|
||||
if (!force && this.options.repository.countSelectedSince(this.options.accountId, today) >= target) {
|
||||
const nextDueAt = moveOutsideQuietHours(today + 24 * 60 * 60_000, settings);
|
||||
this.options.repository.setNextDueAt(this.options.accountId, nextDueAt, now);
|
||||
return { kind: 'scheduled', nextDueAt };
|
||||
@@ -103,10 +116,22 @@ export class AccountBrowsingSimulationService {
|
||||
if (candidates.length === 0) return { kind: 'scheduled', nextDueAt };
|
||||
|
||||
const selected = candidates[Math.floor(this.random() * candidates.length)];
|
||||
const decision = toDecision(this.options.accountId, selected, 'simulated-upvote', 'Eligible simulated selection', sessionId, now);
|
||||
const decision = toDecision(this.options.accountId, selected, 'simulated-upvote', 'Eligible browsing selection', sessionId, now);
|
||||
await this.options.sink.recordSimulation(decision);
|
||||
if (settings.autoVoteEnabled && allowVoting && this.options.voter) {
|
||||
const result = await this.options.voter.upvote(selected.fullname);
|
||||
if (result.status === 'submitted') {
|
||||
this.options.repository.markVoteSubmitted(this.options.accountId, selected.fullname);
|
||||
decision.voteSubmitted = true;
|
||||
decision.reason = 'Reddit upvote submitted';
|
||||
this.options.logger.info(`Submitted browsing upvote for ${selected.fullname} in r/${selected.subreddit}`);
|
||||
} else {
|
||||
decision.reason = `Reddit upvote not submitted: ${result.reason ?? result.status}`;
|
||||
this.options.logger.warn(`Browsing upvote for ${selected.fullname} was not submitted: ${result.reason ?? result.status}`);
|
||||
}
|
||||
}
|
||||
this.options.onActivity?.(decision);
|
||||
this.options.logger.info(`Simulated browsing selection for ${selected.fullname} in r/${selected.subreddit}; no Reddit vote was submitted`);
|
||||
if (!decision.voteSubmitted) this.options.logger.info(`Simulated browsing selection for ${selected.fullname} in r/${selected.subreddit}; no Reddit vote was submitted`);
|
||||
return { kind: 'simulated', decision, nextDueAt };
|
||||
}
|
||||
|
||||
@@ -278,5 +303,6 @@ function toDecision(
|
||||
discoveredAt: now,
|
||||
decidedAt: now,
|
||||
scheduledSessionId: sessionId,
|
||||
voteSubmitted: false,
|
||||
};
|
||||
}
|
||||
@@ -298,11 +298,14 @@ async function buildRuntime(
|
||||
redditApi.getBrowsingComments(subreddit, postId, limit),
|
||||
},
|
||||
sink: browsingRepository,
|
||||
voter: commentClient,
|
||||
logger,
|
||||
onActivity: (decision) => activityLogRepo.insert({
|
||||
timestamp: decision.decidedAt,
|
||||
type: 'response',
|
||||
message: `Simulated ${decision.thingKind} upvote in r/${decision.subreddit}; no Reddit vote was submitted`,
|
||||
message: decision.voteSubmitted
|
||||
? `Submitted ${decision.thingKind} upvote in r/${decision.subreddit}`
|
||||
: `Simulated ${decision.thingKind} upvote in r/${decision.subreddit}; no Reddit vote was submitted`,
|
||||
postUrl: `https://www.reddit.com${decision.permalink}`,
|
||||
postTitle: `r/${decision.subreddit}`,
|
||||
}),
|
||||
@@ -502,12 +505,14 @@ export class AccountRuntimeLoop {
|
||||
this.logger.error('Account growth tick failed:', error);
|
||||
}
|
||||
}
|
||||
// Browsing simulation is strictly below comment growth. It records a
|
||||
// hypothetical decision only and runs in both scan and running modes.
|
||||
// Browsing selection is strictly below comment growth. It runs in scan
|
||||
// and running modes, but submits votes only while fully running and not dry-run.
|
||||
if (this.resources.browsingService && Date.now() >= nextBrowsingCheckAt) {
|
||||
nextBrowsingCheckAt = Date.now() + growthInterval;
|
||||
try {
|
||||
await this.resources.browsingService.tick(signal);
|
||||
await this.resources.browsingService.tick(
|
||||
{ allowVoting: allowsSending(mode) && !this.config.browser.dryRun }, signal,
|
||||
);
|
||||
} catch (error) {
|
||||
if (signal.aborted) return;
|
||||
this.logger.error('Browsing simulation tick failed:', error);
|
||||
|
||||
@@ -181,11 +181,15 @@ export const API_OPERATIONS: readonly ApiOperation[] = [
|
||||
requestSchema: GrowthSettingsUpdateRequestSchema,
|
||||
responseSchema: GrowthAccountResponseSchema,
|
||||
}),
|
||||
operation('updateBrowsingSimulation', 'PATCH', '/api/growth/{accountId}/browsing', 'Configure simulation-only browsing selections for an account', ['Growth'], {
|
||||
operation('updateBrowsingSimulation', 'PATCH', '/api/growth/{accountId}/browsing', 'Configure per-account browsing selection and automatic upvotes', ['Growth'], {
|
||||
access: 'admin',
|
||||
requestSchema: BrowsingSettingsUpdateRequestSchema,
|
||||
responseSchema: GrowthAccountResponseSchema,
|
||||
}),
|
||||
operation('runBrowsingSimulation', 'POST', '/api/growth/{accountId}/browsing/run', 'Run one simulation-only browsing session immediately', ['Growth'], {
|
||||
access: 'admin',
|
||||
responseSchema: GrowthAccountResponseSchema,
|
||||
}),
|
||||
operation('pauseGrowth', 'POST', '/api/growth/{accountId}/pause', 'Stop an account from posting further comments', ['Growth'], {
|
||||
access: 'control',
|
||||
requestSchema: GrowthPauseRequestSchema,
|
||||
|
||||
@@ -28,12 +28,14 @@ export const BrowsingSettingsUpdateRequestSchema = z.strictObject({
|
||||
minimumGapMs: z.number().int().min(45 * 60_000).max(DAY_MS).optional(),
|
||||
jitterMs: z.number().int().min(0).max(6 * 60 * 60_000).optional(),
|
||||
allowNsfw: z.boolean().optional(),
|
||||
autoVoteEnabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const BrowsingSettingsSchema = z.strictObject({
|
||||
enabled: z.boolean(), dailyTargetMin: z.number().int(), dailyTargetMax: z.number().int(),
|
||||
postWeight: z.number(), commentWeight: z.number(), quietHours: GrowthQuietHoursSchema,
|
||||
minimumGapMs: z.number().int(), jitterMs: z.number().int(), allowNsfw: z.boolean(),
|
||||
autoVoteEnabled: z.boolean(),
|
||||
nextDueAt: z.number().nullable(), updatedAt: z.number(),
|
||||
});
|
||||
|
||||
@@ -45,12 +47,12 @@ export const BrowsingStatsSchema = z.strictObject({
|
||||
export const BrowsingDecisionSchema = z.strictObject({
|
||||
thingFullname: z.string(), thingKind: z.enum(['post', 'comment']), postId: z.string(),
|
||||
subreddit: z.string(), author: z.string(), permalink: z.string(),
|
||||
decision: z.enum(['simulated-upvote', 'skipped', 'rejected']), reason: z.string(), decidedAt: z.number(),
|
||||
decision: z.enum(['simulated-upvote', 'skipped', 'rejected']), reason: z.string(), voteSubmitted: z.boolean(), decidedAt: z.number(),
|
||||
});
|
||||
|
||||
export const BrowsingSummarySchema = z.strictObject({
|
||||
settings: BrowsingSettingsSchema, todayTarget: z.number().int(), stats: BrowsingStatsSchema,
|
||||
noVotesSubmitted: z.literal(true),
|
||||
noVotesSubmitted: z.boolean(),
|
||||
});
|
||||
|
||||
export const BrowsingDetailSchema = BrowsingSummarySchema.extend({
|
||||
|
||||
@@ -89,6 +89,28 @@ export function createGrowthRouter(ctx: WebContext): Router {
|
||||
return res.json({ success: true, account: detail(repo, browsingRepo, accountId, username) });
|
||||
});
|
||||
|
||||
router.post('/growth/:accountId/browsing/run', async (req, res) => {
|
||||
const repo = growth();
|
||||
const browsingRepo = browsing();
|
||||
if (!repo || !browsingRepo) return res.status(503).json({ error: 'Growth storage is unavailable' });
|
||||
const { accountId } = req.params;
|
||||
const username = accountName(accountId);
|
||||
if (username === null) return res.status(404).json({ error: 'Reddit account not found' });
|
||||
|
||||
const runtime = ctx.services.accountRuntimes?.get(accountId);
|
||||
const browsingService = runtime?.resources.browsingService;
|
||||
if (runtime?.snapshot().state !== 'running' || !browsingService) {
|
||||
return res.status(409).json({ error: 'Start this account before running a browsing simulation' });
|
||||
}
|
||||
try {
|
||||
await browsingService.runNow();
|
||||
} catch (error) {
|
||||
return res.status(502).json({ error: `Browsing simulation failed: ${errorMessage(error)}` });
|
||||
}
|
||||
ctx.broadcaster.push('info', `Browsing simulation run manually for u/${username}; no Reddit vote was submitted`);
|
||||
return res.json({ success: true, account: detail(repo, browsingRepo, accountId, username) });
|
||||
});
|
||||
|
||||
router.patch('/growth/:accountId', (req, res) => {
|
||||
const repo = growth();
|
||||
const browsingRepo = browsing();
|
||||
@@ -294,12 +316,13 @@ function presentBrowsing(
|
||||
minimumGapMs: settings.minimumGapMs,
|
||||
jitterMs: settings.jitterMs,
|
||||
allowNsfw: settings.allowNsfw,
|
||||
autoVoteEnabled: settings.autoVoteEnabled,
|
||||
nextDueAt: settings.nextDueAt,
|
||||
updatedAt: settings.updatedAt,
|
||||
},
|
||||
todayTarget: dailyTarget(accountId, day.getTime(), settings),
|
||||
stats: repo.getCountsSince(accountId, day.getTime()),
|
||||
noVotesSubmitted: true as const,
|
||||
noVotesSubmitted: !settings.autoVoteEnabled,
|
||||
...(includeRecent ? {
|
||||
recentDecisions: repo.listRecentSelections(accountId, 50).map((decision) => ({
|
||||
thingFullname: decision.thingFullname,
|
||||
@@ -310,6 +333,7 @@ function presentBrowsing(
|
||||
permalink: decision.permalink,
|
||||
decision: decision.decision,
|
||||
reason: decision.reason,
|
||||
voteSubmitted: decision.voteSubmitted,
|
||||
decidedAt: decision.decidedAt,
|
||||
})),
|
||||
} : {}),
|
||||
|
||||
Reference in new issue
Block a user