@khorsheed/dsh-reader 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +90 -0
- package/README.i18n.yaml +6 -0
- package/README.md +90 -0
- package/cordis.patch.yml +13 -0
- package/lib/client.js +15119 -0
- package/lib/index.js +3660 -0
- package/lib/invariant.js +38 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +1738 -0
- package/lib/typert.remote-client.d.ts +82 -0
- package/lib/typert.remote-client.js +1115 -0
- package/lib/types/arxiv.d.ts +47 -0
- package/lib/types/arxiv.js +76 -0
- package/lib/types/client/ReaderPane.d.ts +71 -0
- package/lib/types/client/ReaderPane.js +3135 -0
- package/lib/types/client/contract.d.ts +254 -0
- package/lib/types/client/contract.js +1 -0
- package/lib/types/client/definition.d.ts +27 -0
- package/lib/types/client/definition.js +32 -0
- package/lib/types/client/extract-article.d.ts +102 -0
- package/lib/types/client/extract-article.js +1024 -0
- package/lib/types/client/icons.d.ts +27 -0
- package/lib/types/client/icons.js +11 -0
- package/lib/types/client/index.d.ts +34 -0
- package/lib/types/client/index.js +222 -0
- package/lib/types/client/locales.d.ts +391 -0
- package/lib/types/client/locales.js +382 -0
- package/lib/types/client/parse-rss.d.ts +69 -0
- package/lib/types/client/parse-rss.js +656 -0
- package/lib/types/client/quote.d.ts +89 -0
- package/lib/types/client/quote.js +119 -0
- package/lib/types/client/selectors.d.ts +167 -0
- package/lib/types/client/selectors.js +413 -0
- package/lib/types/client/session.d.ts +171 -0
- package/lib/types/client/session.js +223 -0
- package/lib/types/client/store.d.ts +181 -0
- package/lib/types/client/store.js +226 -0
- package/lib/types/client/translate.d.ts +379 -0
- package/lib/types/client/translate.js +873 -0
- package/lib/types/index.d.ts +48 -0
- package/lib/types/index.js +25 -0
- package/lib/types/invariant.d.ts +29 -0
- package/lib/types/invariant.js +38 -0
- package/lib/types/link-resolvers.d.ts +107 -0
- package/lib/types/link-resolvers.js +299 -0
- package/lib/types/remote.d.ts +224 -0
- package/lib/types/remote.js +275 -0
- package/lib/types/schedule.d.ts +78 -0
- package/lib/types/schedule.js +114 -0
- package/lib/types/service.d.ts +633 -0
- package/lib/types/service.js +1697 -0
- package/lib/types/store.d.ts +224 -0
- package/lib/types/store.js +916 -0
- package/lib/types/types.d.ts +710 -0
- package/lib/types/types.js +182 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +128 -0
|
@@ -0,0 +1,3135 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* The inspiration space: the right-Sidebar tab body.
|
|
4
|
+
*
|
|
5
|
+
* It is the only React surface this package ships, and it owns the whole
|
|
6
|
+
* interaction the design settled on:
|
|
7
|
+
*
|
|
8
|
+
* - **the wall** — one card per entry, with a search box, an all/today filter,
|
|
9
|
+
* an unread-only toggle and a sort menu. All four are local predicates over
|
|
10
|
+
* entries this process parsed (design decision D14), so none of them costs a
|
|
11
|
+
* round trip.
|
|
12
|
+
* - **the detail view** — clicking a card opens it. This is the whole point:
|
|
13
|
+
* the body renders as DOM text, which is what makes passage quoting work
|
|
14
|
+
* (the QUOTE plugin's own selection menu sees it), and it is why the pane
|
|
15
|
+
* never hosts a sandboxed iframe.
|
|
16
|
+
* - **the add dialog** — an overlay in the members-tab idiom, because adding is
|
|
17
|
+
* a momentary act and the wall should stay where it was behind it. The host
|
|
18
|
+
* decides whether a pasted URL is a feed or an article from what comes back
|
|
19
|
+
* (D15); this surface reports the verdict, plus the fetch seam's own words
|
|
20
|
+
* when the fetch failed.
|
|
21
|
+
* - **the subscription page** — every source with its schedule, its pause
|
|
22
|
+
* switch and its last-fetch status. It exists because "why am I not seeing
|
|
23
|
+
* anything" and "stop refreshing this one" are questions about SOURCES, and
|
|
24
|
+
* neither belongs on the wall.
|
|
25
|
+
*
|
|
26
|
+
* The browser has no XML parser on the host side to lean on, so the pane parses
|
|
27
|
+
* payloads itself through `parse-rss` / `extract-article`.
|
|
28
|
+
*
|
|
29
|
+
* @module @khorsheed/dsh-reader/client/ReaderPane
|
|
30
|
+
*/
|
|
31
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
32
|
+
import { IconChevronDownOutlineMedium, IconChevronLeftOutlineMedium, IconClockOutlineMedium, IconCopyOutlineMedium, IconGlobeOutlineMedium, IconPlusOutlineMedium, IconRefreshOutlineMedium, IconRightUpOutlineMedium, IconSettingsOutlineMedium, IconTrashOutlineMedium } from "./icons.js";
|
|
33
|
+
import { kindQuery, linkEntryId, READER_SOURCE_KINDS, } from "../types.js";
|
|
34
|
+
import { extractArticle } from "./extract-article.js";
|
|
35
|
+
import { parseFeed } from "./parse-rss.js";
|
|
36
|
+
import { absoluteDate, clockOf, formatReaderRef, mergedDraft, provenanceOf, relativeWhen } from "./quote.js";
|
|
37
|
+
import { TARGET_CANDIDATES, buildArticle, createSession, detectSourceLanguage, detectTranslator, isTargetLanguage, primeMemory, restoreArticle, runTranslation, segmentAt, segmentsOf, setPairHover, setView, toggleSegment, translateTexts, translationHash, } from "./translate.js";
|
|
38
|
+
import { countUnread, dedupeRows, flattenEntries, fetchStateOf, LIST_RENDER_LIMIT, hueForSource, rowFor, selectRows, sourceQuery, tagQuery, tileForSource, } from "./selectors.js";
|
|
39
|
+
import { cachedTranslator, forgetTranslation, patchSession, readSession, rememberReadingPosition, rememberTranslation, rememberWallScroll, rememberTranslator, } from "./session.js";
|
|
40
|
+
import css from './ReaderPane.module.css';
|
|
41
|
+
/** A tiny stroke glyph set for the chrome the host has no icon for. */
|
|
42
|
+
const STROKE = {
|
|
43
|
+
search: 'M7 2.2a4.8 4.8 0 1 0 0 9.6 4.8 4.8 0 0 0 0-9.6Zm3.4 8.2 3 3',
|
|
44
|
+
filter: 'M2.6 3.4h10.8L9.2 8.3v4.1l-2.4-1.3V8.3z',
|
|
45
|
+
funnel: 'M2.6 3.4h10.8L9.2 8.3v4.1l-2.4-1.3V8.3z',
|
|
46
|
+
sort: 'M4.6 3v10M2.4 10.8 4.6 13l2.2-2.2M11.4 13V3M9.2 5.2 11.4 3l2.2 2.2',
|
|
47
|
+
chevron: 'M6.2 3.4 10.7 8l-4.5 4.6',
|
|
48
|
+
close: 'M4.4 4.4 11.6 11.6 M11.6 4.4 4.4 11.6',
|
|
49
|
+
fetch: 'M8 2.6v8.2M4.8 7.8 8 11l3.2-3.2M3 13.4h10',
|
|
50
|
+
check: 'M3.4 8.6 6.6 11.8 12.6 4.6',
|
|
51
|
+
alert: 'M8 3.2v5.4M8 11.6v1.2',
|
|
52
|
+
};
|
|
53
|
+
/** Render one of the small stroke glyphs. */
|
|
54
|
+
function glyph(name, size = 15) {
|
|
55
|
+
return (_jsx("svg", { width: size, height: size, viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("path", { d: STROKE[name] }) }));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Whether the body's dominant script is CJK. The article typography differs by
|
|
59
|
+
* script (line height, letter spacing and measure — see the design's D5), and
|
|
60
|
+
* the script is a property of the TEXT, not of the source it came from.
|
|
61
|
+
*
|
|
62
|
+
* @param html - the normalized article markup.
|
|
63
|
+
* @returns true when the body reads mostly as Han/Kana/Hangul.
|
|
64
|
+
*/
|
|
65
|
+
function isCjk(html) {
|
|
66
|
+
const text = html.replace(/<[^>]*>/g, '');
|
|
67
|
+
if (text.length === 0)
|
|
68
|
+
return false;
|
|
69
|
+
const cjk = text.match(/[\u3400-\u4dbf\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/g);
|
|
70
|
+
return (cjk?.length ?? 0) / text.length > 0.2;
|
|
71
|
+
}
|
|
72
|
+
/** The wall's tag panel box: the width it is clamped with, and the height
|
|
73
|
+
* placement estimates from the vocabulary (its real height is bounded by CSS). */
|
|
74
|
+
const TAG_PANEL_WIDTH = 236;
|
|
75
|
+
const TAG_PANEL_MAX_HEIGHT = 296;
|
|
76
|
+
/**
|
|
77
|
+
* How many sources it takes before the picker's own search box appears. Below
|
|
78
|
+
* it the list is short enough to read at a glance, and an input that filters
|
|
79
|
+
* three rows is noise; above it the list is unbounded and the box is the only
|
|
80
|
+
* way to reach a name without scrolling.
|
|
81
|
+
*/
|
|
82
|
+
const SOURCE_SEARCH_MIN = 6;
|
|
83
|
+
/** The one target language this milestone ships: Chinese, in the spellings the
|
|
84
|
+
* candidate chain in `translate.ts` knows how to try. Only the language PICKER
|
|
85
|
+
* is deferred — every layer below takes the tag as a parameter. */
|
|
86
|
+
const TRANSLATION_TARGET = TARGET_CANDIDATES[0] ?? 'zh';
|
|
87
|
+
/** The globe's menu, in the order it renders (view id + dictionary key). */
|
|
88
|
+
const TRANSLATION_VIEWS = [
|
|
89
|
+
{ view: 'trans', key: 'translate.onlyTranslation' },
|
|
90
|
+
{ view: 'both', key: 'translate.bilingual' },
|
|
91
|
+
{ view: 'orig', key: 'translate.onlyOriginal' },
|
|
92
|
+
];
|
|
93
|
+
/**
|
|
94
|
+
* The tag vocabulary, as rows: the card panel's checklist and the detail page's
|
|
95
|
+
* inline editor both render this.
|
|
96
|
+
*
|
|
97
|
+
* It replaces the native `<datalist>` those two inputs used to share. The
|
|
98
|
+
* browser draws that popup itself — outside the pane's surface, with no way to
|
|
99
|
+
* mark which tags are already on the entry, and with no way to offer the one
|
|
100
|
+
* action the vocabulary does not already contain: creating the name you typed.
|
|
101
|
+
*
|
|
102
|
+
* @param t - the namespace translator.
|
|
103
|
+
* @param tags - the whole vocabulary, in the host's order.
|
|
104
|
+
* @param applied - the tag ids already on this entry.
|
|
105
|
+
* @param draft - what the input currently holds; empty means "show everything".
|
|
106
|
+
* @param onToggle - apply or remove one tag.
|
|
107
|
+
* @param onCreate - make (or reuse) a tag from the typed name and apply it.
|
|
108
|
+
*/
|
|
109
|
+
function TagSuggestions({ t, tags, applied, draft, onToggle, onCreate }) {
|
|
110
|
+
const name = draft.trim();
|
|
111
|
+
const needle = name.toLowerCase();
|
|
112
|
+
const suggested = needle === '' ? tags : tags.filter(tag => tag.name.toLowerCase().includes(needle));
|
|
113
|
+
const exact = tags.some(tag => tag.name.toLowerCase() === needle);
|
|
114
|
+
return (_jsxs(_Fragment, { children: [suggested.map(tag => {
|
|
115
|
+
const on = applied.includes(tag.id);
|
|
116
|
+
return (_jsxs("button", { type: "button", className: css.tagRow, "data-on": on || undefined,
|
|
117
|
+
// Keep the field focused: the detail editor closes on blur, and a
|
|
118
|
+
// mousedown that moves focus would dismiss the list mid-click.
|
|
119
|
+
onMouseDown: event => { event.preventDefault(); }, onClick: () => { onToggle(tag); }, children: [_jsx("span", { className: css.tagRowCheck, children: on ? '✓' : '' }), _jsx("span", { className: css.tagRowName, children: tag.name })] }, tag.id));
|
|
120
|
+
}), name !== '' && !exact && (_jsxs("button", { type: "button", className: css.tagCreate, onMouseDown: event => { event.preventDefault(); }, onClick: () => { onCreate(name); }, children: [_jsx(IconPlusOutlineMedium, { size: 12 }), _jsx("span", { className: css.tagRowName, children: t('tag.create', { name }) })] }))] }));
|
|
121
|
+
}
|
|
122
|
+
/** The dictionary key each verdict message lives under. */
|
|
123
|
+
const VERDICT_KEY = {
|
|
124
|
+
subscribed: 'verdict.subscribed',
|
|
125
|
+
savedLink: 'verdict.savedLink',
|
|
126
|
+
savedLinkNoPreview: 'verdict.savedLinkNoPreview',
|
|
127
|
+
duplicate: 'verdict.duplicate',
|
|
128
|
+
invalidUrl: 'verdict.invalidUrl',
|
|
129
|
+
unsupportedContent: 'verdict.unsupportedContent',
|
|
130
|
+
fetchFailed: 'verdict.fetchFailed',
|
|
131
|
+
};
|
|
132
|
+
/** Map an `addSource` refusal onto its verdict message. */
|
|
133
|
+
function verdictForRefusal(refusal) {
|
|
134
|
+
switch (refusal) {
|
|
135
|
+
case 'duplicate': return 'duplicate';
|
|
136
|
+
case 'invalid-url': return 'invalidUrl';
|
|
137
|
+
case 'unsupported-content': return 'unsupportedContent';
|
|
138
|
+
default: return 'fetchFailed';
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** The dictionary key for each reason a link cannot be previewed. */
|
|
142
|
+
const PREVIEW_KEY = {
|
|
143
|
+
blocked: 'preview.blocked',
|
|
144
|
+
login: 'preview.login',
|
|
145
|
+
'unsupported-type': 'preview.unsupportedType',
|
|
146
|
+
redirected: 'preview.redirected',
|
|
147
|
+
empty: 'preview.empty',
|
|
148
|
+
unreachable: 'preview.unreachable',
|
|
149
|
+
http: 'preview.http',
|
|
150
|
+
unreadable: 'preview.unreadable',
|
|
151
|
+
};
|
|
152
|
+
/**
|
|
153
|
+
* The failure causes a real browser can solve: a bot wall, a login wall, a
|
|
154
|
+
* non-web file, a known-unreadable app — each opens fine where a human is
|
|
155
|
+
* driving. The other codes differ per action: `unreachable` is the retryable
|
|
156
|
+
* one, and `http` / `empty` / `redirected` are final (no gesture changes the
|
|
157
|
+
* answer), which is exactly the retry policy `isRetryablePreviewFailure`
|
|
158
|
+
* states host-side.
|
|
159
|
+
*/
|
|
160
|
+
const OPEN_IN_BROWSER_CODES = new Set(['blocked', 'login', 'unsupported-type', 'unreadable']);
|
|
161
|
+
/**
|
|
162
|
+
* Why this link has no body to show, in the reader's words.
|
|
163
|
+
*
|
|
164
|
+
* A recorded CODE is preferred over the raw message: the code is the host's
|
|
165
|
+
* classification (a bot wall, a login wall, a PDF) and each one sends the
|
|
166
|
+
* reader somewhere different, while the seam's message is diagnostic text
|
|
167
|
+
* written for whoever debugs this next.
|
|
168
|
+
*
|
|
169
|
+
* @param t - the namespace translator.
|
|
170
|
+
* @param code - the host's classification, when there is one.
|
|
171
|
+
* @param fallback - the message to show when there is no code.
|
|
172
|
+
* @returns the sentence.
|
|
173
|
+
*/
|
|
174
|
+
function previewReason(t, code, fallback) {
|
|
175
|
+
return code === undefined ? describeFetchFailure(fallback, t) : t(PREVIEW_KEY[code]);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* One "when" phrase, with its count filled in.
|
|
179
|
+
*
|
|
180
|
+
* `when.minutes` / `when.hours` / `when.days` are parameterized copy, so
|
|
181
|
+
* rendering the bare key prints `{count} d ago` at the reader. Every site that
|
|
182
|
+
* shows a relative time goes through here.
|
|
183
|
+
*
|
|
184
|
+
* @param t - the namespace translator.
|
|
185
|
+
* @param iso - the instant, when there is one.
|
|
186
|
+
* @returns the phrase.
|
|
187
|
+
*/
|
|
188
|
+
function whenLabel(t, iso) {
|
|
189
|
+
const when = relativeWhen(iso, new Date());
|
|
190
|
+
return when.count === undefined ? t(when.key) : t(when.key, { count: when.count });
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Turn a fetch/extraction failure into the sentence it deserves.
|
|
194
|
+
*
|
|
195
|
+
* The seam's reasons are distinguishable and the reader needs the distinction: a
|
|
196
|
+
* bot challenge is final, an unreachable host is not, and an unreadable
|
|
197
|
+
* extraction is a third thing again. Claiming "exceeds the fetch cap" for all
|
|
198
|
+
* three was measured wrong on the acceptance instance, whose recorded reason
|
|
199
|
+
* was `web fetch failed: TypeError: fetch failed`.
|
|
200
|
+
*
|
|
201
|
+
* @param reason - the reason the host or the extractor reported.
|
|
202
|
+
* @param t - the namespace translator.
|
|
203
|
+
* @returns the reader-facing sentence.
|
|
204
|
+
*/
|
|
205
|
+
function describeFetchFailure(reason, t) {
|
|
206
|
+
if (/^HTTP (401|403)/.test(reason))
|
|
207
|
+
return t('sources.blocked');
|
|
208
|
+
if (/^HTTP \d{3}/.test(reason))
|
|
209
|
+
return t('sources.httpError');
|
|
210
|
+
if (/fetch failed|timed out|timeout|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|network|socket/i.test(reason)) {
|
|
211
|
+
return t('sources.unreachable');
|
|
212
|
+
}
|
|
213
|
+
return t('detail.fetchFailed', { reason: reason.slice(0, 160) });
|
|
214
|
+
}
|
|
215
|
+
/** How many entries the automatic backfill fetches at once. */
|
|
216
|
+
const BACKFILL_CONCURRENCY = 2;
|
|
217
|
+
/**
|
|
218
|
+
* How many animation frames a restored reading position keeps trying.
|
|
219
|
+
*
|
|
220
|
+
* A body can still be growing when it first renders (images decoding, fonts
|
|
221
|
+
* settling, the real page arriving after the feed's summary), and an offset that
|
|
222
|
+
* cannot be reached yet is silently clamped. ~half a second of retries covers
|
|
223
|
+
* the first layout; the resize observer below covers the slow part.
|
|
224
|
+
*/
|
|
225
|
+
const SCROLL_SETTLE_ATTEMPTS = 30;
|
|
226
|
+
/**
|
|
227
|
+
* How fresh a human scroll gesture must be for a scroll event to count as the
|
|
228
|
+
* reader's.
|
|
229
|
+
*
|
|
230
|
+
* The pane's own writes are caught by the echo guard; a BROWSER's writes are
|
|
231
|
+
* not — when the document shrinks under a scrolled scroller (a translation
|
|
232
|
+
* rebuild, a body swap, the remount's intermediate short states) the browser
|
|
233
|
+
* clamps the offset and fires a scroll event that looks exactly like the
|
|
234
|
+
* reader's. Measured on the live instance: a remount's translation rebuild
|
|
235
|
+
* clamped the scroller and the clamp overwrote the stored anchor with the
|
|
236
|
+
* clamped value, so the reader came back to the top. Only wheel / touchmove /
|
|
237
|
+
* page-key gestures are the reader; a scroll event with no recent one is the
|
|
238
|
+
* browser's and is never recorded.
|
|
239
|
+
*/
|
|
240
|
+
const SCROLL_GESTURE_WINDOW_MS = 400;
|
|
241
|
+
/**
|
|
242
|
+
* How long a restored reading position keeps re-anchoring itself.
|
|
243
|
+
*
|
|
244
|
+
* An article's images carry no dimensions, so the document keeps growing well
|
|
245
|
+
* after the body renders — the acceptance instance's paper has 27 of them. While
|
|
246
|
+
* the reader has not scrolled, re-anchoring on every layout change is what keeps
|
|
247
|
+
* them at the same SENTENCE instead of the same pixel. After this window the
|
|
248
|
+
* position is theirs.
|
|
249
|
+
*/
|
|
250
|
+
const POSITION_SETTLE_MS = 20_000;
|
|
251
|
+
/**
|
|
252
|
+
* Format the store's character accounting as an approximate size.
|
|
253
|
+
*
|
|
254
|
+
* The host counts CHARACTERS (every budget in the document is written in
|
|
255
|
+
* chars), and this readout must never pretend to a precision the unit does not
|
|
256
|
+
* have — so it says MB/KB of text, one decimal at most.
|
|
257
|
+
*/
|
|
258
|
+
function formatStorageSize(chars) {
|
|
259
|
+
if (chars >= 1_000_000)
|
|
260
|
+
return `${(chars / 1_000_000).toFixed(1)} MB`;
|
|
261
|
+
return `${Math.max(1, Math.round(chars / 1_000))} KB`;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* The article's top-level blocks, ignoring the translation's own reveal lines.
|
|
265
|
+
*
|
|
266
|
+
* The ordinals are what the reading position is anchored to, so the nodes
|
|
267
|
+
* `translate.ts` inserts between blocks must not count: a translated article has
|
|
268
|
+
* one reveal under most paragraphs, and counting them would make a remembered
|
|
269
|
+
* block index point somewhere else entirely.
|
|
270
|
+
*/
|
|
271
|
+
function articleBlocks(article) {
|
|
272
|
+
return [...article.children].filter(child => child.getAttribute('data-reader-reveal') !== '1');
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Measure the article once.
|
|
276
|
+
*
|
|
277
|
+
* Reading layout is what makes Chrome report "forced reflow", and an article can
|
|
278
|
+
* have hundreds of blocks — so this happens when the layout CHANGES (a new body,
|
|
279
|
+
* a resize), never inside a scroll handler. Anchoring then costs no layout read
|
|
280
|
+
* at all, which is what a scroll handler needs.
|
|
281
|
+
*
|
|
282
|
+
* @param scroller - the detail view's scroll container.
|
|
283
|
+
* @param article - the rendered article.
|
|
284
|
+
* @returns the metrics, or `null` when there is no layout to measure.
|
|
285
|
+
*/
|
|
286
|
+
function measureArticle(scroller, article) {
|
|
287
|
+
const articleRect = article.getBoundingClientRect();
|
|
288
|
+
// No layout (a headless test, or a body that has not been measured yet): an
|
|
289
|
+
// anchor would be meaningless, and the caller falls back to the pixel offset.
|
|
290
|
+
if (articleRect.height <= 0)
|
|
291
|
+
return null;
|
|
292
|
+
const contentTop = articleRect.top - scroller.getBoundingClientRect().top + scroller.scrollTop;
|
|
293
|
+
const blocks = articleBlocks(article).map(block => {
|
|
294
|
+
const rect = block.getBoundingClientRect();
|
|
295
|
+
return {
|
|
296
|
+
top: rect.top - articleRect.top,
|
|
297
|
+
height: rect.height,
|
|
298
|
+
textLength: block.textContent?.length ?? 0,
|
|
299
|
+
element: block,
|
|
300
|
+
};
|
|
301
|
+
});
|
|
302
|
+
return { contentTop, blocks, height: articleRect.height };
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Which block the viewport top is in, and how far into it — from the cache.
|
|
306
|
+
*
|
|
307
|
+
* A binary search rather than a walk: this runs on every scroll event, and the
|
|
308
|
+
* walk it replaces is exactly the forced reflow the browser reported.
|
|
309
|
+
*
|
|
310
|
+
* @param metrics - the last measurement.
|
|
311
|
+
* @param visibleY - how far into the article the viewport top sits.
|
|
312
|
+
* @returns the block index and the offset into it.
|
|
313
|
+
*/
|
|
314
|
+
function blockAt(metrics, visibleY) {
|
|
315
|
+
const blocks = metrics.blocks;
|
|
316
|
+
if (blocks.length === 0)
|
|
317
|
+
return { block: 0, offset: Math.max(0, visibleY) };
|
|
318
|
+
let low = 0;
|
|
319
|
+
let high = blocks.length - 1;
|
|
320
|
+
let found = 0;
|
|
321
|
+
while (low <= high) {
|
|
322
|
+
const middle = (low + high) >> 1;
|
|
323
|
+
const block = blocks[middle];
|
|
324
|
+
if (block.top + block.height > visibleY) {
|
|
325
|
+
found = middle;
|
|
326
|
+
high = middle - 1;
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
low = middle + 1;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return { block: found, offset: Math.max(0, visibleY - blocks[found].top) };
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* How far into the anchor block the viewport top sat, in the CURRENT layout.
|
|
336
|
+
*
|
|
337
|
+
* A pixel offset into a block is only valid for the layout it was measured in,
|
|
338
|
+
* and the translated view IS a different layout: the same paragraph sets
|
|
339
|
+
* different words at a different height, so the recorded `offset` points at the
|
|
340
|
+
* wrong sentence — the drift the reader reported. The recorded TEXT offset is
|
|
341
|
+
* the fix: when the block still holds the same text it resolves to an exact
|
|
342
|
+
* pixel spot (a Range rect over the text node that holds that character); when
|
|
343
|
+
* the words changed, the same FRACTION of the text is the honest place. An
|
|
344
|
+
* anchor written before the text offset existed falls back to the pixel offset,
|
|
345
|
+
* unchanged.
|
|
346
|
+
*
|
|
347
|
+
* Exported for the specs; the pane itself reaches it through
|
|
348
|
+
* `applyReadingPosition`.
|
|
349
|
+
*
|
|
350
|
+
* @param block - the anchor block's CURRENT metrics.
|
|
351
|
+
* @param anchor - the recorded place.
|
|
352
|
+
* @returns pixels into the block.
|
|
353
|
+
*/
|
|
354
|
+
export function offsetInBlock(block, anchor) {
|
|
355
|
+
if (anchor.text !== undefined && anchor.text > 0 && anchor.textLength !== undefined && anchor.textLength > 0) {
|
|
356
|
+
if (block.textLength === anchor.textLength) {
|
|
357
|
+
const exact = textOffsetTop(block.element, anchor.text);
|
|
358
|
+
if (exact !== null)
|
|
359
|
+
return Math.min(exact, block.height);
|
|
360
|
+
}
|
|
361
|
+
// Different words (a translation), or no layout to resolve against: the
|
|
362
|
+
// place is the same fraction of the text.
|
|
363
|
+
return Math.round(Math.min(1, anchor.text / anchor.textLength) * block.height);
|
|
364
|
+
}
|
|
365
|
+
return anchor.offset;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Where a character offset into a block's text sits, in pixels into the block.
|
|
369
|
+
*
|
|
370
|
+
* `null` when there is no layout to answer with (jsdom, an unlaid-out body) —
|
|
371
|
+
* the caller's proportional fallback then applies. This is a layout read, which
|
|
372
|
+
* is why it runs only on restores and view switches, never in a scroll handler.
|
|
373
|
+
*
|
|
374
|
+
* @param block - the block element.
|
|
375
|
+
* @param charOffset - the character offset into its text.
|
|
376
|
+
* @returns pixels from the block's top, or `null`.
|
|
377
|
+
*/
|
|
378
|
+
function textOffsetTop(block, charOffset) {
|
|
379
|
+
try {
|
|
380
|
+
const doc = block.ownerDocument;
|
|
381
|
+
const walker = doc.createTreeWalker(block, 4 /* NodeFilter.SHOW_TEXT */);
|
|
382
|
+
let remaining = charOffset;
|
|
383
|
+
let node = walker.nextNode();
|
|
384
|
+
while (node !== null) {
|
|
385
|
+
const length = (node.textContent ?? '').length;
|
|
386
|
+
if (remaining <= length)
|
|
387
|
+
break;
|
|
388
|
+
remaining -= length;
|
|
389
|
+
node = walker.nextNode();
|
|
390
|
+
}
|
|
391
|
+
if (node === null)
|
|
392
|
+
return null;
|
|
393
|
+
const range = doc.createRange();
|
|
394
|
+
range.setStart(node, remaining);
|
|
395
|
+
range.setEnd(node, remaining);
|
|
396
|
+
// jsdom's Range has no geometry at all: the method itself is absent.
|
|
397
|
+
if (typeof range.getBoundingClientRect !== 'function')
|
|
398
|
+
return null;
|
|
399
|
+
const rect = range.getBoundingClientRect();
|
|
400
|
+
// A text position has a line height; an all-zero rect is a stub, not a spot.
|
|
401
|
+
if (rect.height === 0 && rect.width === 0)
|
|
402
|
+
return null;
|
|
403
|
+
return rect.top - block.getBoundingClientRect().top;
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Rebuild one entry from a 「最近阅读」 record.
|
|
411
|
+
*
|
|
412
|
+
* The feed that published an entry may have rolled it out of its window, and
|
|
413
|
+
* the recent list keeps only the four fields it needs — so a row the wall no
|
|
414
|
+
* longer knows is reconstructed here rather than being dropped. `rowFor` then
|
|
415
|
+
* decides whether it can still be opened (its source must still exist).
|
|
416
|
+
*
|
|
417
|
+
* @param item - the stored record.
|
|
418
|
+
* @returns an entry shaped like the ones the wall parses.
|
|
419
|
+
*/
|
|
420
|
+
function entryFromRecent(item) {
|
|
421
|
+
return {
|
|
422
|
+
id: item.entryId,
|
|
423
|
+
sourceId: item.sourceId,
|
|
424
|
+
title: item.title,
|
|
425
|
+
...(item.url === undefined ? {} : { link: item.url }),
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* The extracted meta a saved link's row carries once its card was upgraded.
|
|
430
|
+
*
|
|
431
|
+
* The `title !== sourceLabel` check is what "upgraded" means: synthesis swaps
|
|
432
|
+
* the URL-derived label for the article's own name when extraction reads one,
|
|
433
|
+
* and only then is there anything worth persisting. Feed rows never carry
|
|
434
|
+
* this — their titles are the publisher's own.
|
|
435
|
+
*/
|
|
436
|
+
function linkMetaOf(row) {
|
|
437
|
+
if (row.sourceKind !== 'link' || row.entry.title === row.sourceLabel)
|
|
438
|
+
return undefined;
|
|
439
|
+
return { title: row.entry.title, ...(row.entry.summary === undefined ? {} : { excerpt: row.entry.summary }) };
|
|
440
|
+
}
|
|
441
|
+
/** The reader tab body. */
|
|
442
|
+
export function ReaderPane(props) {
|
|
443
|
+
const { sessionId, useStore, actions, t } = props;
|
|
444
|
+
const sources = useStore(s => s.sources);
|
|
445
|
+
const parsed = useStore(s => s.parsed);
|
|
446
|
+
const articleHtml = useStore(s => s.articleHtml);
|
|
447
|
+
const articleTruncated = useStore(s => s.articleTruncated);
|
|
448
|
+
const articleError = useStore(s => s.articleError);
|
|
449
|
+
/**
|
|
450
|
+
* True while the OPEN entry's body is being fetched.
|
|
451
|
+
*
|
|
452
|
+
* The detail view pays for its own fetch when it has nothing to show, so it
|
|
453
|
+
* has to say so: an empty body area for the length of a network request reads
|
|
454
|
+
* as "this article has no text", which is the impression the auto-fetch
|
|
455
|
+
* exists to remove.
|
|
456
|
+
*/
|
|
457
|
+
const bodyFetching = useStore(s => (s.openEntryId === null ? false : s.fetching[s.openEntryId] === true));
|
|
458
|
+
const openEntryId = useStore(s => s.openEntryId);
|
|
459
|
+
const openSourceId = useStore(s => s.openSourceId);
|
|
460
|
+
const view = useStore(s => s.view);
|
|
461
|
+
const filter = useStore(s => s.filter);
|
|
462
|
+
const query = useStore(s => s.query);
|
|
463
|
+
const unreadOnly = useStore(s => s.unreadOnly);
|
|
464
|
+
const hideDupes = useStore(s => s.hideDupes);
|
|
465
|
+
const sort = useStore(s => s.sort);
|
|
466
|
+
const read = useStore(s => s.read);
|
|
467
|
+
const lastRefreshAt = useStore(s => s.lastRefreshAt);
|
|
468
|
+
const nextRefreshAt = useStore(s => s.nextRefreshAt);
|
|
469
|
+
const refreshing = useStore(s => s.refreshing);
|
|
470
|
+
const tags = useStore(s => s.tags);
|
|
471
|
+
const tagCounts = useStore(s => s.tagCounts);
|
|
472
|
+
const entryTagIds = useStore(s => s.entryTagIds);
|
|
473
|
+
const cacheTtlHours = useStore(s => s.cacheTtlHours);
|
|
474
|
+
const backfill = useStore(s => s.backfill);
|
|
475
|
+
const backfilled = useStore(s => s.backfilled);
|
|
476
|
+
const loading = useStore(s => s.loading);
|
|
477
|
+
const error = useStore(s => s.error);
|
|
478
|
+
const rev = useStore(s => s.rev);
|
|
479
|
+
/* ------------------------------------------- where this pane already was */
|
|
480
|
+
/**
|
|
481
|
+
* Where the reader stood before the host unmounted this pane — read ONCE, at
|
|
482
|
+
* mount, because hydration is a one-shot act: re-reading it later would undo
|
|
483
|
+
* the narrowing the reader is doing right now.
|
|
484
|
+
*
|
|
485
|
+
* The right sidebar unmounts whenever another main panel takes over (hopping
|
|
486
|
+
* to the side chat is the everyday case: `RightbarRoot` renders only while no
|
|
487
|
+
* other panel is active), and this store is created per mount — so the open
|
|
488
|
+
* article, the reading position, the wall's narrowing and the fact that a
|
|
489
|
+
* translation was on all used to be gone on the way back.
|
|
490
|
+
*
|
|
491
|
+
* This is a COPY, and it is deliberately not what the restore steps on the
|
|
492
|
+
* next screens read: those ask `readSession()` directly. A translation the
|
|
493
|
+
* reader turns on during THIS mount is written to the page memory after this
|
|
494
|
+
* copy was taken, so reading the copy there would make the record invisible
|
|
495
|
+
* for exactly the case it exists for (the body being replaced under the
|
|
496
|
+
* reader).
|
|
497
|
+
*
|
|
498
|
+
* The memory is the PAGE's, not this session's: the same wall and the same
|
|
499
|
+
* article should be there in the next conversation too, so switching sessions
|
|
500
|
+
* is not a reason to start over.
|
|
501
|
+
*/
|
|
502
|
+
const snapshotRef = useRef(null);
|
|
503
|
+
if (snapshotRef.current === null)
|
|
504
|
+
snapshotRef.current = readSession();
|
|
505
|
+
// The mount-time copy, narrowed once for the `useState` initializers below
|
|
506
|
+
// (their closures cannot see through the ref's null check).
|
|
507
|
+
const snapshot = snapshotRef.current;
|
|
508
|
+
/** The store's first render happens BEFORE the hydration below lands. */
|
|
509
|
+
const hydrateStartedRef = useRef(false);
|
|
510
|
+
/** The mirror effect is skipped once, so a pre-hydration render cannot erase the record. */
|
|
511
|
+
const mirrorStartedRef = useRef(false);
|
|
512
|
+
/** The entry whose translation record has already been honoured (once per open). */
|
|
513
|
+
const restoredTranslationRef = useRef(null);
|
|
514
|
+
/** The entry the pane ITSELF opened, so the restore below never re-opens it. */
|
|
515
|
+
const openedRef = useRef(null);
|
|
516
|
+
/**
|
|
517
|
+
* The entry the detail view belongs to RIGHT NOW.
|
|
518
|
+
*
|
|
519
|
+
* Opening is async: `getEntryBody` / `fetchEntryBody` resolve whenever the
|
|
520
|
+
* host answers, and an answer that comes back LATE must not land under a
|
|
521
|
+
* newer entry's title — rapid "open A, back, open B" otherwise ends with A's
|
|
522
|
+
* body on B's screen (and the translation restore, keyed on
|
|
523
|
+
* `[openEntryId, articleHtml]`, re-translating a pairing that never was).
|
|
524
|
+
* `open()` sets this synchronously; every continuation that writes article
|
|
525
|
+
* state checks it first.
|
|
526
|
+
*/
|
|
527
|
+
const openRequestRef = useRef(null);
|
|
528
|
+
/** The entry the restore has dealt with, whether or not it could be reopened. */
|
|
529
|
+
const restoredOpenRef = useRef(null);
|
|
530
|
+
/** The offset the LAST programmatic scroll landed on, so its echo is not saved. */
|
|
531
|
+
const scrollEchoRef = useRef(null);
|
|
532
|
+
/** True once the reader has scrolled the article's body themselves. */
|
|
533
|
+
const readerScrolledRef = useRef(false);
|
|
534
|
+
/** The last human scroll gesture on each scroller (0 = none): the pane only
|
|
535
|
+
records positions the reader CAUSED — see onDetailScroll. */
|
|
536
|
+
const lastDetailGestureRef = useRef(0);
|
|
537
|
+
const lastWallGestureRef = useRef(0);
|
|
538
|
+
/** The same for the wall's scroller. */
|
|
539
|
+
const wallEchoRef = useRef(null);
|
|
540
|
+
/** The wall's position is put back once per entry into the list view. */
|
|
541
|
+
const wallRestoredRef = useRef(false);
|
|
542
|
+
/** True once the host's recent list has been read at least once. */
|
|
543
|
+
const recentLoadedRef = useRef(false);
|
|
544
|
+
// Put the narrowing back before anything reads it. The restore of the OPEN
|
|
545
|
+
// article is a separate step (it needs the entry list, which is still being
|
|
546
|
+
// parsed), and it lives next to `open()`.
|
|
547
|
+
useEffect(() => {
|
|
548
|
+
hydrateStartedRef.current = true;
|
|
549
|
+
const patch = snapshotRef.current;
|
|
550
|
+
if (patch === null)
|
|
551
|
+
return;
|
|
552
|
+
actions.hydrate({
|
|
553
|
+
...(patch.view === undefined ? {} : { view: patch.view }),
|
|
554
|
+
...(patch.openEntryId === undefined ? {} : { openEntryId: patch.openEntryId }),
|
|
555
|
+
...(patch.openSourceId === undefined ? {} : { openSourceId: patch.openSourceId }),
|
|
556
|
+
...(patch.filter === undefined ? {} : { filter: patch.filter }),
|
|
557
|
+
...(patch.query === undefined ? {} : { query: patch.query }),
|
|
558
|
+
...(patch.sort === undefined ? {} : { sort: patch.sort }),
|
|
559
|
+
...(patch.unreadOnly === undefined ? {} : { unreadOnly: patch.unreadOnly }),
|
|
560
|
+
...(patch.hideDupes === undefined ? {} : { hideDupes: patch.hideDupes }),
|
|
561
|
+
...(patch.read === undefined ? {} : { read: patch.read }),
|
|
562
|
+
});
|
|
563
|
+
}, [actions]);
|
|
564
|
+
/**
|
|
565
|
+
* Figures the open body's page draws with its own scripts, which a fetch
|
|
566
|
+
* cannot capture. Session state: the host reports the count with the body
|
|
567
|
+
* (cached or fresh), so reopening an entry shows the same note.
|
|
568
|
+
*/
|
|
569
|
+
const [scriptFigures, setScriptFigures] = useState(0);
|
|
570
|
+
/** True while a capture-rendered refetch of the open entry is in flight. */
|
|
571
|
+
const [captureBusy, setCaptureBusy] = useState(false);
|
|
572
|
+
/** Entries whose stored body came from a rendered fetch (the「重新抓取」route reads it). */
|
|
573
|
+
const renderedBodiesRef = useRef(new Set());
|
|
574
|
+
/** Auto-render fires once per entry per pane session; failures leave the manual retry. */
|
|
575
|
+
const autoRenderTriedRef = useRef(new Set());
|
|
576
|
+
/** What the plugin holds per entry, as the host reports it (the 抓取 pills). */
|
|
577
|
+
const fetchStates = useStore(s => s.fetchStates);
|
|
578
|
+
/** What the reader opened, as the host stores it (the 「最近阅读」 page). */
|
|
579
|
+
const recent = useStore(s => s.recent);
|
|
580
|
+
const [addOpen, setAddOpen] = useState(false);
|
|
581
|
+
const [sortOpen, setSortOpen] = useState(false);
|
|
582
|
+
const [draftUrl, setDraftUrl] = useState('');
|
|
583
|
+
const [verdict, setVerdict] = useState(null);
|
|
584
|
+
const [draft, setDraft] = useState('');
|
|
585
|
+
const [sideChatAvailable, setSideChatAvailable] = useState(false);
|
|
586
|
+
const [timeOfDay, setTimeOfDay] = useState('10:00');
|
|
587
|
+
const [filterOpen, setFilterOpen] = useState(false);
|
|
588
|
+
/** The filter panel's page: its root, or the drilled-in source list. */
|
|
589
|
+
const [filterPage, setFilterPage] = useState('root');
|
|
590
|
+
/** The source page's own search box (the list grows without bound). */
|
|
591
|
+
const [sourceFilter, setSourceFilter] = useState('');
|
|
592
|
+
/** The entry whose card menu is open, plus where to anchor it. */
|
|
593
|
+
const [cardMenu, setCardMenu] = useState(null);
|
|
594
|
+
/** The failed fetch pill whose reason popover is open, plus where to anchor it. */
|
|
595
|
+
const [pillInfo, setPillInfo] = useState(null);
|
|
596
|
+
const [tagDraft, setTagDraft] = useState('');
|
|
597
|
+
const [tagInputOpen, setTagInputOpen] = useState(false);
|
|
598
|
+
/** The entry whose tag panel is open on the wall (and where to anchor it). */
|
|
599
|
+
const [cardTag, setCardTag] = useState(null);
|
|
600
|
+
/**
|
|
601
|
+
* The management list's own narrowing and order.
|
|
602
|
+
*
|
|
603
|
+
* Session state, like the wall's filter: it is where the reader is standing,
|
|
604
|
+
* not something the deployment should remember. Sorting by ADD TIME is the
|
|
605
|
+
* default because that is the question the page is usually asked — "I just
|
|
606
|
+
* added something and want to deal with it".
|
|
607
|
+
*/
|
|
608
|
+
const [manageKind, setManageKind] = useState('all');
|
|
609
|
+
const [manageSort, setManageSort] = useState('added');
|
|
610
|
+
/** The manage page's storage readout, loaded on entry (host aggregates; the tables never cross). */
|
|
611
|
+
const [storageStats, setStorageStats] = useState(null);
|
|
612
|
+
/** The translation budget draft, in MB (committed on blur/Enter, like the TTL select commits on pick). */
|
|
613
|
+
const [budgetDraft, setBudgetDraft] = useState('');
|
|
614
|
+
/** The clear button's second step: armed once, executed on the next click. */
|
|
615
|
+
const [clearArmed, setClearArmed] = useState(false);
|
|
616
|
+
/* ---------------------------------------------------- on-device translation */
|
|
617
|
+
/** The page's Translator API, read once: no API means no button at all. */
|
|
618
|
+
const translator = useMemo(() => detectTranslator(), []);
|
|
619
|
+
const [translateAvailability, setTranslateAvailability] = useState(null);
|
|
620
|
+
const [translatePhase, setTranslatePhase] = useState('idle');
|
|
621
|
+
const [translateView, setTranslateView] = useState('trans');
|
|
622
|
+
const [packProgress, setPackProgress] = useState(null);
|
|
623
|
+
const [translateProgress, setTranslateProgress] = useState(null);
|
|
624
|
+
const [translateError, setTranslateError] = useState(null);
|
|
625
|
+
const [translateMenu, setTranslateMenu] = useState(false);
|
|
626
|
+
const [tipDismissed, setTipDismissed] = useState(false);
|
|
627
|
+
/** The rendered article, so the segmentation can be applied to it. */
|
|
628
|
+
const articleRef = useRef(null);
|
|
629
|
+
/** The detail view's scroller, which is where a reading position lives. */
|
|
630
|
+
const detailRef = useRef(null);
|
|
631
|
+
/** The live segmentation, or null while the article is untouched. */
|
|
632
|
+
const builtRef = useRef(null);
|
|
633
|
+
/** The running translation's cancel flag. */
|
|
634
|
+
const cancelRef = useRef(null);
|
|
635
|
+
/** The last translation view, so turning the globe back on returns to it. */
|
|
636
|
+
const lastViewRef = useRef('trans');
|
|
637
|
+
/* ------------------------------------------------ the wall's card texts */
|
|
638
|
+
/**
|
|
639
|
+
* Whether the wall is showing translated cards (its own switch, its own memory).
|
|
640
|
+
*
|
|
641
|
+
* Initialized from the page's snapshot, like every "where the reader was"
|
|
642
|
+
* field: the mirror below writes these three into that snapshot on every
|
|
643
|
+
* change, so starting from the defaults meant the first post-hydrate mirror
|
|
644
|
+
* ERASED the remembered globe and its card texts with `false`/`false`/`{}`.
|
|
645
|
+
*/
|
|
646
|
+
const [wallOn, setWallOn] = useState(snapshot.wallOn ?? false);
|
|
647
|
+
/** Side-by-side on the wall: the original stays under each translated field. */
|
|
648
|
+
const [wallBoth, setWallBoth] = useState(snapshot.wallBoth ?? false);
|
|
649
|
+
const [wallMenu, setWallMenu] = useState(false);
|
|
650
|
+
const [wallAvailability, setWallAvailability] = useState(null);
|
|
651
|
+
/** Translated card fields, by entry id. */
|
|
652
|
+
const [cardTranslations, setCardTranslations] = useState(() => ({ ...snapshot.cardTranslations }));
|
|
653
|
+
const [wallProgress, setWallProgress] = useState(null);
|
|
654
|
+
/**
|
|
655
|
+
* The translated FIELD under the pointer, in the translation-only view.
|
|
656
|
+
*
|
|
657
|
+
* Not the card: hovering a card's blank space, tile or chevron must leave the
|
|
658
|
+
* translation alone — the reader asked for exactly that, because flipping a
|
|
659
|
+
* whole card on any pointer contact made the wall feel like it had to be
|
|
660
|
+
* tiptoed around.
|
|
661
|
+
*/
|
|
662
|
+
const [hoverField, setHoverField] = useState(null);
|
|
663
|
+
/** Entries whose fields still need translating (filled by the observer). */
|
|
664
|
+
const wallPendingRef = useRef(new Set());
|
|
665
|
+
const wallRunningRef = useRef(false);
|
|
666
|
+
const wallCancelRef = useRef(null);
|
|
667
|
+
/** The wall's scroll container, so cards are observed where they appear. */
|
|
668
|
+
const wallRef = useRef(null);
|
|
669
|
+
/** The rows the observer resolves ids against (kept fresh for the pass). */
|
|
670
|
+
const rowsRef = useRef([]);
|
|
671
|
+
/**
|
|
672
|
+
* Mirror "where the reader is" into the session memory.
|
|
673
|
+
*
|
|
674
|
+
* One write for the whole crossing rather than a call at every gesture: the
|
|
675
|
+
* values ARE the state, so a new field only has to be named here to be
|
|
676
|
+
* remembered. The FIRST run is skipped because the hydration lands after it in
|
|
677
|
+
* the same commit — without the skip, mounting would overwrite the record with
|
|
678
|
+
* the store's defaults before it had ever been read back.
|
|
679
|
+
*/
|
|
680
|
+
useEffect(() => {
|
|
681
|
+
if (!mirrorStartedRef.current) {
|
|
682
|
+
mirrorStartedRef.current = true;
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
if (!hydrateStartedRef.current)
|
|
686
|
+
return;
|
|
687
|
+
patchSession({
|
|
688
|
+
view, openEntryId, openSourceId, filter, query, sort, unreadOnly, hideDupes, read,
|
|
689
|
+
wallOn, wallBoth, cardTranslations,
|
|
690
|
+
});
|
|
691
|
+
}, [
|
|
692
|
+
view, openEntryId, openSourceId, filter, query, sort, unreadOnly, hideDupes, read,
|
|
693
|
+
wallOn, wallBoth, cardTranslations,
|
|
694
|
+
]);
|
|
695
|
+
/**
|
|
696
|
+
* The unmount flush: the mirror above is a PASSIVE effect, and a pane that is
|
|
697
|
+
* reseated in the same commit window as the reader's last gesture (a dockkit
|
|
698
|
+
* split moving the tab body) can unmount before the flush — the next mount
|
|
699
|
+
* then hydrates the PREVIOUS narrowing (the 3199 report: source filter set,
|
|
700
|
+
* split, and the box came back empty). Render writes the ref synchronously on
|
|
701
|
+
* every commit, and the cleanup — which DOES always run — records it. The
|
|
702
|
+
* same `hydrateStartedRef` guard as the mirror: a pane that never hydrated
|
|
703
|
+
* must not overwrite the record with the store's defaults.
|
|
704
|
+
*/
|
|
705
|
+
const narrowingRef = useRef(null);
|
|
706
|
+
narrowingRef.current = {
|
|
707
|
+
view, openEntryId, openSourceId, filter, query, sort, unreadOnly, hideDupes, read,
|
|
708
|
+
wallOn, wallBoth, cardTranslations,
|
|
709
|
+
};
|
|
710
|
+
useEffect(() => () => {
|
|
711
|
+
if (!hydrateStartedRef.current)
|
|
712
|
+
return;
|
|
713
|
+
if (narrowingRef.current !== null)
|
|
714
|
+
patchSession(narrowingRef.current);
|
|
715
|
+
}, []);
|
|
716
|
+
/** The class names `translate.ts` decorates the article with. */
|
|
717
|
+
const translateClasses = useMemo(() => ({
|
|
718
|
+
unit: css.unit ?? 'dsh-reader-unit',
|
|
719
|
+
reveal: css.reveal ?? 'dsh-reader-reveal',
|
|
720
|
+
line: css.revealLine ?? 'dsh-reader-reveal-line',
|
|
721
|
+
}), []);
|
|
722
|
+
/** Load the source list and parse whatever payloads the host is holding. */
|
|
723
|
+
const load = useCallback(async () => {
|
|
724
|
+
actions.setLoading(true);
|
|
725
|
+
try {
|
|
726
|
+
const listed = await props.listSources();
|
|
727
|
+
if (!listed.ok) {
|
|
728
|
+
actions.setError(listed.error.message);
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
const known = listed.value.sources;
|
|
732
|
+
actions.setSources(known);
|
|
733
|
+
// Sources whose payload is wanted: the ones that have a body, PLUS every
|
|
734
|
+
// saved link — a link with no body is still a card the reader must see,
|
|
735
|
+
// and the host answers that request with the reason instead of a payload.
|
|
736
|
+
// (Asking only for sources with bodies made an unreadable link vanish from
|
|
737
|
+
// the wall entirely, which is a worse answer than "link only".)
|
|
738
|
+
const wanted = known.filter(source => source.hasBody || source.kind === 'link');
|
|
739
|
+
if (wanted.length === 0)
|
|
740
|
+
return;
|
|
741
|
+
const bodies = await props.getBodies(wanted.map(source => source.id));
|
|
742
|
+
if (!bodies.ok) {
|
|
743
|
+
actions.setError(bodies.error.message);
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
for (const body of bodies.value.bodies) {
|
|
747
|
+
const source = known.find(item => item.id === body.id);
|
|
748
|
+
if (source === undefined)
|
|
749
|
+
continue;
|
|
750
|
+
// `truncated` is a property of the FETCH, not of the payload: the host
|
|
751
|
+
// seam caps a body at its size limit and says so on the envelope. It
|
|
752
|
+
// has to be carried onto the parsed entry here, because that flag is
|
|
753
|
+
// what the detail view's "content shown in part" note reads — and a
|
|
754
|
+
// source whose payload never arrived still needs the entry, or the
|
|
755
|
+
// detail view would show a blank page instead of the reason.
|
|
756
|
+
const cut = body.truncated === true;
|
|
757
|
+
if (body.raw === undefined) {
|
|
758
|
+
if (source.kind === 'rss') {
|
|
759
|
+
actions.setParsed({ id: source.id, entries: [], error: body.error ?? t('detail.extractFailed') });
|
|
760
|
+
}
|
|
761
|
+
else {
|
|
762
|
+
actions.setParsed({
|
|
763
|
+
id: source.id,
|
|
764
|
+
entries: [{
|
|
765
|
+
id: linkEntryId(source.id),
|
|
766
|
+
sourceId: source.id,
|
|
767
|
+
// The captured title/excerpt from a past extraction beat the
|
|
768
|
+
// URL-derived label: the card should name the paper, not the
|
|
769
|
+
// address, even after the payload's eviction.
|
|
770
|
+
title: body.title ?? source.label,
|
|
771
|
+
link: source.url,
|
|
772
|
+
...(body.excerpt === undefined ? {} : { summary: body.excerpt }),
|
|
773
|
+
// No payload at all: whatever the detail view can show is
|
|
774
|
+
// partial by definition, so it says so instead of pretending.
|
|
775
|
+
truncated: true,
|
|
776
|
+
}],
|
|
777
|
+
error: body.error ?? t('detail.extractFailed'),
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
// A feed is parsed as a feed; a saved link is its own single entry,
|
|
783
|
+
// extracted here so opening it is instant (D7).
|
|
784
|
+
if (source.kind === 'rss') {
|
|
785
|
+
const result = parseFeed(body.raw, source.id);
|
|
786
|
+
const fetchedAt = source.fetchedAt;
|
|
787
|
+
// A feed declares its own name, and that is what a reader recognises.
|
|
788
|
+
// The stored label is only the URL-derived fallback (the host cannot
|
|
789
|
+
// parse a feed — it has no XML parser), so the parsed title wins.
|
|
790
|
+
if (result.ok && result.feed.title !== undefined) {
|
|
791
|
+
actions.setSourceLabel(source.id, result.feed.title);
|
|
792
|
+
}
|
|
793
|
+
actions.setParsed(result.ok
|
|
794
|
+
? {
|
|
795
|
+
id: source.id,
|
|
796
|
+
entries: result.feed.entries.map(entry => cut ? { ...entry, truncated: true } : entry),
|
|
797
|
+
...(result.feed.incomplete === true ? { incomplete: true } : {}),
|
|
798
|
+
...(fetchedAt === undefined ? {} : { fetchedAt }),
|
|
799
|
+
}
|
|
800
|
+
: { id: source.id, entries: [], error: result.error, ...(fetchedAt === undefined ? {} : { fetchedAt }) });
|
|
801
|
+
}
|
|
802
|
+
else {
|
|
803
|
+
const extracted = extractArticle(body.raw, source.url);
|
|
804
|
+
// The card's title/excerpt: the article's own when THIS extraction
|
|
805
|
+
// read one, else the captured ones from an earlier extraction, else
|
|
806
|
+
// the URL-derived label — a saved link's card names the paper.
|
|
807
|
+
const extractedTitle = extracted.ok ? extracted.title : undefined;
|
|
808
|
+
const extractedExcerpt = extracted.ok ? extracted.excerpt : undefined;
|
|
809
|
+
const excerpt = extractedExcerpt ?? body.excerpt;
|
|
810
|
+
actions.setParsed({
|
|
811
|
+
id: source.id,
|
|
812
|
+
entries: [{
|
|
813
|
+
id: linkEntryId(source.id),
|
|
814
|
+
sourceId: source.id,
|
|
815
|
+
title: extractedTitle ?? body.title ?? source.label,
|
|
816
|
+
link: source.url,
|
|
817
|
+
...(excerpt === undefined ? {} : { summary: excerpt }),
|
|
818
|
+
...(extracted.ok ? { contentHtml: extracted.html } : {}),
|
|
819
|
+
// The count rides the entry: opening goes through getEntryBody
|
|
820
|
+
// (feedHtml echo), which knows nothing about figures for a fresh
|
|
821
|
+
// link — extraction here is the only place that counted them.
|
|
822
|
+
...(extracted.ok && extracted.scriptFigures !== undefined && extracted.scriptFigures > 0
|
|
823
|
+
? { scriptFigures: extracted.scriptFigures }
|
|
824
|
+
: {}),
|
|
825
|
+
// Incompleteness comes from the FETCH, not from extraction: the
|
|
826
|
+
// seam truncated the page, so whatever we extracted is partial.
|
|
827
|
+
...(cut ? { truncated: true } : {}),
|
|
828
|
+
}],
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
finally {
|
|
834
|
+
actions.setLoading(false);
|
|
835
|
+
}
|
|
836
|
+
}, [actions, props, t]);
|
|
837
|
+
const backfillRunning = useRef(false);
|
|
838
|
+
/** Entry ids this session already attempted, so a re-render cannot loop. */
|
|
839
|
+
const backfillTried = useRef(new Set());
|
|
840
|
+
useEffect(() => { void load(); }, [load, rev]);
|
|
841
|
+
useEffect(() => {
|
|
842
|
+
void props.listTags().then(result => {
|
|
843
|
+
if (result.ok)
|
|
844
|
+
actions.setTags(result.value.tags, result.value.counts);
|
|
845
|
+
});
|
|
846
|
+
void props.getCachePolicy().then(result => {
|
|
847
|
+
if (result.ok) {
|
|
848
|
+
actions.setCacheTtl(result.value.ttlHours);
|
|
849
|
+
setBudgetDraft(String(Math.round(result.value.translationBudgetChars / 1_000_000)));
|
|
850
|
+
}
|
|
851
|
+
});
|
|
852
|
+
}, [actions, props, rev]);
|
|
853
|
+
/** The manage page's storage readout: read on entry, and after a clear. */
|
|
854
|
+
const loadStorageStats = useCallback(async () => {
|
|
855
|
+
const result = await props.getStorageStats();
|
|
856
|
+
if (result.ok)
|
|
857
|
+
setStorageStats(result.value);
|
|
858
|
+
}, [props]);
|
|
859
|
+
useEffect(() => {
|
|
860
|
+
if (view === 'manage')
|
|
861
|
+
void loadStorageStats();
|
|
862
|
+
else
|
|
863
|
+
setClearArmed(false);
|
|
864
|
+
}, [view, loadStorageStats]);
|
|
865
|
+
/** Commit the translation budget draft (MB) through the cache policy. */
|
|
866
|
+
const commitBudget = useCallback(() => {
|
|
867
|
+
const mb = Number(budgetDraft);
|
|
868
|
+
if (!Number.isFinite(mb) || mb <= 0)
|
|
869
|
+
return;
|
|
870
|
+
void props.setCachePolicy(cacheTtlHours, undefined, Math.round(mb * 1_000_000)).then(result => {
|
|
871
|
+
if (result.ok)
|
|
872
|
+
void loadStorageStats();
|
|
873
|
+
});
|
|
874
|
+
}, [props, budgetDraft, cacheTtlHours, loadStorageStats]);
|
|
875
|
+
/** Clear both translation tiers, on the button's second click. */
|
|
876
|
+
const clearTranslations = useCallback(() => {
|
|
877
|
+
if (!clearArmed) {
|
|
878
|
+
setClearArmed(true);
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
setClearArmed(false);
|
|
882
|
+
void props.clearTranslations().then(result => { if (result.ok)
|
|
883
|
+
void loadStorageStats(); });
|
|
884
|
+
}, [props, clearArmed, loadStorageStats]);
|
|
885
|
+
useEffect(() => {
|
|
886
|
+
void props.capabilities().then(result => {
|
|
887
|
+
if (!result.ok)
|
|
888
|
+
return;
|
|
889
|
+
setSideChatAvailable(result.value.hasSideChat);
|
|
890
|
+
actions.setSchedule(result.value.lastRefreshAt, result.value.nextRefreshAt);
|
|
891
|
+
if (result.value.nextRefreshAt !== undefined)
|
|
892
|
+
setTimeOfDay(clockOf(result.value.nextRefreshAt));
|
|
893
|
+
});
|
|
894
|
+
}, [actions, props, rev]);
|
|
895
|
+
/**
|
|
896
|
+
* Fetch every enabled source now, then reload the wall.
|
|
897
|
+
*
|
|
898
|
+
* The reload is the point: a refresh that only replaced the host's stored
|
|
899
|
+
* payloads and never re-read them looked like a dead button on the
|
|
900
|
+
* acceptance instance, even though the fetch itself had succeeded.
|
|
901
|
+
*/
|
|
902
|
+
const refreshAll = useCallback(async () => {
|
|
903
|
+
actions.setRefreshing(true);
|
|
904
|
+
try {
|
|
905
|
+
const result = await props.refresh();
|
|
906
|
+
actions.noteRefreshed(new Date().toISOString());
|
|
907
|
+
if (!result.ok)
|
|
908
|
+
actions.setError(result.error.message);
|
|
909
|
+
else
|
|
910
|
+
actions.refresh();
|
|
911
|
+
}
|
|
912
|
+
finally {
|
|
913
|
+
actions.setRefreshing(false);
|
|
914
|
+
}
|
|
915
|
+
}, [actions, props]);
|
|
916
|
+
const presentation = useMemo(() => {
|
|
917
|
+
const map = new Map();
|
|
918
|
+
for (const source of sources) {
|
|
919
|
+
map.set(source.id, {
|
|
920
|
+
id: source.id,
|
|
921
|
+
label: source.label,
|
|
922
|
+
tile: tileForSource(source.label),
|
|
923
|
+
hue: hueForSource(source.label),
|
|
924
|
+
kind: source.kind,
|
|
925
|
+
addedAt: source.addedAt,
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
return map;
|
|
929
|
+
}, [sources]);
|
|
930
|
+
const allEntries = useMemo(() => flattenEntries(parsed), [parsed]);
|
|
931
|
+
/** How many entries each source kind contributes, for the filter panel. */
|
|
932
|
+
const kindCounts = useMemo(() => {
|
|
933
|
+
const counts = { rss: 0, link: 0 };
|
|
934
|
+
for (const entry of allEntries) {
|
|
935
|
+
const kind = presentation.get(entry.sourceId)?.kind;
|
|
936
|
+
if (kind !== undefined)
|
|
937
|
+
counts[kind] += 1;
|
|
938
|
+
}
|
|
939
|
+
return counts;
|
|
940
|
+
}, [allEntries, presentation]);
|
|
941
|
+
/** One sweep at a time: the wall can re-render while an extraction runs. */
|
|
942
|
+
const sweepRunning = useRef(false);
|
|
943
|
+
/** The entry ids currently on the wall, for the fetch-state round trip. */
|
|
944
|
+
const entryIds = useMemo(() => allEntries.map(entry => entry.id), [allEntries]);
|
|
945
|
+
/** Ask the host what it holds for the entries on screen. */
|
|
946
|
+
const refreshFetchStates = useCallback(async () => {
|
|
947
|
+
if (entryIds.length === 0)
|
|
948
|
+
return;
|
|
949
|
+
const result = await props.entryFetchStates(entryIds.slice(0, LIST_RENDER_LIMIT));
|
|
950
|
+
if (result.ok)
|
|
951
|
+
actions.setFetchStates(result.value.states);
|
|
952
|
+
}, [actions, entryIds, props]);
|
|
953
|
+
/**
|
|
954
|
+
* Re-read ONE entry's fetch state from the host after something settled it.
|
|
955
|
+
*
|
|
956
|
+
* The host's annotation is the only authority (body presence, the in-flight
|
|
957
|
+
* or stored-raw record, the classified failure with its reason), and the pane
|
|
958
|
+
* mirrors it — so a path that just changed the host's state re-reads the one
|
|
959
|
+
* entry it touched, and the card flips on the same gesture instead of at the
|
|
960
|
+
* next mount. A full-wall poll per fetch would be the same answer at N times
|
|
961
|
+
* the cost.
|
|
962
|
+
*/
|
|
963
|
+
const syncFetchState = useCallback(async (entryId) => {
|
|
964
|
+
const result = await props.entryFetchStates([entryId]);
|
|
965
|
+
if (result.ok)
|
|
966
|
+
actions.setFetchStates(result.value.states);
|
|
967
|
+
}, [actions, props]);
|
|
968
|
+
/**
|
|
969
|
+
* Read the recent list from the host.
|
|
970
|
+
*
|
|
971
|
+
* Host-side state, so it is read when the page opens rather than mirrored in
|
|
972
|
+
* the pane: another tab (or the same reader on another device) may have read
|
|
973
|
+
* something since this pane last looked, and the pane's own write happens on
|
|
974
|
+
* every open anyway.
|
|
975
|
+
*/
|
|
976
|
+
const refreshRecent = useCallback(async () => {
|
|
977
|
+
const result = await props.listRecent();
|
|
978
|
+
if (result.ok) {
|
|
979
|
+
actions.setRecent(result.value.entries);
|
|
980
|
+
recentLoadedRef.current = true;
|
|
981
|
+
}
|
|
982
|
+
}, [actions, props]);
|
|
983
|
+
// Read the list on mount AND whenever the page is showing. The mount read is
|
|
984
|
+
// what lets the detail view resolve an article the feed has rolled out of its
|
|
985
|
+
// window (the record is the only thing left that knows it); the page read
|
|
986
|
+
// covers the return from another panel, where the session snapshot restores
|
|
987
|
+
// the VIEW but can never restore host state.
|
|
988
|
+
useEffect(() => {
|
|
989
|
+
if (view === 'recent' || !recentLoadedRef.current)
|
|
990
|
+
void refreshRecent();
|
|
991
|
+
}, [view, refreshRecent, rev]);
|
|
992
|
+
/**
|
|
993
|
+
* Empty the recent list, here and on the host.
|
|
994
|
+
*
|
|
995
|
+
* The confirm-free gesture is deliberate: the list is a convenience, nothing
|
|
996
|
+
* in it is the reader's only copy of anything (the entries themselves live in
|
|
997
|
+
* the wall and on the sites), and a modal for "forget my history" would make
|
|
998
|
+
* the one action this page owns feel like a hazard.
|
|
999
|
+
*/
|
|
1000
|
+
const clearRecent = useCallback(async () => {
|
|
1001
|
+
const result = await props.clearRecent();
|
|
1002
|
+
if (result.ok)
|
|
1003
|
+
actions.setRecent([]);
|
|
1004
|
+
}, [actions, props]);
|
|
1005
|
+
/**
|
|
1006
|
+
* Turn stored raw payloads into bodies.
|
|
1007
|
+
*
|
|
1008
|
+
* This is what makes a fetch survive the page: the host writes the payload
|
|
1009
|
+
* before it answers, so a browser that went away leaves work here instead of
|
|
1010
|
+
* losing the download. Two at a time, like every other network path here.
|
|
1011
|
+
*/
|
|
1012
|
+
const sweepRaw = useCallback(async () => {
|
|
1013
|
+
if (sweepRunning.current)
|
|
1014
|
+
return;
|
|
1015
|
+
const raws = allEntries.filter(entry => fetchStateOf(fetchStates, entry.id).state === 'raw').slice(0, 2);
|
|
1016
|
+
if (raws.length === 0)
|
|
1017
|
+
return;
|
|
1018
|
+
sweepRunning.current = true;
|
|
1019
|
+
try {
|
|
1020
|
+
for (const entry of raws) {
|
|
1021
|
+
const stored = await props.getRawBody(entry.id);
|
|
1022
|
+
if (!stored.ok || stored.value.raw === undefined)
|
|
1023
|
+
continue;
|
|
1024
|
+
const url = stored.value.url ?? entry.link ?? '';
|
|
1025
|
+
const extracted = extractArticle(stored.value.raw, url);
|
|
1026
|
+
if (!extracted.ok)
|
|
1027
|
+
continue;
|
|
1028
|
+
await props.storeEntryBody({
|
|
1029
|
+
entryId: entry.id,
|
|
1030
|
+
url,
|
|
1031
|
+
html: extracted.html,
|
|
1032
|
+
// The map rides the body's identity: a re-extraction of the same
|
|
1033
|
+
// payload keeps it, a changed body retires it in the same commit.
|
|
1034
|
+
bodyHash: translationHash(extracted.html),
|
|
1035
|
+
// The article's own title/excerpt: a saved link's card upgrade.
|
|
1036
|
+
...(extracted.title === undefined ? {} : { title: extracted.title }),
|
|
1037
|
+
...(extracted.excerpt === undefined ? {} : { excerpt: extracted.excerpt }),
|
|
1038
|
+
...(stored.value.truncated === true ? { truncated: true } : {}),
|
|
1039
|
+
...(extracted.scriptFigures === undefined ? {} : { scriptFigures: extracted.scriptFigures }),
|
|
1040
|
+
});
|
|
1041
|
+
actions.noteExtractedMeta(entry.id, {
|
|
1042
|
+
...(extracted.title === undefined ? {} : { title: extracted.title }),
|
|
1043
|
+
...(extracted.excerpt === undefined ? {} : { excerpt: extracted.excerpt }),
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
finally {
|
|
1048
|
+
sweepRunning.current = false;
|
|
1049
|
+
await refreshFetchStates();
|
|
1050
|
+
}
|
|
1051
|
+
}, [allEntries, fetchStates, props, refreshFetchStates]);
|
|
1052
|
+
/**
|
|
1053
|
+
* Cache the feed's own full text as the entry's body, on the open that
|
|
1054
|
+
* displays it.
|
|
1055
|
+
*
|
|
1056
|
+
* A fulltext feed has already paid for the article — but the payload lives
|
|
1057
|
+
* only inside the feed's window: once the entry rolls off the feed, the host
|
|
1058
|
+
* holds nothing and the next open becomes a fetch (or a failure). Storing on
|
|
1059
|
+
* the first open costs no request (the text is already on screen), lets the
|
|
1060
|
+
* body survive the window, and flips the card's pill to 「已抓取」 on the
|
|
1061
|
+
* same gesture. The body hash keeps the entry's translation map riding the
|
|
1062
|
+
* same identity rule as a fetched body.
|
|
1063
|
+
*/
|
|
1064
|
+
const persistFeedBody = useCallback((entryId, url, html, truncated, meta) => {
|
|
1065
|
+
if (html.trim().length === 0)
|
|
1066
|
+
return;
|
|
1067
|
+
void props.storeEntryBody({
|
|
1068
|
+
entryId,
|
|
1069
|
+
url,
|
|
1070
|
+
html,
|
|
1071
|
+
bodyHash: translationHash(html),
|
|
1072
|
+
...(truncated ? { truncated: true } : {}),
|
|
1073
|
+
...(meta?.title === undefined ? {} : { title: meta.title }),
|
|
1074
|
+
...(meta?.excerpt === undefined ? {} : { excerpt: meta.excerpt }),
|
|
1075
|
+
}).then((stored) => {
|
|
1076
|
+
if (!stored.ok)
|
|
1077
|
+
return;
|
|
1078
|
+
// The host holds a fresh body now, whichever marker said otherwise (a
|
|
1079
|
+
// stale cache this store replaced, or the card's 「抓取」).
|
|
1080
|
+
actions.setStaleBody(entryId, false);
|
|
1081
|
+
void syncFetchState(entryId);
|
|
1082
|
+
});
|
|
1083
|
+
}, [actions, props, syncFetchState]);
|
|
1084
|
+
const rows = useMemo(() => selectRows(allEntries, presentation, {
|
|
1085
|
+
filter, query, unreadOnly, sort, read, tags: entryTagIds, now: new Date(),
|
|
1086
|
+
}), [allEntries, presentation, filter, query, unreadOnly, sort, read, entryTagIds]);
|
|
1087
|
+
/**
|
|
1088
|
+
* The wall's dedupe: republished articles fold behind one card (survivor and
|
|
1089
|
+
* hidden copies per `dedupeRows`). The `dupesBy` map is what the surviving
|
|
1090
|
+
* card's badge reads; hiddenCount feeds the footer. When the switch is off
|
|
1091
|
+
* the maps are empty and displayRows IS rows — the fold is invisible.
|
|
1092
|
+
*/
|
|
1093
|
+
const { rows: displayRows, dupesBy, hiddenCount } = useMemo(() => (hideDupes ? dedupeRows(rows, fetchStates) : { rows, dupesBy: new Map(), hiddenCount: 0 }), [hideDupes, rows, fetchStates]);
|
|
1094
|
+
// The wall's own observer pass reads the displayed rows, like the renderer.
|
|
1095
|
+
rowsRef.current = displayRows;
|
|
1096
|
+
/**
|
|
1097
|
+
* One entry by id: the wall's own parse first, the recent record as a fallback.
|
|
1098
|
+
*
|
|
1099
|
+
* The fallback is what lets the 「最近阅读」 page reopen an article the feed has
|
|
1100
|
+
* rolled out of its window — the case this list exists for. Everything the
|
|
1101
|
+
* detail view needs is the four stored fields plus the source row, which is
|
|
1102
|
+
* still there as long as the reader kept the subscription.
|
|
1103
|
+
*/
|
|
1104
|
+
const entryById = useCallback((id) => {
|
|
1105
|
+
const parsed = allEntries.find(entry => entry.id === id);
|
|
1106
|
+
if (parsed !== undefined)
|
|
1107
|
+
return parsed;
|
|
1108
|
+
const item = recent.find(candidate => candidate.entryId === id);
|
|
1109
|
+
return item === undefined ? undefined : entryFromRecent(item);
|
|
1110
|
+
}, [allEntries, recent]);
|
|
1111
|
+
const openEntry = openEntryId === null ? undefined : entryById(openEntryId);
|
|
1112
|
+
/** The source the strip is currently narrowing to, if any. */
|
|
1113
|
+
const activeSource = sources.find(source => query.trim() === sourceQuery(source.id))?.id ?? null;
|
|
1114
|
+
/** The source KIND the search box is narrowing to, if any (`#rss` / `#link`). */
|
|
1115
|
+
const activeKind = READER_SOURCE_KINDS.find(kind => query.trim() === kindQuery(kind)) ?? null;
|
|
1116
|
+
/** The label for whichever narrowing the search box currently holds. */
|
|
1117
|
+
const kindLabel = activeKind === null ? null : t(activeKind === 'link' ? 'sources.kindLink' : 'sources.kindRss');
|
|
1118
|
+
/** The active source's display name, for the filter panel's drill row. */
|
|
1119
|
+
const activeSourceLabel = activeSource === null
|
|
1120
|
+
? kindLabel
|
|
1121
|
+
: sources.find(source => source.id === activeSource)?.label ?? activeSource;
|
|
1122
|
+
/** The source page's list, narrowed by its own search box (name or address). */
|
|
1123
|
+
const matchingSources = useMemo(() => {
|
|
1124
|
+
const needle = sourceFilter.trim().toLowerCase();
|
|
1125
|
+
if (needle === '')
|
|
1126
|
+
return sources;
|
|
1127
|
+
return sources.filter(source => source.label.toLowerCase().includes(needle) || source.url.toLowerCase().includes(needle));
|
|
1128
|
+
}, [sources, sourceFilter]);
|
|
1129
|
+
/** Sources whose payload could not be read whole — what the notice lists. */
|
|
1130
|
+
const brokenSources = sources.filter(source => parsed[source.id]?.incomplete === true || parsed[source.id]?.error !== undefined);
|
|
1131
|
+
/** The management list: narrowed by kind, ordered by the chosen key. */
|
|
1132
|
+
const manageSources = useMemo(() => {
|
|
1133
|
+
const filtered = sources.filter(source => manageKind === 'all' || source.kind === manageKind);
|
|
1134
|
+
const sorted = [...filtered];
|
|
1135
|
+
// `?? ''` on the dates: the host always reports them, but a degraded or
|
|
1136
|
+
// older host must not turn the subscription page into a crash — an empty
|
|
1137
|
+
// key sorts last, which is the honest place for "unknown when".
|
|
1138
|
+
if (manageSort === 'name')
|
|
1139
|
+
sorted.sort((a, b) => a.label.localeCompare(b.label));
|
|
1140
|
+
else if (manageSort === 'fetched')
|
|
1141
|
+
sorted.sort((a, b) => (b.fetchedAt ?? '').localeCompare(a.fetchedAt ?? ''));
|
|
1142
|
+
else
|
|
1143
|
+
sorted.sort((a, b) => (b.addedAt ?? '').localeCompare(a.addedAt ?? ''));
|
|
1144
|
+
return sorted;
|
|
1145
|
+
}, [sources, manageKind, manageSort]);
|
|
1146
|
+
/* --------------------------------------------------- the translation flow */
|
|
1147
|
+
/**
|
|
1148
|
+
* The source language of the open body: the script heuristic first (it cannot
|
|
1149
|
+
* tell German from English, but it measures the WHOLE body, so it recognises
|
|
1150
|
+
* "already Chinese" reliably), refined by the browser's detector. The detector
|
|
1151
|
+
* only sees a 600-character prefix sample, and an arXiv author block is
|
|
1152
|
+
* romanized Chinese names — so an answer of "this is Chinese" that contradicts
|
|
1153
|
+
* the script measurement is the detector misreading a name list, not a Chinese
|
|
1154
|
+
* body, and is distrusted. A wrong source makes `create()` reject for a pair
|
|
1155
|
+
* the device does not have — and that error does not name either language,
|
|
1156
|
+
* which is why it is worth asking properly.
|
|
1157
|
+
*/
|
|
1158
|
+
const guessSource = articleHtml !== null && isCjk(articleHtml) ? TRANSLATION_TARGET : 'en';
|
|
1159
|
+
const [translationSource, setTranslationSource] = useState('en');
|
|
1160
|
+
useEffect(() => {
|
|
1161
|
+
if (view !== 'detail' || articleHtml === null || articleHtml.length === 0)
|
|
1162
|
+
return;
|
|
1163
|
+
if (guessSource === TRANSLATION_TARGET) {
|
|
1164
|
+
setTranslationSource(guessSource);
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
// A new body starts from the script guess, not the previous entry's answer;
|
|
1168
|
+
// the detector then refines it asynchronously.
|
|
1169
|
+
setTranslationSource(guessSource);
|
|
1170
|
+
let cancelled = false;
|
|
1171
|
+
void (async () => {
|
|
1172
|
+
const detected = await detectSourceLanguage(articleHtml.replace(/<[^>]*>/g, ' '), guessSource);
|
|
1173
|
+
if (!cancelled)
|
|
1174
|
+
setTranslationSource(detected.split('-')[0] === TRANSLATION_TARGET ? guessSource : detected);
|
|
1175
|
+
})();
|
|
1176
|
+
return () => { cancelled = true; };
|
|
1177
|
+
}, [view, articleHtml, guessSource]);
|
|
1178
|
+
/**
|
|
1179
|
+
* Whether the globe belongs on this article at all. Three ways it does not:
|
|
1180
|
+
* the browser has no Translator API, the pair is unavailable on this device,
|
|
1181
|
+
* or the body is already the target language. All three hide the control
|
|
1182
|
+
* rather than offering one that cannot work — the pass's own degradation rule.
|
|
1183
|
+
*/
|
|
1184
|
+
const translationOffered = translator !== null
|
|
1185
|
+
&& translateAvailability !== null
|
|
1186
|
+
&& translateAvailability !== 'unavailable'
|
|
1187
|
+
&& translationSource !== TRANSLATION_TARGET;
|
|
1188
|
+
// Ask the browser about this pair once per opened article, in every target
|
|
1189
|
+
// spelling: the probe is async, and the control appears only once one of them
|
|
1190
|
+
// is not `unavailable`.
|
|
1191
|
+
useEffect(() => {
|
|
1192
|
+
if (view !== 'detail' || translator === null || articleHtml === null || articleHtml.length === 0)
|
|
1193
|
+
return undefined;
|
|
1194
|
+
if (translationSource === TRANSLATION_TARGET) {
|
|
1195
|
+
setTranslateAvailability('unavailable');
|
|
1196
|
+
return undefined;
|
|
1197
|
+
}
|
|
1198
|
+
let cancelled = false;
|
|
1199
|
+
void (async () => {
|
|
1200
|
+
// The detector's answer is only the FIRST source candidate; the script
|
|
1201
|
+
// guess is probed behind it, the same chain startTranslation builds
|
|
1202
|
+
// sessions with. A wrong detection (the prefix sample reads a pinyin
|
|
1203
|
+
// author list as another language) would otherwise probe a pair this
|
|
1204
|
+
// device does not have and hide a globe that could have worked.
|
|
1205
|
+
const sources = translationSource === guessSource ? [guessSource] : [translationSource, guessSource];
|
|
1206
|
+
let best = 'unavailable';
|
|
1207
|
+
for (const sourceLanguage of sources) {
|
|
1208
|
+
for (const targetLanguage of TARGET_CANDIDATES) {
|
|
1209
|
+
try {
|
|
1210
|
+
const answer = await translator.availability({ sourceLanguage, targetLanguage });
|
|
1211
|
+
if (answer !== 'unavailable') {
|
|
1212
|
+
best = answer;
|
|
1213
|
+
break;
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
catch {
|
|
1217
|
+
// Try the next spelling; only "every candidate said unavailable" hides
|
|
1218
|
+
// the globe.
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
if (best !== 'unavailable')
|
|
1222
|
+
break;
|
|
1223
|
+
}
|
|
1224
|
+
if (!cancelled)
|
|
1225
|
+
setTranslateAvailability(best);
|
|
1226
|
+
})();
|
|
1227
|
+
return () => { cancelled = true; };
|
|
1228
|
+
}, [view, translator, articleHtml, translationSource, guessSource]);
|
|
1229
|
+
// A new body means a new DOM: the old segmentation dies with the old element,
|
|
1230
|
+
// so this effect is the only owner of that lifecycle. The pair probe is reset
|
|
1231
|
+
// with it too: the previous entry's answer must neither show nor hide the
|
|
1232
|
+
// globe over a body it was never asked about.
|
|
1233
|
+
useEffect(() => {
|
|
1234
|
+
if (cancelRef.current !== null)
|
|
1235
|
+
cancelRef.current.cancelled = true;
|
|
1236
|
+
cancelRef.current = null;
|
|
1237
|
+
const built = builtRef.current;
|
|
1238
|
+
if (built !== null)
|
|
1239
|
+
restoreArticle(built.root);
|
|
1240
|
+
builtRef.current = null;
|
|
1241
|
+
setTranslatePhase('idle');
|
|
1242
|
+
setTranslateView('trans');
|
|
1243
|
+
setTranslateProgress(null);
|
|
1244
|
+
setPackProgress(null);
|
|
1245
|
+
setTranslateError(null);
|
|
1246
|
+
setTranslateMenu(false);
|
|
1247
|
+
setTranslateAvailability(null);
|
|
1248
|
+
}, [openEntryId, articleHtml]);
|
|
1249
|
+
// Leaving the pane must not leave spans behind in a DOM React will reuse.
|
|
1250
|
+
useEffect(() => () => {
|
|
1251
|
+
if (cancelRef.current !== null)
|
|
1252
|
+
cancelRef.current.cancelled = true;
|
|
1253
|
+
const built = builtRef.current;
|
|
1254
|
+
if (built !== null)
|
|
1255
|
+
restoreArticle(built.root);
|
|
1256
|
+
builtRef.current = null;
|
|
1257
|
+
}, []);
|
|
1258
|
+
/** Stop the run and put the article back exactly as the host sent it. */
|
|
1259
|
+
const cancelTranslation = useCallback(() => {
|
|
1260
|
+
if (cancelRef.current !== null)
|
|
1261
|
+
cancelRef.current.cancelled = true;
|
|
1262
|
+
cancelRef.current = null;
|
|
1263
|
+
const built = builtRef.current;
|
|
1264
|
+
if (built !== null)
|
|
1265
|
+
restoreArticle(built.root);
|
|
1266
|
+
builtRef.current = null;
|
|
1267
|
+
setTranslatePhase('idle');
|
|
1268
|
+
setTranslateProgress(null);
|
|
1269
|
+
setPackProgress(null);
|
|
1270
|
+
setTranslateError(null);
|
|
1271
|
+
// The reader asked for the original: that is the state a return to this
|
|
1272
|
+
// entry should find, not a translation that switches itself back on.
|
|
1273
|
+
if (openEntryId !== null)
|
|
1274
|
+
forgetTranslation(openEntryId);
|
|
1275
|
+
}, [openEntryId]);
|
|
1276
|
+
/**
|
|
1277
|
+
* Warm the page's sentence memory from the host's two tiers before a run
|
|
1278
|
+
* spends the model.
|
|
1279
|
+
*
|
|
1280
|
+
* The entry's own exact-fit map answers first — but only while its `bodyHash`
|
|
1281
|
+
* is the body's, so a re-fetched body invalidates the map and the global
|
|
1282
|
+
* memory still serves every unchanged sentence. Either tier failing is just a
|
|
1283
|
+
* miss: the run then pays the model, exactly as before this store existed.
|
|
1284
|
+
*
|
|
1285
|
+
* @param pair - the run's pair label.
|
|
1286
|
+
* @param entryId - the open entry.
|
|
1287
|
+
* @param html - the body on screen (its hash gates the entry map).
|
|
1288
|
+
* @param built - the segmented article (its sentences are what is asked for).
|
|
1289
|
+
*/
|
|
1290
|
+
const warmMemory = useCallback(async (pair, entryId, html, built) => {
|
|
1291
|
+
const byHash = new Map();
|
|
1292
|
+
for (const segment of segmentsOf(built))
|
|
1293
|
+
byHash.set(translationHash(segment.original), segment.original);
|
|
1294
|
+
if (byHash.size === 0)
|
|
1295
|
+
return;
|
|
1296
|
+
const covered = new Set();
|
|
1297
|
+
try {
|
|
1298
|
+
const answer = await props.getEntryTranslation(entryId);
|
|
1299
|
+
const record = answer.ok ? answer.value.translation : undefined;
|
|
1300
|
+
if (record !== undefined && record.pair === pair && record.bodyHash === translationHash(html)) {
|
|
1301
|
+
const pour = [];
|
|
1302
|
+
for (const [hash, translation] of Object.entries(record.segments)) {
|
|
1303
|
+
const text = byHash.get(hash);
|
|
1304
|
+
if (text !== undefined) {
|
|
1305
|
+
pour.push({ text, translation });
|
|
1306
|
+
covered.add(hash);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
primeMemory(pair, pour);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
catch {
|
|
1313
|
+
// A missing or unreadable record is a miss, never an error in the run.
|
|
1314
|
+
}
|
|
1315
|
+
const missing = [...byHash.keys()].filter(hash => !covered.has(hash));
|
|
1316
|
+
if (missing.length === 0)
|
|
1317
|
+
return;
|
|
1318
|
+
try {
|
|
1319
|
+
const slice = await props.getSentenceTranslations({ pair, hashes: missing });
|
|
1320
|
+
if (!slice.ok)
|
|
1321
|
+
return;
|
|
1322
|
+
primeMemory(pair, Object.entries(slice.value.translations).flatMap(([hash, translation]) => {
|
|
1323
|
+
const text = byHash.get(hash);
|
|
1324
|
+
return text === undefined ? [] : [{ text, translation }];
|
|
1325
|
+
}));
|
|
1326
|
+
}
|
|
1327
|
+
catch {
|
|
1328
|
+
// Same: a miss.
|
|
1329
|
+
}
|
|
1330
|
+
}, [props]);
|
|
1331
|
+
/**
|
|
1332
|
+
* Persist what one run produced, in one call — batched by run, never per
|
|
1333
|
+
* sentence. The recalled sentences ride along: their LRU stamps are what
|
|
1334
|
+
* makes the host's budget a use-clock, and they keep the entry's map complete.
|
|
1335
|
+
*/
|
|
1336
|
+
const persistTranslations = useCallback((pair, learned, recalled, entry) => {
|
|
1337
|
+
if (learned.length === 0 && recalled.length === 0 && entry === undefined)
|
|
1338
|
+
return;
|
|
1339
|
+
void props.rememberSentences({
|
|
1340
|
+
pair,
|
|
1341
|
+
entries: learned,
|
|
1342
|
+
recalled,
|
|
1343
|
+
...(entry === undefined ? {} : { entryId: entry.entryId, bodyHash: entry.bodyHash }),
|
|
1344
|
+
});
|
|
1345
|
+
}, [props]);
|
|
1346
|
+
/**
|
|
1347
|
+
* Get the article translated, reusing a session this page already built.
|
|
1348
|
+
*
|
|
1349
|
+
* The `Translator` API demands user activation for `create()`, so a session
|
|
1350
|
+
* that exists is the ONLY way a translation can be restored after the sidebar
|
|
1351
|
+
* remounted without a click. `client/session.ts` holds those sessions for the
|
|
1352
|
+
* whole page (the wall and the detail view share them), and this function
|
|
1353
|
+
* checks there first: a cache hit skips the pack phase entirely.
|
|
1354
|
+
*
|
|
1355
|
+
* @param initialView - the view to show once the first units exist.
|
|
1356
|
+
* @param sourceOverride - the source language to build for, when the caller is
|
|
1357
|
+
* restoring a translation rather than starting one from the detected language.
|
|
1358
|
+
*/
|
|
1359
|
+
const startTranslation = useCallback(async (initialView, sourceOverride) => {
|
|
1360
|
+
const api = translator;
|
|
1361
|
+
const container = articleRef.current;
|
|
1362
|
+
if (api === null || container === null)
|
|
1363
|
+
return;
|
|
1364
|
+
// One run at a time. A second start (the reader's retry, or the restore
|
|
1365
|
+
// re-applying a translation to a body that was just replaced) must not build
|
|
1366
|
+
// its own segmentation over the first one's: two overlapping runs would
|
|
1367
|
+
// decorate the same DOM twice and race each other's view updates. Whatever
|
|
1368
|
+
// was in flight is cancelled and the article is put back as the host sent it.
|
|
1369
|
+
if (cancelRef.current !== null)
|
|
1370
|
+
cancelRef.current.cancelled = true;
|
|
1371
|
+
const previous = builtRef.current;
|
|
1372
|
+
if (previous !== null)
|
|
1373
|
+
restoreArticle(previous.root);
|
|
1374
|
+
builtRef.current = null;
|
|
1375
|
+
const cancel = { cancelled: false };
|
|
1376
|
+
cancelRef.current = cancel;
|
|
1377
|
+
setTranslateError(null);
|
|
1378
|
+
setTranslateProgress(null);
|
|
1379
|
+
// The source candidates: what the browser's detector said, then the script
|
|
1380
|
+
// heuristic's answer. The targets: every Chinese spelling the API accepts.
|
|
1381
|
+
const sourceLanguage = sourceOverride ?? translationSource;
|
|
1382
|
+
const sources = sourceLanguage === 'en' ? ['en'] : [sourceLanguage, 'en'];
|
|
1383
|
+
const existing = cachedTranslator(sources, TARGET_CANDIDATES);
|
|
1384
|
+
if (existing !== undefined) {
|
|
1385
|
+
setTranslatePhase('working');
|
|
1386
|
+
}
|
|
1387
|
+
else {
|
|
1388
|
+
setTranslatePhase('pack');
|
|
1389
|
+
setPackProgress(0);
|
|
1390
|
+
}
|
|
1391
|
+
const outcome = existing === undefined
|
|
1392
|
+
? await createSession(api, {
|
|
1393
|
+
sources,
|
|
1394
|
+
targets: TARGET_CANDIDATES,
|
|
1395
|
+
monitor: monitor => {
|
|
1396
|
+
monitor.addEventListener('downloadprogress', event => { setPackProgress(event.loaded); });
|
|
1397
|
+
},
|
|
1398
|
+
})
|
|
1399
|
+
: { ok: true, session: existing, sourceLanguage, targetLanguage: TARGET_CANDIDATES[0] };
|
|
1400
|
+
setPackProgress(null);
|
|
1401
|
+
if (!outcome.ok) {
|
|
1402
|
+
// Say WHICH pair was asked for: the browser's own message names neither
|
|
1403
|
+
// language, and a reader who reports it needs to be able to.
|
|
1404
|
+
const pairs = outcome.attempts
|
|
1405
|
+
.map(attempt => `${attempt.sourceLanguage} → ${attempt.targetLanguage}`)
|
|
1406
|
+
.filter((pair, index, all) => all.indexOf(pair) === index)
|
|
1407
|
+
.join(' / ');
|
|
1408
|
+
const first = outcome.attempts[0]?.reason ?? 'unknown';
|
|
1409
|
+
cancelRef.current = null;
|
|
1410
|
+
if (outcome.unsupported)
|
|
1411
|
+
setTranslateAvailability('unavailable');
|
|
1412
|
+
setTranslatePhase('failed');
|
|
1413
|
+
setTranslateError(outcome.unsupported
|
|
1414
|
+
? t('translate.unsupported', { pair: pairs })
|
|
1415
|
+
: t('translate.failed', { reason: `${pairs} · ${first}`.slice(0, 200) }));
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
const session = outcome.session;
|
|
1419
|
+
rememberTranslator(sources, TARGET_CANDIDATES, session);
|
|
1420
|
+
if (cancel.cancelled) {
|
|
1421
|
+
setTranslatePhase('idle');
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
const built = buildArticle(container, translateClasses);
|
|
1425
|
+
if (built === null) {
|
|
1426
|
+
// Nothing in this body is prose the browser can translate (all code, an
|
|
1427
|
+
// empty body): say so instead of spinning forever.
|
|
1428
|
+
cancelRef.current = null;
|
|
1429
|
+
setTranslatePhase('failed');
|
|
1430
|
+
setTranslateError(t('translate.nothing'));
|
|
1431
|
+
return;
|
|
1432
|
+
}
|
|
1433
|
+
builtRef.current = built;
|
|
1434
|
+
lastViewRef.current = initialView;
|
|
1435
|
+
setTranslateView(initialView);
|
|
1436
|
+
setTranslatePhase('working');
|
|
1437
|
+
setView(built, initialView, translateClasses);
|
|
1438
|
+
// Recorded BEFORE the run: the record is what makes a remounted pane turn
|
|
1439
|
+
// the globe back on, and the sentence memory makes the re-run cheap.
|
|
1440
|
+
if (openEntryId !== null)
|
|
1441
|
+
rememberTranslation(openEntryId, initialView, sourceLanguage);
|
|
1442
|
+
// The host's tiers answer first: the entry's exact-fit map, then the global
|
|
1443
|
+
// sentence memory. Only what neither knows is spent on the model.
|
|
1444
|
+
const pair = `${sourceLanguage}→${TRANSLATION_TARGET}`;
|
|
1445
|
+
if (openEntryId !== null && articleHtml !== null) {
|
|
1446
|
+
await warmMemory(pair, openEntryId, articleHtml, built);
|
|
1447
|
+
if (cancel.cancelled) {
|
|
1448
|
+
setTranslatePhase('idle');
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
const result = await runTranslation({
|
|
1453
|
+
built,
|
|
1454
|
+
session,
|
|
1455
|
+
pair,
|
|
1456
|
+
cancelled: () => cancel.cancelled,
|
|
1457
|
+
onProgress: (done, total) => { setTranslateProgress({ done, total }); },
|
|
1458
|
+
});
|
|
1459
|
+
// What the run learned (and what it was served) persists in one batch: a
|
|
1460
|
+
// reload loses the page's memory, and the next visit pays the model again
|
|
1461
|
+
// without this.
|
|
1462
|
+
persistTranslations(pair, result.learned, result.recalled, openEntryId !== null && articleHtml !== null
|
|
1463
|
+
? { entryId: openEntryId, bodyHash: translationHash(articleHtml) }
|
|
1464
|
+
: undefined);
|
|
1465
|
+
if (cancel.cancelled)
|
|
1466
|
+
return;
|
|
1467
|
+
cancelRef.current = null;
|
|
1468
|
+
if (result.total > 0 && result.done === 0) {
|
|
1469
|
+
// Every batch failed: keep the original text, explain, offer the retry in
|
|
1470
|
+
// the menu. The spans stay segmented so a retry reuses them.
|
|
1471
|
+
setTranslatePhase('failed');
|
|
1472
|
+
setTranslateError(t('translate.failedAll'));
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
setTranslatePhase('ready');
|
|
1476
|
+
setTranslateProgress(null);
|
|
1477
|
+
// Re-paint once the first units exist, so the translated typography applies.
|
|
1478
|
+
setView(built, initialView, translateClasses);
|
|
1479
|
+
}, [translator, translationSource, translateClasses, t, openEntryId, articleHtml, warmMemory, persistTranslations]);
|
|
1480
|
+
/** Apply one view to a finished translation. */
|
|
1481
|
+
const applyView = useCallback((next) => {
|
|
1482
|
+
setTranslateView(next);
|
|
1483
|
+
if (next !== 'orig')
|
|
1484
|
+
lastViewRef.current = next;
|
|
1485
|
+
if (openEntryId !== null)
|
|
1486
|
+
rememberTranslation(openEntryId, next, translationSource);
|
|
1487
|
+
const built = builtRef.current;
|
|
1488
|
+
if (built !== null)
|
|
1489
|
+
setView(built, next, translateClasses);
|
|
1490
|
+
}, [translateClasses, openEntryId, translationSource]);
|
|
1491
|
+
/** A menu choice: it also starts the translation when there is none yet. */
|
|
1492
|
+
const pickView = useCallback((next) => {
|
|
1493
|
+
setTranslateMenu(false);
|
|
1494
|
+
if (translatePhase === 'ready' || translatePhase === 'pack' || translatePhase === 'working') {
|
|
1495
|
+
applyView(next);
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
setTranslateView(next);
|
|
1499
|
+
void startTranslation(next);
|
|
1500
|
+
}, [translatePhase, applyView, startTranslation]);
|
|
1501
|
+
/** The globe: start, cancel, or flip between the translation and the original. */
|
|
1502
|
+
const toggleGlobe = useCallback(() => {
|
|
1503
|
+
if (translatePhase === 'pack' || translatePhase === 'working') {
|
|
1504
|
+
cancelTranslation();
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
if (translatePhase === 'ready') {
|
|
1508
|
+
applyView(translateView === 'orig' ? lastViewRef.current : 'orig');
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
void startTranslation(translateView === 'orig' ? lastViewRef.current : translateView);
|
|
1512
|
+
}, [translatePhase, translateView, cancelTranslation, applyView, startTranslation]);
|
|
1513
|
+
/**
|
|
1514
|
+
* The pairing cue: hovering a translated sentence lights its original line,
|
|
1515
|
+
* and hovering an original line lights the sentence it belongs to. Nothing is
|
|
1516
|
+
* painted by default — the marks are thrown away when the pointer leaves.
|
|
1517
|
+
*/
|
|
1518
|
+
const onArticleHover = useCallback((event) => {
|
|
1519
|
+
if (translatePhase !== 'ready')
|
|
1520
|
+
return;
|
|
1521
|
+
const built = builtRef.current;
|
|
1522
|
+
if (built === null)
|
|
1523
|
+
return;
|
|
1524
|
+
setPairHover(built, event.target, event.type === 'mouseover');
|
|
1525
|
+
}, [translatePhase]);
|
|
1526
|
+
/**
|
|
1527
|
+
* One gesture, on the TRANSLATED side only: clicking a sentence opens its
|
|
1528
|
+
* original, clicking it again closes it.
|
|
1529
|
+
*
|
|
1530
|
+
* The original itself is deliberately NOT a control. It is text the reader
|
|
1531
|
+
* wants to SELECT — to quote it, or to quote it together with its translation
|
|
1532
|
+
* — and a click handler there would fire on the mouseup that ends a drag
|
|
1533
|
+
* selection, collapsing the sentence and taking the selection (and the quote
|
|
1534
|
+
* overlay with it) away. For the same reason the click is ignored while a
|
|
1535
|
+
* selection is active: ending a selection inside a translated sentence must
|
|
1536
|
+
* not fold its original either.
|
|
1537
|
+
*/
|
|
1538
|
+
const onArticleClick = useCallback((event) => {
|
|
1539
|
+
// Only a finished translation: mid-run a reveal would just duplicate the
|
|
1540
|
+
// original text that is still on screen.
|
|
1541
|
+
if (translatePhase !== 'ready')
|
|
1542
|
+
return;
|
|
1543
|
+
if ((window.getSelection()?.toString().length ?? 0) > 0)
|
|
1544
|
+
return;
|
|
1545
|
+
const built = builtRef.current;
|
|
1546
|
+
if (built === null)
|
|
1547
|
+
return;
|
|
1548
|
+
const segment = segmentAt(built, event.target);
|
|
1549
|
+
if (segment === null)
|
|
1550
|
+
return;
|
|
1551
|
+
toggleSegment(built, segment, translateClasses);
|
|
1552
|
+
}, [translateClasses, translatePhase]);
|
|
1553
|
+
/* ---------------------------------------------------- the wall's own pass */
|
|
1554
|
+
// The wall has no article to segment: a card's unit is a FIELD (title,
|
|
1555
|
+
// summary), and the wall must translate lazily — a wall of forty cards is
|
|
1556
|
+
// eighty fields, and firing them all at once would be a burst the sequential
|
|
1557
|
+
// API cannot absorb. So: the browser's own IntersectionObserver feeds a queue,
|
|
1558
|
+
// one pass drains it, and the translation memory makes a second pass free.
|
|
1559
|
+
// (rowsRef is fed where the displayed rows are computed, dedupe included.)
|
|
1560
|
+
const wallTranslateOffered = translator !== null && wallAvailability !== null && wallAvailability !== 'unavailable';
|
|
1561
|
+
// Probe the browser once for the wall's pair (the wall has no single body, so
|
|
1562
|
+
// English is the probe; a card in another language gets its own session below).
|
|
1563
|
+
useEffect(() => {
|
|
1564
|
+
if (view !== 'list' || translator === null || wallAvailability !== null)
|
|
1565
|
+
return undefined;
|
|
1566
|
+
let cancelled = false;
|
|
1567
|
+
void (async () => {
|
|
1568
|
+
let best = 'unavailable';
|
|
1569
|
+
for (const targetLanguage of TARGET_CANDIDATES) {
|
|
1570
|
+
try {
|
|
1571
|
+
const answer = await translator.availability({ sourceLanguage: 'en', targetLanguage });
|
|
1572
|
+
if (answer !== 'unavailable') {
|
|
1573
|
+
best = answer;
|
|
1574
|
+
break;
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
catch {
|
|
1578
|
+
// Try the next spelling.
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
if (!cancelled)
|
|
1582
|
+
setWallAvailability(best);
|
|
1583
|
+
})();
|
|
1584
|
+
return () => { cancelled = true; };
|
|
1585
|
+
}, [view, translator, wallAvailability]);
|
|
1586
|
+
/**
|
|
1587
|
+
* A translator for one source language, created once and reused.
|
|
1588
|
+
*
|
|
1589
|
+
* The cache is the page's, not this mount's (`client/session.ts`): a session
|
|
1590
|
+
* the wall built is the one the detail view can restore for free, and it is
|
|
1591
|
+
* the only way a translation survives the sidebar being unmounted — Chrome
|
|
1592
|
+
* demands user activation for `create()`.
|
|
1593
|
+
*/
|
|
1594
|
+
const wallSession = useCallback(async (language) => {
|
|
1595
|
+
const api = translator;
|
|
1596
|
+
if (api === null)
|
|
1597
|
+
return null;
|
|
1598
|
+
const sources = language === 'en' ? ['en'] : [language, 'en'];
|
|
1599
|
+
const cached = cachedTranslator(sources, TARGET_CANDIDATES);
|
|
1600
|
+
if (cached !== undefined)
|
|
1601
|
+
return cached;
|
|
1602
|
+
const outcome = await createSession(api, { sources, targets: TARGET_CANDIDATES });
|
|
1603
|
+
if (!outcome.ok)
|
|
1604
|
+
return null;
|
|
1605
|
+
rememberTranslator(sources, TARGET_CANDIDATES, outcome.session);
|
|
1606
|
+
return outcome.session;
|
|
1607
|
+
}, [translator]);
|
|
1608
|
+
/**
|
|
1609
|
+
* Drain the pending queue: six cards at a time, grouped by detected language
|
|
1610
|
+
* (a wall can mix them — that is the point of asking at all), one batch per
|
|
1611
|
+
* group through the shared batching contract.
|
|
1612
|
+
*/
|
|
1613
|
+
const runWallPass = useCallback(async () => {
|
|
1614
|
+
if (wallRunningRef.current || translator === null)
|
|
1615
|
+
return;
|
|
1616
|
+
wallRunningRef.current = true;
|
|
1617
|
+
const cancel = wallCancelRef.current ?? { cancelled: false };
|
|
1618
|
+
wallCancelRef.current = cancel;
|
|
1619
|
+
try {
|
|
1620
|
+
while (!cancel.cancelled && wallPendingRef.current.size > 0) {
|
|
1621
|
+
const ids = [...wallPendingRef.current].slice(0, 6);
|
|
1622
|
+
for (const id of ids)
|
|
1623
|
+
wallPendingRef.current.delete(id);
|
|
1624
|
+
const fields = [];
|
|
1625
|
+
for (const id of ids) {
|
|
1626
|
+
const row = rowsRef.current.find(candidate => candidate.entry.id === id);
|
|
1627
|
+
if (row === undefined)
|
|
1628
|
+
continue;
|
|
1629
|
+
// Chinese cards (and Chinese fields inside a mixed card) are left
|
|
1630
|
+
// exactly as they are: translating them to Chinese is not a feature.
|
|
1631
|
+
if (!isTargetLanguage(row.entry.title))
|
|
1632
|
+
fields.push({ id, field: 'title', text: row.entry.title });
|
|
1633
|
+
const summary = row.entry.summary;
|
|
1634
|
+
if (summary !== undefined && summary.length > 0 && !isTargetLanguage(summary)) {
|
|
1635
|
+
fields.push({ id, field: 'summary', text: summary });
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
if (fields.length === 0)
|
|
1639
|
+
continue;
|
|
1640
|
+
const groups = new Map();
|
|
1641
|
+
for (const field of fields) {
|
|
1642
|
+
const detected = await detectSourceLanguage(field.text, 'en');
|
|
1643
|
+
const key = detected === 'zh' ? 'en' : detected;
|
|
1644
|
+
groups.set(key, [...groups.get(key) ?? [], field]);
|
|
1645
|
+
}
|
|
1646
|
+
for (const [language, group] of groups) {
|
|
1647
|
+
if (cancel.cancelled)
|
|
1648
|
+
break;
|
|
1649
|
+
const session = await wallSession(language);
|
|
1650
|
+
if (session === null) {
|
|
1651
|
+
setWallAvailability('unavailable');
|
|
1652
|
+
return;
|
|
1653
|
+
}
|
|
1654
|
+
// The host's memory answers first, one slice call per group: a wall
|
|
1655
|
+
// re-translated after a reload should be a read, not a model run.
|
|
1656
|
+
const pair = `${language}→${TRANSLATION_TARGET}`;
|
|
1657
|
+
try {
|
|
1658
|
+
const byHash = new Map(group.map(field => [translationHash(field.text), field.text]));
|
|
1659
|
+
const slice = await props.getSentenceTranslations({ pair, hashes: [...byHash.keys()] });
|
|
1660
|
+
if (slice.ok) {
|
|
1661
|
+
primeMemory(pair, Object.entries(slice.value.translations).flatMap(([hash, translation]) => {
|
|
1662
|
+
const text = byHash.get(hash);
|
|
1663
|
+
return text === undefined ? [] : [{ text, translation }];
|
|
1664
|
+
}));
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
catch {
|
|
1668
|
+
// A miss is the model's job, as before.
|
|
1669
|
+
}
|
|
1670
|
+
setWallProgress({ done: 0, total: group.length });
|
|
1671
|
+
const outcome = await translateTexts(group.map(field => field.text), pair, session, () => cancel.cancelled, (done, total) => setWallProgress({ done, total }));
|
|
1672
|
+
if (cancel.cancelled)
|
|
1673
|
+
break;
|
|
1674
|
+
persistTranslations(pair, outcome.learned, outcome.recalled);
|
|
1675
|
+
setCardTranslations(current => {
|
|
1676
|
+
const next = { ...current };
|
|
1677
|
+
for (const field of group) {
|
|
1678
|
+
const value = outcome.translated.get(field.text);
|
|
1679
|
+
if (value === undefined)
|
|
1680
|
+
continue;
|
|
1681
|
+
next[field.id] = { ...next[field.id], [field.field]: value };
|
|
1682
|
+
}
|
|
1683
|
+
return next;
|
|
1684
|
+
});
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
finally {
|
|
1689
|
+
wallRunningRef.current = false;
|
|
1690
|
+
setWallProgress(null);
|
|
1691
|
+
}
|
|
1692
|
+
}, [translator, wallSession, props, persistTranslations]);
|
|
1693
|
+
// Enqueue the cards as they become visible. jsdom has no IntersectionObserver,
|
|
1694
|
+
// and neither would a very old browser, so the fallback translates what is
|
|
1695
|
+
// rendered — the pass is bounded per batch either way.
|
|
1696
|
+
useEffect(() => {
|
|
1697
|
+
if (!wallOn || translator === null)
|
|
1698
|
+
return undefined;
|
|
1699
|
+
const container = wallRef.current;
|
|
1700
|
+
const cards = container === null ? [] : [...container.querySelectorAll('[data-reader-entry]')];
|
|
1701
|
+
const ids = cards.map(card => card.getAttribute('data-reader-entry') ?? '').filter(id => id !== '');
|
|
1702
|
+
if (typeof IntersectionObserver === 'undefined') {
|
|
1703
|
+
for (const id of ids)
|
|
1704
|
+
wallPendingRef.current.add(id);
|
|
1705
|
+
void runWallPass();
|
|
1706
|
+
return undefined;
|
|
1707
|
+
}
|
|
1708
|
+
const observer = new IntersectionObserver(entries => {
|
|
1709
|
+
let added = false;
|
|
1710
|
+
for (const entry of entries) {
|
|
1711
|
+
if (!entry.isIntersecting)
|
|
1712
|
+
continue;
|
|
1713
|
+
const id = entry.target.getAttribute('data-reader-entry');
|
|
1714
|
+
if (id !== null) {
|
|
1715
|
+
wallPendingRef.current.add(id);
|
|
1716
|
+
added = true;
|
|
1717
|
+
}
|
|
1718
|
+
observer.unobserve(entry.target);
|
|
1719
|
+
}
|
|
1720
|
+
if (added)
|
|
1721
|
+
void runWallPass();
|
|
1722
|
+
}, { root: container, rootMargin: '120px' });
|
|
1723
|
+
for (const card of cards)
|
|
1724
|
+
observer.observe(card);
|
|
1725
|
+
return () => { observer.disconnect(); };
|
|
1726
|
+
}, [wallOn, translator, runWallPass, rows]);
|
|
1727
|
+
// Turning the wall's translation off cancels the pass and forgets nothing: the
|
|
1728
|
+
// memory keeps what came back, so switching on again is instant.
|
|
1729
|
+
useEffect(() => {
|
|
1730
|
+
if (wallOn)
|
|
1731
|
+
return;
|
|
1732
|
+
if (wallCancelRef.current !== null)
|
|
1733
|
+
wallCancelRef.current.cancelled = true;
|
|
1734
|
+
wallCancelRef.current = null;
|
|
1735
|
+
wallPendingRef.current.clear();
|
|
1736
|
+
setWallProgress(null);
|
|
1737
|
+
setWallMenu(false);
|
|
1738
|
+
}, [wallOn]);
|
|
1739
|
+
/** Unmount: stop the wall's pass as well as the article's. */
|
|
1740
|
+
useEffect(() => () => {
|
|
1741
|
+
if (wallCancelRef.current !== null)
|
|
1742
|
+
wallCancelRef.current.cancelled = true;
|
|
1743
|
+
}, []);
|
|
1744
|
+
// Closing the detail (back button, a removed link, a restore that found its
|
|
1745
|
+
// entry gone) releases the screen: a fetch still in flight for the closed
|
|
1746
|
+
// entry must not write into the next one.
|
|
1747
|
+
useEffect(() => {
|
|
1748
|
+
if (openEntryId === null)
|
|
1749
|
+
openRequestRef.current = null;
|
|
1750
|
+
}, [openEntryId]);
|
|
1751
|
+
/**
|
|
1752
|
+
* Fetch one entry's full article, extract it (this process) and show it.
|
|
1753
|
+
*
|
|
1754
|
+
* Two callers, one contract: the card's own "抓取正文" action, and opening an
|
|
1755
|
+
* entry that has no body yet. Both pay exactly one fetch and both record the
|
|
1756
|
+
* outcome — a success caches the extracted body, a failure records the reason
|
|
1757
|
+
* the host and the extractor gave, which is what the next open reads.
|
|
1758
|
+
*
|
|
1759
|
+
* @param entryId - the entry whose body is wanted.
|
|
1760
|
+
* @param url - the article URL to fetch.
|
|
1761
|
+
*/
|
|
1762
|
+
const fetchBody = useCallback(async (entryId, url) => {
|
|
1763
|
+
actions.setFetching(entryId, true);
|
|
1764
|
+
try {
|
|
1765
|
+
const result = await props.fetchEntryBody(entryId, url);
|
|
1766
|
+
// A late answer belongs to the entry that ASKED for it: if the detail
|
|
1767
|
+
// view has moved on (another entry, or the wall), the article area is no
|
|
1768
|
+
// longer this entry's to write. The per-entry records are unaffected —
|
|
1769
|
+
// they are keyed by id either way.
|
|
1770
|
+
const onScreen = openRequestRef.current === entryId;
|
|
1771
|
+
if (result.html !== undefined) {
|
|
1772
|
+
// A fresh body just landed: the entry is no longer expired, whichever
|
|
1773
|
+
// screen is showing.
|
|
1774
|
+
actions.setStaleBody(entryId, false);
|
|
1775
|
+
// A saved link's card upgrades to the article's own title/excerpt on
|
|
1776
|
+
// the same gesture (the action itself refuses feed entries).
|
|
1777
|
+
actions.noteExtractedMeta(entryId, {
|
|
1778
|
+
...(result.title === undefined ? {} : { title: result.title }),
|
|
1779
|
+
...(result.excerpt === undefined ? {} : { excerpt: result.excerpt }),
|
|
1780
|
+
});
|
|
1781
|
+
if (onScreen) {
|
|
1782
|
+
actions.setArticle(result.html, result.truncated === true, null);
|
|
1783
|
+
setScriptFigures(result.scriptFigures ?? 0);
|
|
1784
|
+
}
|
|
1785
|
+
// The caller escalates on a shell: a body that still counts script
|
|
1786
|
+
// figures wants the rendered fetch next.
|
|
1787
|
+
return result.scriptFigures ?? 0;
|
|
1788
|
+
}
|
|
1789
|
+
else if (result.error !== undefined && onScreen) {
|
|
1790
|
+
// Keep whatever is already rendered (a feed summary, say) and add the
|
|
1791
|
+
// reason: a failed fetch must not take the little text the reader has.
|
|
1792
|
+
// It also leaves the host's copy — and its stale marker — untouched.
|
|
1793
|
+
actions.setArticle(articleHtml ?? '', false, result.error);
|
|
1794
|
+
}
|
|
1795
|
+
return undefined;
|
|
1796
|
+
}
|
|
1797
|
+
finally {
|
|
1798
|
+
actions.setFetching(entryId, false);
|
|
1799
|
+
// The card reads the host's annotation through the mirror: re-read the
|
|
1800
|
+
// one entry this fetch settled, so the pill flips on this gesture and
|
|
1801
|
+
// not at the next mount.
|
|
1802
|
+
void syncFetchState(entryId);
|
|
1803
|
+
}
|
|
1804
|
+
}, [actions, props, articleHtml, syncFetchState]);
|
|
1805
|
+
/**
|
|
1806
|
+
* Refetch the open entry through the capture package's rendered fetch.
|
|
1807
|
+
*
|
|
1808
|
+
* The captured HTML goes through the SAME whitelist extractor as any fetched
|
|
1809
|
+
* page — captured markup is never rendered raw — and the stored body then
|
|
1810
|
+
* rides every existing path (the cache, the translation memory, quoting).
|
|
1811
|
+
* The slot that calls this renders only while a capture Remote probes
|
|
1812
|
+
* present, so a missing package can never reach the middle of this flow.
|
|
1813
|
+
*
|
|
1814
|
+
* @param entry - the entry on screen.
|
|
1815
|
+
*/
|
|
1816
|
+
const captureBody = useCallback(async (entryId, link) => {
|
|
1817
|
+
const capture = props.captureRemote();
|
|
1818
|
+
if (capture === undefined)
|
|
1819
|
+
return;
|
|
1820
|
+
setCaptureBusy(true);
|
|
1821
|
+
try {
|
|
1822
|
+
const rendered = await capture.render({ url: link });
|
|
1823
|
+
if (!rendered.ok) {
|
|
1824
|
+
if (openRequestRef.current === entryId)
|
|
1825
|
+
actions.setArticle(articleHtml ?? '', false, rendered.error.message);
|
|
1826
|
+
return;
|
|
1827
|
+
}
|
|
1828
|
+
const finalUrl = rendered.value.finalUrl ?? link;
|
|
1829
|
+
const extracted = extractArticle(rendered.value.html, finalUrl);
|
|
1830
|
+
if (!extracted.ok) {
|
|
1831
|
+
if (openRequestRef.current === entryId)
|
|
1832
|
+
actions.setArticle(articleHtml ?? '', false, extracted.error);
|
|
1833
|
+
return;
|
|
1834
|
+
}
|
|
1835
|
+
const stored = await props.storeEntryBody({
|
|
1836
|
+
entryId,
|
|
1837
|
+
url: finalUrl,
|
|
1838
|
+
html: extracted.html,
|
|
1839
|
+
bodyHash: translationHash(extracted.html),
|
|
1840
|
+
...(rendered.value.truncated === true ? { truncated: true } : {}),
|
|
1841
|
+
// A capture render ALWAYS carries its count, zero included: the entry's
|
|
1842
|
+
// own load-time count (84 script figures, say) is what the remount
|
|
1843
|
+
// falls back to when the body record says nothing — storing the zero
|
|
1844
|
+
// is what keeps the notice (and 「渲染抓取」) from rising from the dead
|
|
1845
|
+
// on the next open.
|
|
1846
|
+
scriptFigures: extracted.scriptFigures ?? 0,
|
|
1847
|
+
// …and the body is marked rendered, so 「重新抓取」 re-renders instead
|
|
1848
|
+
// of clobbering it with the page's pre-JS shell.
|
|
1849
|
+
rendered: true,
|
|
1850
|
+
});
|
|
1851
|
+
renderedBodiesRef.current.add(entryId);
|
|
1852
|
+
if (openRequestRef.current !== entryId)
|
|
1853
|
+
return;
|
|
1854
|
+
if (stored.ok && stored.value.html !== undefined) {
|
|
1855
|
+
actions.setArticle(stored.value.html, stored.value.truncated === true, null);
|
|
1856
|
+
setScriptFigures(stored.value.scriptFigures ?? 0);
|
|
1857
|
+
actions.setStaleBody(entryId, false);
|
|
1858
|
+
void syncFetchState(entryId);
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
finally {
|
|
1862
|
+
setCaptureBusy(false);
|
|
1863
|
+
}
|
|
1864
|
+
}, [actions, props, articleHtml, syncFetchState]);
|
|
1865
|
+
/**
|
|
1866
|
+
* One gesture, the whole pipeline: an explicit per-entry ask (add a link,
|
|
1867
|
+
* the card's 「抓取」, opening an entry, the detail's 「重新抓取」) that lands
|
|
1868
|
+
* on a script-figure shell continues into the rendered fetch on its own —
|
|
1869
|
+
* the gesture already authorized capturing that URL. Fires once per entry
|
|
1870
|
+
* per pane session (a failure leaves the manual「渲染抓取」retry); background
|
|
1871
|
+
* paths (scheduled feed refresh, backfill) never reach here.
|
|
1872
|
+
*/
|
|
1873
|
+
const maybeAutoRender = useCallback((entryId, link, count) => {
|
|
1874
|
+
if (link === undefined || count <= 0)
|
|
1875
|
+
return;
|
|
1876
|
+
if (props.captureRemote() === undefined)
|
|
1877
|
+
return;
|
|
1878
|
+
if (autoRenderTriedRef.current.has(entryId))
|
|
1879
|
+
return;
|
|
1880
|
+
autoRenderTriedRef.current.add(entryId);
|
|
1881
|
+
void captureBody(entryId, link);
|
|
1882
|
+
}, [props, captureBody]);
|
|
1883
|
+
/** A plain fetch that escalates on a shell — the explicit-gesture fetch path. */
|
|
1884
|
+
const fetchThenMaybeRender = useCallback((entryId, link) => {
|
|
1885
|
+
void fetchBody(entryId, link).then(count => maybeAutoRender(entryId, link, count ?? 0));
|
|
1886
|
+
}, [fetchBody, maybeAutoRender]);
|
|
1887
|
+
/** Open one entry: mark it read and make sure a body is available. */
|
|
1888
|
+
const open = useCallback(async (row) => {
|
|
1889
|
+
// Synchronous: every continuation of this call checks the ref before
|
|
1890
|
+
// writing article state, so the LAST open (or a close) owns the screen.
|
|
1891
|
+
openRequestRef.current = row.entry.id;
|
|
1892
|
+
openedRef.current = row.entry.id;
|
|
1893
|
+
actions.openEntry(row.entry.id, row.sourceId);
|
|
1894
|
+
actions.setView('detail');
|
|
1895
|
+
void loadEntryTags(row.entry.id);
|
|
1896
|
+
// The recent list is the READER's record, so it is written on the same
|
|
1897
|
+
// gesture that opens the article — including a reopen from that list, which
|
|
1898
|
+
// is what moves an entry back to the top. Fire and forget: the page re-reads
|
|
1899
|
+
// the host's copy when it opens, and a failure here must not stop the read.
|
|
1900
|
+
const recorded = {
|
|
1901
|
+
entryId: row.entry.id,
|
|
1902
|
+
sourceId: row.sourceId,
|
|
1903
|
+
title: row.entry.title,
|
|
1904
|
+
...(row.entry.link === undefined ? {} : { url: row.entry.link }),
|
|
1905
|
+
readAt: new Date().toISOString(),
|
|
1906
|
+
};
|
|
1907
|
+
// Mirrored locally as well, so the page is right the first time it is opened
|
|
1908
|
+
// in this session instead of after another round trip.
|
|
1909
|
+
actions.setRecent([recorded, ...recent.filter(item => item.entryId !== recorded.entryId)]);
|
|
1910
|
+
void props.recordRead({
|
|
1911
|
+
entryId: recorded.entryId,
|
|
1912
|
+
sourceId: recorded.sourceId,
|
|
1913
|
+
title: recorded.title,
|
|
1914
|
+
...(recorded.url === undefined ? {} : { url: recorded.url }),
|
|
1915
|
+
});
|
|
1916
|
+
// A cached fetch outlives the feed's own payload, so the host is the one
|
|
1917
|
+
// that knows whether there is full text: it returns the fresh cache, else
|
|
1918
|
+
// the feed's body, else nothing plus the reason a previous fetch failed.
|
|
1919
|
+
if (row.entry.link !== undefined) {
|
|
1920
|
+
// A feed that published only a summary still owes the reader an article,
|
|
1921
|
+
// whichever branch below supplies the text on screen (the host answers
|
|
1922
|
+
// `feedHtml` for the feed's own payload, which is exactly the summary).
|
|
1923
|
+
const summaryOwed = row.entry.summaryOnly === true;
|
|
1924
|
+
const view = await props.getEntryBody({
|
|
1925
|
+
entryId: row.entry.id,
|
|
1926
|
+
url: row.entry.link,
|
|
1927
|
+
...(row.entry.contentHtml === undefined ? {} : { feedHtml: row.entry.contentHtml }),
|
|
1928
|
+
});
|
|
1929
|
+
// A per-entry host fact, true no matter which entry is on screen by now,
|
|
1930
|
+
// so it is written before the screen guard: whether the host's cached
|
|
1931
|
+
// copy is past its deadline (the card's 「已过期」 reads this).
|
|
1932
|
+
if (view.ok)
|
|
1933
|
+
actions.setStaleBody(row.entry.id, view.value.cached === true && view.value.fresh === false);
|
|
1934
|
+
// The rendered-body flag rides the same answer: 「重新抓取」 re-renders a
|
|
1935
|
+
// rendered entry instead of clobbering it with the page's pre-JS shell.
|
|
1936
|
+
if (view.ok && view.value.rendered === true)
|
|
1937
|
+
renderedBodiesRef.current.add(row.entry.id);
|
|
1938
|
+
// The answer came back to a screen that has moved on: another open (or a
|
|
1939
|
+
// close) happened while the host was answering. Writing here would put
|
|
1940
|
+
// this entry's body under THAT entry's title.
|
|
1941
|
+
if (openRequestRef.current !== row.entry.id)
|
|
1942
|
+
return;
|
|
1943
|
+
// The mirror the card reads may predate this answer (a body cached after
|
|
1944
|
+
// the mount poll, or a recorded failure it has not re-read). When the two
|
|
1945
|
+
// disagree, re-read this one entry so the card stops lying.
|
|
1946
|
+
const known = fetchStateOf(fetchStates, row.entry.id).state;
|
|
1947
|
+
if (view.ok && ((view.value.cached === true && view.value.html !== undefined && known !== 'ready')
|
|
1948
|
+
|| (view.value.html === undefined && view.value.error !== undefined && known !== 'failed')))
|
|
1949
|
+
void syncFetchState(row.entry.id);
|
|
1950
|
+
if (!view.ok) {
|
|
1951
|
+
actions.setArticle('', false, view.error.message);
|
|
1952
|
+
}
|
|
1953
|
+
else if (view.value.html !== undefined) {
|
|
1954
|
+
actions.setArticle(view.value.html, view.value.truncated === true, null);
|
|
1955
|
+
// A stored body carries its count; a fresh link's feedHtml echo is the
|
|
1956
|
+
// load-time extraction, whose count rides the entry instead.
|
|
1957
|
+
const knownCount = view.value.scriptFigures ?? row.entry.scriptFigures ?? 0;
|
|
1958
|
+
setScriptFigures(knownCount);
|
|
1959
|
+
maybeAutoRender(row.entry.id, row.entry.link, knownCount);
|
|
1960
|
+
actions.setStaleBody(row.entry.id, view.value.fresh === false);
|
|
1961
|
+
// `html` here is either the FEED's own payload or a body this plugin
|
|
1962
|
+
// already fetched and cached — a cached one is paid for already.
|
|
1963
|
+
//
|
|
1964
|
+
// Re-fetching a cached body was the bug behind "entering the article
|
|
1965
|
+
// again makes me translate it all over again": a summary-only feed
|
|
1966
|
+
// kept `summaryOwed` true forever, so every look at an article that
|
|
1967
|
+
// had already been fetched replaced its DOM (and its translation) with
|
|
1968
|
+
// the same text from the network. The body is paid for once; the
|
|
1969
|
+
// detail view's own 「重新抓取」 is how a reader asks again.
|
|
1970
|
+
if (view.value.fromFeed === true) {
|
|
1971
|
+
if (summaryOwed)
|
|
1972
|
+
fetchThenMaybeRender(row.entry.id, row.entry.link);
|
|
1973
|
+
// Full text the feed itself published: keep it, or it dies with the
|
|
1974
|
+
// feed's window (see persistFeedBody).
|
|
1975
|
+
else
|
|
1976
|
+
persistFeedBody(row.entry.id, row.entry.link, view.value.html, view.value.truncated === true, linkMetaOf(row));
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
else if (row.entry.contentHtml !== undefined) {
|
|
1980
|
+
actions.setArticle(row.entry.contentHtml, row.entry.truncated === true, null);
|
|
1981
|
+
setScriptFigures(0);
|
|
1982
|
+
// The host holds no body at all, and the feed published a summary: show
|
|
1983
|
+
// it immediately (better than an empty page) and fetch the real text
|
|
1984
|
+
// behind it. A feed FULL text in the same situation is kept instead —
|
|
1985
|
+
// there is nothing to fetch for it.
|
|
1986
|
+
if (summaryOwed)
|
|
1987
|
+
fetchThenMaybeRender(row.entry.id, row.entry.link);
|
|
1988
|
+
else
|
|
1989
|
+
persistFeedBody(row.entry.id, row.entry.link, row.entry.contentHtml, row.entry.truncated === true, linkMetaOf(row));
|
|
1990
|
+
}
|
|
1991
|
+
else {
|
|
1992
|
+
// Nothing cached, nothing from the feed, and no recorded reason.
|
|
1993
|
+
//
|
|
1994
|
+
// For a FEED entry that is the ordinary state of a body the automatic
|
|
1995
|
+
// backfill has not reached (or one whose cached body expired): opening
|
|
1996
|
+
// an entry IS the reader asking for its text, so pay for one fetch
|
|
1997
|
+
// instead of showing a title over blank space. This is the same call
|
|
1998
|
+
// the card's own 「抓取正文」 uses, so it extracts, caches, and records
|
|
1999
|
+
// a failure reason — which is what the next open reads.
|
|
2000
|
+
//
|
|
2001
|
+
// For a SAVED LINK with no reason, the payload arrived but did not
|
|
2002
|
+
// become a body: that already has its sentence (and a manual retry in
|
|
2003
|
+
// the card menu), and fetching again on every open would be a request
|
|
2004
|
+
// per look at a page that has already refused to be extracted.
|
|
2005
|
+
const reason = view.value.error ?? (row.sourceKind === 'link' ? t('detail.extractFailed') : null);
|
|
2006
|
+
actions.setArticle('', false, reason);
|
|
2007
|
+
if (row.sourceKind === 'rss' && view.value.error === undefined) {
|
|
2008
|
+
fetchThenMaybeRender(row.entry.id, row.entry.link);
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
return;
|
|
2012
|
+
}
|
|
2013
|
+
if (row.entry.contentHtml !== undefined) {
|
|
2014
|
+
actions.setArticle(row.entry.contentHtml, row.entry.truncated === true, null);
|
|
2015
|
+
return;
|
|
2016
|
+
}
|
|
2017
|
+
const bodies = await props.getBodies([row.sourceId]);
|
|
2018
|
+
// Same guard as above: a no-link entry's body also arrives late.
|
|
2019
|
+
if (openRequestRef.current !== row.entry.id)
|
|
2020
|
+
return;
|
|
2021
|
+
if (!bodies.ok) {
|
|
2022
|
+
actions.setArticle('', false, bodies.error.message);
|
|
2023
|
+
return;
|
|
2024
|
+
}
|
|
2025
|
+
const body = bodies.value.bodies[0];
|
|
2026
|
+
if (body?.raw === undefined) {
|
|
2027
|
+
actions.setArticle('', false, body?.error ?? t('detail.extractFailed'));
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
2030
|
+
const truncated = body.truncated === true;
|
|
2031
|
+
const extracted = extractArticle(body.raw, row.entry.link ?? '');
|
|
2032
|
+
if (extracted.ok) {
|
|
2033
|
+
actions.setArticle(extracted.html, truncated, null);
|
|
2034
|
+
// The link-entry path extracts inline, so the script-figure count (and
|
|
2035
|
+
// with it the notice + auto-render escalation) must be set here too — the
|
|
2036
|
+
// fetch paths set it from the host's answer, this one computes it locally.
|
|
2037
|
+
const count = extracted.scriptFigures ?? 0;
|
|
2038
|
+
setScriptFigures(count);
|
|
2039
|
+
maybeAutoRender(row.entry.id, row.entry.link, count);
|
|
2040
|
+
}
|
|
2041
|
+
else
|
|
2042
|
+
actions.setArticle('', truncated, extracted.error);
|
|
2043
|
+
}, [actions, props, t, fetchThenMaybeRender, persistFeedBody, recent, fetchStates, syncFetchState, maybeAutoRender]);
|
|
2044
|
+
/* ------------------------------ coming back to where the reader already was */
|
|
2045
|
+
/**
|
|
2046
|
+
* Re-open the article the pane had on screen when the sidebar went away.
|
|
2047
|
+
*
|
|
2048
|
+
* Only the LIST came back with the store snapshot; bodies are host state, so
|
|
2049
|
+
* the page has to be re-established by the same call a tap makes — which
|
|
2050
|
+
* answers from the host's cache and does not fetch. Waiting for the parsed
|
|
2051
|
+
* entries is what makes this safe: until they arrive there is nothing to open.
|
|
2052
|
+
*/
|
|
2053
|
+
useEffect(() => {
|
|
2054
|
+
if (allEntries.length === 0)
|
|
2055
|
+
return;
|
|
2056
|
+
const wanted = readSession().openEntryId;
|
|
2057
|
+
if (wanted === undefined || wanted === null)
|
|
2058
|
+
return;
|
|
2059
|
+
if (restoredOpenRef.current === wanted || openedRef.current === wanted) {
|
|
2060
|
+
restoredOpenRef.current = wanted;
|
|
2061
|
+
return;
|
|
2062
|
+
}
|
|
2063
|
+
if (openEntryId !== wanted) {
|
|
2064
|
+
restoredOpenRef.current = wanted;
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
const entry = entryById(wanted);
|
|
2068
|
+
if (entry === undefined) {
|
|
2069
|
+
// Two different "not there yet": the wall's parse is still filling in, or
|
|
2070
|
+
// the recent list (which may be the only record of this entry) has not
|
|
2071
|
+
// been read. Only a settled miss means the entry is gone for good, and
|
|
2072
|
+
// then the wall is the honest place to stand — which also means dropping
|
|
2073
|
+
// the entry from the store, because the pane now renders the detail
|
|
2074
|
+
// surface for as long as an entry is open (a settle that gave up while
|
|
2075
|
+
// the id stayed behind would sit on that surface forever).
|
|
2076
|
+
if (!loading && recentLoadedRef.current) {
|
|
2077
|
+
restoredOpenRef.current = wanted;
|
|
2078
|
+
actions.closeEntry();
|
|
2079
|
+
}
|
|
2080
|
+
return;
|
|
2081
|
+
}
|
|
2082
|
+
restoredOpenRef.current = wanted;
|
|
2083
|
+
const row = rowFor(entry, presentation, read);
|
|
2084
|
+
if (row === undefined)
|
|
2085
|
+
return;
|
|
2086
|
+
void open(row);
|
|
2087
|
+
}, [allEntries, openEntryId, presentation, read, loading, open, entryById, actions]);
|
|
2088
|
+
/**
|
|
2089
|
+
* Put the reader back where they were inside the article.
|
|
2090
|
+
*
|
|
2091
|
+
* The saved ANCHOR is the target — the Nth top-level block and how far into it
|
|
2092
|
+
* the viewport top sat — and it is re-applied whenever the body is (re)built:
|
|
2093
|
+
* a restored pane, a re-opened entry, a fetch landing after the feed's summary,
|
|
2094
|
+
* an element React recreated.
|
|
2095
|
+
*
|
|
2096
|
+
* A pixel offset alone was not enough, and neither was applying it once. An
|
|
2097
|
+
* article's images carry no dimensions (`extract-article` keeps the site's own
|
|
2098
|
+
* markup), so the document GROWS as they load: a pixel target recorded against
|
|
2099
|
+
* the settled page lands in the middle of a short one, the browser clamps it,
|
|
2100
|
+
* and the reader is left at the top while the content keeps moving under them.
|
|
2101
|
+
* The anchor names the content, and the retry below keeps naming it.
|
|
2102
|
+
*/
|
|
2103
|
+
/** The last measured article geometry; anchoring reads this, never the DOM. */
|
|
2104
|
+
const metricsRef = useRef(null);
|
|
2105
|
+
/** Measure the article (one layout pass) and remember the result. */
|
|
2106
|
+
const measure = useCallback(() => {
|
|
2107
|
+
const scroller = detailRef.current;
|
|
2108
|
+
const article = articleRef.current;
|
|
2109
|
+
if (scroller === null || article === null)
|
|
2110
|
+
return null;
|
|
2111
|
+
const metrics = measureArticle(scroller, article);
|
|
2112
|
+
metricsRef.current = metrics;
|
|
2113
|
+
return metrics;
|
|
2114
|
+
}, []);
|
|
2115
|
+
/**
|
|
2116
|
+
* Put the reader back where they were inside the article.
|
|
2117
|
+
*
|
|
2118
|
+
* The saved ANCHOR is the target — the Nth top-level block and how far into it
|
|
2119
|
+
* the viewport top sat — and it is re-applied whenever the body is (re)built:
|
|
2120
|
+
* a restored pane, a re-opened entry, a fetch landing after the feed's summary,
|
|
2121
|
+
* an element React recreated.
|
|
2122
|
+
*
|
|
2123
|
+
* A pixel offset alone was not enough, and neither was applying it once. An
|
|
2124
|
+
* article's images carry no dimensions (`extract-article` keeps the site's own
|
|
2125
|
+
* markup), so the document GROWS as they load: a pixel target recorded against
|
|
2126
|
+
* the settled page lands in the middle of a short one, the browser clamps it,
|
|
2127
|
+
* and the reader is left at the top while the content keeps moving under them.
|
|
2128
|
+
* The anchor names the content, and this keeps naming it.
|
|
2129
|
+
*
|
|
2130
|
+
* @returns true when the scroller actually reached the target.
|
|
2131
|
+
*/
|
|
2132
|
+
const applyReadingPosition = useCallback(() => {
|
|
2133
|
+
const scroller = detailRef.current;
|
|
2134
|
+
if (scroller === null || openEntryId === null)
|
|
2135
|
+
return true;
|
|
2136
|
+
const anchor = readSession().scroll?.[openEntryId];
|
|
2137
|
+
if (anchor === undefined || anchor.top <= 0)
|
|
2138
|
+
return true;
|
|
2139
|
+
let target = anchor.top;
|
|
2140
|
+
// With no measurement (a test environment, or a body that has not been laid
|
|
2141
|
+
// out yet) the pixel offset is all there is to go on.
|
|
2142
|
+
const metrics = metricsRef.current;
|
|
2143
|
+
if (metrics !== null) {
|
|
2144
|
+
const block = metrics.blocks[anchor.block];
|
|
2145
|
+
if (block !== undefined) {
|
|
2146
|
+
target = Math.max(0, metrics.contentTop + block.top + offsetInBlock(block, anchor));
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
const before = scroller.scrollTop;
|
|
2150
|
+
scroller.scrollTop = target;
|
|
2151
|
+
// The browser's own echo of this write carries what it actually accepted.
|
|
2152
|
+
// When the assignment changed nothing there is no echo coming, so the record
|
|
2153
|
+
// is cleared — a stale echo is what makes a reader's scroll look like ours.
|
|
2154
|
+
scrollEchoRef.current = scroller.scrollTop === before ? null : scroller.scrollTop;
|
|
2155
|
+
return Math.abs(scroller.scrollTop - target) <= 1;
|
|
2156
|
+
}, [openEntryId]);
|
|
2157
|
+
useEffect(() => {
|
|
2158
|
+
if (openEntryId === null || articleHtml === null)
|
|
2159
|
+
return undefined;
|
|
2160
|
+
// A new body is a new chance for the reader to take over by scrolling.
|
|
2161
|
+
readerScrolledRef.current = false;
|
|
2162
|
+
let frame = 0;
|
|
2163
|
+
let attempt = 0;
|
|
2164
|
+
const step = () => {
|
|
2165
|
+
if (readerScrolledRef.current)
|
|
2166
|
+
return;
|
|
2167
|
+
if (applyReadingPosition())
|
|
2168
|
+
return;
|
|
2169
|
+
attempt += 1;
|
|
2170
|
+
if (attempt < SCROLL_SETTLE_ATTEMPTS)
|
|
2171
|
+
frame = typeof requestAnimationFrame === 'function' ? requestAnimationFrame(step) : 0;
|
|
2172
|
+
};
|
|
2173
|
+
// Measured after the body is in the document, once: the first layout of a
|
|
2174
|
+
// long article is expensive, and a scroll handler must never pay for it.
|
|
2175
|
+
if (typeof requestAnimationFrame === 'function')
|
|
2176
|
+
frame = requestAnimationFrame(() => { measure(); step(); });
|
|
2177
|
+
else {
|
|
2178
|
+
measure();
|
|
2179
|
+
step();
|
|
2180
|
+
}
|
|
2181
|
+
// Late layout is the common case, not the exception: every `<img>` with no
|
|
2182
|
+
// dimensions resizes the document when it arrives. The observer re-measures
|
|
2183
|
+
// (the geometry really did change) and re-anchors, until the reader takes
|
|
2184
|
+
// over or the window closes.
|
|
2185
|
+
const article = articleRef.current;
|
|
2186
|
+
const deadline = Date.now() + POSITION_SETTLE_MS;
|
|
2187
|
+
let observer = null;
|
|
2188
|
+
if (typeof ResizeObserver === 'function' && article !== null) {
|
|
2189
|
+
observer = new ResizeObserver(() => {
|
|
2190
|
+
if (readerScrolledRef.current || Date.now() > deadline)
|
|
2191
|
+
return;
|
|
2192
|
+
measure();
|
|
2193
|
+
applyReadingPosition();
|
|
2194
|
+
});
|
|
2195
|
+
observer.observe(article);
|
|
2196
|
+
}
|
|
2197
|
+
const giveUp = typeof setTimeout === 'function'
|
|
2198
|
+
? setTimeout(() => { observer?.disconnect(); }, POSITION_SETTLE_MS)
|
|
2199
|
+
: undefined;
|
|
2200
|
+
return () => {
|
|
2201
|
+
if (frame !== 0 && typeof cancelAnimationFrame === 'function')
|
|
2202
|
+
cancelAnimationFrame(frame);
|
|
2203
|
+
observer?.disconnect();
|
|
2204
|
+
if (giveUp !== undefined)
|
|
2205
|
+
clearTimeout(giveUp);
|
|
2206
|
+
};
|
|
2207
|
+
}, [openEntryId, articleHtml, applyReadingPosition, measure]);
|
|
2208
|
+
/**
|
|
2209
|
+
* A translation rewrites the article's GEOMETRY — different words, different
|
|
2210
|
+
* block heights — whenever it lands or changes view, which is typically long
|
|
2211
|
+
* after the settle window above has closed. Re-measure and re-anchor on those
|
|
2212
|
+
* transitions so the reader stays at the same SENTENCE across an
|
|
2213
|
+
* original↔translation switch (the anchor's text offset is what makes "the
|
|
2214
|
+
* same sentence" computable; `offsetInBlock` owns the mapping).
|
|
2215
|
+
*
|
|
2216
|
+
* A view switch is a deliberate one-shot, so the layout read here is fine —
|
|
2217
|
+
* the no-layout-reads rule is the scroll handler's. `measure()` answering
|
|
2218
|
+
* null (no body on screen yet) skips the apply, and the effect fires on
|
|
2219
|
+
* `openEntryId` changes only through `applyReadingPosition`'s identity —
|
|
2220
|
+
* harmless, because an unmeasured body is exactly what that skips.
|
|
2221
|
+
*/
|
|
2222
|
+
useEffect(() => {
|
|
2223
|
+
if (measure() === null)
|
|
2224
|
+
return;
|
|
2225
|
+
applyReadingPosition();
|
|
2226
|
+
}, [translateView, translatePhase, measure, applyReadingPosition]);
|
|
2227
|
+
/**
|
|
2228
|
+
* The scroller's own gestures, which is how the reader takes over.
|
|
2229
|
+
*
|
|
2230
|
+
* `wheel`, `touchmove` and the page keys are things only a person produces:
|
|
2231
|
+
* every programmatic write this pane makes comes from `scrollTop`, so listening
|
|
2232
|
+
* for gestures is a far safer signal than trying to recognise our own echo —
|
|
2233
|
+
* mistaking one for the other is what left a reader at the top of an article
|
|
2234
|
+
* whose position we had just applied.
|
|
2235
|
+
*/
|
|
2236
|
+
useEffect(() => {
|
|
2237
|
+
const scroller = detailRef.current;
|
|
2238
|
+
if (scroller === null)
|
|
2239
|
+
return undefined;
|
|
2240
|
+
const takeOver = () => {
|
|
2241
|
+
readerScrolledRef.current = true;
|
|
2242
|
+
lastDetailGestureRef.current = Date.now();
|
|
2243
|
+
};
|
|
2244
|
+
scroller.addEventListener('wheel', takeOver, { passive: true });
|
|
2245
|
+
scroller.addEventListener('touchmove', takeOver, { passive: true });
|
|
2246
|
+
scroller.addEventListener('keydown', takeOver);
|
|
2247
|
+
return () => {
|
|
2248
|
+
scroller.removeEventListener('wheel', takeOver);
|
|
2249
|
+
scroller.removeEventListener('touchmove', takeOver);
|
|
2250
|
+
scroller.removeEventListener('keydown', takeOver);
|
|
2251
|
+
};
|
|
2252
|
+
}, [openEntryId, articleHtml]);
|
|
2253
|
+
/**
|
|
2254
|
+
* Remember the reading position as it moves.
|
|
2255
|
+
*
|
|
2256
|
+
* Deliberately unthrottled: the work is a binary search over the measured
|
|
2257
|
+
* blocks plus one small record write — no layout reads at all, because reading
|
|
2258
|
+
* layout here is what made the browser report a forced reflow on every scroll.
|
|
2259
|
+
*
|
|
2260
|
+
* Only the READER's own scrolling is recorded. Setting `scrollTop` to restore
|
|
2261
|
+
* a position fires this handler too, and saving that echo is how a position
|
|
2262
|
+
* gets destroyed: a clamped assignment carries the clamped value, which would
|
|
2263
|
+
* overwrite the real target with the short body's height.
|
|
2264
|
+
*/
|
|
2265
|
+
const onDetailScroll = useCallback(() => {
|
|
2266
|
+
const scroller = detailRef.current;
|
|
2267
|
+
if (scroller === null || openEntryId === null)
|
|
2268
|
+
return;
|
|
2269
|
+
if (scrollEchoRef.current !== null && Math.abs(scroller.scrollTop - scrollEchoRef.current) < 2) {
|
|
2270
|
+
scrollEchoRef.current = null;
|
|
2271
|
+
return;
|
|
2272
|
+
}
|
|
2273
|
+
scrollEchoRef.current = null;
|
|
2274
|
+
// Only a scroll the reader CAUSED is their position. A programmatic write
|
|
2275
|
+
// has its echo above; a browser clamp (the document shrinking under a
|
|
2276
|
+
// scrolled scroller — a translation rebuild, a body swap, a remount's
|
|
2277
|
+
// intermediate short state) has neither: on 3080 a remount's translation
|
|
2278
|
+
// rebuild clamped the scroller and the clamp's scroll event overwrote the
|
|
2279
|
+
// stored anchor with the clamped value, so the reader came back to the top.
|
|
2280
|
+
// The gesture window is what a scroll event's cause is read from.
|
|
2281
|
+
if (Date.now() - lastDetailGestureRef.current > SCROLL_GESTURE_WINDOW_MS)
|
|
2282
|
+
return;
|
|
2283
|
+
const metrics = metricsRef.current;
|
|
2284
|
+
const top = Math.round(scroller.scrollTop);
|
|
2285
|
+
if (metrics === null) {
|
|
2286
|
+
rememberReadingPosition(openEntryId, { block: 0, offset: top, top });
|
|
2287
|
+
return;
|
|
2288
|
+
}
|
|
2289
|
+
const visibleY = top - metrics.contentTop;
|
|
2290
|
+
const { block, offset } = blockAt(metrics, visibleY);
|
|
2291
|
+
const anchorBlock = metrics.blocks[block];
|
|
2292
|
+
// The same place as a TEXT offset too, so a layout that sets different
|
|
2293
|
+
// words (the translated view) can still find the sentence. From the cached
|
|
2294
|
+
// metrics only — this handler reads no layout and no DOM. Only a position
|
|
2295
|
+
// strictly INSIDE the block gets a text offset: past the last block (the
|
|
2296
|
+
// also-in-this-source section), the pixel clamp is the honest restore.
|
|
2297
|
+
if (anchorBlock !== undefined && anchorBlock.height > 0 && anchorBlock.textLength > 0 && offset < anchorBlock.height) {
|
|
2298
|
+
const text = Math.round(Math.min(1, offset / anchorBlock.height) * anchorBlock.textLength);
|
|
2299
|
+
rememberReadingPosition(openEntryId, { block, offset, top, text, textLength: anchorBlock.textLength });
|
|
2300
|
+
return;
|
|
2301
|
+
}
|
|
2302
|
+
rememberReadingPosition(openEntryId, { block, offset, top });
|
|
2303
|
+
}, [openEntryId]);
|
|
2304
|
+
/**
|
|
2305
|
+
* Remember where the reader had scrolled the WALL itself.
|
|
2306
|
+
*
|
|
2307
|
+
* Same rule as the article: only the reader's own scrolling is recorded, so
|
|
2308
|
+
* the echo of a restore never becomes the new target.
|
|
2309
|
+
*/
|
|
2310
|
+
const onWallScroll = useCallback(() => {
|
|
2311
|
+
const scroller = wallRef.current;
|
|
2312
|
+
if (scroller === null)
|
|
2313
|
+
return;
|
|
2314
|
+
if (wallEchoRef.current !== null && Math.abs(scroller.scrollTop - wallEchoRef.current) < 2) {
|
|
2315
|
+
wallEchoRef.current = null;
|
|
2316
|
+
return;
|
|
2317
|
+
}
|
|
2318
|
+
wallEchoRef.current = null;
|
|
2319
|
+
// Same contract as the article's: only a scroll the reader caused is their
|
|
2320
|
+
// place — a clamp while the wall's content shrinks is not.
|
|
2321
|
+
if (Date.now() - lastWallGestureRef.current > SCROLL_GESTURE_WINDOW_MS)
|
|
2322
|
+
return;
|
|
2323
|
+
rememberWallScroll(scroller.scrollTop);
|
|
2324
|
+
}, []);
|
|
2325
|
+
/** The wall scroller's gestures, the same "only a person produces these" signal. */
|
|
2326
|
+
useEffect(() => {
|
|
2327
|
+
const scroller = wallRef.current;
|
|
2328
|
+
if (scroller === null)
|
|
2329
|
+
return undefined;
|
|
2330
|
+
const mark = () => { lastWallGestureRef.current = Date.now(); };
|
|
2331
|
+
scroller.addEventListener('wheel', mark, { passive: true });
|
|
2332
|
+
scroller.addEventListener('touchmove', mark, { passive: true });
|
|
2333
|
+
scroller.addEventListener('keydown', mark);
|
|
2334
|
+
return () => {
|
|
2335
|
+
scroller.removeEventListener('wheel', mark);
|
|
2336
|
+
scroller.removeEventListener('touchmove', mark);
|
|
2337
|
+
scroller.removeEventListener('keydown', mark);
|
|
2338
|
+
};
|
|
2339
|
+
}, [view, displayRows.length]);
|
|
2340
|
+
// Put the wall back where it was, once per entry into the list view: a long
|
|
2341
|
+
// wall that jumps to the top on every panel switch is the same complaint as a
|
|
2342
|
+
// lost reading position. Re-applying it whenever the ROWS change would fight
|
|
2343
|
+
// the reader's own filtering.
|
|
2344
|
+
useEffect(() => {
|
|
2345
|
+
if (view !== 'list') {
|
|
2346
|
+
wallRestoredRef.current = false;
|
|
2347
|
+
return;
|
|
2348
|
+
}
|
|
2349
|
+
if (wallRestoredRef.current)
|
|
2350
|
+
return;
|
|
2351
|
+
const scroller = wallRef.current;
|
|
2352
|
+
if (scroller === null || displayRows.length === 0)
|
|
2353
|
+
return;
|
|
2354
|
+
wallRestoredRef.current = true;
|
|
2355
|
+
const target = readSession().wallScroll;
|
|
2356
|
+
if (target === undefined || target <= 0)
|
|
2357
|
+
return;
|
|
2358
|
+
scroller.scrollTop = target;
|
|
2359
|
+
wallEchoRef.current = scroller.scrollTop;
|
|
2360
|
+
}, [view, displayRows.length]);
|
|
2361
|
+
/**
|
|
2362
|
+
* A rebuilt body means the old segmentation died with the old DOM — so the
|
|
2363
|
+
* record gets another chance, keyed on the BODY and not only on the entry.
|
|
2364
|
+
*
|
|
2365
|
+
* That is the difference between "the globe is on for this entry" and "the
|
|
2366
|
+
* globe was on the first time this entry was opened": a body replaced under
|
|
2367
|
+
* the reader (a fetch landing after a restore, an expired cache re-fetched, a
|
|
2368
|
+
* re-render that recreated the element) used to leave the article on screen
|
|
2369
|
+
* untranslated with nothing to put the translation back. The record is the
|
|
2370
|
+
* single source of truth, and it is deleted the moment the reader turns the
|
|
2371
|
+
* globe off or asks for a fresh fetch.
|
|
2372
|
+
*/
|
|
2373
|
+
useEffect(() => { restoredTranslationRef.current = null; }, [openEntryId, articleHtml]);
|
|
2374
|
+
/**
|
|
2375
|
+
* Turn the globe back on for an entry that was translated before the sidebar
|
|
2376
|
+
* went away.
|
|
2377
|
+
*
|
|
2378
|
+
* Only when a translator session for that pair is ALREADY built (the page's
|
|
2379
|
+
* cache): `Translator.create()` needs user activation, so a restore that had
|
|
2380
|
+
* to build one would fail with an error the reader never asked for. No session
|
|
2381
|
+
* means no restore — the record stays, and the reader's next click on the
|
|
2382
|
+
* globe costs nothing because the same cache answers it.
|
|
2383
|
+
*/
|
|
2384
|
+
useEffect(() => {
|
|
2385
|
+
if (view !== 'detail' || openEntryId === null)
|
|
2386
|
+
return;
|
|
2387
|
+
if (articleHtml === null || articleHtml.length === 0)
|
|
2388
|
+
return;
|
|
2389
|
+
if (translatePhase !== 'idle')
|
|
2390
|
+
return;
|
|
2391
|
+
if (restoredTranslationRef.current === openEntryId)
|
|
2392
|
+
return;
|
|
2393
|
+
const wanted = readSession().translationView?.[openEntryId];
|
|
2394
|
+
if (wanted === undefined)
|
|
2395
|
+
return;
|
|
2396
|
+
const source = readSession().translationSource?.[openEntryId] ?? translationSource;
|
|
2397
|
+
const sources = source === 'en' ? ['en'] : [source, 'en'];
|
|
2398
|
+
if (cachedTranslator(sources, TARGET_CANDIDATES) === undefined)
|
|
2399
|
+
return;
|
|
2400
|
+
restoredTranslationRef.current = openEntryId;
|
|
2401
|
+
void startTranslation(wanted, source);
|
|
2402
|
+
}, [view, openEntryId, articleHtml, translatePhase, translationSource, startTranslation]);
|
|
2403
|
+
/** Submit the add form and report the host's verdict. */
|
|
2404
|
+
const submit = useCallback(async () => {
|
|
2405
|
+
const url = draftUrl.trim();
|
|
2406
|
+
if (url.length === 0)
|
|
2407
|
+
return;
|
|
2408
|
+
setVerdict(null);
|
|
2409
|
+
const result = await props.addSource(url);
|
|
2410
|
+
if (!result.ok) {
|
|
2411
|
+
// A transport-level failure: the host never answered, so the reason is
|
|
2412
|
+
// whatever the wire layer said.
|
|
2413
|
+
setVerdict({ kind: 'fetchFailed', reason: result.error.message });
|
|
2414
|
+
return;
|
|
2415
|
+
}
|
|
2416
|
+
const value = result.value;
|
|
2417
|
+
if (typeof value === 'string') {
|
|
2418
|
+
setVerdict({ kind: verdictForRefusal(value) });
|
|
2419
|
+
return;
|
|
2420
|
+
}
|
|
2421
|
+
if (value.outcome === 'fetch-failed') {
|
|
2422
|
+
// The fetch failed with a reason of its own ("connect ECONNREFUSED …",
|
|
2423
|
+
// "body over the seam's byte cap"). Showing it is the difference between
|
|
2424
|
+
// a retry and a report nobody can act on.
|
|
2425
|
+
setVerdict({ kind: 'fetchFailed', reason: value.reason });
|
|
2426
|
+
return;
|
|
2427
|
+
}
|
|
2428
|
+
if (value.failure !== undefined) {
|
|
2429
|
+
// Saved, but there is no body to preview. This is NOT a failure verdict:
|
|
2430
|
+
// the link is in the wall, and the sentence names the reason so the
|
|
2431
|
+
// reader knows whether it is worth opening in a browser. The URL rides
|
|
2432
|
+
// along: for the causes a real browser solves, the verdict itself offers
|
|
2433
|
+
// the way out (the moment "why" lands is the moment to act on it).
|
|
2434
|
+
setVerdict({
|
|
2435
|
+
kind: 'savedLinkNoPreview',
|
|
2436
|
+
label: value.label,
|
|
2437
|
+
reason: value.failure.message,
|
|
2438
|
+
code: value.failure.code,
|
|
2439
|
+
url,
|
|
2440
|
+
});
|
|
2441
|
+
setDraftUrl('');
|
|
2442
|
+
actions.refresh();
|
|
2443
|
+
return;
|
|
2444
|
+
}
|
|
2445
|
+
setVerdict({
|
|
2446
|
+
kind: value.outcome === 'subscribed' ? 'subscribed' : 'savedLink',
|
|
2447
|
+
label: value.label,
|
|
2448
|
+
});
|
|
2449
|
+
setDraftUrl('');
|
|
2450
|
+
// Reload so the new entry is on the wall behind the dialog, not only in
|
|
2451
|
+
// the host's state.
|
|
2452
|
+
actions.refresh();
|
|
2453
|
+
}, [actions, draftUrl, props]);
|
|
2454
|
+
/** Re-fetch one source from the subscription page. */
|
|
2455
|
+
const refreshOne = useCallback(async (id) => {
|
|
2456
|
+
actions.setRefreshing(true);
|
|
2457
|
+
try {
|
|
2458
|
+
const result = await props.refresh([id]);
|
|
2459
|
+
if (!result.ok)
|
|
2460
|
+
actions.setError(result.error.message);
|
|
2461
|
+
else
|
|
2462
|
+
actions.refresh();
|
|
2463
|
+
}
|
|
2464
|
+
finally {
|
|
2465
|
+
actions.setRefreshing(false);
|
|
2466
|
+
}
|
|
2467
|
+
}, [actions, props]);
|
|
2468
|
+
/** Pause or resume one source from the subscription page. */
|
|
2469
|
+
const toggleSource = useCallback(async (id, enabled) => {
|
|
2470
|
+
const result = await props.updateSource({ id, enabled });
|
|
2471
|
+
if (result.ok)
|
|
2472
|
+
actions.refresh();
|
|
2473
|
+
}, [actions, props]);
|
|
2474
|
+
/** Drop one source (and everything it brought) from the subscription page. */
|
|
2475
|
+
const removeSource = useCallback(async (id) => {
|
|
2476
|
+
// The entries this pane has parsed for the source: their translation maps
|
|
2477
|
+
// die with the source (the host never parses feeds, so the caller names
|
|
2478
|
+
// them). A rolled-off entry's map is the budget's business, as before.
|
|
2479
|
+
const entryIds = allEntries.filter(entry => entry.sourceId === id).map(entry => entry.id);
|
|
2480
|
+
const result = await props.removeSource(id, entryIds);
|
|
2481
|
+
if (result.ok) {
|
|
2482
|
+
actions.clearParsed();
|
|
2483
|
+
actions.refresh();
|
|
2484
|
+
}
|
|
2485
|
+
}, [actions, props, allEntries]);
|
|
2486
|
+
/**
|
|
2487
|
+
* Delete one saved link from wherever the reader is standing.
|
|
2488
|
+
*
|
|
2489
|
+
* A `link` source IS its single entry, so deleting the entry and deleting the
|
|
2490
|
+
* source are the same act — which is why the detail view can offer it. A feed
|
|
2491
|
+
* entry has no such button: the source would bring it back on the next
|
|
2492
|
+
* refresh, so the honest verbs for it are the subscription ones.
|
|
2493
|
+
*
|
|
2494
|
+
* @param sourceId - the link source to drop.
|
|
2495
|
+
*/
|
|
2496
|
+
const removeLink = useCallback(async (sourceId) => {
|
|
2497
|
+
await removeSource(sourceId);
|
|
2498
|
+
actions.closeEntry();
|
|
2499
|
+
}, [actions, removeSource]);
|
|
2500
|
+
/**
|
|
2501
|
+
* Apply an edited URL and/or label to one source.
|
|
2502
|
+
*
|
|
2503
|
+
* A URL change is not a cosmetic edit: the next fetch is a different
|
|
2504
|
+
* document, so the stored payload is replaced by the fetch that follows and
|
|
2505
|
+
* the label falls back to the feed's own title when the editor clears it.
|
|
2506
|
+
*/
|
|
2507
|
+
const saveSource = useCallback(async (id, url, label) => {
|
|
2508
|
+
const result = await props.updateSource({
|
|
2509
|
+
id,
|
|
2510
|
+
url: url.trim(),
|
|
2511
|
+
label: label.trim(),
|
|
2512
|
+
});
|
|
2513
|
+
if (!result.ok) {
|
|
2514
|
+
actions.setError(result.error.message);
|
|
2515
|
+
return;
|
|
2516
|
+
}
|
|
2517
|
+
await props.refresh([id]);
|
|
2518
|
+
actions.refresh();
|
|
2519
|
+
}, [actions, props]);
|
|
2520
|
+
/** Move the daily refresh time. */
|
|
2521
|
+
const setRefreshTime = useCallback(async (value) => {
|
|
2522
|
+
setTimeOfDay(value);
|
|
2523
|
+
// The host reports `invalid-time` for anything it cannot parse, and the
|
|
2524
|
+
// input is a native time field, so a bad value only comes from a manual
|
|
2525
|
+
// edit; keep the field's value and let the next handshake correct it.
|
|
2526
|
+
await props.updateSource({ id: '', timeOfDay: value });
|
|
2527
|
+
actions.refresh();
|
|
2528
|
+
}, [actions, props]);
|
|
2529
|
+
/**
|
|
2530
|
+
* Fill in missing full text automatically, a slice per load.
|
|
2531
|
+
*
|
|
2532
|
+
* This is the package's answer to "the feed gave me a summary": the host says
|
|
2533
|
+
* which entries lack text (and which are not worth a retry yet), this process
|
|
2534
|
+
* fetches + extracts each one, and the wall marks what arrived. Two requests
|
|
2535
|
+
* at a time, because this talks to other people's servers.
|
|
2536
|
+
*
|
|
2537
|
+
* @param entries - the entries currently on the wall.
|
|
2538
|
+
*/
|
|
2539
|
+
const backfillBodies = useCallback(async (entries) => {
|
|
2540
|
+
if (backfillRunning.current)
|
|
2541
|
+
return;
|
|
2542
|
+
const missing = entries.filter(row => (row.entry.contentHtml === undefined || row.entry.summaryOnly === true)
|
|
2543
|
+
&& row.entry.link !== undefined
|
|
2544
|
+
&& !backfillTried.current.has(row.entry.id));
|
|
2545
|
+
if (missing.length === 0)
|
|
2546
|
+
return;
|
|
2547
|
+
backfillRunning.current = true;
|
|
2548
|
+
try {
|
|
2549
|
+
const listed = await props.listBackfillCandidates(missing.map(row => ({
|
|
2550
|
+
entryId: row.entry.id,
|
|
2551
|
+
url: row.entry.link,
|
|
2552
|
+
label: row.entry.title,
|
|
2553
|
+
hasBody: false,
|
|
2554
|
+
})));
|
|
2555
|
+
if (!listed.ok)
|
|
2556
|
+
return;
|
|
2557
|
+
const queue = [...listed.value.candidates];
|
|
2558
|
+
if (queue.length === 0)
|
|
2559
|
+
return;
|
|
2560
|
+
actions.setBackfill({ total: queue.length, done: 0 });
|
|
2561
|
+
let filled = 0;
|
|
2562
|
+
const workers = Array.from({ length: Math.min(BACKFILL_CONCURRENCY, queue.length) }, async () => {
|
|
2563
|
+
for (;;) {
|
|
2564
|
+
const next = queue.shift();
|
|
2565
|
+
if (next === undefined)
|
|
2566
|
+
return;
|
|
2567
|
+
backfillTried.current.add(next.entryId);
|
|
2568
|
+
const result = await props.fetchEntryBody(next.entryId, next.url);
|
|
2569
|
+
const got = result.html !== undefined;
|
|
2570
|
+
if (got)
|
|
2571
|
+
filled += 1;
|
|
2572
|
+
actions.noteBackfilled(next.entryId, got);
|
|
2573
|
+
}
|
|
2574
|
+
});
|
|
2575
|
+
await Promise.all(workers);
|
|
2576
|
+
// Deliberately no `actions.refresh()` here: bumping `rev` would re-enter
|
|
2577
|
+
// `load()` and start the whole thing over. Only the DETAIL view re-reads,
|
|
2578
|
+
// and it does so when it mounts. But the fetch-state mirror DOES re-read
|
|
2579
|
+
// once per run — otherwise a card whose body the backfill just landed
|
|
2580
|
+
// keeps saying 抓取 until the next mount (the reported symptom).
|
|
2581
|
+
await refreshFetchStates();
|
|
2582
|
+
}
|
|
2583
|
+
finally {
|
|
2584
|
+
backfillRunning.current = false;
|
|
2585
|
+
actions.setBackfill(null);
|
|
2586
|
+
}
|
|
2587
|
+
}, [actions, props, refreshFetchStates]);
|
|
2588
|
+
// Automatic, after the wall has something to look at — never before, so the
|
|
2589
|
+
// reader never waits on the network for a list they already had. A folded
|
|
2590
|
+
// duplicate's copy is not fetched while it is hidden: the survivor answers.
|
|
2591
|
+
useEffect(() => {
|
|
2592
|
+
if (loading || allEntries.length === 0)
|
|
2593
|
+
return;
|
|
2594
|
+
const pool = selectRows(allEntries, presentation, {
|
|
2595
|
+
filter: 'all', query: '', unreadOnly: false, sort, read, tags: entryTagIds, now: new Date(),
|
|
2596
|
+
});
|
|
2597
|
+
void backfillBodies(hideDupes ? dedupeRows(pool).rows : pool);
|
|
2598
|
+
}, [loading, allEntries, presentation, sort, read, entryTagIds, hideDupes, backfillBodies]);
|
|
2599
|
+
// Any menu/panel dismisses on the next click outside it — the host's own
|
|
2600
|
+
// menus behave that way, and a popover that outlives its context is a trap.
|
|
2601
|
+
useEffect(() => {
|
|
2602
|
+
if (cardMenu === null && cardTag === null && !filterOpen && !translateMenu && !wallMenu && pillInfo === null)
|
|
2603
|
+
return undefined;
|
|
2604
|
+
const dismiss = (event) => {
|
|
2605
|
+
const target = event.target;
|
|
2606
|
+
if (target?.closest('[class*="cardMenu"], [class*="cardTagPanel"], [class*="filterPanel"], [class*="translateWrap"], [class*="pillPop"]') !== null)
|
|
2607
|
+
return;
|
|
2608
|
+
setCardMenu(null);
|
|
2609
|
+
setCardTag(null);
|
|
2610
|
+
setFilterOpen(false);
|
|
2611
|
+
setTranslateMenu(false);
|
|
2612
|
+
setWallMenu(false);
|
|
2613
|
+
setPillInfo(null);
|
|
2614
|
+
};
|
|
2615
|
+
window.addEventListener('mousedown', dismiss);
|
|
2616
|
+
return () => window.removeEventListener('mousedown', dismiss);
|
|
2617
|
+
}, [cardMenu, cardTag, filterOpen, translateMenu, wallMenu, pillInfo]);
|
|
2618
|
+
/** Fetch one entry without opening it: the card's own 抓取 action. */
|
|
2619
|
+
const startFetch = useCallback(async (entry) => {
|
|
2620
|
+
if (entry.link === undefined)
|
|
2621
|
+
return;
|
|
2622
|
+
actions.setFetchStates({ [entry.id]: { state: 'fetching', at: new Date().toISOString() } });
|
|
2623
|
+
// `fetchBody` is the same call the detail view makes on open — the host
|
|
2624
|
+
// stores the payload first, so leaving the page does not cancel it. It also
|
|
2625
|
+
// re-reads this entry's state from the host when the fetch settles, so the
|
|
2626
|
+
// pill flips without a full-wall poll here.
|
|
2627
|
+
const count = await fetchBody(entry.id, entry.link);
|
|
2628
|
+
// One gesture, the whole pipeline: a shell answer continues into the
|
|
2629
|
+
// rendered fetch on its own (see maybeAutoRender).
|
|
2630
|
+
maybeAutoRender(entry.id, entry.link, count ?? 0);
|
|
2631
|
+
}, [actions, fetchBody, maybeAutoRender]);
|
|
2632
|
+
/** Reload the tags on one entry. */
|
|
2633
|
+
const loadEntryTags = useCallback(async (entryId) => {
|
|
2634
|
+
const result = await props.entryTags(entryId);
|
|
2635
|
+
if (result.ok)
|
|
2636
|
+
actions.setEntryTags(entryId, result.value.tags.map(tag => tag.id));
|
|
2637
|
+
}, [actions, props]);
|
|
2638
|
+
/**
|
|
2639
|
+
* Fetch this entry's article again, from the detail view.
|
|
2640
|
+
*
|
|
2641
|
+
* The same call the card's pill makes, with one addition: the translation
|
|
2642
|
+
* record is dropped first. The body is about to be a different DOM, and a
|
|
2643
|
+
* record that outlived it would switch the globe back on over text it was
|
|
2644
|
+
* never built for.
|
|
2645
|
+
*
|
|
2646
|
+
* @param entry - the entry on screen.
|
|
2647
|
+
*/
|
|
2648
|
+
const refetchEntry = useCallback(async (entry) => {
|
|
2649
|
+
if (entry.link === undefined)
|
|
2650
|
+
return;
|
|
2651
|
+
forgetTranslation(entry.id);
|
|
2652
|
+
restoredTranslationRef.current = entry.id;
|
|
2653
|
+
// A rendered body re-renders: a plain fetch would clobber it with the
|
|
2654
|
+
// page's pre-JS shell. A manual gesture always allowed (the auto-render
|
|
2655
|
+
// loop guard does not apply to it).
|
|
2656
|
+
if (renderedBodiesRef.current.has(entry.id) && props.captureRemote() !== undefined) {
|
|
2657
|
+
autoRenderTriedRef.current.delete(entry.id);
|
|
2658
|
+
await captureBody(entry.id, entry.link);
|
|
2659
|
+
return;
|
|
2660
|
+
}
|
|
2661
|
+
await startFetch(entry);
|
|
2662
|
+
}, [startFetch, captureBody, props]);
|
|
2663
|
+
/**
|
|
2664
|
+
* Delete a tag from the vocabulary and from every entry carrying it.
|
|
2665
|
+
*
|
|
2666
|
+
* The vocabulary is the reader's own creation, so it has to be deletable —
|
|
2667
|
+
* a tag that can only be made is a one-way door. Two follow-ups are not
|
|
2668
|
+
* optional: the active narrowing is dropped when it was that tag (otherwise
|
|
2669
|
+
* the wall keeps filtering by something that no longer exists), and the
|
|
2670
|
+
* session's tag ids are pruned so no card keeps drawing a dead chip.
|
|
2671
|
+
*
|
|
2672
|
+
* @param tagId - the tag to delete.
|
|
2673
|
+
*/
|
|
2674
|
+
const removeTag = useCallback(async (tagId) => {
|
|
2675
|
+
const result = await props.deleteTag(tagId);
|
|
2676
|
+
if (!result.ok)
|
|
2677
|
+
return;
|
|
2678
|
+
if (query.trim() === tagQuery(tagId))
|
|
2679
|
+
actions.setQuery('');
|
|
2680
|
+
actions.dropTag(tagId);
|
|
2681
|
+
actions.refresh();
|
|
2682
|
+
}, [actions, props, query]);
|
|
2683
|
+
/**
|
|
2684
|
+
* Open the tag panel for one card, without opening the article.
|
|
2685
|
+
*
|
|
2686
|
+
* The panel is `position: fixed`, so its seat is decided here instead of by a
|
|
2687
|
+
* CSS constant: below the card when the viewport has room for it, above the
|
|
2688
|
+
* card when it does not, and never past either gutter. The previous version
|
|
2689
|
+
* only knew `card.bottom + 4`, which opened a panel that ran off the bottom
|
|
2690
|
+
* of a short sidebar.
|
|
2691
|
+
*/
|
|
2692
|
+
const openCardTag = useCallback((entryId) => {
|
|
2693
|
+
void loadEntryTags(entryId);
|
|
2694
|
+
setTagDraft('');
|
|
2695
|
+
const box = document.querySelector(`[data-reader-entry="${entryId}"]`)?.getBoundingClientRect();
|
|
2696
|
+
// 84px of chrome (header + input + padding) plus one row per tag; the head
|
|
2697
|
+
// and the input are fixed here, so this is exact enough to choose a side.
|
|
2698
|
+
const height = Math.min(TAG_PANEL_MAX_HEIGHT, 84 + tags.length * 30);
|
|
2699
|
+
const left = Math.max(8, Math.min(box?.left ?? 40, window.innerWidth - TAG_PANEL_WIDTH - 8));
|
|
2700
|
+
const below = (box?.bottom ?? 120) + 6;
|
|
2701
|
+
const above = (box?.top ?? 120) - height - 6;
|
|
2702
|
+
const top = below + height <= window.innerHeight - 8 || above < 8 ? below : above;
|
|
2703
|
+
setCardTag({ entryId, top, left });
|
|
2704
|
+
}, [loadEntryTags, tags.length]);
|
|
2705
|
+
/** Toggle one tag on one entry. */
|
|
2706
|
+
const toggleTag = useCallback(async (entryId, tagId, on) => {
|
|
2707
|
+
const result = await props.tagEntry(entryId, tagId, on);
|
|
2708
|
+
if (!result.ok)
|
|
2709
|
+
return;
|
|
2710
|
+
await loadEntryTags(entryId);
|
|
2711
|
+
const listed = await props.listTags();
|
|
2712
|
+
if (listed.ok)
|
|
2713
|
+
actions.setTags(listed.value.tags, listed.value.counts);
|
|
2714
|
+
}, [actions, loadEntryTags, props]);
|
|
2715
|
+
/** Create a tag by name (or reuse the one that already has it) and apply it. */
|
|
2716
|
+
const createAndTag = useCallback(async (entryId, name) => {
|
|
2717
|
+
const created = await props.createTag(name);
|
|
2718
|
+
if (!created.ok || typeof created.value === 'string')
|
|
2719
|
+
return;
|
|
2720
|
+
setTagDraft('');
|
|
2721
|
+
setTagInputOpen(false);
|
|
2722
|
+
await toggleTag(entryId, created.value.id, true);
|
|
2723
|
+
}, [props, toggleTag]);
|
|
2724
|
+
/** Quote the open entry (or a selection) into the conversation draft. */
|
|
2725
|
+
const quote = useCallback((explicit) => {
|
|
2726
|
+
if (openEntry === undefined)
|
|
2727
|
+
return;
|
|
2728
|
+
const selection = explicit ?? window.getSelection()?.toString() ?? '';
|
|
2729
|
+
const source = presentation.get(openEntry.sourceId);
|
|
2730
|
+
const block = formatReaderRef(selection, provenanceOf(openEntry, {
|
|
2731
|
+
label: source?.label ?? openEntry.title,
|
|
2732
|
+
}));
|
|
2733
|
+
const next = mergedDraft(props.readDraft(), block);
|
|
2734
|
+
props.setDraft(next);
|
|
2735
|
+
setDraft(next);
|
|
2736
|
+
}, [openEntry, presentation, props]);
|
|
2737
|
+
const copyLink = useCallback(async () => {
|
|
2738
|
+
if (openEntry?.link === undefined)
|
|
2739
|
+
return;
|
|
2740
|
+
await props.copyText(openEntry.link);
|
|
2741
|
+
}, [openEntry, props]);
|
|
2742
|
+
/**
|
|
2743
|
+
* The way out to a real browser for a page the fetch cannot do justice to.
|
|
2744
|
+
*
|
|
2745
|
+
* The in-app Sidebar Browser (host 0.1.6-alpha.2+) keeps the reader inside
|
|
2746
|
+
* the product; on an older host the same gesture is the ordinary external
|
|
2747
|
+
* link. Probed at render, never thrown.
|
|
2748
|
+
*/
|
|
2749
|
+
const browserTab = props.browserTabAvailable();
|
|
2750
|
+
/** The capture package's Remote, while one is mounted (M1; absent by default). */
|
|
2751
|
+
const captureRemote = props.captureRemote();
|
|
2752
|
+
const openElsewhere = useCallback((url) => {
|
|
2753
|
+
if (props.openBrowserTab(url))
|
|
2754
|
+
return;
|
|
2755
|
+
props.openExternal(url);
|
|
2756
|
+
}, [props]);
|
|
2757
|
+
/* ------------------------------------------------------------------ render */
|
|
2758
|
+
/**
|
|
2759
|
+
* The pane's header.
|
|
2760
|
+
*
|
|
2761
|
+
* @param back - true on a page that left the wall (detail, subscriptions),
|
|
2762
|
+
* which needs the way back. The wall itself shows none — there is nothing
|
|
2763
|
+
* behind it.
|
|
2764
|
+
*/
|
|
2765
|
+
const header = (back) => (_jsxs("div", { className: css.head, children: [back && (_jsx("button", { type: "button", className: css.tool, title: t('action.back'), onClick: () => { actions.setView('list'); actions.closeEntry(); }, children: _jsx(IconChevronLeftOutlineMedium, { size: 14 }) })), _jsxs("span", { className: css.headTitle, children: [_jsx(IconGlobeOutlineMedium, { size: 14 }), t('tab.label'), _jsxs("span", { className: css.count, title: t('filter.unreadOnly'), children: [countUnread(displayRows), " ", t('foot.unread')] })] }), _jsx("button", { type: "button", className: `${css.tool} ${refreshing ? css.toolSpinning : ''}`, title: t('action.refresh'), disabled: refreshing, onClick: () => { void refreshAll(); }, children: _jsx(IconRefreshOutlineMedium, { size: 15 }) }), _jsxs("span", { className: css.toolWrap, children: [_jsx("button", { type: "button", className: `${css.tool} ${activeSource !== null || activeKind !== null || unreadOnly ? css.toolOn : ''}`, title: t('action.filter'), onClick: () => {
|
|
2766
|
+
setSortOpen(false);
|
|
2767
|
+
// Every open starts at the panel's root, with the source page's
|
|
2768
|
+
// search box empty: the panel is a place, not a form that keeps
|
|
2769
|
+
// yesterday's state.
|
|
2770
|
+
if (!filterOpen) {
|
|
2771
|
+
setFilterPage('root');
|
|
2772
|
+
setSourceFilter('');
|
|
2773
|
+
}
|
|
2774
|
+
setFilterOpen(!filterOpen);
|
|
2775
|
+
}, children: glyph('funnel', 15) }), filterOpen && (_jsx("div", { className: css.filterPanel, onKeyDown: event => { if (event.key === 'Escape')
|
|
2776
|
+
setFilterOpen(false); }, children: filterPage === 'source' ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: css.filterPanelHead, children: [_jsx("button", { type: "button", className: css.filterBack, title: t('action.back'), onClick: () => { setFilterPage('root'); setSourceFilter(''); }, children: _jsx(IconChevronLeftOutlineMedium, { size: 12 }) }), _jsx("span", { className: css.filterPanelTitle, children: t('filter.bySource') }), _jsx("span", { className: css.filterCount, children: sources.length })] }), sources.length >= SOURCE_SEARCH_MIN && (_jsxs("div", { className: css.filterSearch, children: [glyph('search', 11), _jsx("input", { autoFocus: true, value: sourceFilter, placeholder: t('filter.searchSource'), onChange: event => setSourceFilter(event.target.value) }), sourceFilter.length > 0 && (_jsx("button", { type: "button", className: css.searchClear, title: t('action.clearSearch'), "aria-label": t('action.clearSearch'), onClick: () => { setSourceFilter(''); }, children: glyph('close', 13) }))] })), _jsxs("div", { className: css.filterList, children: [_jsxs("button", { type: "button", className: css.filterRow, onClick: () => { actions.setQuery(''); setFilterOpen(false); }, children: [_jsx("span", { className: css.filterCheck, children: activeSource === null ? '✓' : '' }), _jsx("span", { className: css.filterLabel, children: t('filter.all') }), _jsx("span", { className: css.filterCount, children: allEntries.length })] }), matchingSources.map(source => {
|
|
2777
|
+
const group = parsed[source.id];
|
|
2778
|
+
const broken = group?.error !== undefined || group?.incomplete === true;
|
|
2779
|
+
return (_jsxs("button", { type: "button", className: css.filterRow, title: source.url, onClick: () => { actions.setQuery(sourceQuery(source.id)); setFilterOpen(false); }, children: [_jsx("span", { className: css.filterCheck, children: activeSource === source.id ? '✓' : '' }), _jsx("span", { className: css.filterLabel, children: source.label }), broken
|
|
2780
|
+
? _jsx("span", { className: css.chipBroken, title: t('state.incomplete'), children: "!" })
|
|
2781
|
+
: _jsx("span", { className: css.filterCount, children: group?.entries.length ?? 0 })] }, source.id));
|
|
2782
|
+
}), matchingSources.length === 0 && (_jsx("div", { className: css.filterEmpty, children: t('filter.noSourceMatch') }))] })] })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: css.filterSection, children: t('filter.readState') }), _jsxs("button", { type: "button", className: css.filterRow, onClick: () => actions.toggleUnreadOnly(), children: [_jsx("span", { className: css.filterCheck, children: unreadOnly ? '✓' : '' }), _jsx("span", { className: css.filterLabel, children: t('filter.unreadOnly') })] }), _jsxs("button", { type: "button", className: css.filterRow, onClick: () => actions.toggleHideDupes(), children: [_jsx("span", { className: css.filterCheck, children: hideDupes ? '✓' : '' }), _jsx("span", { className: css.filterLabel, children: t('filter.hideDupes') })] }), _jsxs("button", { type: "button", className: css.filterRow, onClick: () => { setSourceFilter(''); setFilterPage('source'); }, children: [_jsx("span", { className: css.filterLabel, children: t('filter.bySource') }), _jsx("span", { className: css.filterValue, children: activeSourceLabel ?? t('filter.all') }), _jsx("span", { className: css.filterChevron, children: glyph('chevron', 12) })] }), _jsx("div", { className: css.filterSection, children: t('filter.byKind') }), _jsxs("button", { type: "button", className: css.filterRow, onClick: () => { actions.setQuery(''); setFilterOpen(false); }, children: [_jsx("span", { className: css.filterCheck, children: query.trim() === '' ? '✓' : '' }), _jsx("span", { className: css.filterLabel, children: t('filter.all') }), _jsx("span", { className: css.filterCount, children: allEntries.length })] }), READER_SOURCE_KINDS.map(kind => (_jsxs("button", { type: "button", className: css.filterRow, onClick: () => { actions.setQuery(kindQuery(kind)); setFilterOpen(false); }, children: [_jsx("span", { className: css.filterCheck, children: activeKind === kind ? '✓' : '' }), _jsx("span", { className: css.filterLabel, children: t(kind === 'link' ? 'sources.kindLink' : 'sources.kindRss') }), _jsx("span", { className: css.filterCount, children: kindCounts[kind] })] }, kind))), tags.length > 0 && _jsx("div", { className: css.filterSection, children: t('filter.byTag') }), tags.map(tag => (_jsxs("div", { className: css.filterTagRow, children: [_jsxs("button", { type: "button", className: css.filterRow, onClick: () => { actions.setQuery(tagQuery(tag.id)); setFilterOpen(false); }, children: [_jsx("span", { className: css.filterCheck, children: query.trim() === tagQuery(tag.id) ? '✓' : '' }), _jsx("span", { className: css.filterLabel, children: tag.name }), _jsx("span", { className: css.filterCount, children: tagCounts[tag.id] ?? 0 })] }), _jsx("button", { type: "button", className: css.filterTagDelete, title: t('filter.deleteTag'), "aria-label": `${t('filter.deleteTag')}: ${tag.name}`, onClick: () => { void removeTag(tag.id); }, children: "\u00D7" })] }, tag.id)))] })) }))] }), _jsxs("span", { className: css.toolWrap, children: [_jsx("button", { type: "button", className: `${css.tool} ${sort === 'newest' ? '' : css.toolOn}`, title: t('sort.title'), onClick: () => { setFilterOpen(false); setSortOpen(open => !open); }, children: glyph('sort', 15) }), sortOpen && (_jsx("div", { className: css.menu, children: ['newest', 'oldest', 'source'].map(option => (_jsxs("button", { type: "button", onClick: () => { actions.setSort(option); setSortOpen(false); }, children: [_jsx("span", { children: t(`sort.${option}`) }), sort === option && _jsx("span", { className: css.check, children: "\u2713" })] }, option))) }))] }), wallTranslateOffered && (_jsxs("span", { className: css.translateWrap, children: [_jsx("button", { type: "button", className: `${css.tool}${wallOn ? ` ${css.toolOn}` : ''}`, title: t('action.translate'), "aria-pressed": wallOn, onClick: () => {
|
|
2783
|
+
setSortOpen(false);
|
|
2784
|
+
setFilterOpen(false);
|
|
2785
|
+
if (wallOn) {
|
|
2786
|
+
setWallOn(false);
|
|
2787
|
+
return;
|
|
2788
|
+
}
|
|
2789
|
+
wallCancelRef.current = { cancelled: false };
|
|
2790
|
+
setWallOn(true);
|
|
2791
|
+
// Cards already on screen are queued by the observer effect; a
|
|
2792
|
+
// wall that never scrolls still gets translated in one pass.
|
|
2793
|
+
void runWallPass();
|
|
2794
|
+
}, children: _jsx(IconGlobeOutlineMedium, { size: 15 }) }), wallOn && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: css.translateCaret, "aria-expanded": wallMenu, title: t('translate.view'), onClick: () => { setWallMenu(open => !open); }, children: _jsx(IconChevronDownOutlineMedium, { size: 10 }) }), wallMenu && (_jsxs("div", { className: css.translateMenu, role: "menu", children: [_jsx("div", { className: css.translateMenuHead, children: t('translate.local') }), _jsxs("button", { type: "button", role: "menuitemradio", "aria-checked": !wallBoth, className: css.translateMenuItem, onClick: () => { setWallMenu(false); setWallBoth(false); }, children: [_jsx("span", { className: css.translateCheck, children: !wallBoth ? '✓' : '' }), _jsx("span", { className: css.translateMenuLabel, children: t('translate.onlyTranslation') })] }), _jsxs("button", { type: "button", role: "menuitemradio", "aria-checked": wallBoth, className: css.translateMenuItem, onClick: () => { setWallMenu(false); setWallBoth(true); }, children: [_jsx("span", { className: css.translateCheck, children: wallBoth ? '✓' : '' }), _jsx("span", { className: css.translateMenuLabel, children: t('translate.bilingual') })] })] }))] }))] })), _jsx("button", { type: "button", className: css.tool, title: t('action.recent'), onClick: () => { actions.closeEntry(); actions.setView('recent'); }, children: _jsx(IconClockOutlineMedium, { size: 15 }) }), _jsx("button", { type: "button", className: css.tool, title: t('action.manage'), onClick: () => { actions.closeEntry(); actions.setView('manage'); }, children: _jsx(IconSettingsOutlineMedium, { size: 15 }) }), _jsx("button", { type: "button", className: css.tool, title: t('action.add'), onClick: () => { setAddOpen(true); setVerdict(null); }, children: _jsx(IconPlusOutlineMedium, { size: 15 }) })] }));
|
|
2795
|
+
/** Load the states once the wall knows which entries it is showing. */
|
|
2796
|
+
useEffect(() => { void refreshFetchStates(); }, [refreshFetchStates, rev]);
|
|
2797
|
+
/**
|
|
2798
|
+
* Keep the states fresh while anything is in flight, and drain stored payloads.
|
|
2799
|
+
*
|
|
2800
|
+
* The effect depends on the BOOLEAN alone and reaches the callbacks through
|
|
2801
|
+
* refs. Depending on the callbacks themselves made this a loop: a poll
|
|
2802
|
+
* replaces `fetchStates`, which recreates `sweepRaw` (it closes over them),
|
|
2803
|
+
* which re-runs this effect, which polls again — a render cycle that never
|
|
2804
|
+
* settles and a test suite that never finishes.
|
|
2805
|
+
*/
|
|
2806
|
+
const inFlight = Object.values(fetchStates).some(state => state.state === 'fetching' || state.state === 'raw');
|
|
2807
|
+
const pollRef = useRef(() => { });
|
|
2808
|
+
pollRef.current = () => {
|
|
2809
|
+
void refreshFetchStates();
|
|
2810
|
+
void sweepRaw();
|
|
2811
|
+
};
|
|
2812
|
+
useEffect(() => {
|
|
2813
|
+
if (!inFlight)
|
|
2814
|
+
return undefined;
|
|
2815
|
+
void pollRef.current();
|
|
2816
|
+
const timer = setInterval(() => { pollRef.current(); }, 2000);
|
|
2817
|
+
return () => { clearInterval(timer); };
|
|
2818
|
+
}, [inFlight]);
|
|
2819
|
+
/** The add dialog: overlay + centered card, Esc and overlay-click close. */
|
|
2820
|
+
const dialog = addOpen && (_jsx("div", { className: css.overlay, role: "presentation", onClick: event => { if (event.target === event.currentTarget)
|
|
2821
|
+
setAddOpen(false); }, children: _jsxs("div", { className: css.dialog, role: "dialog", "aria-modal": "true", "aria-label": t('add.title'), onKeyDown: event => { if (event.key === 'Escape')
|
|
2822
|
+
setAddOpen(false); }, children: [_jsx("h2", { children: t('add.title') }), _jsx("p", { children: t('add.help') }), _jsx("input", { autoFocus: true, value: draftUrl, placeholder: t('add.placeholder'), onChange: event => setDraftUrl(event.target.value), onKeyDown: event => { if (event.key === 'Enter')
|
|
2823
|
+
void submit(); } }), verdict !== null && (_jsxs("div", { className: css.verdict, children: [_jsx("b", { children: t(VERDICT_KEY[verdict.kind], verdict.kind === 'savedLinkNoPreview'
|
|
2824
|
+
? { label: verdict.label ?? '', reason: previewReason(t, verdict.code, verdict.reason ?? '') }
|
|
2825
|
+
: verdict.label === undefined ? {} : { label: verdict.label }) }), verdict.reason !== undefined && verdict.kind === 'fetchFailed' && (_jsx("span", { className: css.verdictReason, children: verdict.reason })), verdict.url !== undefined && verdict.code !== undefined && OPEN_IN_BROWSER_CODES.has(verdict.code) && (_jsxs(_Fragment, { children: [' ', _jsx("button", { type: "button", className: css.incompleteLink, onClick: () => { openElsewhere(verdict.url); }, children: t('action.openExternal') })] }))] })), _jsxs("div", { className: css.dialogActions, children: [_jsx("button", { type: "button", className: css.ghost, onClick: () => setAddOpen(false), children: verdict !== null && (verdict.kind === 'subscribed' || verdict.kind === 'savedLink' || verdict.kind === 'savedLinkNoPreview')
|
|
2826
|
+
? t('action.done')
|
|
2827
|
+
: t('action.cancel') }), _jsx("button", { type: "button", className: css.submit, onClick: () => { void submit(); }, children: t('action.submit') })] })] }) }));
|
|
2828
|
+
/* ------------------------------------------------------------- detail view */
|
|
2829
|
+
if (view === 'detail' && (openEntry !== undefined || openEntryId !== null)) {
|
|
2830
|
+
/**
|
|
2831
|
+
* A restore that has not opened its entry yet.
|
|
2832
|
+
*
|
|
2833
|
+
* The pane knows from its first render WHICH entry it should be showing (the
|
|
2834
|
+
* page memory), but the entry itself comes from the host's payloads, which
|
|
2835
|
+
* are still being parsed. Falling through to the wall for that moment is the
|
|
2836
|
+
* "the article disappeared and then loaded again" flash — the page it is
|
|
2837
|
+
* about to be on is the detail view, so it says so.
|
|
2838
|
+
*/
|
|
2839
|
+
if (openEntry === undefined) {
|
|
2840
|
+
return (_jsxs("div", { className: css.root, children: [_jsxs("div", { className: css.bar, children: [_jsx("button", { type: "button", className: css.tool, title: t('action.back'), onClick: () => { actions.setView('list'); actions.closeEntry(); }, children: _jsx(IconChevronLeftOutlineMedium, { size: 14 }) }), _jsx("span", { className: css.barLabel, children: t('tab.label') }), _jsx("span", { className: css.spacer })] }), _jsx("div", { className: css.detailBody, children: _jsx("p", { className: css.incomplete, children: t('state.loading') }) })] }));
|
|
2841
|
+
}
|
|
2842
|
+
const source = presentation.get(openEntry.sourceId);
|
|
2843
|
+
/** The host's own row for this entry's source: the failure code lives here. */
|
|
2844
|
+
const sourceSummary = sources.find(item => item.id === openEntry.sourceId);
|
|
2845
|
+
const when = relativeWhen(openEntry.publishedAt, new Date());
|
|
2846
|
+
const date = absoluteDate(openEntry.publishedAt);
|
|
2847
|
+
const alsoFrom = allEntries
|
|
2848
|
+
.filter(entry => entry.sourceId === openEntry.sourceId && entry.id !== openEntry.id)
|
|
2849
|
+
.slice(0, 3);
|
|
2850
|
+
/** How far the pack download, or the translation, has come. */
|
|
2851
|
+
const statusPercent = translatePhase === 'pack'
|
|
2852
|
+
? Math.round((packProgress ?? 0) * 100)
|
|
2853
|
+
: translateProgress !== null && translateProgress.total > 0
|
|
2854
|
+
? Math.round((translateProgress.done / translateProgress.total) * 100)
|
|
2855
|
+
: 0;
|
|
2856
|
+
return (_jsxs("div", { className: css.root, children: [_jsxs("div", { className: css.bar, children: [_jsx("button", { type: "button", className: css.tool, title: t('action.back'), onClick: () => { actions.setView('list'); actions.closeEntry(); }, children: _jsx(IconChevronLeftOutlineMedium, { size: 14 }) }), _jsx("span", { className: css.barLabel, children: openEntry.title }), _jsx("span", { className: css.spacer }), translationOffered && (_jsxs("span", { className: css.translateWrap, children: [_jsx("button", { type: "button", className: `${css.tool}${translatePhase === 'pack' || translatePhase === 'working' ? ` ${css.toolSpinning}` : ''}${translatePhase === 'ready' && translateView !== 'orig' ? ` ${css.toolOn}` : ''}`, title: t('action.translate'), "aria-pressed": translatePhase === 'ready' && translateView !== 'orig', onClick: toggleGlobe, children: _jsx(IconGlobeOutlineMedium, { size: 15 }) }), _jsx("button", { type: "button", className: css.translateCaret, "aria-expanded": translateMenu, title: t('translate.view'), onClick: () => { setTranslateMenu(open => !open); }, children: _jsx(IconChevronDownOutlineMedium, { size: 10 }) }), translateMenu && (_jsxs("div", { className: css.translateMenu, role: "menu", children: [_jsx("div", { className: css.translateMenuHead, children: t('translate.local') }), TRANSLATION_VIEWS.map(item => (_jsxs("button", { type: "button", role: "menuitemradio", "aria-checked": translateView === item.view, className: css.translateMenuItem, onClick: () => { pickView(item.view); }, children: [_jsx("span", { className: css.translateCheck, children: translateView === item.view ? '✓' : '' }), _jsx("span", { className: css.translateMenuLabel, children: t(item.key) })] }, item.view))), (translatePhase === 'ready' || translatePhase === 'failed') && (_jsxs("button", { type: "button", role: "menuitem", className: css.translateMenuItem, onClick: () => {
|
|
2857
|
+
setTranslateMenu(false);
|
|
2858
|
+
if (builtRef.current !== null)
|
|
2859
|
+
restoreArticle(builtRef.current.root);
|
|
2860
|
+
builtRef.current = null;
|
|
2861
|
+
void startTranslation(lastViewRef.current);
|
|
2862
|
+
}, children: [_jsx("span", { className: css.translateCheck }), _jsx("span", { className: css.translateMenuLabel, children: t('translate.retry') })] }))] }))] })), _jsx("button", { type: "button", className: css.tool, title: t('action.copyLink'), onClick: () => { void copyLink(); }, children: _jsx(IconCopyOutlineMedium, { size: 15 }) }), _jsx("button", { type: "button", className: css.tool, title: t('action.openExternal'), onClick: () => { if (openEntry.link !== undefined)
|
|
2863
|
+
props.openExternal(openEntry.link); }, children: _jsx(IconRightUpOutlineMedium, { size: 15 }) }), sourceSummary?.kind === 'link' && (_jsx("button", { type: "button", className: `${css.tool} ${css.toolDanger}`, title: t('detail.removeLink'), onClick: () => { void removeLink(openEntry.sourceId); }, children: _jsx(IconTrashOutlineMedium, { size: 15 }) }))] }), _jsxs("div", { className: css.detailBody, ref: detailRef, onScroll: onDetailScroll, children: [_jsxs("div", { className: css.kicker, children: [_jsx("span", { className: css.tile, style: { background: source?.hue }, children: source?.tile }), _jsx("span", { children: source?.label }), _jsx("span", { className: css.sep, children: "\u00B7" }), _jsx("span", { children: date ?? t(when.key, when.count === undefined ? {} : { count: when.count }) }), openEntry.author !== undefined && (_jsxs(_Fragment, { children: [_jsx("span", { className: css.sep, children: "\u00B7" }), _jsx("span", { children: openEntry.author })] })), sourceSummary?.resolvedFrom !== undefined && (_jsxs(_Fragment, { children: [_jsx("span", { className: css.sep, children: "\u00B7" }), _jsx("button", { type: "button", className: css.incompleteLink, title: t('detail.resolvedFrom', { url: sourceSummary.resolvedFrom }), onClick: () => { props.openExternal(sourceSummary.resolvedFrom); }, children: sourceSummary.resolvedFrom.replace(/^https?:\/\//, '') })] }))] }), _jsx("h1", { className: css.detailTitle, children: openEntry.title }), ((openEntry.tags ?? []).length > 0 || openEntry.link !== undefined) && (_jsxs("div", { className: css.tagRow, children: [(openEntry.tags ?? []).map(tag => _jsx("span", { className: css.tag, children: tag }, tag)), openEntry.link !== undefined && (_jsxs("button", { type: "button", className: css.refetch,
|
|
2864
|
+
// A rendered entry's「重新抓取」re-renders through capture —
|
|
2865
|
+
// captureBusy is that in-flight state, or the button looks
|
|
2866
|
+
// dead for minutes.
|
|
2867
|
+
disabled: bodyFetching || captureBusy, title: t('detail.refetchTitle'), onClick: () => { void refetchEntry(openEntry); }, children: [glyph('fetch', 11), _jsx("span", { children: bodyFetching || captureBusy ? t('detail.refetching') : t('detail.refetch') })] }))] })), _jsx("div", { className: css.rule }), (translatePhase === 'pack' || translatePhase === 'working') && (_jsxs("div", { className: css.translateStatus, children: [_jsx("span", { children: translatePhase === 'pack'
|
|
2868
|
+
? t('translate.preparing')
|
|
2869
|
+
: _jsxs(_Fragment, { children: [_jsx("b", { children: t('translate.working') }), translateProgress !== null ? ` ${translateProgress.done}/${translateProgress.total}` : ''] }) }), _jsx("span", { className: css.translateBar, children: _jsx("i", { style: { width: `${String(statusPercent)}%` } }) }), _jsx("button", { type: "button", className: css.incompleteLink, onClick: cancelTranslation, children: t('action.cancel') })] })), translatePhase === 'failed' && translateError !== null && (_jsxs("div", { className: css.translateStatus, "data-failed": "true", children: [translateError, _jsx("button", { type: "button", className: css.incompleteLink, onClick: cancelTranslation, children: t('action.done') })] })), translatePhase === 'ready' && !tipDismissed && (_jsxs("div", { className: css.translateTip, children: [t('translate.tip'), _jsx("button", { type: "button", title: t('translate.dismiss'), "aria-label": t('translate.dismiss'), onClick: () => { setTipDismissed(true); }, children: "\u00D7" })] })), bodyFetching && (_jsx("p", { className: css.incomplete, children: t('detail.fetchingBody') })), scriptFigures > 0 && (_jsxs("p", { className: css.incomplete, children: [t('detail.scriptFigures', { count: scriptFigures }), ' ', captureRemote !== undefined && openEntry.link !== undefined && (captureBusy
|
|
2870
|
+
? _jsx("span", { children: t('detail.rendering') })
|
|
2871
|
+
: (_jsx("button", { type: "button", className: css.incompleteLink, onClick: () => { void captureBody(openEntry.id, openEntry.link); }, children: t('detail.renderFetch') }))), ' ', openEntry.link !== undefined && (_jsx("button", { type: "button", className: css.incompleteLink, onClick: () => { openElsewhere(openEntry.link); }, children: browserTab ? t('action.openExternal') : t('detail.readOriginal') }))] })), articleError !== null && (_jsxs("p", { className: css.incomplete, children: [previewReason(t, sourceSummary?.failure?.code, articleError), openEntry.link !== undefined && (_jsxs(_Fragment, { children: [' ', _jsx("button", { type: "button", className: css.incompleteLink, onClick: () => { openElsewhere(openEntry.link); }, children: t('detail.readOriginal') })] }))] })), openEntry.summaryOnly === true && articleHtml !== null && articleHtml === openEntry.contentHtml && (_jsx("p", { className: css.incomplete, children: t('detail.summaryOnly') })), articleHtml !== null && articleHtml.length > 0 && (_jsx("div", {
|
|
2872
|
+
// The markup is the output of this package's own whitelist
|
|
2873
|
+
// normalizer (`extract-article`): scripts, styles, forms, iframes
|
|
2874
|
+
// and event attributes are already gone, and every URL that
|
|
2875
|
+
// survives has been scheme-checked. Re-sanitizing here would mean
|
|
2876
|
+
// a second implementation of the same policy.
|
|
2877
|
+
//
|
|
2878
|
+
// `translate.ts` decorates THIS subtree in place when the reader
|
|
2879
|
+
// asks for a translation (text nodes only), so the click handler
|
|
2880
|
+
// is delegation: the sentences it creates are not React's.
|
|
2881
|
+
ref: articleRef, onClick: onArticleClick, onMouseOver: onArticleHover, onMouseOut: onArticleHover, className: `${css.article} ${isCjk(articleHtml) ? css.articleZh : css.articleEn}`, dangerouslySetInnerHTML: { __html: articleHtml } })), (articleTruncated || openEntry.truncated === true) && (_jsxs("p", { className: css.incomplete, children: [t('detail.incomplete'), " \u2014", ' ', _jsx("button", { type: "button", className: css.incompleteLink, onClick: () => { if (openEntry.link !== undefined)
|
|
2882
|
+
props.openExternal(openEntry.link); }, children: t('detail.readOriginal') })] })), _jsxs("div", { className: css.tagEdit, children: [_jsx("span", { className: css.tagEditLabel, children: t('tag.title') }), tags.filter(tag => (entryTagIds[openEntry.id] ?? []).includes(tag.id)).map(tag => (_jsxs("span", { className: css.tagChip, children: [tag.name, _jsx("button", { type: "button", className: css.tagChipRemove, title: t('action.remove'), onClick: () => { void toggleTag(openEntry.id, tag.id, false); }, children: "\u00D7" })] }, tag.id))), tagInputOpen
|
|
2883
|
+
? (_jsxs("div", { className: css.tagEditField, children: [_jsx("input", { autoFocus: true, className: css.tagInput, value: tagDraft, placeholder: t('tag.placeholder'), onChange: event => setTagDraft(event.target.value),
|
|
2884
|
+
// A suggestion's mousedown is suppressed, so this fires only
|
|
2885
|
+
// when focus really left the editor.
|
|
2886
|
+
onBlur: () => { setTagInputOpen(false); setTagDraft(''); }, onKeyDown: event => {
|
|
2887
|
+
if (event.key === 'Enter' && tagDraft.trim().length > 0) {
|
|
2888
|
+
const existing = tags.find(tag => tag.name.toLowerCase() === tagDraft.trim().toLowerCase());
|
|
2889
|
+
if (existing === undefined)
|
|
2890
|
+
void createAndTag(openEntry.id, tagDraft.trim());
|
|
2891
|
+
else {
|
|
2892
|
+
setTagDraft('');
|
|
2893
|
+
setTagInputOpen(false);
|
|
2894
|
+
void toggleTag(openEntry.id, existing.id, true);
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
if (event.key === 'Escape') {
|
|
2898
|
+
setTagInputOpen(false);
|
|
2899
|
+
setTagDraft('');
|
|
2900
|
+
}
|
|
2901
|
+
} }), tagDraft.trim().length > 0 && (_jsx("div", { className: css.tagSuggest, children: _jsx(TagSuggestions, { t: t, tags: tags, applied: entryTagIds[openEntry.id] ?? [], draft: tagDraft, onToggle: tag => {
|
|
2902
|
+
const on = (entryTagIds[openEntry.id] ?? []).includes(tag.id);
|
|
2903
|
+
void toggleTag(openEntry.id, tag.id, !on);
|
|
2904
|
+
}, onCreate: name => { void createAndTag(openEntry.id, name); } }) }))] }))
|
|
2905
|
+
: (_jsx("button", { type: "button", className: css.tagAdd, onClick: () => setTagInputOpen(true), children: _jsx(IconPlusOutlineMedium, { size: 12 }) }))] }), alsoFrom.length > 0 && (_jsxs("div", { className: css.alsoFrom, children: [_jsx("div", { className: css.alsoFromLabel, children: t('detail.alsoFrom') }), alsoFrom.map(entry => (_jsxs("button", { type: "button", className: css.alsoFromRow, onClick: () => {
|
|
2906
|
+
void open({
|
|
2907
|
+
entry,
|
|
2908
|
+
sourceId: entry.sourceId,
|
|
2909
|
+
sourceLabel: source?.label ?? '',
|
|
2910
|
+
sourceTile: source?.tile ?? '·',
|
|
2911
|
+
sourceHue: source?.hue ?? '',
|
|
2912
|
+
sourceAddedAt: source?.addedAt ?? '',
|
|
2913
|
+
sourceKind: source?.kind ?? 'rss',
|
|
2914
|
+
unread: read[entry.id] !== true,
|
|
2915
|
+
});
|
|
2916
|
+
}, children: [_jsx("span", { className: css.alsoFromTitle, children: entry.title }), _jsx("span", { className: css.when, children: whenLabel(t, entry.publishedAt) })] }, entry.id)))] }))] }), _jsxs("div", { className: css.composer, children: [_jsx("div", { className: css.composerLabel, children: t('detail.composerLabel') }), _jsx("div", { className: `${css.composerText} ${draft.length === 0 ? css.composerEmpty : ''}`, children: draft.length === 0 ? t('detail.composerEmpty') : draft })] }), _jsxs("div", { className: css.foot, children: [_jsx("button", { type: "button", className: css.incompleteLink, onClick: () => quote(), children: t('action.quote') }), sideChatAvailable && openEntry.link !== undefined && (_jsx("button", { type: "button", className: css.incompleteLink, onClick: () => {
|
|
2917
|
+
void props.quoteToSideChat({
|
|
2918
|
+
contextKey: sessionId,
|
|
2919
|
+
label: openEntry.title,
|
|
2920
|
+
text: formatReaderRef('', provenanceOf(openEntry, { label: source?.label ?? '' })),
|
|
2921
|
+
});
|
|
2922
|
+
}, children: t('quote.toSideChat') }))] })] }));
|
|
2923
|
+
}
|
|
2924
|
+
/* ------------------------------------------------------ subscriptions page */
|
|
2925
|
+
if (view === 'recent') {
|
|
2926
|
+
return (_jsxs("div", { className: css.root, children: [_jsxs("div", { className: css.bar, children: [_jsx("button", { type: "button", className: css.tool, title: t('action.back'), onClick: () => actions.setView('list'), children: _jsx(IconChevronLeftOutlineMedium, { size: 14 }) }), _jsx("span", { className: css.barLabel, children: t('recent.title') }), _jsx("span", { className: css.spacer }), recent.length > 0 && (_jsx("button", { type: "button", className: css.tool, title: t('recent.clearTitle'), onClick: () => { void clearRecent(); }, children: _jsx(IconTrashOutlineMedium, { size: 15 }) }))] }), _jsx("div", { className: css.paneBody, children: recent.length === 0
|
|
2927
|
+
? _jsx("p", { className: css.help, children: t('recent.empty') })
|
|
2928
|
+
: (_jsxs(_Fragment, { children: [_jsx("p", { className: css.help, children: t('recent.help') }), _jsx("div", { className: css.recentList, children: recent.map(item => {
|
|
2929
|
+
// The wall's own parse wins when the feed still publishes the
|
|
2930
|
+
// entry (it carries the real summary and body); the stored
|
|
2931
|
+
// record is the fallback, which is why this list outlives a
|
|
2932
|
+
// feed's rolling window at all.
|
|
2933
|
+
const row = rowFor(entryById(item.entryId) ?? entryFromRecent(item), presentation, read);
|
|
2934
|
+
const when = relativeWhen(item.readAt, new Date());
|
|
2935
|
+
const label = presentation.get(item.sourceId)?.label ?? item.sourceId;
|
|
2936
|
+
return (_jsxs("button", { type: "button", className: css.recentRow, disabled: row === undefined, title: row === undefined ? t('recent.sourceGone') : item.title, onClick: () => { if (row !== undefined)
|
|
2937
|
+
void open(row); }, children: [_jsx("span", { className: css.recentTitle, children: item.title.length > 0 ? item.title : item.url ?? item.entryId }), _jsxs("span", { className: css.recentMeta, children: [_jsx("span", { className: css.recentSource, children: label }), _jsx("span", { className: css.sep, children: "\u00B7" }), _jsx("span", { children: t(when.key, when.count === undefined ? {} : { count: when.count }) })] })] }, item.entryId));
|
|
2938
|
+
}) })] })) })] }));
|
|
2939
|
+
}
|
|
2940
|
+
if (view === 'manage') {
|
|
2941
|
+
return (_jsxs("div", { className: css.root, children: [_jsxs("div", { className: css.bar, children: [_jsx("button", { type: "button", className: css.tool, title: t('action.back'), onClick: () => actions.setView('list'), children: _jsx(IconChevronLeftOutlineMedium, { size: 14 }) }), _jsx("span", { className: css.barLabel, children: t('sources.title') }), _jsx("span", { className: css.spacer }), _jsx("button", { type: "button", className: css.tool, title: t('action.add'), onClick: () => setAddOpen(true), children: _jsx(IconPlusOutlineMedium, { size: 15 }) })] }), _jsxs("div", { className: css.paneBody, children: [_jsx("p", { className: css.help, children: t('sources.help') }), _jsxs("div", { className: css.settingsRow, children: [_jsxs("label", { className: css.setting, htmlFor: "reader-refresh-time", title: t('sources.timeHelp'), children: [_jsx("span", { className: css.settingLabel, children: t('sources.time') }), _jsx("input", { id: "reader-refresh-time", className: css.settingControl, type: "time", value: timeOfDay, onChange: event => { void setRefreshTime(event.target.value); } })] }), _jsxs("label", { className: css.setting, htmlFor: "reader-cache-ttl", title: t('sources.cacheHelp'), children: [_jsx("span", { className: css.settingLabel, children: t('sources.cache') }), _jsx("select", { id: "reader-cache-ttl", className: css.settingControl, value: String(cacheTtlHours), onChange: event => {
|
|
2942
|
+
const hours = Number(event.target.value);
|
|
2943
|
+
void props.setCachePolicy(hours).then(result => { if (result.ok)
|
|
2944
|
+
actions.setCacheTtl(hours); });
|
|
2945
|
+
}, children: [12, 24, 168, 0].map(hours => (_jsx("option", { value: hours, children: t(hours === 0 ? 'sources.cacheForever' : 'sources.cacheHours', { count: hours }) }, hours))) })] })] }), _jsxs("div", { className: css.settingsRow, children: [_jsxs("span", { className: css.setting, title: t('sources.storageBodiesHelp'), children: [_jsx("span", { className: css.settingLabel, children: t('sources.storageBodies') }), _jsx("span", { className: css.settingReadout, children: storageStats === null
|
|
2946
|
+
? '—'
|
|
2947
|
+
: t('sources.storageBodiesUsage', {
|
|
2948
|
+
entries: String(storageStats.bodies.entries),
|
|
2949
|
+
size: formatStorageSize(storageStats.bodies.chars),
|
|
2950
|
+
}) })] }), _jsxs("span", { className: css.setting, title: t('sources.storageTranslationsHelp'), children: [_jsx("span", { className: css.settingLabel, children: t('sources.storageTranslations') }), _jsx("span", { className: css.settingReadout, children: storageStats === null
|
|
2951
|
+
? '—'
|
|
2952
|
+
: t('sources.storageTranslationsUsage', {
|
|
2953
|
+
maps: String(storageStats.translations.entries),
|
|
2954
|
+
sentences: String(storageStats.translations.memoryEntries),
|
|
2955
|
+
size: formatStorageSize(storageStats.translations.chars + storageStats.translations.memoryChars),
|
|
2956
|
+
}) })] }), _jsxs("label", { className: css.setting, htmlFor: "reader-translation-budget", title: t('sources.translationBudgetHelp'), children: [_jsx("span", { className: css.settingLabel, children: t('sources.translationBudget') }), _jsx("input", { id: "reader-translation-budget", className: css.settingControl, type: "number", min: 1, step: 1, value: budgetDraft, onChange: event => { setBudgetDraft(event.target.value); }, onBlur: commitBudget, onKeyDown: event => { if (event.key === 'Enter')
|
|
2957
|
+
commitBudget(); } }), _jsx("span", { className: css.settingLabel, children: "MB" })] }), _jsx("button", { type: "button", className: css.chip, "aria-pressed": clearArmed, title: t('sources.storageTranslationsHelp'), onClick: clearTranslations, children: t(clearArmed ? 'sources.clearTranslationsConfirm' : 'sources.clearTranslations') })] }), sources.length > 0 && (_jsxs("div", { className: css.manageTools, children: [_jsx("span", { className: css.chipGroupLabel, children: t('filter.byKind') }), _jsx("div", { className: css.chipRow, role: "group", "aria-label": t('sources.kindFilter'), children: ['all', ...READER_SOURCE_KINDS].map(kind => (_jsxs("button", { type: "button", className: `${css.chip}${manageKind === kind ? ` ${css.chipOn}` : ''}`, "aria-pressed": manageKind === kind, onClick: () => { setManageKind(kind); }, children: [kind === 'all'
|
|
2958
|
+
? t('filter.all')
|
|
2959
|
+
: t(kind === 'link' ? 'sources.kindLink' : 'sources.kindRss'), _jsx("span", { className: css.chipCount, children: kind === 'all' ? sources.length : sources.filter(source => source.kind === kind).length })] }, kind))) }), _jsx("span", { className: css.chipGroupLabel, children: t('sort.title') }), _jsx("div", { className: css.chipRow, role: "group", "aria-label": t('sort.title'), children: ['added', 'name', 'fetched'].map(option => (_jsx("button", { type: "button", className: `${css.chip}${manageSort === option ? ` ${css.chipOn}` : ''}`, "aria-pressed": manageSort === option, onClick: () => { setManageSort(option); }, children: t(option === 'added' ? 'sources.sortAdded' : option === 'name' ? 'sources.sortName' : 'sources.sortFetched') }, option))) })] })), sources.length === 0
|
|
2960
|
+
? _jsx("div", { className: css.state, children: t('sources.empty') })
|
|
2961
|
+
: manageSources.length === 0
|
|
2962
|
+
? _jsx("div", { className: css.state, children: t('sources.noMatch') })
|
|
2963
|
+
: (_jsx("div", { className: css.sourceList, children: manageSources.map(source => {
|
|
2964
|
+
const group = parsed[source.id];
|
|
2965
|
+
const failed = source.status === 'error';
|
|
2966
|
+
const when = relativeWhen(source.fetchedAt, new Date());
|
|
2967
|
+
return (_jsxs("div", { className: css.sourceRow, children: [_jsx("span", { className: css.tile, style: { background: hueForSource(source.label) }, children: tileForSource(source.label) }), _jsxs("span", { className: css.sourceInfo, children: [_jsx("input", { className: css.sourceNameInput, defaultValue: source.label, "aria-label": t('sources.name'), placeholder: t('sources.name'), onBlur: event => {
|
|
2968
|
+
if (event.target.value !== source.label)
|
|
2969
|
+
void saveSource(source.id, source.url, event.target.value);
|
|
2970
|
+
} }), _jsx("input", { className: css.sourceUrlInput, defaultValue: source.url, "aria-label": t('sources.url'), placeholder: t('sources.url'), onBlur: event => {
|
|
2971
|
+
if (event.target.value !== source.url)
|
|
2972
|
+
void saveSource(source.id, event.target.value, source.label);
|
|
2973
|
+
} }), _jsxs("span", { className: css.sourceMeta, children: [t(source.kind === 'rss' ? 'sources.kindRss' : 'sources.kindLink'), ' · ', source.kind === 'rss' ? t('sources.items', { count: group?.entries.length ?? 0 }) : t('tab.subtitle'), ' · ', source.fetchedAt === undefined
|
|
2974
|
+
? t('sources.never')
|
|
2975
|
+
: t('foot.refreshedAt', { when: t(when.key, when.count === undefined ? {} : { count: when.count }) }), ' · ', source.enabled ? t('sources.enabled') : t('sources.disabled'), failed && _jsxs(_Fragment, { children: [" ", ' · ', _jsx("span", { className: css.sourceFailed, children: t('sources.failed') })] })] }), failed && source.error !== undefined && (
|
|
2976
|
+
// The classified reason, not the seam's raw sentence:
|
|
2977
|
+
// this line is read by the person deciding whether to
|
|
2978
|
+
// refresh, delete, or open the page themselves.
|
|
2979
|
+
_jsx("span", { className: css.sourceError, children: previewReason(t, source.failure?.code, source.error) }))] }), _jsx("button", { type: "button", className: css.rowAction, title: t('action.refreshOne'), onClick: () => { void refreshOne(source.id); }, children: _jsx(IconRefreshOutlineMedium, { size: 14 }) }), _jsx("button", { type: "button", className: css.rowAction, onClick: () => { void toggleSource(source.id, !source.enabled); }, children: source.enabled ? t('action.pause') : t('action.resume') }), _jsx("button", { type: "button", className: css.rowActionDanger, onClick: () => { void removeSource(source.id); }, children: t('action.remove') })] }, source.id));
|
|
2980
|
+
}) }))] }), dialog] }));
|
|
2981
|
+
}
|
|
2982
|
+
/* ------------------------------------------------------------------- wall */
|
|
2983
|
+
return (_jsxs("div", { className: css.root, children: [header(false), _jsx("div", { className: css.tools, children: _jsxs("div", { className: css.search, children: [glyph('search', 12), _jsx("input", { value: query, placeholder: t('search.placeholder'), onChange: event => actions.setQuery(event.target.value) }), query.length > 0 && (_jsx("button", { type: "button", className: css.searchClear, title: t('action.clearSearch'), "aria-label": t('action.clearSearch'), onClick: () => { actions.setQuery(''); }, children: glyph('close', 15) }))] }) }), wallProgress !== null && (_jsxs("div", { className: css.translateStatus, children: [_jsxs("span", { children: [_jsx("b", { children: t('translate.working') }), ` ${wallProgress.done}/${wallProgress.total}`] }), _jsx("span", { className: css.translateBar, children: _jsx("i", { style: { width: `${wallProgress.total > 0 ? Math.round((wallProgress.done / wallProgress.total) * 100) : 0}%` } }) }), _jsx("button", { type: "button", className: css.incompleteLink, onClick: () => {
|
|
2984
|
+
if (wallCancelRef.current !== null)
|
|
2985
|
+
wallCancelRef.current.cancelled = true;
|
|
2986
|
+
wallPendingRef.current.clear();
|
|
2987
|
+
setWallProgress(null);
|
|
2988
|
+
}, children: t('action.cancel') })] })), _jsxs("div", { className: css.scroll, ref: wallRef, onScroll: onWallScroll, children: [loading && _jsx("div", { className: css.state, children: t('state.loading') }), !loading && error !== null && _jsxs("div", { className: css.state, children: [_jsx("b", { children: t('state.error') }), error] }), !loading && error === null && sources.length === 0 && (
|
|
2989
|
+
// The empty state is the members tab's trailing dashed card, alone:
|
|
2990
|
+
// adding is the one thing to do here, so the card IS the action.
|
|
2991
|
+
_jsxs("button", { type: "button", className: css.emptyCard, onClick: () => { setAddOpen(true); setVerdict(null); }, children: [_jsx("span", { className: css.emptyTitle, children: t('state.emptyTitle') }), _jsx("span", { className: css.emptyBody, children: t('state.emptyBody') })] })), !loading && sources.length > 0 && brokenSources.length > 0 && (
|
|
2992
|
+
// "Why is this list short?" answered where the reader is looking,
|
|
2993
|
+
// once per affected source, with the way to the full text.
|
|
2994
|
+
_jsx("div", { className: css.notice, children: brokenSources.map(source => (_jsxs("div", { className: css.noticeRow, children: [_jsxs("span", { className: css.noticeText, children: [t('state.incomplete'), " \u00B7 ", source.label, parsed[source.id]?.incomplete === true && _jsxs(_Fragment, { children: [" \u2014 ", t('state.incompleteReason')] })] }), _jsx("button", { type: "button", className: css.noticeLink, onClick: () => props.openExternal(source.url), children: t('detail.readOriginal') })] }, source.id))) })), !loading && sources.length > 0 && displayRows.length === 0 && (_jsx("div", { className: css.state, children: query.trim().length === 0 ? t('state.emptyWall') : t('state.noMatch', { query }) })), !loading && displayRows.length > 0 && (_jsx("div", { className: css.list, children: displayRows.map(row => {
|
|
2995
|
+
// The card's text box is FIXED (two clamped lines per field, see
|
|
2996
|
+
// the CSS), because Chinese and English wrap differently: swapping
|
|
2997
|
+
// languages must not resize the card or shift the grid.
|
|
2998
|
+
const card = cardTranslations[row.entry.id];
|
|
2999
|
+
const hovered = hoverField?.id === row.entry.id ? hoverField.field : null;
|
|
3000
|
+
const peekTitle = wallOn && !wallBoth && hovered === 'title' && card?.title !== undefined;
|
|
3001
|
+
const summary = row.entry.summary;
|
|
3002
|
+
const peekSummary = wallOn && !wallBoth && hovered === 'summary' && card?.summary !== undefined;
|
|
3003
|
+
const titleText = wallOn && !peekTitle && card?.title !== undefined ? card.title : row.entry.title;
|
|
3004
|
+
const summaryText = wallOn && !peekSummary && card?.summary !== undefined ? card.summary : summary;
|
|
3005
|
+
const originalTitle = wallOn && wallBoth && card?.title !== undefined ? row.entry.title : null;
|
|
3006
|
+
const originalSummary = wallOn && wallBoth && card?.summary !== undefined ? summary : null;
|
|
3007
|
+
// A saved link with no body is a link and nothing more: the card
|
|
3008
|
+
// says so, because the alternative — a card that opens onto an
|
|
3009
|
+
// empty page — reads as a broken plugin rather than a site that
|
|
3010
|
+
// refuses us. Feed entries are excluded: their missing body is
|
|
3011
|
+
// the backfill's job and it is already marked when it arrives.
|
|
3012
|
+
const rowSource = sources.find(item => item.id === row.sourceId);
|
|
3013
|
+
const linkOnly = row.sourceKind === 'link' && row.entry.contentHtml === undefined;
|
|
3014
|
+
const linkOnlyReason = previewReason(t, rowSource?.failure?.code, parsed[row.sourceId]?.error ?? t('detail.extractFailed'));
|
|
3015
|
+
// The duplicates this card stands for, when the wall's dedupe
|
|
3016
|
+
// folded them behind it (a badge, never a deletion).
|
|
3017
|
+
const dupes = dupesBy.get(row.entry.id) ?? [];
|
|
3018
|
+
// Only a field that actually has a translation can be peeked: an
|
|
3019
|
+
// untranslated (or Chinese) card keeps its ordinary hover.
|
|
3020
|
+
const peekable = (field) => wallOn && !wallBoth && card?.[field] !== undefined;
|
|
3021
|
+
const hoverProps = (field) => peekable(field)
|
|
3022
|
+
? {
|
|
3023
|
+
onMouseEnter: () => { setHoverField({ id: row.entry.id, field }); },
|
|
3024
|
+
onMouseLeave: () => { setHoverField(current => (current?.id === row.entry.id && current.field === field ? null : current)); },
|
|
3025
|
+
}
|
|
3026
|
+
: {};
|
|
3027
|
+
return (_jsxs("div", { className: css.card, "data-reader-entry": row.entry.id, "data-translated": wallOn && card !== undefined ? '1' : undefined, "data-unread": row.unread || undefined, role: "button", tabIndex: 0, onClick: () => { if (cardMenu !== null || cardTag !== null) {
|
|
3028
|
+
setCardMenu(null);
|
|
3029
|
+
setCardTag(null);
|
|
3030
|
+
return;
|
|
3031
|
+
} void open(row); }, onKeyDown: event => { if (event.key === 'Enter' || event.key === ' ')
|
|
3032
|
+
void open(row); }, onContextMenu: event => {
|
|
3033
|
+
// The host's own row-menu gesture: right-click opens the
|
|
3034
|
+
// entry's actions without a click-through into the article.
|
|
3035
|
+
if (row.entry.link === undefined)
|
|
3036
|
+
return;
|
|
3037
|
+
event.preventDefault();
|
|
3038
|
+
setCardMenu({ entryId: row.entry.id, top: event.clientY, left: event.clientX });
|
|
3039
|
+
}, children: [_jsxs("span", { className: css.tileWrap, children: [_jsx("span", { className: css.tile, style: { background: row.sourceHue }, children: row.sourceTile }), row.unread && _jsx("span", { className: css.unread })] }), _jsxs("span", { className: css.meta, children: [_jsx("span", { className: css.source, children: row.sourceLabel }), _jsx("span", { className: css.sep, children: "\u00B7" }), _jsx("span", { className: css.when, children: whenLabel(t, row.entry.publishedAt) })] }), _jsx("span", { className: css.title, ...hoverProps('title'), children: titleText }), originalTitle !== null && _jsx("span", { className: css.cardOrigTitle, children: originalTitle }), summary !== undefined && _jsx("span", { className: css.summary, ...hoverProps('summary'), children: summaryText }), originalSummary !== null && _jsx("span", { className: css.cardOrigSummary, children: originalSummary }), _jsxs("span", { className: css.tags, children: [(row.entry.tags ?? []).slice(0, 2).map(tag => _jsx("span", { className: css.tag, children: tag }, tag)), row.entry.author !== undefined && _jsx("span", { className: css.author, children: row.entry.author }), backfilled[row.entry.id] === true && (_jsx("span", { className: css.tagOwned, title: t('detail.filledIn'), children: t('detail.filledInBadge') })), linkOnly && (_jsx("span", { className: css.tagLinkOnly, title: linkOnlyReason, children: t('detail.linkOnlyBadge') })), dupes.length > 0 && (_jsx("span", { className: css.tagOwned, title: t('dedupe.title', { sources: dupes.map(dupe => dupe.sourceLabel).join('、') }), children: t('dedupe.badge', { count: dupes.length }) })), (entryTagIds[row.entry.id] ?? []).map(tagId => (_jsx("span", { className: css.tagOwned, children: tags.find(tag => tag.id === tagId)?.name ?? tagId }, tagId))), row.entry.link !== undefined && (() => {
|
|
3040
|
+
const fetchState = fetchStateOf(fetchStates, row.entry.id);
|
|
3041
|
+
const state = fetchState.state;
|
|
3042
|
+
const labelKey = state === 'none' ? 'fetch.none'
|
|
3043
|
+
: state === 'fetching' ? 'fetch.fetching'
|
|
3044
|
+
: state === 'raw' ? 'fetch.raw'
|
|
3045
|
+
: state === 'ready' ? 'fetch.ready' : 'fetch.failed';
|
|
3046
|
+
const failedReason = fetchState.state === 'failed'
|
|
3047
|
+
? previewReason(t, fetchState.code, fetchState.message)
|
|
3048
|
+
: undefined;
|
|
3049
|
+
const failedCode = fetchState.state === 'failed' ? fetchState.code : undefined;
|
|
3050
|
+
const failedOpen = failedCode !== undefined && OPEN_IN_BROWSER_CODES.has(failedCode);
|
|
3051
|
+
// Unclassified and transport failures may retry; the rest is final.
|
|
3052
|
+
const failedFinal = fetchState.state === 'failed' && !failedOpen && failedCode !== undefined && failedCode !== 'unreachable';
|
|
3053
|
+
const title = state === 'ready'
|
|
3054
|
+
? t('fetch.readyTitle')
|
|
3055
|
+
: fetchState.state === 'failed'
|
|
3056
|
+
? t(failedOpen ? 'fetch.failedOpenTitle' : failedFinal ? 'fetch.failedFinalTitle' : 'fetch.failedTitle', { reason: failedReason ?? '' })
|
|
3057
|
+
: t('fetch.noneTitle');
|
|
3058
|
+
const failed = fetchState.state === 'failed';
|
|
3059
|
+
const openInfo = (anchor) => {
|
|
3060
|
+
const box = anchor.getBoundingClientRect();
|
|
3061
|
+
setPillInfo(current => current?.entryId === row.entry.id ? null : {
|
|
3062
|
+
entryId: row.entry.id,
|
|
3063
|
+
top: box.bottom + 6,
|
|
3064
|
+
left: Math.max(8, Math.min(box.left, window.innerWidth - 256)),
|
|
3065
|
+
});
|
|
3066
|
+
};
|
|
3067
|
+
return (_jsxs("span", { className: `${css.fetchPill} ${css[`fetchPill_${state === 'raw' ? 'fetching' : state}`] ?? ''}`, ...(state !== 'ready' ? { role: 'button', tabIndex: 0 } : {}), title: title, onClick: event => {
|
|
3068
|
+
event.stopPropagation();
|
|
3069
|
+
if (state === 'ready')
|
|
3070
|
+
return;
|
|
3071
|
+
if (failed)
|
|
3072
|
+
openInfo(event.currentTarget);
|
|
3073
|
+
else
|
|
3074
|
+
void startFetch(row.entry);
|
|
3075
|
+
}, onKeyDown: event => {
|
|
3076
|
+
if (event.key !== 'Enter' && event.key !== ' ')
|
|
3077
|
+
return;
|
|
3078
|
+
event.stopPropagation();
|
|
3079
|
+
if (state === 'ready')
|
|
3080
|
+
return;
|
|
3081
|
+
if (failed)
|
|
3082
|
+
openInfo(event.currentTarget);
|
|
3083
|
+
else
|
|
3084
|
+
void startFetch(row.entry);
|
|
3085
|
+
}, children: [glyph(state === 'ready' ? 'check' : state === 'failed' ? 'alert' : 'fetch', 10), _jsx("span", { children: t(labelKey) })] }));
|
|
3086
|
+
})(), _jsx("span", { className: css.tagOnCard, role: "button", tabIndex: 0, title: t('action.addTag'), onClick: event => { event.stopPropagation(); setCardMenu(null); openCardTag(row.entry.id); }, onKeyDown: event => { if (event.key === 'Enter') {
|
|
3087
|
+
event.stopPropagation();
|
|
3088
|
+
openCardTag(row.entry.id);
|
|
3089
|
+
} }, children: _jsx(IconPlusOutlineMedium, { size: 11 }) })] }), _jsx("span", { className: css.chevron, children: glyph('chevron', 13) })] }, row.entry.id));
|
|
3090
|
+
}) }))] }), sources.length > 0 && (() => {
|
|
3091
|
+
const when = relativeWhen(lastRefreshAt ?? undefined, new Date());
|
|
3092
|
+
return (_jsxs("div", { className: css.metaBar, children: [query.trim() !== '' && (_jsxs(_Fragment, { children: [_jsx("span", { children: t('state.matches', { count: displayRows.length, query: query.trim() }) }), _jsx("span", { className: css.sep, children: "\u00B7" })] })), hiddenCount > 0 && (_jsxs(_Fragment, { children: [_jsx("span", { children: t('foot.deduped', { count: hiddenCount }) }), _jsx("span", { className: css.sep, children: "\u00B7" })] })), _jsx("span", { children: backfill !== null
|
|
3093
|
+
? t('state.backfilling', { done: backfill.done, total: backfill.total })
|
|
3094
|
+
: refreshing
|
|
3095
|
+
? t('foot.refreshing')
|
|
3096
|
+
: lastRefreshAt === null
|
|
3097
|
+
? t('foot.never')
|
|
3098
|
+
: t('foot.refreshedAt', { when: t(when.key, when.count === undefined ? {} : { count: when.count }) }) }), nextRefreshAt !== null && (_jsxs(_Fragment, { children: [_jsx("span", { className: css.sep, children: "\u00B7" }), _jsx("span", { children: t('foot.scheduled', { time: clockOf(nextRefreshAt) }) })] }))] }));
|
|
3099
|
+
})(), cardTag !== null && (_jsxs("div", { className: css.cardTagPanel, style: { top: cardTag.top, left: cardTag.left }, children: [_jsxs("div", { className: css.tagPanelHead, children: [_jsx("span", { className: css.tagPanelTitle, children: t('tag.title') }), _jsx("span", { className: css.tagPanelHint, children: t('tag.hint') })] }), _jsxs("div", { className: css.tagPanelList, children: [tags.length === 0 && _jsx("div", { className: css.tagPanelEmpty, children: t('tag.empty') }), _jsx(TagSuggestions, { t: t, tags: tags, applied: entryTagIds[cardTag.entryId] ?? [], draft: tagDraft, onToggle: tag => {
|
|
3100
|
+
const on = (entryTagIds[cardTag.entryId] ?? []).includes(tag.id);
|
|
3101
|
+
void toggleTag(cardTag.entryId, tag.id, !on);
|
|
3102
|
+
}, onCreate: name => { void createAndTag(cardTag.entryId, name); } })] }), _jsxs("div", { className: css.tagPanelField, children: [_jsx(IconPlusOutlineMedium, { size: 12 }), _jsx("input", { autoFocus: true, className: css.tagInput, value: tagDraft, placeholder: t('tag.placeholder'), onChange: event => { setTagDraft(event.target.value); }, onKeyDown: event => {
|
|
3103
|
+
if (event.key === 'Escape') {
|
|
3104
|
+
setTagDraft('');
|
|
3105
|
+
setCardTag(null);
|
|
3106
|
+
return;
|
|
3107
|
+
}
|
|
3108
|
+
if (event.key !== 'Enter' || tagDraft.trim().length === 0)
|
|
3109
|
+
return;
|
|
3110
|
+
const name = tagDraft.trim();
|
|
3111
|
+
const existing = tags.find(tag => tag.name.toLowerCase() === name.toLowerCase());
|
|
3112
|
+
if (existing === undefined)
|
|
3113
|
+
void createAndTag(cardTag.entryId, name);
|
|
3114
|
+
else {
|
|
3115
|
+
void toggleTag(cardTag.entryId, existing.id, true);
|
|
3116
|
+
setTagDraft('');
|
|
3117
|
+
}
|
|
3118
|
+
} })] })] })), pillInfo !== null && (() => {
|
|
3119
|
+
const entry = allEntries.find(item => item.id === pillInfo.entryId);
|
|
3120
|
+
const fetchState = fetchStateOf(fetchStates, pillInfo.entryId);
|
|
3121
|
+
if (entry === undefined || fetchState.state !== 'failed')
|
|
3122
|
+
return null;
|
|
3123
|
+
const reason = previewReason(t, fetchState.code, fetchState.message);
|
|
3124
|
+
const canOpen = fetchState.code !== undefined && OPEN_IN_BROWSER_CODES.has(fetchState.code) && entry.link !== undefined;
|
|
3125
|
+
const canRetry = fetchState.code === undefined || fetchState.code === 'unreachable';
|
|
3126
|
+
return (_jsxs("div", { className: css.pillPop, style: { top: pillInfo.top, left: pillInfo.left }, role: "dialog", "aria-label": t('fetch.failed'), onKeyDown: event => { if (event.key === 'Escape')
|
|
3127
|
+
setPillInfo(null); }, children: [_jsx("p", { className: css.pillPopReason, children: reason }), (canRetry || canOpen) && (_jsxs("div", { className: css.pillPopActions, children: [canRetry && (_jsx("button", { type: "button", className: css.incompleteLink, onClick: () => { setPillInfo(null); void startFetch(entry); }, children: t('detail.refetch') })), canOpen && (_jsx("button", { type: "button", className: css.incompleteLink, onClick: () => { setPillInfo(null); openElsewhere(entry.link); }, children: browserTab ? t('action.openExternal') : t('detail.readOriginal') }))] }))] }));
|
|
3128
|
+
})(), cardMenu !== null && (() => {
|
|
3129
|
+
const entry = allEntries.find(item => item.id === cardMenu.entryId);
|
|
3130
|
+
if (entry === undefined)
|
|
3131
|
+
return null;
|
|
3132
|
+
const entrySource = sources.find(item => item.id === entry.sourceId);
|
|
3133
|
+
return (_jsxs("div", { className: css.cardMenu, style: { top: cardMenu.top, left: cardMenu.left }, role: "menu", children: [entry.contentHtml === undefined && entry.link !== undefined && (_jsx("button", { type: "button", onClick: () => { setCardMenu(null); fetchThenMaybeRender(entry.id, entry.link); }, children: t('action.fetchBody') })), _jsx("button", { type: "button", onClick: () => { setCardMenu(null); openCardTag(entry.id); }, children: t('action.addTag') }), entry.link !== undefined && (_jsx("button", { type: "button", onClick: () => { setCardMenu(null); props.openExternal(entry.link); }, children: t('action.openExternal') })), entrySource?.kind === 'link' && (_jsx("button", { type: "button", className: css.cardMenuDanger, onClick: () => { setCardMenu(null); void removeSource(entry.sourceId); }, children: t('detail.removeLink') }))] }));
|
|
3134
|
+
})(), dialog] }));
|
|
3135
|
+
}
|