Skip to content

Commit 457d080

Browse files
committed
wip
1 parent d78ba63 commit 457d080

7 files changed

Lines changed: 222 additions & 81 deletions

File tree

‎src/background/evaluator.ts‎

Lines changed: 53 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -36,61 +36,67 @@ export function createEvaluator(runClassification = classify) {
3636
});
3737
const clear = lock.withPermits(1)(attempt(() => chrome.storage.local.remove(CACHE_KEY)));
3838

39+
/** The lock covers the cache read and the cache write, never the provider
40+
* call, so requests overlap. The merge re-reads under the lock rather than
41+
* writing back the snapshot it started from, so overlapping batches cannot
42+
* drop each other's verdicts. */
3943
function evaluate(settings: Settings, posts: readonly Post[]) {
40-
return lock
41-
.withPermits(1)(
42-
Effect.gen(function* () {
43-
const cache = yield* load;
44-
const now = Date.now();
45-
const keys = yield* Effect.forEach(posts, (post) => cacheKey(settings, post));
46-
const unique = new Map<string, Post>();
47-
posts.forEach((post, index) => {
48-
const key = keys[index]!;
49-
if (!cache[key] || now - cache[key].at > TTL) unique.set(key, post);
50-
});
51-
const updates: Record<string, typeof CacheEntry.Type> = {};
52-
if (unique.size) {
53-
// A paid request is never automatically retried here. The content controller
54-
// owns a bounded retry budget and leaves the timeline visible on failure.
55-
yield* bumpStats({ requests: 1 });
56-
const result = yield* runClassification(settings, [...unique.values()]).pipe(
57-
Effect.tapError((error) =>
58-
bumpStats({
59-
tokens: 'tokens' in error && typeof error.tokens === 'number' ? error.tokens : 0,
60-
}),
61-
),
62-
);
63-
yield* bumpStats({ tokens: result.tokens });
64-
const byId = new Map(result.verdicts.map((verdict) => [verdict.key, verdict]));
65-
for (const [key, post] of unique) {
66-
const verdict = byId.get(post.key);
67-
if (verdict && !verdict.failed)
68-
updates[key] = { hide: verdict.hide, reason: verdict.reason, at: now };
69-
}
44+
return Effect.gen(function* () {
45+
const keys = yield* Effect.forEach(posts, (post) => cacheKey(settings, post));
46+
const cache = yield* lock.withPermits(1)(load);
47+
const now = Date.now();
48+
const unique = new Map<string, Post>();
49+
posts.forEach((post, index) => {
50+
const key = keys[index]!;
51+
if (!cache[key] || now - cache[key].at > TTL) unique.set(key, post);
52+
});
53+
const updates: Record<string, typeof CacheEntry.Type> = {};
54+
if (unique.size) {
55+
// A paid request is never automatically retried here. The content controller
56+
// owns a bounded retry budget and leaves the timeline visible on failure.
57+
yield* bumpStats({ requests: 1 });
58+
const result = yield* runClassification(settings, [...unique.values()]).pipe(
59+
Effect.tapError((error) =>
60+
bumpStats({
61+
tokens: 'tokens' in error && typeof error.tokens === 'number' ? error.tokens : 0,
62+
}),
63+
),
64+
);
65+
yield* bumpStats({ tokens: result.tokens });
66+
const byId = new Map(result.verdicts.map((verdict) => [verdict.key, verdict]));
67+
for (const [key, post] of unique) {
68+
const verdict = byId.get(post.key);
69+
if (verdict && !verdict.failed)
70+
updates[key] = { hide: verdict.hide, reason: verdict.reason, at: now };
71+
}
72+
yield* lock.withPermits(1)(
73+
Effect.gen(function* () {
74+
const current = yield* load;
7075
const next = Object.fromEntries(
71-
Object.entries({ ...cache, ...updates })
76+
Object.entries({ ...current, ...updates })
7277
.filter(([, entry]) => now - entry.at <= TTL)
7378
.sort((a, b) => b[1].at - a[1].at)
7479
.slice(0, LIMIT),
7580
);
7681
yield* writeLocal({ [CACHE_KEY]: next });
77-
yield* setStatus(
78-
result.verdicts.some((verdict) => verdict.failed)
79-
? 'The model skipped some posts. They remain visible.'
80-
: '',
81-
);
82-
}
83-
return posts.map((post, index): Verdict => {
84-
const key = keys[index]!;
85-
const entry = updates[key] ?? cache[key];
86-
return entry && now - entry.at <= TTL
87-
? { key: post.key, hide: entry.hide, reason: entry.reason }
88-
: { key: post.key, hide: false, reason: '', failed: true };
89-
});
90-
}),
91-
)
92-
.pipe(Effect.timeout('32 seconds'));
82+
}),
83+
);
84+
yield* setStatus(
85+
result.verdicts.some((verdict) => verdict.failed)
86+
? 'The model skipped some posts. They remain visible.'
87+
: '',
88+
);
89+
}
90+
return posts.map((post, index): Verdict => {
91+
const key = keys[index]!;
92+
const entry = updates[key] ?? cache[key];
93+
return entry && now - entry.at <= TTL
94+
? { key: post.key, hide: entry.hide, reason: entry.reason }
95+
: { key: post.key, hide: false, reason: '', failed: true };
96+
});
97+
}).pipe(Effect.timeout('32 seconds'));
9398
}
99+
94100
return {
95101
evaluate,
96102
clear,

‎src/common/settings.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export const Settings = Schema.Struct({
3434
maxImagesPerPost: boundedInt(1, 4),
3535
hideStyle: Schema.Literal('collapse', 'blur'),
3636
motion: Schema.Literal('auto', 'full', 'reduced'),
37+
lookahead: Schema.Number.pipe(Schema.int(), Schema.clamp(0, 500)),
3738
allowedAuthors: list,
3839
blockedAuthors: list,
3940
blockedWords: list,
@@ -61,6 +62,7 @@ export const defaults: Settings = {
6162
maxImagesPerPost: 2,
6263
hideStyle: 'collapse',
6364
motion: 'auto',
65+
lookahead: 200,
6466
allowedAuthors: [],
6567
blockedAuthors: [],
6668
blockedWords: [],

‎src/popup/XPanel.tsx‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'preact/hooks';
22
import type { Stats } from '../common/messages';
33
import { Alert, ChevronRight, Close } from './icons';
44
import { lists, type ListKey } from './ListSheet';
5-
import { ToggleRow, type SettingsEditor } from './ui';
5+
import { RangeField, ToggleRow, type SettingsEditor } from './ui';
66

77
export type XTab = 'filtering' | 'rules' | 'activity';
88

@@ -306,6 +306,24 @@ export function XPanel({
306306
)}
307307
</section>
308308

309+
<section class="group">
310+
<h2>Lookahead</h2>
311+
<RangeField
312+
label="Judge posts within"
313+
min={0}
314+
max={500}
315+
step={25}
316+
value={settings.lookahead}
317+
display={`${settings.lookahead}vh`}
318+
onChange={(next) => update('lookahead', next)}
319+
/>
320+
<p class="note tight">
321+
{settings.lookahead === 0
322+
? 'Only posts on screen are judged, so you will watch them resolve.'
323+
: `Posts within ${settings.lookahead / 100} screens of the viewport are judged early, so most have settled before you reach them. Higher costs more requests.`}
324+
</p>
325+
</section>
326+
309327
<section class="group">
310328
<h2>Images</h2>
311329
<ToggleRow

‎src/popup/style.css‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,7 @@ svg {
338338
font-weight: 550;
339339
color: var(--muted);
340340
}
341-
input:not([type='checkbox']),
341+
input:not([type='checkbox']):not([type='range']),
342342
textarea,
343343
select {
344344
width: 100%;
@@ -396,6 +396,46 @@ input[type='number'] {
396396
font-weight: 550;
397397
color: var(--muted);
398398
}
399+
.range-value {
400+
font-size: 11.5px;
401+
font-weight: 600;
402+
color: var(--accent-text);
403+
font-variant-numeric: tabular-nums;
404+
}
405+
input[type='range'] {
406+
width: 100%;
407+
height: 18px;
408+
padding: 0;
409+
border: 0;
410+
background: none;
411+
cursor: pointer;
412+
}
413+
input[type='range']::-webkit-slider-runnable-track {
414+
height: 4px;
415+
border-radius: 99px;
416+
background: linear-gradient(
417+
90deg,
418+
var(--accent) var(--fill, 0%),
419+
var(--line-strong) var(--fill, 0%)
420+
);
421+
}
422+
input[type='range']::-webkit-slider-thumb {
423+
appearance: none;
424+
width: 14px;
425+
height: 14px;
426+
margin-top: -5px;
427+
border: 2px solid var(--surface);
428+
border-radius: 99px;
429+
background: var(--accent);
430+
box-shadow: var(--shadow-pop);
431+
}
432+
input[type='range']:focus-visible {
433+
outline: none;
434+
}
435+
input[type='range']:focus-visible::-webkit-slider-thumb {
436+
outline: 2px solid var(--accent);
437+
outline-offset: 2px;
438+
}
399439
.counter {
400440
font-size: 10.5px;
401441
color: var(--faint);

‎src/popup/ui.tsx‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,45 @@ export function Notice({ tone, children }: { tone: 'bad' | 'good' | ''; children
9898
);
9999
}
100100

101+
export function RangeField({
102+
label,
103+
value,
104+
min,
105+
max,
106+
step,
107+
display,
108+
onChange,
109+
}: {
110+
label: string;
111+
value: number;
112+
min: number;
113+
max: number;
114+
step: number;
115+
display: string;
116+
onChange: (value: number) => void;
117+
}) {
118+
const fill = ((value - min) / (max - min)) * 100;
119+
return (
120+
<div class="field">
121+
<span class="rowline">
122+
<span>{label}</span>
123+
<span class="spacer" />
124+
<b class="range-value">{display}</b>
125+
</span>
126+
<input
127+
type="range"
128+
min={min}
129+
max={max}
130+
step={step}
131+
value={value}
132+
aria-label={label}
133+
style={`--fill:${fill}%`}
134+
onInput={(event) => onChange(event.currentTarget.valueAsNumber)}
135+
/>
136+
</div>
137+
);
138+
}
139+
101140
/** A number field a reader can clear. The DOM reports NaN for an empty input,
102141
* which is not a value worth writing anywhere. */
103142
export function NumberField({

0 commit comments

Comments
 (0)